服务之间的调用
这个示例构建两个服务,其中一个调用另一个。inventory(库存服务)保存产品的库存,并通过服务动作提供给其他服务;orders(订单服务)有一个用来下单的 HTTP API,它通过代理在库存服务中为订单预留库存。两个服务都从 service 模板开始。示例展示了服务动作及其模式、HTTP 服务传输方式和调用它的代理、两个服务共用的令牌、库存服务拒绝订单时错误如何传到客户端,以及每个服务各自的测试。
| 服务 | 调用 | 作用 |
|---|---|---|
inventory | {"resource": "stock", "act": "get", "product": "lamp"} | 查询一种产品的库存数量 |
inventory | {"resource": "stock", "act": "reserve", "order": "<id>", "items": [{"product": "lamp", "quantity": 2}]} | 为订单的各项预留库存,要么全部预留,要么一项都不预留 |
orders | POST /orders | 下单:{"items": [{"product": "lamp", "quantity": 2}]} |
1. 创建项目
npx @lakutata/cli create --name inventory --template service --yes
npx @lakutata/cli create --name orders --template service --yes
rm inventory/packages/app/src/controllers/ExampleController.ts inventory/packages/app/src/dtos/GreetDTO.ts
rm orders/packages/app/src/controllers/ExampleController.ts orders/packages/app/src/dtos/GreetDTO.ts两个项目并排放在一起,每个都是一个工作区,服务在 packages/app 中(见 ProjectStructure);下面的路径都以所属项目的名字开头。两个项目不共享代码:动作的模式和消息的结构就是它们之间的约定。这里不需要安装任何包:HTTP 服务传输方式和它的代理都是 lakutata 自带的。
2. 库存
inventory/packages/app/src/exceptions/StockExceptions.ts
import {Exception} from 'lakutata'
/**
* 调用中指定的产品不存在
*/
export class ProductNotFoundException extends Exception {
public errno: string = 'E_PRODUCT_NOT_FOUND'
public readonly statusCode: number = 404
}
/**
* 产品的库存少于要求的数量
*/
export class OutOfStockException extends Exception {
public errno: string = 'E_OUT_OF_STOCK'
public readonly statusCode: number = 409
}每个异常都有自己的 errno 和状态码(见 Exceptions)。异常的消息在抛出时给出。
inventory/packages/app/src/components/Stock.ts
import {Component, DTO} from 'lakutata'
import {Configurable} from 'lakutata/decorator/di'
import {OutOfStockException, ProductNotFoundException} from '../exceptions/StockExceptions.js'
export type StockItem = { product: string, quantity: number }
export type StockLevel = { product: string, available: number }
/**
* 产品的库存(保存在内存中),以及已经预留了库存的订单
*/
export class Stock extends Component {
/**
* 启动时每种产品的数量
*/
@Configurable(DTO.Object().pattern(DTO.String(), DTO.Number().integer().min(0)).default({}))
protected readonly products: Record<string, number>
/**
* 每种产品的可用数量
*/
protected readonly levels: Map<string, number> = new Map()
/**
* 已经预留的订单,以及预留后剩下的库存
*/
protected readonly reserved: Map<string, StockLevel[]> = new Map()
protected async init(): Promise<void> {
Object.entries(this.products).forEach(([product, quantity]: [string, number]): unknown => this.levels.set(product, quantity))
}
/**
* 一种产品的可用数量
* @param product
*/
public level(product: string): StockLevel {
const available: number | undefined = this.levels.get(product)
if (available === undefined) throw new ProductNotFoundException('The product {product} does not exist', {product: product})
return {product: product, available: available}
}
/**
* 为订单的各项预留库存:要么全部预留,要么一项都不预留
* @param order
* @param items
*/
public reserve(order: string, items: StockItem[]): StockLevel[] {
//已经预留过的订单(重发的调用)不会再预留一次
const done: StockLevel[] | undefined = this.reserved.get(order)
if (done) return done
for (const item of items) {
const available: number = this.level(item.product).available
if (available < item.quantity) throw new OutOfStockException('{product}: {quantity} asked, {available} in stock', {...item, available: available})
}
const levels: StockLevel[] = items.map((item: StockItem): StockLevel => {
this.levels.set(item.product, this.level(item.product).available - item.quantity)
return this.level(item.product)
})
this.reserved.set(order, levels)
return levels
}
}- 库存是一个组件(见 Objects):整个服务只有一个实例,而控制器只存在于一个请求之内。库存保存在内存中,初始数量来自它的
products选项(一个@Configurable属性),在init()中复制过来。 reserve()在修改库存之前先检查每一项:订单要么整个预留成功,要么什么都不预留。- 订单的 id 保证同一个订单只预留一次:同一个调用再发一次(比如在超时之后),得到的结果相同,也不会多预留库存。
OutOfStockException的消息由随它传入的值生成:desk: 5 asked, 2 in stock。
3. 服务动作
inventory/packages/app/src/dtos/StockDTO.ts
import {DTO} from 'lakutata'
import {Expect} from 'lakutata/decorator/dto'
/**
* 一种产品
*/
export class ProductDTO extends DTO {
@Expect(DTO.String().required())
public product: string
}
/**
* 一种产品的数量
*/
export class StockItemDTO extends ProductDTO {
@Expect(DTO.Number().integer().min(1).required())
public quantity: number
}
/**
* 订单的各项,每种产品只出现一次
*/
export class ReserveDTO extends DTO {
@Expect(DTO.String().required())
public order: string
@Expect(DTO.Array(StockItemDTO.Schema()).min(1).unique('product').required())
public items: StockItemDTO[]
}DTO 校验动作输入的方式和 HTTP 中一样(见 DTOValidation)。消息是 JSON,其中的值保留各自的类型,所以不需要 .strict(false)。.unique('product') 会拒绝同一种产品出现两次的订单。
inventory/packages/app/src/controllers/StockController.ts
import {Controller} from 'lakutata/com/entrypoint'
import {Prefix, ServiceAction} from 'lakutata/decorator/ctrl'
import {Inject} from 'lakutata/decorator/di'
import {type Stock, type StockLevel} from '../components/Stock.js'
import {ProductDTO, ReserveDTO} from '../dtos/StockDTO.js'
@Prefix({pattern: {resource: 'stock'}})
export class StockController extends Controller {
/**
* 库存(选项中的 "stock" 组件)
*/
@Inject('stock')
protected readonly stock: Stock
/**
* {"resource": "stock", "act": "get", "product": "lamp"}
* @param input
*/
@ServiceAction({act: 'get'}, ProductDTO)
public async get(input: ProductDTO): Promise<StockLevel> {
return this.stock.level(input.product)
}
/**
* {"resource": "stock", "act": "reserve", "order": "…", "items": [{"product": "lamp", "quantity": 2}]}
* @param input
*/
@ServiceAction({act: 'reserve'}, ReserveDTO)
public async reserve(input: ReserveDTO): Promise<StockLevel[]> {
return this.stock.reserve(input.order, input.items)
}
}@ServiceAction(pattern, DTO)为动作指定模式(见 ServiceCalls)。消息包含模式中的全部键,并且各键的值都相同时,就与这个模式匹配;消息中其余的键就是动作的输入。@Prefix({pattern: {resource: 'stock'}})把resource: 'stock'合并到控制器所有动作的模式中(见 Controllers):动作get处理消息{"resource": "stock", "act": "get", "product": "lamp"},它的输入是{"product": "lamp"}。- 动作返回的值就是响应的
payload,抛出的异常就是响应的error。
4. 调用方的令牌
inventory/packages/app/src/middlewares/Authenticate.ts
import {Exception} from 'lakutata'
import {type ActionInvocation, httpRequestOfContext, type Next, type ServiceContext} from 'lakutata/com/entrypoint'
export class UnauthorizedException extends Exception {
public errno: string = 'E_UNAUTHORIZED'
public readonly statusCode: number = 401
}
/**
* 环境中有 API_TOKEN 时,服务调用必须带上 "Authorization: Bearer <API_TOKEN>":调用库存服务的服务会拿到这个令牌
* @param invocation
* @param next
*/
export async function authenticate(invocation: ActionInvocation, next: Next): Promise<unknown> {
const token: string | undefined = process.env.API_TOKEN
if (!token) return next()
//承载这次调用的 HTTP 请求
const authorization: string | undefined = httpRequestOfContext(invocation.context as ServiceContext)?.headers.authorization
if (authorization !== `Bearer ${token}`) throw new UnauthorizedException('The caller is not authenticated')
return next()
}模板中的中间件检查的是 HTTP 请求,这里改为检查服务调用。httpRequestOfContext() 给出承载调用的 HTTP 请求及其请求头。环境中有 API_TOKEN 时,不带 Authorization: Bearer <API_TOKEN> 的调用在校验输入之前就会被拒绝,错误为 E_UNAUTHORIZED(见 Middlewares)。调用库存服务的服务会拿到这个令牌。如果要让每个调用方只能调用一部分动作,可以给每个调用方各自的令牌,再加一条检查动作分组的访问规则(见 Middlewares 第 4 节)。
5. 库存服务的选项
inventory/packages/app/src/config/Config.ts
import type {ApplicationOptions} from 'lakutata'
import {buildEntrypoints, httpServiceEntrypoint} from 'lakutata/com/entrypoint'
import {expressHTTPEntrypoint, jsonEnvelope} from 'lakutata/adapter/express'
import {authenticate} from '../middlewares/Authenticate.js'
import {HealthController} from '../controllers/HealthController.js'
import {StockController} from '../controllers/StockController.js'
import {Stock} from '../components/Stock.js'
/**
* 服务的选项
* @param port HTTP 服务器(健康检查)的端口,默认取环境变量 PORT,没有时为 8080
* @param servicePort 服务调用的端口,默认取环境变量 SERVICE_PORT,没有时为 3000
*/
export function config(port: number = Number(process.env.PORT ?? 8080), servicePort: number = Number(process.env.SERVICE_PORT ?? 3000)): ApplicationOptions {
return {
id: 'inventory',
name: 'inventory',
mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
components: {
//启动时的库存,保存在内存中
stock: {class: Stock, products: {lamp: 10, desk: 3}},
entrypoint: buildEntrypoints({
controllers: [HealthController, StockController],
//GET /health
http: expressHTTPEntrypoint({port: port, respond: jsonEnvelope()}),
//POST /,请求体是 JSON 格式的消息,只接受持有令牌的服务
service: httpServiceEntrypoint({port: servicePort, middlewares: [authenticate]})
})
},
bootstrap: ['entrypoint']
}
}入口有两个适配器(见 Entrypoint):
http:一个 Express 服务器,监听PORT,留着它是为了模板中的健康检查(GET /health,Dockerfile中的HEALTHCHECK会用到它);service:httpServiceEntrypoint(),即 HTTP 服务传输方式,监听SERVICE_PORT(默认 3000)。它有自己的服务器。一次调用就是以 JSON 格式POST一条消息,响应的状态码总是 200,内容是{success, error, payload}。它的middlewares只对服务调用执行,所以健康检查不需要令牌。
stock 组件启动时有 10 盏台灯和 3 张书桌。
6. 库存服务的客户端
orders/packages/app/src/components/Inventory.ts
import {Component, Exception} from 'lakutata'
import {type HTTPServiceProxy, ServiceInvokeException} from 'lakutata/com/entrypoint'
import {Inject} from 'lakutata/decorator/di'
export type OrderItem = { product: string, quantity: number }
/**
* 库存服务中表示拒绝订单的错误:它们会原样传给订单服务的客户端
*/
const REFUSALS: unknown[] = ['E_OUT_OF_STOCK', 'E_PRODUCT_NOT_FOUND']
/**
* 库存服务没有应答,或者出了故障
*/
export class InventoryUnavailableException extends Exception {
public errno: string = 'E_INVENTORY_UNAVAILABLE'
public readonly statusCode: number = 503
}
/**
* 订单服务眼中的库存服务:对它的调用,以及调用出错对订单意味着什么
*/
export class Inventory extends Component {
/**
* 库存服务的服务动作的代理(选项中的 "inventoryService" 组件)
*/
@Inject('inventoryService')
protected readonly service: HTTPServiceProxy
/**
* 为订单预留库存;同一个订单再预留一次不会改变任何东西
* @param order
* @param items
*/
public async reserve(order: string, items: OrderItem[]): Promise<void> {
try {
//最多等待 5 秒
await this.service.invoke({resource: 'stock', act: 'reserve', order: order, items: items}, 5000)
} catch (error) {
//拒绝订单的错误原样抛出,带着库存服务错误的全部属性(errno、errMsg、statusCode 等)
if (error instanceof ServiceInvokeException && REFUSALS.includes(error.errno)) throw error
//其他错误(没有响应、令牌被拒绝、库存服务看不懂消息)都是订单服务自己的故障,不是客户端的问题
throw new InventoryUnavailableException('The inventory fails: {0}', (error as Error).message)
}
}
}订单服务通过 HTTPServiceProxy(见 ServiceCalls 第 4 节)调用库存服务,它在选项中注册为 inventoryService 组件(第 8 节)。代理负责发送消息、读取响应。Inventory 是订单服务在代理之上的一个组件,它决定库存服务的各种错误对订单意味着什么。
invoke()接收消息和等待响应的最长时间,这里是 5 秒。代理默认等待 10 分钟,订单不该等这么久,所以这里给调用设定了自己的时限。- 订单被拒绝时,错误会一路传回客户端:代理抛出一个
ServiceInvokeException,它带有库存服务错误的各项属性,包括errno、errMsg和statusCode。Inventory把它原样抛出,订单服务的 HTTP 适配器就用这个状态码和这个errno响应,所以客户端收到的就是库存服务的 409E_OUT_OF_STOCK。 - 库存服务的其他错误不是客户端的问题。令牌被拒绝、库存服务看不懂收到的消息,或者根本没有响应(代理抛出的
ServiceUnavailableException),都属于订单服务自己的故障:订单服务以 503E_INVENTORY_UNAVAILABLE响应。 Inventory通过errno识别拒绝,因为异常的类不会随调用传回来(见 Exceptions 第 5 节)。reserve()给调用起了名字、加上了类型,控制器就不用自己拼消息了。
7. 订单
orders/packages/app/src/dtos/OrderDTO.ts
import {DTO} from 'lakutata'
import {Expect} from 'lakutata/decorator/dto'
/**
* 一种产品的数量
*/
export class OrderItemDTO extends DTO {
@Expect(DTO.String().required())
public product: string
@Expect(DTO.Number().integer().min(1).required())
public quantity: number
}
/**
* 新订单:它的各项,每种产品只出现一次
*/
export class PlaceOrderDTO extends DTO {
@Expect(DTO.Array(OrderItemDTO.Schema()).min(1).unique('product').required())
public items: OrderItemDTO[]
}订单各项的结构和库存服务要求的一样。订单服务在调用之前先校验它们,不合法的订单不会到达库存服务。
orders/packages/app/src/controllers/OrderController.ts
import {randomUUID} from 'node:crypto'
import {Controller} from 'lakutata/com/entrypoint'
import {POST, Prefix} from 'lakutata/decorator/ctrl'
import {Inject} from 'lakutata/decorator/di'
import {type Inventory, type OrderItem} from '../components/Inventory.js'
import {PlaceOrderDTO} from '../dtos/OrderDTO.js'
export type Order = { id: string, items: OrderItem[] }
@Prefix({route: '/orders'})
export class OrderController extends Controller {
/**
* 库存服务(选项中的 "inventory" 组件)
*/
@Inject('inventory')
protected readonly inventory: Inventory
/**
* POST /orders {"items": [{"product": "lamp", "quantity": 2}]}
* @param input
*/
@POST('/', PlaceOrderDTO)
public async place(input: PlaceOrderDTO): Promise<Order> {
const order: Order = {id: randomUUID(), items: input.items}
//库存服务的拒绝(E_OUT_OF_STOCK 等)就是这个请求的错误
await this.inventory.reserve(order.id, order.items)
return order
}
}- 控制器注入
inventory组件(@Inject('inventory')),像调用其他组件一样调用它。 - 订单在调用之前就有了 id:即使这个调用再发一次,库存服务也只会为订单预留一次。
- 调用的错误就是请求的错误,控制器不需要
try。
8. 订单服务的选项
orders/packages/app/src/config/Config.ts
import type {ApplicationOptions} from 'lakutata'
import {buildEntrypoints, buildHTTPServiceProxy} from 'lakutata/com/entrypoint'
import {expressHTTPEntrypoint, jsonEnvelope} from 'lakutata/adapter/express'
import compression from 'compression'
import {authenticate} from '../middlewares/Authenticate.js'
import {HealthController} from '../controllers/HealthController.js'
import {OrderController} from '../controllers/OrderController.js'
import {Inventory} from '../components/Inventory.js'
/**
* 服务的选项
* @param port HTTP 服务器的端口,默认取环境变量 PORT,没有时为 8080
* @param inventory 库存服务接收服务调用的地址,默认取环境变量 INVENTORY_URL,没有时为 http://localhost:3000
*/
export function config(port: number = Number(process.env.PORT ?? 8080), inventory: string = process.env.INVENTORY_URL ?? 'http://localhost:3000'): ApplicationOptions {
return {
id: 'orders',
name: 'orders',
mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
components: {
//库存服务的服务动作的代理,以及调用时使用的令牌
inventoryService: buildHTTPServiceProxy({url: inventory, headers: {Authorization: `Bearer ${process.env.INVENTORY_TOKEN ?? ''}`}}),
inventory: {class: Inventory},
entrypoint: buildEntrypoints({
controllers: [HealthController, OrderController],
http: expressHTTPEntrypoint({
port: port,
//结果和错误都是 {errno, errMsg, data},错误保留各自的状态码(400、409 等)
respond: jsonEnvelope(),
body: {limit: '1mb'},
server: {keepAliveTimeout: 60000, headersTimeout: 65000},
middlewares: [authenticate],
before: (app): void => void app.use(compression())
})
})
},
//代理在启动时创建:INVENTORY_URL 无效时,服务无法启动
bootstrap: ['inventoryService', 'entrypoint']
}
}这是模板中的 Config.ts,改动如下:
- 新增
inventoryService组件,也就是代理,带有库存服务的地址(INVENTORY_URL,默认为http://localhost:3000),调用时在Authorization请求头中带上令牌(INVENTORY_TOKEN,即库存服务的API_TOKEN); - 新增
inventory组件,由控制器注入; - 用
OrderController替换示例控制器; jsonEnvelope()不再设置errorStatus:错误保留各自的状态码(400、409、503);- 请求体最多 1 MB(模板允许 100 MB),对订单来说足够了;
bootstrap: ['inventoryService', 'entrypoint']:代理在启动时创建,所以INVENTORY_URL无效时,服务会立即停止,并给出The option "url" of "inventoryService" (HTTPServiceProxy) is invalid。
模板中的中间件 Authenticate.ts 保留不变:订单服务设置了自己的 API_TOKEN 时,它的客户端必须带上这个令牌。
9. 运行
启动库存服务,把它的 HTTP 服务器放到 8081,让订单服务继续使用 8080:
cd inventory
PORT=8081 API_TOKEN=inventory-secret npm run dev然后在另一个终端中启动订单服务,带上库存服务的令牌:
cd orders
INVENTORY_TOKEN=inventory-secret npm run dev在第三个终端中:
curl -X POST localhost:8080/orders -H 'Content-Type: application/json' -d '{"items": [{"product": "desk", "quantity": 2}, {"product": "lamp", "quantity": 1}]}'
# {"errno":null,"errMsg":null,"data":{"id":"91de56de-9ca0-45d8-8197-cd2b2526dde5","items":[{"product":"desk","quantity":2},{"product":"lamp","quantity":1}]}}
curl -i -X POST localhost:8080/orders -H 'Content-Type: application/json' -d '{"items": [{"product": "desk", "quantity": 2}]}'
# HTTP/1.1 409 Conflict
# {"errno":"E_OUT_OF_STOCK","errMsg":"desk: 2 asked, 1 in stock","data":null}
curl -i -X POST localhost:8080/orders -H 'Content-Type: application/json' -d '{"items": [{"product": "chair", "quantity": 1}]}'
# HTTP/1.1 404 Not Found
# {"errno":"E_PRODUCT_NOT_FOUND","errMsg":"The product chair does not exist","data":null}库存服务响应带有令牌的调用,拒绝其他调用:
curl localhost:3000 -H 'Authorization: Bearer inventory-secret' -H 'Content-Type: application/json' -d '{"resource": "stock", "act": "get", "product": "desk"}'
# {"success":true,"error":null,"payload":{"product":"desk","available":1}}
curl localhost:3000 -H 'Content-Type: application/json' -d '{"resource": "stock", "act": "get", "product": "desk"}'
# {"success":false,"error":{…,"statusCode":401,"message":"The caller is not authenticated","errMsg":"The caller is not authenticated","err":"UnauthorizedException","errno":"E_UNAUTHORIZED"},"payload":null}错误带有异常的全部属性,这里省略了其中的 stack、appId 和 appName。停止库存服务(Ctrl+C),再下一个订单:
curl -i -X POST localhost:8080/orders -H 'Content-Type: application/json' -d '{"items": [{"product": "lamp", "quantity": 1}]}'
# HTTP/1.1 503 Service Unavailable
# {"errno":"E_INVENTORY_UNAVAILABLE","errMsg":"The inventory fails: The service http://localhost:3000 cannot be reached: connect ECONNREFUSED ::1:3000, connect ECONNREFUSED 127.0.0.1:3000","data":null}10. 测试
每个服务都有自己的测试,它们替换了模板中的测试。库存服务的测试启动服务,发送它的动作的消息,并读取响应的封套:
inventory/packages/app/src/tests/App.spec.ts
import {after, before, describe, it} from 'node:test'
import assert from 'node:assert'
import {Application} from 'lakutata'
import {config} from '../config/Config.js'
const PORT: number = Number(process.env.TEST_PORT ?? 18085)
const SERVICE_PORT: number = PORT + 1
type Envelope = { success: boolean, error: { errno: string, errMsg: string } | null, payload?: any }
/**
* 调用一个服务动作,返回响应的封套
* @param message
* @param token
*/
async function call(message: Record<string, unknown>, token: string = 'test-token'): Promise<Envelope> {
const response: Response = await fetch(`http://127.0.0.1:${SERVICE_PORT}`, {
method: 'POST',
headers: {'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json'},
body: JSON.stringify(message)
})
return await response.json() as Envelope
}
describe('inventory', (): void => {
let app: Application
before(async (): Promise<void> => {
process.env.API_TOKEN = 'test-token'
app = await new Promise<Application>((resolve: (app: Application) => void): void => {
Application.run(config(PORT, SERVICE_PORT)).onLaunched((launched: Application): void => resolve(launched))
})
})
after(async (): Promise<void> => {
await app?.dispose()
})
it('answers the health check without authentication', async (): Promise<void> => {
assert.deepStrictEqual(await (await fetch(`http://127.0.0.1:${PORT}/health`)).json(), {errno: null, errMsg: null, data: {status: 'ok'}})
})
it('refuses the calls without the token', async (): Promise<void> => {
const refused: Envelope = await call({resource: 'stock', act: 'get', product: 'lamp'}, 'wrong-token')
assert.strictEqual(refused.success, false)
assert.strictEqual(refused.error?.errno, 'E_UNAUTHORIZED')
})
it('gives the stock of a product', async (): Promise<void> => {
assert.deepStrictEqual((await call({resource: 'stock', act: 'get', product: 'lamp'})).payload, {product: 'lamp', available: 10})
assert.strictEqual((await call({resource: 'stock', act: 'get', product: 'chair'})).error?.errno, 'E_PRODUCT_NOT_FOUND')
})
it('reserves all the items of an order, once', async (): Promise<void> => {
const message: Record<string, unknown> = {resource: 'stock', act: 'reserve', order: 'order-1', items: [{product: 'lamp', quantity: 2}, {product: 'desk', quantity: 1}]}
const levels: unknown = [{product: 'lamp', available: 8}, {product: 'desk', available: 2}]
assert.deepStrictEqual((await call(message)).payload, levels)
//重发这个调用,不会再预留任何库存
assert.deepStrictEqual((await call(message)).payload, levels)
assert.deepStrictEqual((await call({resource: 'stock', act: 'get', product: 'lamp'})).payload, {product: 'lamp', available: 8})
})
it('reserves nothing when an item is short', async (): Promise<void> => {
const refused: Envelope = await call({resource: 'stock', act: 'reserve', order: 'order-2', items: [{product: 'lamp', quantity: 1}, {product: 'desk', quantity: 5}]})
assert.strictEqual(refused.error?.errno, 'E_OUT_OF_STOCK')
assert.strictEqual(refused.error?.errMsg, 'desk: 5 asked, 2 in stock')
assert.deepStrictEqual((await call({resource: 'stock', act: 'get', product: 'lamp'})).payload, {product: 'lamp', available: 8})
})
it('refuses an invalid message', async (): Promise<void> => {
assert.strictEqual((await call({resource: 'stock', act: 'reserve', order: 'order-3', items: []})).error?.errno, 'E_INVALID_VALUE')
assert.strictEqual((await call({resource: 'stock', act: 'count'})).error?.errno, 'E_CONTROLLER_ACTION_NOT_FOUND')
})
})订单服务的测试不需要库存服务,而是在同一个进程中启动一个库存服务的替身:一个运行在自己容器中的入口(见 Objects 第 5 节),它的动作和库存服务的动作模式相同。一个进程只运行一个应用,这里是订单服务,所以替身不是应用。替身像库存服务一样检查令牌,最后两个测试先更换它的令牌,再停止它:
orders/packages/app/src/tests/App.spec.ts
import {after, before, describe, it} from 'node:test'
import assert from 'node:assert'
import {Application, Container, Exception} from 'lakutata'
import {buildEntrypoints, Controller, Entrypoint, httpRequestOfContext, httpServiceEntrypoint, type ServiceContext} from 'lakutata/com/entrypoint'
import {Prefix, ServiceAction} from 'lakutata/decorator/ctrl'
import {config} from '../config/Config.js'
const PORT: number = Number(process.env.TEST_PORT ?? 18085)
const INVENTORY_PORT: number = PORT + 1
class UnauthorizedException extends Exception {
public errno: string = 'E_UNAUTHORIZED'
public readonly statusCode: number = 401
}
class OutOfStockException extends Exception {
public errno: string = 'E_OUT_OF_STOCK'
public readonly statusCode: number = 409
}
/**
* 库存服务的替身:模式相同的动作、它要求的令牌、库存中的 3 盏台灯,以及它预留过的订单
*/
@Prefix({pattern: {resource: 'stock'}})
class InventoryStandIn extends Controller {
public static token: string = 'inventory-token'
public static lamps: number = 3
public static readonly orders: string[] = []
@ServiceAction({act: 'reserve'})
public async reserve(input: { order: string, items: { product: string, quantity: number }[] }): Promise<unknown> {
const authorization: string | undefined = httpRequestOfContext(this.context as ServiceContext)?.headers.authorization
if (authorization !== `Bearer ${InventoryStandIn.token}`) throw new UnauthorizedException('The caller is not authenticated')
const quantity: number = input.items[0].quantity
if (quantity > InventoryStandIn.lamps) throw new OutOfStockException('lamp: {0} asked, {1} in stock', quantity, InventoryStandIn.lamps)
InventoryStandIn.lamps -= quantity
InventoryStandIn.orders.push(input.order)
return [{product: 'lamp', available: InventoryStandIn.lamps}]
}
}
/**
* 下一个订单,返回响应的状态码和 {errno, errMsg, data}
* @param body
*/
async function placeOrder(body: unknown): Promise<{ status: number, errno: string | null, errMsg: string | null, data: any }> {
const response: Response = await fetch(`http://127.0.0.1:${PORT}/orders`, {
method: 'POST',
headers: {'Authorization': 'Bearer test-token', 'Content-Type': 'application/json'},
body: JSON.stringify(body)
})
return {status: response.status, ...await response.json() as { errno: string | null, errMsg: string | null, data: any }}
}
describe('orders', (): void => {
let app: Application
//替身运行在自己的容器中,和测试在同一个进程里
const inventory: Container = new Container()
before(async (): Promise<void> => {
await inventory.set(Entrypoint, buildEntrypoints({
controllers: [InventoryStandIn],
service: httpServiceEntrypoint({port: INVENTORY_PORT, host: '127.0.0.1'})
}))
process.env.API_TOKEN = 'test-token'
process.env.INVENTORY_TOKEN = 'inventory-token'
app = await new Promise<Application>((resolve: (app: Application) => void): void => {
Application.run(config(PORT, `http://127.0.0.1:${INVENTORY_PORT}`)).onLaunched((launched: Application): void => resolve(launched))
})
})
after(async (): Promise<void> => {
await app?.dispose()
await inventory.destroy()
})
it('places an order, its stock reserved by the inventory', async (): Promise<void> => {
const placed = await placeOrder({items: [{product: 'lamp', quantity: 2}]})
assert.strictEqual(placed.status, 200)
assert.deepStrictEqual(placed.data.items, [{product: 'lamp', quantity: 2}])
assert.deepStrictEqual(InventoryStandIn.orders, [placed.data.id])
assert.strictEqual(InventoryStandIn.lamps, 1)
})
it('answers the refusal of the inventory', async (): Promise<void> => {
const refused = await placeOrder({items: [{product: 'lamp', quantity: 5}]})
assert.strictEqual(refused.status, 409)
assert.strictEqual(refused.errno, 'E_OUT_OF_STOCK')
assert.strictEqual(refused.errMsg, 'lamp: 5 asked, 1 in stock')
})
it('refuses an invalid order without calling the inventory', async (): Promise<void> => {
const invalid = await placeOrder({items: []})
assert.strictEqual(invalid.status, 400)
assert.strictEqual(invalid.errno, 'E_INVALID_VALUE')
assert.strictEqual(InventoryStandIn.orders.length, 1)
})
it('answers 503 when the inventory refuses its token', async (): Promise<void> => {
InventoryStandIn.token = 'another-token'
const failed = await placeOrder({items: [{product: 'lamp', quantity: 1}]})
assert.strictEqual(failed.status, 503)
assert.strictEqual(failed.errno, 'E_INVENTORY_UNAVAILABLE')
assert.strictEqual(failed.errMsg, 'The inventory fails: The caller is not authenticated')
})
it('answers 503 when the inventory does not answer', async (): Promise<void> => {
await inventory.destroy()
const unavailable = await placeOrder({items: [{product: 'lamp', quantity: 1}]})
assert.strictEqual(unavailable.status, 503)
assert.strictEqual(unavailable.errno, 'E_INVENTORY_UNAVAILABLE')
})
})cd inventory
npm testapp │ ✔ answers the health check without authentication
app │ ✔ refuses the calls without the token
app │ ✔ gives the stock of a product
app │ ✔ reserves all the items of an order, once
app │ ✔ reserves nothing when an item is short
app │ ✔ refuses an invalid message
app │ ℹ pass 6
✔ test ran in appcd ../orders
npm testapp │ ✔ places an order, its stock reserved by the inventory
app │ ✔ answers the refusal of the inventory
app │ ✔ refuses an invalid order without calling the inventory
app │ ✔ answers 503 when the inventory refuses its token
app │ ✔ answers 503 when the inventory does not answer
app │ ℹ pass 5
✔ test ran in app11. 在生产环境中
- 在每个项目中运行
npm run docker:build,用模板中的Dockerfile构建镜像。在镜像中,库存服务在PORT(8080)上提供健康检查,在SERVICE_PORT(3000)上接收调用。在容器网络中,订单服务通过INVENTORY_URL=http://inventory:3000访问库存服务;对外只公开订单服务的端口。 - 两个服务的令牌应来自同一份机密配置:库存服务的
API_TOKEN,以及订单服务的INVENTORY_TOKEN。只有一方换了新令牌时,订单服务会以 503 响应。 - 库存保存在内存中,订单则根本没有保存:请把它们存到数据库中(见 带数据库的 REST API)。那时把订单的 id 作为预留记录的唯一键,就能保证一个订单不会被预留两次。
- 其他传输方式也能提供同样的动作:Socket.IO、JSON-RPC(见 ServiceCalls)、NATS(见 NATS)或 gRPC(见 GRPC),每种都是
service选项中的一个适配器。NATS 和 gRPC 的代理有同样的invoke(),也抛出同样的ServiceInvokeException:只需换用它们的构建函数注册inventoryService,并改掉Inventory.ts中代理的类型,其余不变。