为什么要自己动手?
我知道你在想什么。"为什么不直接用 Elasticsearch 呢?"或者"Algolia 怎么样?"这些都是可行的选择,但它们比较复杂。你需要学习它们的 API,管理它们的基础架构,还要应对它们的各种怪癖。
有时候你想要的只是:
- 可与您现有的数据库配合使用
- 不需要外部服务
- 易于理解和调试
- 实际上找到了相关结果
这就是我所构建的。一个使用您现有数据库、尊重您现有架构并让您完全控制其运行方式的搜索引擎。
核心思想
概念很简单: 对所有内容进行标记化,存储起来,然后在搜索时匹配标记 。
它的运作方式如下:
- 索引 :当您添加或更新内容时,我们会将其拆分为词元(单词、前缀、n-gram),并为其赋予权重进行存储。
- 搜索 :当用户进行搜索时,我们会以相同的方式对他们的查询进行分词,找到匹配的词元,并对结果进行评分。
- 评分 :我们使用存储的权重来计算相关性得分。
关键在于分词和加权。让我来解释一下。
构建模块 1:数据库模式
我们需要两个简单的表格: index_tokens 和 index_entries 。
索引标记
此表存储所有唯一词元及其对应的分词器权重。每个词元名称可以有多条记录,每条记录对应一个不同的权重------每个分词器对应一条记录。
php
// index_tokens table structure
id | name | weight
---|---------|-------
1 | parser | 20 // From WordTokenizer
2 | parser | 5 // From PrefixTokenizer
3 | parser | 1 // From NGramsTokenizer
4 | parser | 10 // From SingularTokenizer
为什么要按权重存储不同的词元?不同的分词器对同一个词元可能赋予不同的权重。例如,WordTokenizer 生成的"parser"权重为 20,而 PrefixTokenizer 生成的"parser"权重为 5。我们需要单独的记录来正确地对匹配项进行评分。
唯一性约束是 (name, weight) ,因此同一个标记名称可以多次存在,但权重不同。
索引条目
此表将标记与文档关联起来,并根据字段赋予不同的权重。
php
// index_entries table structure
id | token_id | document_type | field_id | document_id | weight
---|----------|---------------|----------|-------------|-------
1 | 1 | 1 | 1 | 42 | 2000
2 | 2 | 1 | 1 | 42 | 500
这 weight 是最终计算出的权重: field_weight × tokenizer_weight × ceil(sqrt(token_length)) 。这包含了评分所需的所有信息。我们将在文章后面讨论评分方法。
我们为以下内容添加索引:
(document_type, document_id)- 用于快速查找文档token_id- 用于快速令牌查找(document_type, field_id)- 用于特定字段的查询weight- 用于按重量过滤
为什么选择这种结构?因为它简单高效,并且能充分发挥数据库的优势。
构建模块 2:分词
什么是分词?分词是将文本分解成可搜索的片段。"解析器"这个词会变成诸如 <a>、<b>、<c> 或 <d> 之类的分词符, ["parser"] 具体 ["par", "pars", "parse", "parser"] 取决于 ["par", "ars", "rse", "ser"] 我们使用的分词器。
为什么要使用多个分词器?因为不同的匹配需求需要不同的策略。一个分词器用于精确匹配,另一个用于部分匹配,还有一个用于拼写错误。
所有分词器都实现了一个简单的接口:
php
interface TokenizerInterface
{
public function tokenize(string $text): array; // Returns array of Token objects
public function getWeight(): int; // Returns tokenizer weight
}
合同简单,易于续签。
单词分词器
这个功能很简单------它将文本拆分成单个单词。"parser"就变成了"" ["parser"] 。简单却功能强大,可以实现精确匹配。
首先,我们对文本进行规范化处理。全部转换为小写,移除特殊字符,规范化空格:
php
class WordTokenizer implements TokenizerInterface
{
public function tokenize(string $text): array
{
// Normalize: lowercase, remove special chars
$text = mb_strtolower(trim($text));
$text = preg_replace('/[^a-z0-9]/', ' ', $text);
$text = preg_replace('/\s+/', ' ', $text);
接下来,我们将单词拆分,并过滤掉短单词:
php
// Split into words, filter short ones
$words = explode(' ', $text);
$words = array_filter($words, fn($w) => mb_strlen($w) >= 2);
为什么要过滤短词?单字符词通常太常见,没什么用。"a"、"I"、"x"对搜索没有帮助。
最后,我们将唯一的单词作为 Token 对象返回:
php
// Return as Token objects with weight
return array_map(
fn($word) => new Token($word, $this->weight),
array_unique($words)
);
}
}
权重:20(高度优先匹配)
前缀分词器
这将生成单词前缀。"parser"变为 ["par", "pars", "parse", "parser"] (最小长度为4)。这有助于进行部分匹配和类似自动完成的功能。
首先,我们提取单词(采用与 WordTokenizer 相同的归一化方法):
php
class PrefixTokenizer implements TokenizerInterface
{
public function __construct(
private int $minPrefixLength = 4,
private int $weight = 5
) {}
public function tokenize(string $text): array
{
// Normalize same as WordTokenizer
$words = $this->extractWords($text);
然后,对于每个单词,我们生成从最短长度到完整单词长度的前缀:
php
$tokens = [];
foreach ($words as $word) {
$wordLength = mb_strlen($word);
// Generate prefixes from min length to full word
for ($i = $this->minPrefixLength; $i <= $wordLength; $i++) {
$prefix = mb_substr($word, 0, $i);
$tokens[$prefix] = true; // Use associative array for uniqueness
}
}
为什么要使用关联数组?因为它能确保唯一性。如果文本中"parser"出现了两次,我们只需要一个"parser"标记。
最后,我们将键转换为 Token 对象:
php
return array_map(
fn($prefix) => new Token($prefix, $this->weight),
array_keys($tokens)
);
}
}
权重:5(中等优先级)
为什么要设置最小长度?为了避免使用过多过短的词法单元。长度小于 4 个字符的前缀通常过于常见,没什么实际意义。
N-Grams分词器
这会生成固定长度的字符序列(我这里用 3)。"parser"会变成这样 ["par", "ars", "rse", "ser"] 。这样可以捕获拼写错误和部分匹配的单词。
首先,我们提取单词:
php
class NGramsTokenizer implements TokenizerInterface
{
public function __construct(
private int $ngramLength = 3,
private int $weight = 1
) {}
public function tokenize(string $text): array
{
$words = $this->extractWords($text);
然后,对于每个单词,我们用一个固定长度的窗口在其上滑动:
php
$tokens = [];
foreach ($words as $word) {
$wordLength = mb_strlen($word);
// Sliding window of fixed length
for ($i = 0; $i <= $wordLength - $this->ngramLength; $i++) {
$ngram = mb_substr($word, $i, $this->ngramLength);
$tokens[$ngram] = true;
}
}
滑动窗口:对于长度为 3 的"parser",我们得到:
- 位置 0:"par"
- 位置 1:"ars"
- 位置 2:"rse"
- 位置 3:"ser"
为什么这样可以?即使有人输入"parsr"(拼写错误),我们仍然会得到"par"和"ars"标记,它们与拼写正确的"parser"相匹配。
最后,我们将其转换为 Token 对象:
php
return array_map(
fn($ngram) => new Token($ngram, $this->weight),
array_keys($tokens)
);
}
}
权重:1(优先级低,但能处理极端情况)
为什么是3?为了在覆盖面和信息量之间取得平衡。太短的话,匹配项太多;太长的话,又会漏掉拼写错误。
正常化
所有分词器都执行相同的归一化操作:
- 全部小写
- 删除特殊字符(仅保留字母数字)
- 将空格规范化(多个空格转换为单个空格)
这样可以确保无论输入格式如何,都能实现一致的匹配。
构建模块 3:重量系统
我们有三级重量同时作用:
- 字段权重 :标题 vs 内容 vs 关键词
- 分词器权重 :词、前缀和 n-gram(存储在 index_tokens 中)
- 文档权重 :存储在 index_entries 中(计算得出
field_weight × tokenizer_weight × ceil(sqrt(token_length)):)
最终重量计算
进行索引时,我们按如下方式计算最终权重:
php
$finalWeight = $fieldWeight * $tokenizerWeight * ceil(sqrt($tokenLength));
例如:
- 标题字段:权重 10
- 词分词器:权重 20
- 标记"parser":长度 6
- 最终重量:
10 × 20 × ceil(sqrt(6)) = 10 × 20 × 3 = 600
为什么要使用平方根 ceil(sqrt()) ?较长的词元更具体,但我们不希望权重因词元过长而暴增。"parser"比"par"更具体,但一个100个字符的词元不应该拥有100倍的权重。平方根函数会带来收益递减------较长的词元得分仍然更高,但并非线性增长。我们使用 ceil() 平方根向上取整到最接近的整数,以保持权重为整数。
调音配重
您可以根据实际使用情况调整权重:
- 如果标题最重要,则增加标题字段的权重。
- 如果想要优先处理精确匹配项,请增加精确匹配项的分词器权重。
- 如果您希望较长的词元更重要或更不重要,请调整词元长度函数(ceil(sqrt)、log 或 linear)。
您可以清楚地看到权重是如何计算的,并根据需要进行调整。
构建模块 4:索引服务
索引服务接收一个文档,并将其所有标记存储在数据库中。
界面
可索引文档实现 IndexableDocumentInterface :
php
interface IndexableDocumentInterface
{
public function getDocumentId(): int;
public function getDocumentType(): DocumentType;
public function getIndexableFields(): IndexableFields;
}
要使文档可搜索,您需要实现以下三种方法:
php
class Post implements IndexableDocumentInterface
{
public function getDocumentId(): int
{
return $this->id ?? 0;
}
public function getDocumentType(): DocumentType
{
return DocumentType::POST;
}
public function getIndexableFields(): IndexableFields
{
$fields = IndexableFields::create()
->addField(FieldId::TITLE, $this->title ?? '', 10)
->addField(FieldId::CONTENT, $this->content ?? '', 1);
// Add keywords if present
if (!empty($this->keywords)) {
$fields->addField(FieldId::KEYWORDS, $this->keywords, 20);
}
return $fields;
}
}
三种实现方法:
getDocumentType()返回文档类型枚举getDocumentId()返回文档 IDgetIndexableFields()使用 Fluent API 构建带权重的字段
您可以为文档建立索引:
- 创建/更新时(通过事件监听器)
- 通过命令:
app:index-document,app:reindex-documents - 通过 cron(用于批量重新索引)
工作原理
以下是索引过程的详细步骤。
首先,我们获取文档信息:
php
class SearchIndexingService
{
public function indexDocument(IndexableDocumentInterface $document): void
{
// 1. Get document info
$documentType = $document->getDocumentType();
$documentId = $document->getDocumentId();
$indexableFields = $document->getIndexableFields();
$fields = $indexableFields->getFields();
$weights = $indexableFields->getWeights();
该文档通过 IndexableFields 构建器提供其字段和权重。
接下来,我们删除该文档的现有索引。这可以处理更新------如果文档已更改,我们需要重新建立索引:
php
// 2. Remove existing index for this document
$this->removeDocumentIndex($documentType, $documentId);
// 3. Prepare batch insert data
$insertData = [];
为什么要先删除?如果我们直接添加新的令牌,就会出现重复项。最好还是从头开始。
现在,我们处理每个字段。对于每个字段,我们运行所有分词器:
php
// 4. Process each field
foreach ($fields as $fieldIdValue => $content) {
if (empty($content)) {
continue;
}
$fieldId = FieldId::from($fieldIdValue);
$fieldWeight = $weights[$fieldIdValue] ?? 0;
// 5. Run all tokenizers on this field
foreach ($this->tokenizers as $tokenizer) {
$tokens = $tokenizer->tokenize($content);
对于每个分词器,我们都会得到一些词元。然后,对于每个词元,我们在数据库中查找或创建它,并计算最终权重:
php
foreach ($tokens as $token) {
$tokenValue = $token->value;
$tokenWeight = $token->weight;
// 6. Find or create token in index_tokens
$tokenId = $this->findOrCreateToken($tokenValue, $tokenWeight);
// 7. Calculate final weight
$tokenLength = mb_strlen($tokenValue);
$finalWeight = (int) ($fieldWeight * $tokenWeight * ceil(sqrt($tokenLength)));
// 8. Add to batch insert
$insertData[] = [
'token_id' => $tokenId,
'document_type' => $documentType->value,
'field_id' => $fieldId->value,
'document_id' => $documentId,
'weight' => $finalWeight,
];
}
}
}
为什么要批量插入?为了提高性能。我们不是一次插入一行,而是收集所有行,然后通过一次查询将它们全部插入。
最后,我们批量插入所有内容:
php
// 9. Batch insert for performance
if (!empty($insertData)) {
$this->batchInsertSearchDocuments($insertData);
}
}
方法 findOrCreateToken 很简单:
php
private function findOrCreateToken(string $name, int $weight): int
{
// Try to find existing token with same name and weight
$sql = "SELECT id FROM index_tokens WHERE name = ? AND weight = ?";
$result = $this->connection->executeQuery($sql, [$name, $weight])->fetchAssociative();
if ($result) {
return (int) $result['id'];
}
// Create new token
$insertSql = "INSERT INTO index_tokens (name, weight) VALUES (?, ?)";
$this->connection->executeStatement($insertSql, [$name, $weight]);
return (int) $this->connection->lastInsertId();
}
}
为什么要查找或创建?词元在文档之间是共享的。如果权重为 20 的"parser"已存在,我们就重用它,无需创建重复项。
要点:
- 我们首先删除旧索引(处理更新)
- 我们采用批量插入的方式以提高性能(只需一次查询而不是多次查询)。
- 我们查找或创建令牌(避免重复)
- 我们实时计算最终重量。
构建模块 5:搜索服务
搜索服务接收查询字符串并查找相关文档。它以与索引过程中对文档进行分词相同的方式对查询进行分词,然后将这些分词词与数据库中已索引的分词词进行匹配。结果根据相关性进行评分,并以带有分数的文档 ID 的形式返回。
工作原理
以下是搜索过程的步骤。
首先,我们使用所有分词器对查询进行分词:
php
class SearchService
{
public function search(DocumentType $documentType, string $query, ?int $limit = null): array
{
// 1. Tokenize query using all tokenizers
$queryTokens = $this->tokenizeQuery($query);
if (empty($queryTokens)) {
return [];
}
如果查询没有产生任何标记(例如,只有特殊字符),则返回空结果。
为什么要使用相同的分词器对查询进行分词?
不同的分词器会生成不同的词元值。如果我们用一套分词器进行索引,却用另一套分词器进行搜索,就会漏掉匹配项。
例子:
- 使用 PrefixTokenizer 进行索引会创建以下标记:"par"、"pars"、"parse"、"parser"
- 仅使用 WordTokenizer 进行搜索会生成标记:"parser"。
- 我们会找到"parser",但找不到只包含"par"或"pars"标记的文档。
- 结果:匹配不完整,缺少相关文档!
解决方案 :索引和搜索使用相同的分词器。相同的分词策略 = 相同的词值 = 完全匹配。
这就是为什么 SearchService 两者 SearchIndexingService 使用同一组分词器的原因。
接下来,我们提取唯一的词元值。多个词元器可能会产生相同的词元值,因此我们需要进行去重:
php
// 2. Extract unique token values
$tokenValues = array_unique(array_map(
fn($token) => $token instanceof Token ? $token->value : $token,
$queryTokens
));
为什么要提取值?我们按词元名称搜索,而不是按权重搜索。我们需要唯一的词元名称来进行搜索。
然后,我们按长度对标记进行排序(最长的排在最前面)。这样可以优先匹配特定的内容:
php
// 3. Sort tokens (longest first - prioritize specific matches)
usort($tokenValues, fn($a, $b) => mb_strlen($b) <=> mb_strlen($a));
为什么要排序?较长的词元更具体。"parser"比"par"更具体,所以我们想先搜索"parser"。
我们还限制了令牌数量,以防止使用大量查询发起拒绝服务攻击:
php
// 4. Limit token count (prevent DoS with huge queries)
if (count($tokenValues) > 300) {
$tokenValues = array_slice($tokenValues, 0, 300);
}
为什么要限制?恶意用户可能会发送生成数千个令牌的查询,从而导致性能问题。我们只保留最长的 300 个令牌(已排序)。
现在,我们执行优化后的 SQL 查询。该 executeSearch() 方法会构建 SQL 查询并执行它:
php
// 5. Execute optimized SQL query
$results = $this->executeSearch($documentType, $tokenValues, $limit);
在内部 executeSearch() ,我们使用参数占位符构建 SQL 查询,执行该查询,过滤低分结果,并将其转换为 SearchResult 对象:
php
private function executeSearch(DocumentType $documentType, array $tokenValues, int $tokenCount, ?int $limit, int $minTokenWeight): array
{
// Build parameter placeholders for token values
$tokenPlaceholders = implode(',', array_fill(0, $tokenCount, '?'));
// Build the SQL query (shown in full in "The SQL Query" section below)
$sql = "SELECT sd.document_id, ... FROM index_entries sd ...";
// Build parameters array
$params = [
$documentType->value, // document_type
...$tokenValues, // token values for IN clause
$documentType->value, // for subquery
...$tokenValues, // token values for subquery
$minTokenWeight, // minimum token weight
// ... more parameters
];
// Execute query with parameter binding
$results = $this->connection->executeQuery($sql, $params)->fetchAllAssociative();
// Filter out results with low normalized scores (below threshold)
$results = array_filter($results, fn($r) => (float) $r['score'] >= 0.05);
// Convert to SearchResult objects
return array_map(
fn($result) => new SearchResult(
documentId: (int) $result['document_id'],
score: (float) $result['score']
),
$results
);
}
SQL 查询承担了大部分繁重的工作:查找匹配的文档、计算分数并按相关性排序。我们使用原始 SQL 代码是为了提高性能和实现完全控制------我们可以根据需要精确地优化查询。
该查询使用 JOIN 连接词元和文档,使用子查询进行规范化,使用聚合进行评分,并基于词元名称、文档类型和权重建立索引。我们使用参数绑定来确保安全(防止 SQL 注入)。
我们将在下一节中看到完整的查询。
主 search() 方法随后返回结果:
php
// 5. Return results
return $results;
}
}
评分算法
评分算法综合考虑了多种因素。让我们一步步来分析。
基础得分是所有匹配词元权重的总和:
sql
SELECT
sd.document_id,
SUM(sd.weight) as base_score
FROM index_entries sd
INNER JOIN index_tokens st ON sd.token_id = st.id
WHERE
sd.document_type = ?
AND st.name IN (?, ?, ?) -- Query tokens
GROUP BY sd.document_id
sd.weight: 来自 index_entries (field_weight × tokenizer_weight × ceil(sqrt(token_length)))
为什么不乘以呢?分 词 st.weight 器权重已经 sd.weight 在索引过程中包含在内了。`from` 仅用于完整 SQL 查询的 WHERE 子句中进行过滤(确保至少有一个词元的权重 >= minTokenWeight)。 st.weight index_tokens
这给了我们原始分数。但我们需要的不只是这些。
我们增加了令牌多样性提升机制。包含更多独特令牌的文档得分更高:
sql
(1.0 + LOG(1.0 + COUNT(DISTINCT sd.token_id))) * base_score
为什么?匹配 5 个不同词元的文档比匹配 5 次相同词元的文档相关性更高。LOG 函数使这种提升呈对数级增长------匹配 10 个词元并不会带来 10 倍的提升。
我们还增加了平均权重质量提升。匹配质量更高的文档得分更高:
sql
(1.0 + LOG(1.0 + AVG(sd.weight))) * base_score
为什么?匹配权重高的文档(例如,标题匹配)比匹配权重低的文档(例如,内容匹配)更相关。同样,LOG 函数使这种相关性呈对数增长。
我们对文档长度施加惩罚,以防止长文档占据主导地位。
sql
base_score / (1.0 + LOG(1.0 + doc_token_count.token_count))
为什么?一篇 1000 字的文档并不会仅仅因为字数更多就自动优于一篇 100 字的文档。LOG 函数使得这种惩罚呈对数形式------篇幅是 10 倍的文档并不会受到 10 倍的惩罚。
最后,我们通过除以最高分进行归一化:
sql
score / GREATEST(1.0, max_score) as normalized_score
这样就得到了 0-1 的范围,使得不同查询之间的分数具有可比性。
完整公式如下:
sql
SELECT
sd.document_id,
(
SUM(sd.weight) * -- Base score
(1.0 + LOG(1.0 + COUNT(DISTINCT sd.token_id))) * -- Token diversity boost
(1.0 + LOG(1.0 + AVG(sd.weight))) / -- Average weight quality boost
(1.0 + LOG(1.0 + doc_token_count.token_count)) -- Document length penalty
) / GREATEST(1.0, max_score) as score -- Normalization
FROM index_entries sd
INNER JOIN index_tokens st ON sd.token_id = st.id
INNER JOIN (
SELECT document_id, COUNT(*) as token_count
FROM index_entries
WHERE document_type = ?
GROUP BY document_id
) doc_token_count ON sd.document_id = doc_token_count.document_id
WHERE
sd.document_type = ?
AND st.name IN (?, ?, ?) -- Query tokens
AND sd.document_id IN (
SELECT DISTINCT document_id
FROM index_entries sd2
INNER JOIN index_tokens st2 ON sd2.token_id = st2.id
WHERE sd2.document_type = ?
AND st2.name IN (?, ?, ?)
AND st2.weight >= ? -- Ensure at least one token with meaningful weight
)
GROUP BY sd.document_id
ORDER BY score DESC
LIMIT ?
为什么要使用子查询 st2.weight >= ??这确保我们只包含至少匹配一个具有有意义分词器权重的词元的文档。如果没有这个过滤器,即使文档不匹配任何高优先级词元(例如权重为 20 的词),只要它只匹配低优先级词元(例如权重为 1 的 n-gram),也会被包含在内。这个子查询过滤掉了只匹配噪声的文档。我们想要的是至少匹配一个有意义词元的文档。
为什么采用这种公式?因为它平衡了多种相关性因素。完全匹配得分高,匹配多个词元的文档得分也高。篇幅长的文档并非主导因素,高质量的匹配结果才是。
如果权重为 10 没有结果,我们将重试权重为 1(用于处理极端情况的回退方案)。
将 ID 转换为文档
搜索服务返回 SearchResult 包含文档 ID 和评分的对象:
php
class SearchResult
{
public function __construct(
public readonly int $documentId,
public readonly float $score
) {}
}
但我们需要的是实际的文档,而不仅仅是ID。我们使用存储库进行转换:
php
// Perform search
$searchResults = $this->searchService->search(
DocumentType::POST,
$query,
$limit
);
// Get document IDs from search results (preserving order)
$documentIds = array_map(fn($result) => $result->documentId, $searchResults);
// Get documents by IDs (preserving order from search results)
$documents = $this->documentRepository->findByIds($documentIds);
为什么要保持顺序?搜索结果是按相关性得分排序的。我们希望在显示结果时保持这种顺序。
存储库方法负责处理转换:
php
public function findByIds(array $ids): array
{
if (empty($ids)) {
return [];
}
return $this->createQueryBuilder('d')
->where('d.id IN (:ids)')
->setParameter('ids', $ids)
->orderBy('FIELD(d.id, :ids)') // Preserve order from IDs array
->getQuery()
->getResult();
}
该 FIELD() 函数会保留 ID 数组中的顺序,因此文档会按照搜索结果的顺序显示。
结果:您将获得什么
您将获得一个具备以下功能的搜索引擎:
- 快速找到相关结果 (利用数据库索引)
- 处理拼写错误 (n-gram 可以捕获部分匹配项)
- 处理部分词 (前缀分词器)
- 优先考虑精确匹配 (分词器权重最高)
- 可与现有数据库配合使用 (无需外部服务)
- 易于理解和调试 (一切都是透明的)
- 完全控制行为 (调整权重、添加分词器、修改评分)
扩展系统
想要添加新的分词器?请实现 TokenizerInterface :
php
class StemmingTokenizer implements TokenizerInterface
{
public function tokenize(string $text): array
{
// Your stemming logic here
// Return array of Token objects
}
public function getWeight(): int
{
return 15; // Your weight
}
}
将其注册到您的服务配置中,它将自动用于索引和搜索。
想要添加新的文档类型?请实现 IndexableDocumentInterface :
php
class Comment implements IndexableDocumentInterface
{
public function getIndexableFields(): IndexableFields
{
return IndexableFields::create()
->addField(FieldId::CONTENT, $this->content ?? '', 5);
}
}
想调整权重?修改配置即可。想修改评分标准?编辑 SQL 查询语句。一切尽在您的掌控之中。
结论
瞧,这就是它。一个简单易用的搜索引擎。它并不花哨,也不需要太多的基础设施,但对于大多数使用场景来说,它已经足够完美了。
关键在于:有时候,最好的解决方案就是你能理解的方案。没有魔法,没有黑箱操作,只有简单明了、言简意赅的代码。
它归你所有,你掌控它,你可以调试它。这价值连城。