RawWeb.org 的前三次技术栈迭代
RawWeb.org 是我在 2024-08 启动的一个搜索引擎项目,初衷让更多人看到被主流搜索引擎忽视的个人数字花园,另外也想在实践中探索一些感兴趣的技术栈。
目前已经收录了 17k 个站点,615k 篇文章。欢迎提交你收藏的独立博客。
本文仅代表个人的体验和观点。
中间件
数据库使用 PostgreSQL,没用 SQLite 是因为可能以后要用到 Pg 丰富的插件。缓存是 Redis。消息队列使用 RabbitMQ。
除此之外,一个搜索引擎还必备爬虫全文搜索能力。
全文搜索使用 Elasticsearch,不自己实现倒排索引或用 Meilisearch 等轻量方案是因为 ES 有更好的中文分词器。
为了降低潜在风险和开发难度,爬虫仅从网站的 RSS 中获取数据源。所以爬虫的实现就是一个简单的 HTTP 请求器和 RSS 解析器。
一切从简,上述所有组件都是单节点部署,也没有特殊的调优技巧(我不会)。
多语言内容支持
这是一个能索引多种语言内容的搜索引擎,分词的质量决定了搜索结果的质量。
为了给不同语言配置专用分词器,在 Elasticsearch 中设置了多个字段,比如 content-en
、content-zh
,来存放不同语言的内容。
这涉及到:
- 识别自然语言。
- 将内容分流到专用字段,设置专用分词器。
首先清洗原始内容:
- 解析 HTML,去除 style、script 等无用标签;
- 尽可能去除代码、URL 等内容,避免干扰语言识别的准确性;
- 去除 HTML、XML 标签,获取纯文本;
- 去除多余的空白字符。
然后识别内容的所属语言。有两种方案:
第一种是 lingua,有 Python 和 Go 等语言的实现。性能和准确度都很优秀,还可以选择性加载语言模型。缺点是会让可执行文件增加 100MB 左右。
第二种是 Elasticsearch 内置的 lang_ident_model_1,需要创建 pipeline 来调用。实测下来准确度还不错,但是性能有问题。同等数据下,速度竟然比运行在更低配置服务器上的 Python 版 lingua 慢 4 倍。我猜是因为 lang_ident_model_1 需要测试其支持的所有语言,而 lingua 只需要加载少量语言模型。
考虑到性能和灵活性,最终使用了 lingua。lingua 有高准确度和低准确度两种模式,低准确度性能提升约 1 倍,并且只要输入在 120 字以上就不会损失过多准确率。所以目前采用了高、低准确度混合识别,输入为标题和内容抽样,实际测试中检测一篇文章只需 100μs。
确定了内容的语言,就可以为其设置最佳的分词器。根据 W3Techs 预估的互联网内容含量,为最主流的中、英、西、俄、德、法、日语言单独设置分词器,其他语言使用默认分词器。
后端
爬虫是一个简单的 Go 程序。而后端主体先后经历了 Django、Nest.js、Go 三次重构。
v1 - Django
技术栈:
- Django v5。
- django-ninja 作为 API 入口。
- huey 作为任务队列,实际上我只用它管理定时任务。
- uv 作为包管理工具。
此前被多次安利过 Django,我也很想通过这个项目学习一个 batteries-included 框架。考虑到有个 Django Admin,于是将其用作原型开发。
Django 的文档质量是我目前见过的最高之一,读起来非常舒服。因为项目是前后端分离,且没有用到 auth、view 等内置插件,所以 Django 的 “batteries” 没能减轻我的负担,整个开发体验也没有给我很大的惊喜。
考虑到框架的稳定性和社区繁荣度,如果我是动态语言爱好者,应该会喜欢 Django。可惜我已经被 Go 打上了深深的思想钢印,Django 的“魔法”含量超出了我的接受范围,比如 ORM 用字段名+双下划线+方法名来构建查询条件。很难想象我曾经很想学 RoR。
最终,在开发完成后,即使关闭了所有内置插件、尽可能地使用 async、使用 Uvicorn,压测结果也远低于我的预期。于是我开始研究用 Node.js 进行重构。
v2 - Nest.js
技术栈:
- TypeScript。
- Nest 封装的各种组件。
- Prisma 作为 ORM。
由于一次搜索的主要耗时是等待 Elasticsearch,Web 服务主要是作为一个请求转发器,这种 I/O 密集型场景非常适合 Node.js。
热门框架有 Nest.js 和 Adonis.js,我最终选择了更流行的 Nest。别问为什么不是 Express 或 Fastify,它们不是完备的框架。
Nest 似乎更像是一个依赖注入器加多个官方维护的组件包。虽然内置了缓存、消息队列等常用组件,但据我观察大都是对第三方库的封装,让 Nest 的用户不需要自行东拼西凑。但即使有官方的封装,我仍然不幸被底层库变更波及到了(cache-manager@6)。
对于具有 Java/Spring 背景的开发者来说,Nest 或许很不错。但对我来说 Nest 的各种装饰器、管道等概念让我有很重的记忆负担。时隔两三个月再切换回 Nest 项目时,我需要重新去翻看文档才能确定各种它们的用法。
另外,文档看起来比较丰富,但质量远不如 Django。比如模块生命周期部分,我实在没能靠文档搞懂,最终是靠一篇源码分析的文章才大概捋清了思路。
接触新技术总是好的,但对于这个项目来说选择 Nest 是错误的,因为项目的复杂度甚至不如 Nest 引入的复杂度。
v3 - Go
技术栈:
- Echo 作为 API 入口。
- GORM Gen 作为 ORM。
基于前两次体验,暂时对 batteries-included 框架祛魅了。兜兜转转,发现真爱还是最初的那个—— Go。
曾经我对用 Go 进行 Web 开发的不满有二:
- 语法过于简陋,CRUD 很难受。
- 没有好用的 ORM 或 SQL builder。
幸运地是,这两个问题都基本得到了解决。
得益于 LLM 和 AI IDE 的发展,Go 的简陋语法不仅不再是劣势,反而一定程度上成为了优势,因为 LLM 能非常容易地理解代码,AI 补全的准确率非常高。
说到 ORM,Go 社区中相当流行的观点是“ORM 有害”,更倾向于 sqlc 由 SQL 生成 Go 代码,或 sqlx 直接使用 SQL 的方式。ORM 确实有时让简单的事情变复杂,比如 Prisma 直到近期版本才支持使用真正的 JOIN。但一个 API 设计良好、类型安全的 ORM 能极大提升 CRUD 体验。
GORM Gen 让我重新喜欢上了 GORM。借助代码生成,不仅能实现类型安全,更关键的是能由自定义 SQL 生成 Go 代码,这意味着我拥有几乎满血的 SQL 能力。
于是,这次用 Go 进行的代码重构过程非常愉快。除了灾难级的 Elasticsearch 官方 SDK。
Go 还减轻了 infra 的负担,不需要再在 Dockerfile 中设置多阶段构建(没有 CI 服务器或 GitHub Action,前两种技术栈都是推送代码到线上环境构建 Docker image)。
考虑到一切从简,我还去掉了 RabbitMQ,转而使用一张数据表存放任务,提供一个 API 让爬虫同步数据。因为未来可能精简掉 Redis,所以这里没有用 Redis 做 MQ。
备选项
还有一些有兴趣但放弃的方案,可能以后有空了会试试:
- C# & .Net。久闻 C# 写起来让人幸福感满满,.Net 也是很好的企业级框架。但是我对 OOP 没有兴趣,也担心微软是否在 .Net 开源工作中再次做出危险行为(Hot Reload removed from dotnet watch - Why?)。
- Elixir & Phoenix。Elixir 的特性似乎非常适合高并发场景,开发体验也非常不错。但我目前没有学习函数式编程的精力。
彩蛋
你是不是在找 Rust?哈哈,我永远不会学它来写 Web 的。前端
前端使用我最爱的 SvelteKit,编译为 SSG 和 SPA 混合页面。UI 组件为 shadcn-svelte。
React 很好,但我平等地讨厌这个生态中的大部分东西,特别是 Next.js。我不明白为什么社区越来越“丰富”,却让开发者越来越痛苦。Svelte 暂时是我的止痛药,推荐你也试试。
基础设施
拒绝 vendor lock-in,只使用通用的 infra 技术:
- 后端服务使用 Docker Compose 编排,由一个简易的 Shell 脚本编译并部署到 VPS。
- 主要后端服务用的是 Hetzner 的 Arm VPS,目前是两台 2 vCPU + 4G RAM 的 Debian。(性价比超高,欢迎用我的 aff 注册,你能获得 €20 额度)。
- 爬虫服务在另一家廉价 VPS。
- 前端、CDN、DNS 等在 Cloudflare。
- 监控服务用的是自建的 Uptime Kuma,以及一小部分服务接入了 New Relic。
未来计划自建一套 Prometheus + Grafana 可视化观测系统,统计搜索量、新增收录量等指标。