服务架构与请求流转(youteacher_job)
youteacher_job 拥有 YouTeacher 网络对外暴露的每一条岗位记录——无论它来自雇主直接发布,还是来自 job_scrapers 项目维护的外部爬虫。下游各个界面(Web 前端、通知、搜索)都把这个服务当作唯一真相来源。它是一个基于 Fastify 的服务,按经典的 DDD 分层组织;而它最值得一看的地方,在于一次请求穿过这些层时,各层被隔离得有多干净。
四层,依赖朝内指
src/ 目录分四层,规则和前端一样:依赖只朝内指。
interfaces/—— Fastify 控制器。它们讲 HTTP,负责解析与校验输入、执行限流、把结果翻译回响应。它们完全不知道数据怎么存。application/—— 用例(这里叫 handler)、它们依赖的仓储接口、DTO,以及应用层服务(缓存、搜索、有效性、变更通知)。handler 只负责编排;它依赖的是像JobRepository这样的接口,绝不是具体的 Prisma 类。domain/—— 实体、值对象、业务规则,不 import 任何框架。infrastructure/—— 针对真实世界实现上述接口的适配器:Prisma 仓储、Redis 适配器、队列 provider、搜索客户端,以及调兄弟服务的 HTTP 客户端。
lint 禁止相对父级 import(跨目录一律走 @/... 别名),这样一次重构就不会悄悄把某个依赖指错方向。
一个镜像,两个进程
服务以单一容器镜像交付,运行成两种形态之一:
- Fastify HTTP API,即处理请求的进程;以及
- Bull ingest worker,一个后台进程,订阅共享的 job-updates 队列,把爬虫 payload 规范化后写入。
两者共用同一份代码与配置。worker 无需手写循环就满足了"while(true) 循环"的期待:它用 Bull 的 Queue.process 注册一个 handler,只要进程活着,Bull 就持续拉取任务,重试与退避(backoff)配置在队列上。
Bootstrap:先造零件,再接线
启动被刻意拆成几段,每一段都是一个小而纯的函数,好让测试里跑的接线和生产里完全一致。
bootstrapDatabase先确保表存在,再构造具体的 Prisma 仓储(岗位、举报、收藏)并返回它们。createHandlers拿着这些仓储,加上应用层服务(搜索、缓存、变更通知、有效性服务),构造出每一个用例 handler,只把各自需要的协作者注入进去——比如CreateDirectJobHandler拿到岗位仓储和变更通知器,而GetJobDetailHandler拿到仓储、缓存和有效性服务。createApp构造 Fastify 实例,注册 CORS 与 service-auth 插件,装配好控制器依赖(各 handler、地理编码、一个 auth 助手、限流器,以及默认限流配置),然后把这一切交给控制器注册。
这里没有 DI 容器。这几个 bootstrap 函数就是组合根:它们是唯一同时知道接口与具体实现的地方,因此下面各层对 Prisma、Redis、Fastify 一无所知。
路由是配置,不是字面量
一个有辨识度的选择:没有任何路由路径是硬编码的。config/routes.ts 通过一个 requireEnv 助手从环境变量里读取每一条路径,变量缺失就抛错——于是一个配错的部署会在启动时大声失败,而不是去服务一个错误的 URL。同一个模块还声明了哪些路由是公开的(无需用户认证),并通过把 id 代入配置好的模板来派生带参数的路径(比如岗位详情 URL)。每一条生产路径都带版本号,让服务可以迭代而不破坏前端。
Fastify 实例上的一个 URL 重写钩子允许调用方省略 API base 前缀:内部、健康检查、以及已带前缀的 URL 原样放行,其余的自动补上前缀。
一次请求,端到端
控制器层本身也按关注点拆开。单个 registerJobsHttpController 组合了四个子控制器——岗位、搜索(同时也承载举报端点)、收藏、管理——每个只拿到自己需要的 handler 和限流分片。一次请求随后这样流转:
- 路由 → 控制器。 Fastify 匹配到配置好的路径,调用控制器。
- 控制器把关。 它对该端点施加限流(在校验之前,按合适的范围——发帖按认证身份,匿名端点按客户端 IP),校验 payload,并在需要认证时解析出调用者。
- 控制器 → handler。 它带着一个干净、已校验的命令或查询,调用对应的应用层 handler。
- handler → 仓储 / 服务。 handler 对着仓储接口和应用层服务跑业务逻辑,从不直接碰 Prisma 或 Redis。
- 响应。 handler 返回一个规范化的
JobDTO(或相应结果),由控制器序列化。控制器只抛出一小组带类型的错误,每种都映射成结构化的{ message, code }响应体,限流拒绝还会附带重试提示。
读和写之所以能与缓存、搜索索引保持同步,是因为这些更新发生在 handler 和服务内部、就地完成,而不是走第二条队列:一次写入落进 Postgres 后,同一条代码路径会同步刷新 Redis 详情缓存和搜索文档。
关于服务间认证的一点说明
在架构层面,内部服务之间的调用靠请求上携带的一枚签名服务令牌来认证,由一个在应用启动时注册的 Fastify 插件校验;对需要用户身份的端点,一个 auth 助手会解析出调用者。这明确是一个过渡方案,直到一个专门的 auth 服务来签发受限范围的令牌。具体的签名方式、密钥处理和令牌内容属于部署机密,在此有意略去。
相关
- youteacher_job —— 岗位服务总览
- ddd-hexagonal-architecture —— 同一套分层纪律在 Web 前端上的体现