1699 字
8 分钟
Astro整合自部署 MeiliSearch

序言#

我之前使用的 Fuwari 主题,默认是内置了 PageFind 来作为文章搜索工具的。 尽管 PageFind 并不是一个很差的解决方案,但是作为一个纯客户端搜索,还是带来了一些小问题的:

  • 中文的分词做得相对比较差
  • 依赖于一个索引文件,而索引文件会随着文章大小增大而增大。 在这种情况下,我决定使用一款带有服务端的搜素服务来解决问题。

选择搜索服务#

目前主流的搜索服务中,有牛逼但是非常重量级的 ElasticSearch,也有 Algolia 这这种付费 SaaS 选手。

而开源、省资源且可以自己建立的情况下,则只剩下了 TypeSense 和 MeiliSearch 这两位。

TypeSense相对而言对复杂搜索做的比较好,但是对于博客的搜素,TypeSense 更高的硬件要求则显得有些多此一举。

相对于 TypeSense,MeiliSearch尽管缺失了很多高级功能,比如 StartWith 这些,但是其有特殊的分词优化,对于中文博客搜索还是绰绰有余的。

因为,我选择使用 MeiliSearch 来进行搜索服务的实现。

创建 MeiliSearch 服务#

安装 MeiliSearch#

MeiliSearch 不支持多节点,自然也不需要像 ElasticSearch 那样复杂的安装,一个简单的 DockerCompose 足够拉起来整个项目。

docker-compose.yml
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。按照官方的文档,使用下面的方法创建即可:

Create Index
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:

Create 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

response.json
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 可能会引入很多没用的东西。

按照逻辑,我们应该可以使用下面的代码来读取并生成页面的元数据信息:

loadMeiliSearch.mjs
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
}

上边是本站正在使用的部分,简单讲解下就是以下的思路:

  1. 读取生成目录下的所有文章的 HTML。
  2. 读取页面中的元数据部分。(部分主题,如这里,可能已经将元数据做成了 json 格式)
  3. 去除文章中多余的 HTML 代码部分。
  4. 生成 ID,整理并返回页面的元数据。
提醒

由于截止目前,MeiliSearch 还不支持嵌套值的搜索,所以建议将页面的可搜索字段拉平。

在获取到文章后,则可以将所有的文章提交到 MeiliSearch 服务了。

loadArticles.mjs
const client = new MeiliSearch({host: MEILISEARCH_HOST, apiKey: YOUR_MASTERKEY})
const posts = await getPosts()
const indexName = 'articles'
// Get the index instance
const index = await client.getIndex(indexName)
await index.deleteAllDocuments()
// Add documents to the index
await index.addDocuments(posts);

前端实现搜索功能#

与 ElasticSearch 不同,MeiliSearch 支持将API直接暴露在前端,那么,自然就可以直接与 MeiliSearch 的搜索接口通讯。

可以使用下面的代码与自己的 MeiliSearch 对接:

search.js
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 并增加速率限制等方式防止被刷。

Astro整合自部署 MeiliSearch
https://23h.at/posts/astro-整合自部署-meilisearch/
作者
Hyprocritor
发布于
2024-11-24
许可协议
CC BY-NC-SA 4.0