序言
我之前使用的 Fuwari 主题,默认是内置了 PageFind 来作为文章搜索工具的。 尽管 PageFind 并不是一个很差的解决方案,但是作为一个纯客户端搜索,还是带来了一些小问题的:
- 中文的分词做得相对比较差
- 依赖于一个索引文件,而索引文件会随着文章大小增大而增大。 在这种情况下,我决定使用一款带有服务端的搜素服务来解决问题。
选择搜索服务
目前主流的搜索服务中,有牛逼但是非常重量级的 ElasticSearch,也有 Algolia 这这种付费 SaaS 选手。
而开源、省资源且可以自己建立的情况下,则只剩下了 TypeSense 和 MeiliSearch 这两位。
TypeSense相对而言对复杂搜索做的比较好,但是对于博客的搜素,TypeSense 更高的硬件要求则显得有些多此一举。
相对于 TypeSense,MeiliSearch尽管缺失了很多高级功能,比如 StartWith
这些,但是其有特殊的分词优化,对于中文博客搜索还是绰绰有余的。
因为,我选择使用 MeiliSearch 来进行搜索服务的实现。
创建 MeiliSearch 服务
安装 MeiliSearch
MeiliSearch 不支持多节点,自然也不需要像 ElasticSearch 那样复杂的安装,一个简单的 DockerCompose 足够拉起来整个项目。
services: meilisearch: command: /bin/meilisearch environment: MEILI_CONFIG_FILE_PATH: /config/config.toml MEILI_MASTER_KEY: ${MEILI_MASTER_KEY} image: getmeili/meilisearch:v1.11.3 ports: - 7700:7700 restart: always volumes: - ./config:/config - ./data:/meili_data - ./dumps:/dumps - ./snapshots:/snapshots
这里唯一需要注意的是,设置一个比较复杂的管理员秘钥,越长越好那种,之后所有的操作都是围绕这个秘钥进行的,这个秘钥也需要保管好。
之后所有的请求,均需要在请求头中添加 Authorization
字段,内容则需要以Bareer <YOUR_KEY>
这样的形式书写。
创建索引
创建 MeiliSearch 索引的唯一方法就是通过 API。按照官方的文档,使用下面的方法创建即可:
curl \ -X POST 'http://localhost:7700/indexes' \ -H 'Content-Type: application/json' \ --data-binary '{ "uid": "articles", "primaryKey": "id" }'
这里一共有两个字段需要设置,一个是uid
,这个字段就是索引的名字。另一个字段是primaryKey
,是文档的唯一标识符。
而与 ElasticSearch 等服务不同的是,MeiliSearch 的搜索是需要额外手动提供文档 ID 的。这一点在之后部署的时候需要注意。
创建搜索专用 Key
由于 MasterKey 有着极高的权限。我们有必要为前台搜索提供专门的 Key 并限制具体访问的 API。 MeiliSearch 可以通过一下的 API 来创建 Key:
curl \ -X POST 'http://localhost:7700/keys' \ -H 'Authorization: Bearer MASTER_KEY' \ -H 'Content-Type: application/json' \ --data-binary '{ "description": "The API key for search the articles", "actions": ["search"], "indexes": ["articles"], "expiresAt": "2042-04-02T00:42:42Z" }'
这里同样有两个字段需要注意:
actions
:这个字段主要限制 key 的权限范围,具体可用参数可以 参考这里。indexes
:这个参数则主要限制可以使用的索引名称,里面是有索引 UID 组成的数组。这里我们仅仅设置我们之前设置的索引 ID 即可。
按照官方提供的例子,下面的字段key
就是我们创建好的 key
3 collapsed lines
{ "name": null, "description": "Manage documents: Products/Reviews API key", "key": "d0552b41536279a0ad88bd595327b96f01176a60c2243e906c52ac02375f9bc4",11 collapsed lines
"uid": "6062abda-a5aa-4414-ac91-ecd7944c0f8d", "actions": [ "documents.add" ], "indexes": [ "products" ], "expiresAt": "2021-11-13T00:00:00Z", "createdAt": "2021-11-12T10:00:00Z", "updatedAt": "2021-11-12T10:00:00Z"}
录入文档
由于 Astro 是 SSG 框架,所以索引的录入只能放在编译阶段。 目前其实有两种思路来录入文档,一种是直接读取 Markdown 文件,另外一种是读取编译好的页面。
根据我的实验,建议在页面编译完成后再录入文档,其中主要原因在于:
- Astro 的页面地址在编译前可能并不是确定的。
- 部分文档类型如 MDX 可能会引入很多没用的东西。
按照逻辑,我们应该可以使用下面的代码来读取并生成页面的元数据信息:
async function getPosts() { const postsDirectory = path.join(process.cwd(), 'dist', 'posts') const folders = await readdir(postsDirectory)
const posts = await Promise.all( folders.map(async folderName => { const filePath = path.join(postsDirectory, folderName, 'index.html') const source = await readFile(filePath, 'utf-8')
// Parse HTML content using JSDOM const dom = new JSDOM(source) const document = dom.window.document
// Get the main content const contentElement = document.querySelector('.markdown-content') const content = contentElement ? contentElement.textContent.trim() : ''
// Get metadata from JSON-LD const scriptElement = document.querySelector( 'script[type="application/ld+json"]', ) const metadata = scriptElement ? JSON.parse(scriptElement.textContent) : {}
return { id: nanoid(), content, title: metadata.headline || '', description: metadata.description || '', keywords: metadata.keywords || [], author: metadata.author?.name || '', datePublished: metadata.datePublished || '', language: metadata.inLanguage || 'en', url: `${siteUrl}/posts/${folderName}`, } }), )
return posts}
上边是本站正在使用的部分,简单讲解下就是以下的思路:
- 读取生成目录下的所有文章的 HTML。
- 读取页面中的元数据部分。(部分主题,如这里,可能已经将元数据做成了 json 格式)
- 去除文章中多余的 HTML 代码部分。
- 生成 ID,整理并返回页面的元数据。
由于截止目前,MeiliSearch 还不支持嵌套值的搜索,所以建议将页面的可搜索字段拉平。
在获取到文章后,则可以将所有的文章提交到 MeiliSearch 服务了。
const client = new MeiliSearch({host: MEILISEARCH_HOST, apiKey: YOUR_MASTERKEY})
const posts = await getPosts()const indexName = 'articles'
// Get the index instanceconst index = await client.getIndex(indexName)
await index.deleteAllDocuments()
// Add documents to the indexawait index.addDocuments(posts);
前端实现搜索功能
与 ElasticSearch 不同,MeiliSearch 支持将API直接暴露在前端,那么,自然就可以直接与 MeiliSearch 的搜索接口通讯。
可以使用下面的代码与自己的 MeiliSearch 对接:
async function useMeiliSearch (keyword: string) { if (!keyword){ return { hits:[] } } return (await fetch(`${import.meta.env.PUBLIC_MEILISEARCH_HOST}/indexes/articles/search`, { method: 'POST', headers: { "content-type": "application/json", 'authorization': `Bearer ${import.meta.env.PUBLIC_MEILISEARCH_SEARCH_KEY}`, }, body: JSON.stringify({ 'q': keyword, "attributesToHighlight":["content","title","description"], //这里填写需要高亮的字段 "attributesToCrop":["content"], //这里填写需要剪裁的字段。建议将文章剪裁来实现高亮。 "attributesToRetrieve":["url"], "highlightPostTag":"</mark>", //这两个是高亮使用的 HTML TAG "highlightPreTag":"<mark>" }) })).json()}
如果你想在客户端(浏览器)获取到编译时的部分环境变量,可以在变量前加入 PUBLIC_
这个前缀来解决。
至于与主题的集成,则由于每个人的主题不同,有着不同的集成方法。
如本站,则是简单的替换了原主题使用的 PageFind方法实现的。
再次就不一而足了。
关于安全性
集成 MeiliSearch 的操作其实本身并不复杂。但是其周边生态其实有不少要考虑的。
比如如果使用第三方 CI 或 Serverless 部署,可能需要专门为其提供秘钥和访问方法来限制普通用户的访问。
对于这种情况,可以使用 Cloudflare Access 的 Service Auth 来避免公网直接访问非搜索接口的问题。
而对于搜索接口本身,同样建议反向代理指定 API 并增加速率限制等方式防止被刷。