第6章:服务架构 —— 微服务拆分与服务间调用
约 5 分钟 · 更新于 2026-09-02
"Microservices without boilerplate" 是 Encore 最大的卖点之一:跨服务调用就是函数调用,类型检查、追踪、鉴权透传全部自动。本章讲服务拆分决策、目录组织模式与 ~encore/clients 机制,并给出共享代码的正确姿势。
一、先想清楚:要不要拆服务
Encore 里加一个服务成本极低(一个目录 + 两行声明),但低成本不等于该多拆。官方决策表:
| 信号 | 动作 |
|---|
| 两块逻辑扩缩容需求不同(如鉴权 vs 报表分析) | 拆 |
| 部署节奏不同 | 拆 |
| 领域边界清晰(订单/支付/库存) | 拆 |
| 两块逻辑共享数据库表 | 合 |
| 逻辑紧耦合、频繁互调 | 合 |
| 只是想整理代码 | 用子目录,不要用服务 |
起步策略
新项目从单服务开始,出现上表"拆"信号再动手。Encore 的拆分成本确实低(挪目录 + 加 encore.service.ts),不必为"以后可能要拆"提前付复杂度。
二、三种目录组织模式
单服务(推荐起点)
text
my-app/
├── encore.app
├── package.json
├── encore.service.ts
├── api.ts
├── db.ts
└── migrations/
└── 001_initial.up.sql
多服务(清晰领域边界)
text
my-app/
├── encore.app
├── package.json
├── user/
│ ├── encore.service.ts
│ ├── api.ts
│ └── db.ts
├── order/
│ ├── encore.service.ts
│ ├── api.ts
│ └── db.ts
└── notification/
├── encore.service.ts
└── api.ts
大型应用:系统 (Systems) 分组
服务多了以后用上层目录分组——分组目录本身不是服务(没有 encore.service.ts):
text
my-app/
├── commerce/
│ ├── order/ ← 服务
│ ├── cart/ ← 服务
│ └── payment/ ← 服务
├── identity/
│ ├── user/ ← 服务
│ └── auth/ ← 服务
└── comms/
├── email/ ← 服务
└── push/ ← 服务
规则回顾:服务不能嵌套——commerce/order 里不能再放子服务。
三、服务间调用:~encore/clients
其他服务的 API 通过编译器生成的 ~encore/clients 模块调用:
typescript
import { api } from "encore.dev/api";
import { site } from "~encore/clients"; // site 是服务名
export const check = api(
{ expose: true, method: "POST", path: "/check/:siteID" },
async (p: { siteID: number }): Promise<{ up: boolean }> => {
const s = await site.get({ id: p.siteID }); // ← 跨服务调用 = 函数调用
// ...
},
);
这一行调用背后发生的事:
- 编译期:参数/返回值类型对齐被检查,site.get 改签名,所有调用方立刻编译失败;
- 运行时:走框架内部 RPC(同进程内直调,多进程/分布式部署时透明切换传输方式);
- 可观测:这次调用自动成为当前分布式追踪的一个 span;
- 鉴权:调用方的鉴权数据自动透传(第 10 章)。
对比传统微服务:没有服务发现配置、没有手写 HTTP 客户端、没有接口文档漂移——接口契约就是 TypeScript 函数签名。
禁止直接 import 其他服务的实现
import { get } from "../site/site" 也能编译(普通模块导入),但那是绕过框架的进程内直调:没有追踪 span、没有鉴权语义、没有服务边界。跨服务调用一律走 ~encore/clients。类型(interface)可以直接 import——类型是编译期产物,不破坏边界。
四、共享代码:非服务目录
多个服务共用的纯逻辑(工具函数、上游网关签名、DTO 转换)放在不含 encore.service.ts 的普通目录里,各服务直接 import:
text
my-app/
├── distribution/ ← 共享库:非服务,无 encore.service.ts
│ ├── sign.ts (上游 HMAC 签名)
│ ├── types.ts (上游 DTO)
│ └── transform.ts (数据拍平)
├── search/ ← 服务,import ../distribution/*
└── order/ ← 服务,import ../distribution/*
这是本机 B2B 项目的真实结构:distribution/、usercenter/、yeepay/、alipay/ 全是共享库,只有 search/、order/ 是服务。经验法则:
- 共享库保持纯逻辑:不声明原语、不做 I/O 副作用(需要 I/O 的封装成明确的 client 函数);
- 前后端共用的口径函数(金额格式化、状态映射)更要保持纯净——不 import encore.dev 和 node:*,才能同时被前端构建引用。
五、应用与请求元数据
跨服务场景常需要知道"我在哪、谁在调我",encore.dev 提供两个函数:
appMeta():应用与环境信息
typescript
import { appMeta } from "encore.dev";
const meta = appMeta();
// meta.appId 应用名
// meta.apiBaseUrl 对外 API 基地址
// meta.environment 当前环境 { type: "local"|"test"|"development"|"production", cloud: "local"|"aws"|"gcp" }
// meta.build 版本控制修订
// meta.deploy 部署 id 与时间
典型用法——按环境分流行为:
typescript
switch (appMeta().environment.type) {
case "test":
case "development":
await markEmailVerified(userID); // 测试环境跳过真实发信
break;
default:
await sendVerificationEmail(userID);
}
currentRequest():当前请求上下文
typescript
import { currentRequest } from "encore.dev";
const req = currentRequest();
// API 调用时: { type: "api-call", api, method, path, pathParams, headers, parsedPayload }
// Pub/Sub 时: { type: "pubsub-message", service, topic, subscription, messageId, deliveryAttempt, parsedPayload }
// 服务初始化期间调用返回 undefined
deliveryAttempt 在 Pub/Sub 重试逻辑里特别有用(第 7 章)。
六、микро还是宏:Encore 的架构哲学
Encore 官方立场是"单体优先的微服务"(monolith-first microservices):
- 整个后端是一个 Encore 应用(一个 monorepo、一份应用模型)——不是每服务一仓库;
- 服务是应用内的逻辑边界,部署时既可以整体单进程跑(本地/小规模),也可以按服务独立扩缩(云上);
- 这与"每个微服务一个 repo + 一套 CI + 一套契约管理"的传统路线相比,把分布式系统的组织成本压缩到接近单体。
对照现实:跨 repo 微服务的接口对齐要靠 wiki、口头同步和集成联调;Encore 里改一个接口签名,全部调用方编译失败,联调前置到编译期。
七、常见误区
- 把"服务"当代码整理工具——只是分类代码用子目录;服务边界应对应领域/扩缩容/部署边界。
- 跨服务用相对路径 import 实现函数——绕过框架,丢追踪丢鉴权;一律 ~encore/clients。
- 共享库目录里放了 encore.service.ts——它就变成了服务,产生一堆不存在的"端点"。
- 共享库里做 I/O 副作用、声明原语——让"纯逻辑"和"资源访问"的边界溶解。
- 服务间频繁同步互调形成调用链雪崩——高频协作的两块逻辑要么合并,要么改事件驱动(第 7 章)。
- 在服务初始化代码里调 currentRequest()——此时没有请求上下文,返回 undefined。
八、实践练习
- 把第 5 章练习的单服务应用拆成 flight(航班数据)与 order(订单)两个服务,订单创建时通过 ~encore/clients 调 flight.get 校验航班存在。
- 打开本地仪表盘的 Encore Flow 面板,确认两个服务与依赖连线自动出现。
- 建一个 pricing/ 共享目录(非服务)放运价计算纯函数,让两个服务共用;确认 Flow 图上它不作为服务出现。
- 故意改掉 flight.get 的参数名,观察 order 服务的编译错误——体会"联调前置到编译期"。
- 用 appMeta().environment.type 让订单创建在本地环境跳过风控调用,云上执行。
九、总结
- 拆服务看三个信号:扩缩容、部署节奏、领域边界;共享表与紧耦合是合并信号;纯代码整理用子目录。
- 目录三模式:单服务起步 → 多服务 → systems 分组;服务不可嵌套。
- ~encore/clients 是唯一正确的跨服务调用方式:编译期类型对齐 + 自动追踪 + 鉴权透传。
- 共享代码放非服务目录,保持纯逻辑;前后端共用口径函数不碰 encore/node API。
- appMeta() / currentRequest() 提供环境与请求上下文,支撑按环境分流与重试感知。
- Encore = 单 repo 单应用模型的"单体优先微服务",接口对齐由编译器承担。
请继续阅读:第7章:Pub/Sub。
原始资料引用