Agent X-Ray
RuntimeNotesAbout
Notes/代码工程/Encore/第6章

第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 });   // ← 跨服务调用 = 函数调用
    // ...
  },
);

这一行调用背后发生的事:

  1. 编译期:参数/返回值类型对齐被检查,site.get 改签名,所有调用方立刻编译失败;
  2. 运行时:走框架内部 RPC(同进程内直调,多进程/分布式部署时透明切换传输方式);
  3. 可观测:这次调用自动成为当前分布式追踪的一个 span;
  4. 鉴权:调用方的鉴权数据自动透传(第 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.devnode:*,才能同时被前端构建引用。

五、应用与请求元数据

跨服务场景常需要知道"我在哪、谁在调我",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 里改一个接口签名,全部调用方编译失败,联调前置到编译期

七、常见误区

  1. 把"服务"当代码整理工具——只是分类代码用子目录;服务边界应对应领域/扩缩容/部署边界。
  2. 跨服务用相对路径 import 实现函数——绕过框架,丢追踪丢鉴权;一律 ~encore/clients
  3. 共享库目录里放了 encore.service.ts——它就变成了服务,产生一堆不存在的"端点"。
  4. 共享库里做 I/O 副作用、声明原语——让"纯逻辑"和"资源访问"的边界溶解。
  5. 服务间频繁同步互调形成调用链雪崩——高频协作的两块逻辑要么合并,要么改事件驱动(第 7 章)。
  6. 在服务初始化代码里调 currentRequest()——此时没有请求上下文,返回 undefined。

八、实践练习

  1. 把第 5 章练习的单服务应用拆成 flight(航班数据)与 order(订单)两个服务,订单创建时通过 ~encore/clientsflight.get 校验航班存在。
  2. 打开本地仪表盘的 Encore Flow 面板,确认两个服务与依赖连线自动出现。
  3. 建一个 pricing/ 共享目录(非服务)放运价计算纯函数,让两个服务共用;确认 Flow 图上它作为服务出现。
  4. 故意改掉 flight.get 的参数名,观察 order 服务的编译错误——体会"联调前置到编译期"。
  5. appMeta().environment.type 让订单创建在本地环境跳过风控调用,云上执行。

九、总结

  1. 拆服务看三个信号:扩缩容、部署节奏、领域边界;共享表与紧耦合是合并信号;纯代码整理用子目录。
  2. 目录三模式:单服务起步 → 多服务 → systems 分组;服务不可嵌套。
  3. ~encore/clients 是唯一正确的跨服务调用方式:编译期类型对齐 + 自动追踪 + 鉴权透传。
  4. 共享代码放非服务目录,保持纯逻辑;前后端共用口径函数不碰 encore/node API。
  5. appMeta() / currentRequest() 提供环境与请求上下文,支撑按环境分流与重试感知。
  6. Encore = 单 repo 单应用模型的"单体优先微服务",接口对齐由编译器承担。

请继续阅读:第7章:Pub/Sub


原始资料引用



本章目录
一、先想清楚:要不要拆服务二、三种目录组织模式三、服务间调用:encore/clients四、共享代码:非服务目录五、应用与请求元数据六、микро还是宏:Encore 的架构哲学七、常见误区八、实践练习九、总结原始资料引用Related Documents
苏ICP备2025204887号-2