lakutata 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -30
- package/README.zh-CN.md +130 -0
- package/dist/cjs/exports/QueueComponent.d.ts +1 -0
- package/dist/cjs/exports/QueueComponent.js +20 -0
- package/dist/esm/exports/QueueComponent.js +1 -0
- package/dist/types/exports/QueueComponent.d.ts +1 -0
- package/doc/en/Application.md +107 -0
- package/doc/en/CLI.md +274 -0
- package/doc/en/Cache.md +379 -0
- package/doc/en/Command.md +110 -0
- package/doc/en/ContainerdIntegration.md +17 -16
- package/doc/en/Controllers.md +256 -0
- package/doc/en/DBus.md +291 -0
- package/doc/en/DTOValidation.md +72 -43
- package/doc/en/Database.md +188 -0
- package/doc/en/DatabaseDrivers.md +287 -0
- package/doc/en/Decorator.md +52 -40
- package/doc/en/DockerIntegration.md +7 -6
- package/doc/en/Entities.md +240 -0
- package/doc/en/Entrypoint.md +216 -0
- package/doc/en/Exceptions.md +174 -0
- package/doc/en/GRPC.md +251 -0
- package/doc/en/GettingStarted.md +165 -0
- package/doc/en/HTTP.md +507 -0
- package/doc/en/HelperFunctions.md +11 -11
- package/doc/en/I18n.md +119 -115
- package/doc/en/Injection.md +144 -0
- package/doc/en/Installers.md +280 -0
- package/doc/en/Logger.md +206 -0
- package/doc/en/MCP.md +293 -0
- package/doc/en/Middlewares.md +213 -0
- package/doc/en/Migrations.md +202 -0
- package/doc/en/MongoEmbedded.md +149 -0
- package/doc/en/Monitor.md +164 -0
- package/doc/en/Mutex.md +27 -25
- package/doc/en/NATS.md +246 -0
- package/doc/en/NativeAddons.md +212 -0
- package/doc/en/Objects.md +143 -0
- package/doc/en/Overview.md +62 -85
- package/doc/en/Packaging.md +193 -0
- package/doc/en/ProjectStructure.md +124 -0
- package/doc/en/Queue.md +269 -0
- package/doc/en/Schedules.md +217 -0
- package/doc/en/Security.md +10 -9
- package/doc/en/ServiceCalls.md +504 -0
- package/doc/en/Time.md +19 -20
- package/doc/en/UpgradingFrom1.md +435 -0
- package/doc/en/UpgradingFrom2.md +315 -0
- package/doc/en/Workspaces.md +189 -0
- package/doc/en/examples/BackgroundJobs.md +677 -0
- package/doc/en/examples/CommandLineTool.md +664 -0
- package/doc/en/examples/DesktopApp.md +809 -0
- package/doc/en/examples/DeviceService.md +607 -0
- package/doc/en/examples/MCPServer.md +653 -0
- package/doc/en/examples/README.md +16 -0
- package/doc/en/examples/RestAPI.md +490 -0
- package/doc/en/examples/ServiceCalls.md +829 -0
- package/doc/en/examples/WebApp.md +568 -0
- package/doc/zh/Application.md +75 -0
- package/doc/zh/CLI.md +250 -0
- package/doc/zh/Cache.md +328 -0
- package/doc/zh/Command.md +78 -0
- package/doc/zh/ContainerdIntegration.md +9 -9
- package/doc/zh/Controllers.md +228 -0
- package/doc/zh/DBus.md +257 -0
- package/doc/zh/DTOValidation.md +49 -19
- package/doc/zh/Database.md +175 -0
- package/doc/zh/DatabaseDrivers.md +261 -0
- package/doc/zh/Decorator.md +25 -18
- package/doc/zh/DockerIntegration.md +6 -6
- package/doc/zh/Entities.md +224 -0
- package/doc/zh/Entrypoint.md +193 -0
- package/doc/zh/Exceptions.md +158 -0
- package/doc/zh/GRPC.md +226 -0
- package/doc/zh/GettingStarted.md +148 -0
- package/doc/zh/HTTP.md +445 -0
- package/doc/zh/HelperFunctions.md +2 -2
- package/doc/zh/I18n.md +26 -26
- package/doc/zh/Injection.md +129 -0
- package/doc/zh/Installers.md +232 -0
- package/doc/zh/Logger.md +188 -0
- package/doc/zh/MCP.md +265 -0
- package/doc/zh/Middlewares.md +198 -0
- package/doc/zh/Migrations.md +178 -0
- package/doc/zh/MongoEmbedded.md +118 -0
- package/doc/zh/Monitor.md +145 -0
- package/doc/zh/Mutex.md +10 -10
- package/doc/zh/NATS.md +207 -0
- package/doc/zh/NativeAddons.md +180 -0
- package/doc/zh/Objects.md +119 -0
- package/doc/zh/Overview.md +46 -71
- package/doc/zh/Packaging.md +154 -0
- package/doc/zh/ProjectStructure.md +102 -0
- package/doc/zh/Queue.md +214 -0
- package/doc/zh/Schedules.md +193 -0
- package/doc/zh/Security.md +6 -6
- package/doc/zh/ServiceCalls.md +459 -0
- package/doc/zh/Time.md +2 -2
- package/doc/zh/UpgradingFrom1.md +347 -0
- package/doc/zh/UpgradingFrom2.md +230 -0
- package/doc/zh/Workspaces.md +150 -0
- package/doc/zh/examples/BackgroundJobs.md +618 -0
- package/doc/zh/examples/CommandLineTool.md +618 -0
- package/doc/zh/examples/DesktopApp.md +748 -0
- package/doc/zh/examples/DeviceService.md +546 -0
- package/doc/zh/examples/MCPServer.md +604 -0
- package/doc/zh/examples/README.md +14 -0
- package/doc/zh/examples/RestAPI.md +458 -0
- package/doc/zh/examples/ServiceCalls.md +765 -0
- package/doc/zh/examples/WebApp.md +520 -0
- package/package.json +46 -17
- package/doc/en/ActionPattern.md +0 -408
- package/doc/en/Core.md +0 -312
- package/doc/en/ORMIntegration.md +0 -341
- package/doc/zh/ActionPattern.md +0 -334
- package/doc/zh/Core.md +0 -256
- package/doc/zh/ORMIntegration.md +0 -319
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
# 服务之间的调用
|
|
2
|
+
|
|
3
|
+
这个示例构建两个服务,其中一个调用另一个。`inventory`(库存服务)保存产品的库存,并通过服务动作提供给其他服务;`orders`(订单服务)有一个用来下单的 HTTP API,它通过代理在库存服务中为订单预留库存。两个服务都从 `service` 模板开始。示例展示了服务动作及其模式、HTTP 服务传输方式和调用它的代理、两个服务共用的令牌、库存服务拒绝订单时错误如何传到客户端,以及每个服务各自的测试。
|
|
4
|
+
|
|
5
|
+
| 服务 | 调用 | 作用 |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| `inventory` | `{"resource": "stock", "act": "get", "product": "lamp"}` | 查询一种产品的库存数量 |
|
|
8
|
+
| `inventory` | `{"resource": "stock", "act": "reserve", "order": "<id>", "items": [{"product": "lamp", "quantity": 2}]}` | 为订单的各项预留库存,要么全部预留,要么一项都不预留 |
|
|
9
|
+
| `orders` | `POST /orders` | 下单:`{"items": [{"product": "lamp", "quantity": 2}]}` |
|
|
10
|
+
|
|
11
|
+
## 1. 创建项目
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx @lakutata/cli create --name inventory --template service --yes
|
|
15
|
+
npx @lakutata/cli create --name orders --template service --yes
|
|
16
|
+
rm inventory/packages/app/src/controllers/ExampleController.ts inventory/packages/app/src/dtos/GreetDTO.ts
|
|
17
|
+
rm orders/packages/app/src/controllers/ExampleController.ts orders/packages/app/src/dtos/GreetDTO.ts
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
两个项目并排放在一起,每个都是一个工作区,服务在 `packages/app` 中(见 [ProjectStructure](../ProjectStructure.md));下面的路径都以所属项目的名字开头。两个项目不共享代码:动作的模式和消息的结构就是它们之间的约定。这里不需要安装任何包:HTTP 服务传输方式和它的代理都是 lakutata 自带的。
|
|
21
|
+
|
|
22
|
+
## 2. 库存
|
|
23
|
+
|
|
24
|
+
**`inventory/packages/app/src/exceptions/StockExceptions.ts`**
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
import {Exception} from 'lakutata'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 调用中指定的产品不存在
|
|
31
|
+
*/
|
|
32
|
+
export class ProductNotFoundException extends Exception {
|
|
33
|
+
public errno: string = 'E_PRODUCT_NOT_FOUND'
|
|
34
|
+
|
|
35
|
+
public readonly statusCode: number = 404
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 产品的库存少于要求的数量
|
|
40
|
+
*/
|
|
41
|
+
export class OutOfStockException extends Exception {
|
|
42
|
+
public errno: string = 'E_OUT_OF_STOCK'
|
|
43
|
+
|
|
44
|
+
public readonly statusCode: number = 409
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
每个异常都有自己的 `errno` 和状态码(见 [Exceptions](../Exceptions.md))。异常的消息在抛出时给出。
|
|
49
|
+
|
|
50
|
+
**`inventory/packages/app/src/components/Stock.ts`**
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
import {Component, DTO} from 'lakutata'
|
|
54
|
+
import {Configurable} from 'lakutata/decorator/di'
|
|
55
|
+
import {OutOfStockException, ProductNotFoundException} from '../exceptions/StockExceptions.js'
|
|
56
|
+
|
|
57
|
+
export type StockItem = { product: string, quantity: number }
|
|
58
|
+
|
|
59
|
+
export type StockLevel = { product: string, available: number }
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 产品的库存(保存在内存中),以及已经预留了库存的订单
|
|
63
|
+
*/
|
|
64
|
+
export class Stock extends Component {
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 启动时每种产品的数量
|
|
68
|
+
*/
|
|
69
|
+
@Configurable(DTO.Object().pattern(DTO.String(), DTO.Number().integer().min(0)).default({}))
|
|
70
|
+
protected readonly products: Record<string, number>
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 每种产品的可用数量
|
|
74
|
+
*/
|
|
75
|
+
protected readonly levels: Map<string, number> = new Map()
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 已经预留的订单,以及预留后剩下的库存
|
|
79
|
+
*/
|
|
80
|
+
protected readonly reserved: Map<string, StockLevel[]> = new Map()
|
|
81
|
+
|
|
82
|
+
protected async init(): Promise<void> {
|
|
83
|
+
Object.entries(this.products).forEach(([product, quantity]: [string, number]): unknown => this.levels.set(product, quantity))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 一种产品的可用数量
|
|
88
|
+
* @param product
|
|
89
|
+
*/
|
|
90
|
+
public level(product: string): StockLevel {
|
|
91
|
+
const available: number | undefined = this.levels.get(product)
|
|
92
|
+
if (available === undefined) throw new ProductNotFoundException('The product {product} does not exist', {product: product})
|
|
93
|
+
return {product: product, available: available}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 为订单的各项预留库存:要么全部预留,要么一项都不预留
|
|
98
|
+
* @param order
|
|
99
|
+
* @param items
|
|
100
|
+
*/
|
|
101
|
+
public reserve(order: string, items: StockItem[]): StockLevel[] {
|
|
102
|
+
//已经预留过的订单(重发的调用)不会再预留一次
|
|
103
|
+
const done: StockLevel[] | undefined = this.reserved.get(order)
|
|
104
|
+
if (done) return done
|
|
105
|
+
for (const item of items) {
|
|
106
|
+
const available: number = this.level(item.product).available
|
|
107
|
+
if (available < item.quantity) throw new OutOfStockException('{product}: {quantity} asked, {available} in stock', {...item, available: available})
|
|
108
|
+
}
|
|
109
|
+
const levels: StockLevel[] = items.map((item: StockItem): StockLevel => {
|
|
110
|
+
this.levels.set(item.product, this.level(item.product).available - item.quantity)
|
|
111
|
+
return this.level(item.product)
|
|
112
|
+
})
|
|
113
|
+
this.reserved.set(order, levels)
|
|
114
|
+
return levels
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
- 库存是一个组件(见 [Objects](../Objects.md)):整个服务只有一个实例,而控制器只存在于一个请求之内。库存保存在内存中,初始数量来自它的 `products` 选项(一个 `@Configurable` 属性),在 `init()` 中复制过来。
|
|
120
|
+
- `reserve()` 在修改库存之前先检查每一项:订单要么整个预留成功,要么什么都不预留。
|
|
121
|
+
- 订单的 id 保证同一个订单只预留一次:同一个调用再发一次(比如在超时之后),得到的结果相同,也不会多预留库存。
|
|
122
|
+
- `OutOfStockException` 的消息由随它传入的值生成:`desk: 5 asked, 2 in stock`。
|
|
123
|
+
|
|
124
|
+
## 3. 服务动作
|
|
125
|
+
|
|
126
|
+
**`inventory/packages/app/src/dtos/StockDTO.ts`**
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
import {DTO} from 'lakutata'
|
|
130
|
+
import {Expect} from 'lakutata/decorator/dto'
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 一种产品
|
|
134
|
+
*/
|
|
135
|
+
export class ProductDTO extends DTO {
|
|
136
|
+
@Expect(DTO.String().required())
|
|
137
|
+
public product: string
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 一种产品的数量
|
|
142
|
+
*/
|
|
143
|
+
export class StockItemDTO extends ProductDTO {
|
|
144
|
+
@Expect(DTO.Number().integer().min(1).required())
|
|
145
|
+
public quantity: number
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 订单的各项,每种产品只出现一次
|
|
150
|
+
*/
|
|
151
|
+
export class ReserveDTO extends DTO {
|
|
152
|
+
@Expect(DTO.String().required())
|
|
153
|
+
public order: string
|
|
154
|
+
|
|
155
|
+
@Expect(DTO.Array(StockItemDTO.Schema()).min(1).unique('product').required())
|
|
156
|
+
public items: StockItemDTO[]
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
DTO 校验动作输入的方式和 HTTP 中一样(见 [DTOValidation](../DTOValidation.md))。消息是 JSON,其中的值保留各自的类型,所以不需要 `.strict(false)`。`.unique('product')` 会拒绝同一种产品出现两次的订单。
|
|
161
|
+
|
|
162
|
+
**`inventory/packages/app/src/controllers/StockController.ts`**
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
import {Controller} from 'lakutata/com/entrypoint'
|
|
166
|
+
import {Prefix, ServiceAction} from 'lakutata/decorator/ctrl'
|
|
167
|
+
import {Inject} from 'lakutata/decorator/di'
|
|
168
|
+
import {type Stock, type StockLevel} from '../components/Stock.js'
|
|
169
|
+
import {ProductDTO, ReserveDTO} from '../dtos/StockDTO.js'
|
|
170
|
+
|
|
171
|
+
@Prefix({pattern: {resource: 'stock'}})
|
|
172
|
+
export class StockController extends Controller {
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 库存(选项中的 "stock" 组件)
|
|
176
|
+
*/
|
|
177
|
+
@Inject('stock')
|
|
178
|
+
protected readonly stock: Stock
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* {"resource": "stock", "act": "get", "product": "lamp"}
|
|
182
|
+
* @param input
|
|
183
|
+
*/
|
|
184
|
+
@ServiceAction({act: 'get'}, ProductDTO)
|
|
185
|
+
public async get(input: ProductDTO): Promise<StockLevel> {
|
|
186
|
+
return this.stock.level(input.product)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* {"resource": "stock", "act": "reserve", "order": "…", "items": [{"product": "lamp", "quantity": 2}]}
|
|
191
|
+
* @param input
|
|
192
|
+
*/
|
|
193
|
+
@ServiceAction({act: 'reserve'}, ReserveDTO)
|
|
194
|
+
public async reserve(input: ReserveDTO): Promise<StockLevel[]> {
|
|
195
|
+
return this.stock.reserve(input.order, input.items)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
- `@ServiceAction(pattern, DTO)` 为动作指定模式(见 [ServiceCalls](../ServiceCalls.md))。消息包含模式中的全部键,并且各键的值都相同时,就与这个模式匹配;消息中其余的键就是动作的输入。
|
|
201
|
+
- `@Prefix({pattern: {resource: 'stock'}})` 把 `resource: 'stock'` 合并到控制器所有动作的模式中(见 [Controllers](../Controllers.md)):动作 `get` 处理消息 `{"resource": "stock", "act": "get", "product": "lamp"}`,它的输入是 `{"product": "lamp"}`。
|
|
202
|
+
- 动作返回的值就是响应的 `payload`,抛出的异常就是响应的 `error`。
|
|
203
|
+
|
|
204
|
+
## 4. 调用方的令牌
|
|
205
|
+
|
|
206
|
+
**`inventory/packages/app/src/middlewares/Authenticate.ts`**
|
|
207
|
+
|
|
208
|
+
```typescript
|
|
209
|
+
import {Exception} from 'lakutata'
|
|
210
|
+
import {type ActionInvocation, httpRequestOfContext, type Next, type ServiceContext} from 'lakutata/com/entrypoint'
|
|
211
|
+
|
|
212
|
+
export class UnauthorizedException extends Exception {
|
|
213
|
+
public errno: string = 'E_UNAUTHORIZED'
|
|
214
|
+
|
|
215
|
+
public readonly statusCode: number = 401
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* 环境中有 API_TOKEN 时,服务调用必须带上 "Authorization: Bearer <API_TOKEN>":调用库存服务的服务会拿到这个令牌
|
|
220
|
+
* @param invocation
|
|
221
|
+
* @param next
|
|
222
|
+
*/
|
|
223
|
+
export async function authenticate(invocation: ActionInvocation, next: Next): Promise<unknown> {
|
|
224
|
+
const token: string | undefined = process.env.API_TOKEN
|
|
225
|
+
if (!token) return next()
|
|
226
|
+
//承载这次调用的 HTTP 请求
|
|
227
|
+
const authorization: string | undefined = httpRequestOfContext(invocation.context as ServiceContext)?.headers.authorization
|
|
228
|
+
if (authorization !== `Bearer ${token}`) throw new UnauthorizedException('The caller is not authenticated')
|
|
229
|
+
return next()
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
模板中的中间件检查的是 HTTP 请求,这里改为检查服务调用。`httpRequestOfContext()` 给出承载调用的 HTTP 请求及其请求头。环境中有 `API_TOKEN` 时,不带 `Authorization: Bearer <API_TOKEN>` 的调用在校验输入之前就会被拒绝,错误为 `E_UNAUTHORIZED`(见 [Middlewares](../Middlewares.md))。调用库存服务的服务会拿到这个令牌。如果要让每个调用方只能调用一部分动作,可以给每个调用方各自的令牌,再加一条检查动作分组的访问规则(见 [Middlewares](../Middlewares.md) 第 4 节)。
|
|
234
|
+
|
|
235
|
+
## 5. 库存服务的选项
|
|
236
|
+
|
|
237
|
+
**`inventory/packages/app/src/config/Config.ts`**
|
|
238
|
+
|
|
239
|
+
```typescript
|
|
240
|
+
import type {ApplicationOptions} from 'lakutata'
|
|
241
|
+
import {buildEntrypoints, httpServiceEntrypoint} from 'lakutata/com/entrypoint'
|
|
242
|
+
import {expressHTTPEntrypoint, jsonEnvelope} from 'lakutata/adapter/express'
|
|
243
|
+
import {authenticate} from '../middlewares/Authenticate.js'
|
|
244
|
+
import {HealthController} from '../controllers/HealthController.js'
|
|
245
|
+
import {StockController} from '../controllers/StockController.js'
|
|
246
|
+
import {Stock} from '../components/Stock.js'
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* 服务的选项
|
|
250
|
+
* @param port HTTP 服务器(健康检查)的端口,默认取环境变量 PORT,没有时为 8080
|
|
251
|
+
* @param servicePort 服务调用的端口,默认取环境变量 SERVICE_PORT,没有时为 3000
|
|
252
|
+
*/
|
|
253
|
+
export function config(port: number = Number(process.env.PORT ?? 8080), servicePort: number = Number(process.env.SERVICE_PORT ?? 3000)): ApplicationOptions {
|
|
254
|
+
return {
|
|
255
|
+
id: 'inventory',
|
|
256
|
+
name: 'inventory',
|
|
257
|
+
mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
|
|
258
|
+
components: {
|
|
259
|
+
//启动时的库存,保存在内存中
|
|
260
|
+
stock: {class: Stock, products: {lamp: 10, desk: 3}},
|
|
261
|
+
entrypoint: buildEntrypoints({
|
|
262
|
+
controllers: [HealthController, StockController],
|
|
263
|
+
//GET /health
|
|
264
|
+
http: expressHTTPEntrypoint({port: port, respond: jsonEnvelope()}),
|
|
265
|
+
//POST /,请求体是 JSON 格式的消息,只接受持有令牌的服务
|
|
266
|
+
service: httpServiceEntrypoint({port: servicePort, middlewares: [authenticate]})
|
|
267
|
+
})
|
|
268
|
+
},
|
|
269
|
+
bootstrap: ['entrypoint']
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
入口有两个适配器(见 [Entrypoint](../Entrypoint.md)):
|
|
275
|
+
|
|
276
|
+
- `http`:一个 Express 服务器,监听 `PORT`,留着它是为了模板中的健康检查(`GET /health`,`Dockerfile` 中的 `HEALTHCHECK` 会用到它);
|
|
277
|
+
- `service`:`httpServiceEntrypoint()`,即 HTTP 服务传输方式,监听 `SERVICE_PORT`(默认 3000)。它有自己的服务器。一次调用就是以 JSON 格式 `POST` 一条消息,响应的状态码总是 200,内容是 `{success, error, payload}`。它的 `middlewares` 只对服务调用执行,所以健康检查不需要令牌。
|
|
278
|
+
|
|
279
|
+
`stock` 组件启动时有 10 盏台灯和 3 张书桌。
|
|
280
|
+
|
|
281
|
+
## 6. 库存服务的客户端
|
|
282
|
+
|
|
283
|
+
**`orders/packages/app/src/components/Inventory.ts`**
|
|
284
|
+
|
|
285
|
+
```typescript
|
|
286
|
+
import {Component, Exception} from 'lakutata'
|
|
287
|
+
import {type HTTPServiceProxy, ServiceInvokeException} from 'lakutata/com/entrypoint'
|
|
288
|
+
import {Inject} from 'lakutata/decorator/di'
|
|
289
|
+
|
|
290
|
+
export type OrderItem = { product: string, quantity: number }
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* 库存服务中表示拒绝订单的错误:它们会原样传给订单服务的客户端
|
|
294
|
+
*/
|
|
295
|
+
const REFUSALS: unknown[] = ['E_OUT_OF_STOCK', 'E_PRODUCT_NOT_FOUND']
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* 库存服务没有应答,或者出了故障
|
|
299
|
+
*/
|
|
300
|
+
export class InventoryUnavailableException extends Exception {
|
|
301
|
+
public errno: string = 'E_INVENTORY_UNAVAILABLE'
|
|
302
|
+
|
|
303
|
+
public readonly statusCode: number = 503
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* 订单服务眼中的库存服务:对它的调用,以及调用出错对订单意味着什么
|
|
308
|
+
*/
|
|
309
|
+
export class Inventory extends Component {
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* 库存服务的服务动作的代理(选项中的 "inventoryService" 组件)
|
|
313
|
+
*/
|
|
314
|
+
@Inject('inventoryService')
|
|
315
|
+
protected readonly service: HTTPServiceProxy
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* 为订单预留库存;同一个订单再预留一次不会改变任何东西
|
|
319
|
+
* @param order
|
|
320
|
+
* @param items
|
|
321
|
+
*/
|
|
322
|
+
public async reserve(order: string, items: OrderItem[]): Promise<void> {
|
|
323
|
+
try {
|
|
324
|
+
//最多等待 5 秒
|
|
325
|
+
await this.service.invoke({resource: 'stock', act: 'reserve', order: order, items: items}, 5000)
|
|
326
|
+
} catch (error) {
|
|
327
|
+
//拒绝订单的错误原样抛出,带着库存服务错误的全部属性(errno、errMsg、statusCode 等)
|
|
328
|
+
if (error instanceof ServiceInvokeException && REFUSALS.includes(error.errno)) throw error
|
|
329
|
+
//其他错误(没有响应、令牌被拒绝、库存服务看不懂消息)都是订单服务自己的故障,不是客户端的问题
|
|
330
|
+
throw new InventoryUnavailableException('The inventory fails: {0}', (error as Error).message)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
订单服务通过 `HTTPServiceProxy`(见 [ServiceCalls](../ServiceCalls.md) 第 4 节)调用库存服务,它在选项中注册为 `inventoryService` 组件(第 8 节)。代理负责发送消息、读取响应。`Inventory` 是订单服务在代理之上的一个组件,它决定库存服务的各种错误对订单意味着什么。
|
|
337
|
+
|
|
338
|
+
- `invoke()` 接收消息和等待响应的最长时间,这里是 5 秒。代理默认等待 10 分钟,订单不该等这么久,所以这里给调用设定了自己的时限。
|
|
339
|
+
- 订单被拒绝时,错误会一路传回客户端:代理抛出一个 `ServiceInvokeException`,它带有库存服务错误的各项属性,包括 `errno`、`errMsg` 和 `statusCode`。`Inventory` 把它原样抛出,订单服务的 HTTP 适配器就用这个状态码和这个 `errno` 响应,所以客户端收到的就是库存服务的 409 `E_OUT_OF_STOCK`。
|
|
340
|
+
- 库存服务的其他错误不是客户端的问题。令牌被拒绝、库存服务看不懂收到的消息,或者根本没有响应(代理抛出的 `ServiceUnavailableException`),都属于订单服务自己的故障:订单服务以 503 `E_INVENTORY_UNAVAILABLE` 响应。
|
|
341
|
+
- `Inventory` 通过 `errno` 识别拒绝,因为异常的类不会随调用传回来(见 [Exceptions](../Exceptions.md) 第 5 节)。
|
|
342
|
+
- `reserve()` 给调用起了名字、加上了类型,控制器就不用自己拼消息了。
|
|
343
|
+
|
|
344
|
+
## 7. 订单
|
|
345
|
+
|
|
346
|
+
**`orders/packages/app/src/dtos/OrderDTO.ts`**
|
|
347
|
+
|
|
348
|
+
```typescript
|
|
349
|
+
import {DTO} from 'lakutata'
|
|
350
|
+
import {Expect} from 'lakutata/decorator/dto'
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* 一种产品的数量
|
|
354
|
+
*/
|
|
355
|
+
export class OrderItemDTO extends DTO {
|
|
356
|
+
@Expect(DTO.String().required())
|
|
357
|
+
public product: string
|
|
358
|
+
|
|
359
|
+
@Expect(DTO.Number().integer().min(1).required())
|
|
360
|
+
public quantity: number
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* 新订单:它的各项,每种产品只出现一次
|
|
365
|
+
*/
|
|
366
|
+
export class PlaceOrderDTO extends DTO {
|
|
367
|
+
@Expect(DTO.Array(OrderItemDTO.Schema()).min(1).unique('product').required())
|
|
368
|
+
public items: OrderItemDTO[]
|
|
369
|
+
}
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
订单各项的结构和库存服务要求的一样。订单服务在调用之前先校验它们,不合法的订单不会到达库存服务。
|
|
373
|
+
|
|
374
|
+
**`orders/packages/app/src/controllers/OrderController.ts`**
|
|
375
|
+
|
|
376
|
+
```typescript
|
|
377
|
+
import {randomUUID} from 'node:crypto'
|
|
378
|
+
import {Controller} from 'lakutata/com/entrypoint'
|
|
379
|
+
import {POST, Prefix} from 'lakutata/decorator/ctrl'
|
|
380
|
+
import {Inject} from 'lakutata/decorator/di'
|
|
381
|
+
import {type Inventory, type OrderItem} from '../components/Inventory.js'
|
|
382
|
+
import {PlaceOrderDTO} from '../dtos/OrderDTO.js'
|
|
383
|
+
|
|
384
|
+
export type Order = { id: string, items: OrderItem[] }
|
|
385
|
+
|
|
386
|
+
@Prefix({route: '/orders'})
|
|
387
|
+
export class OrderController extends Controller {
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* 库存服务(选项中的 "inventory" 组件)
|
|
391
|
+
*/
|
|
392
|
+
@Inject('inventory')
|
|
393
|
+
protected readonly inventory: Inventory
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* POST /orders {"items": [{"product": "lamp", "quantity": 2}]}
|
|
397
|
+
* @param input
|
|
398
|
+
*/
|
|
399
|
+
@POST('/', PlaceOrderDTO)
|
|
400
|
+
public async place(input: PlaceOrderDTO): Promise<Order> {
|
|
401
|
+
const order: Order = {id: randomUUID(), items: input.items}
|
|
402
|
+
//库存服务的拒绝(E_OUT_OF_STOCK 等)就是这个请求的错误
|
|
403
|
+
await this.inventory.reserve(order.id, order.items)
|
|
404
|
+
return order
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
- 控制器注入 `inventory` 组件(`@Inject('inventory')`),像调用其他组件一样调用它。
|
|
410
|
+
- 订单在调用之前就有了 id:即使这个调用再发一次,库存服务也只会为订单预留一次。
|
|
411
|
+
- 调用的错误就是请求的错误,控制器不需要 `try`。
|
|
412
|
+
|
|
413
|
+
## 8. 订单服务的选项
|
|
414
|
+
|
|
415
|
+
**`orders/packages/app/src/config/Config.ts`**
|
|
416
|
+
|
|
417
|
+
```typescript
|
|
418
|
+
import type {ApplicationOptions} from 'lakutata'
|
|
419
|
+
import {buildEntrypoints, buildHTTPServiceProxy} from 'lakutata/com/entrypoint'
|
|
420
|
+
import {expressHTTPEntrypoint, jsonEnvelope} from 'lakutata/adapter/express'
|
|
421
|
+
import compression from 'compression'
|
|
422
|
+
import {authenticate} from '../middlewares/Authenticate.js'
|
|
423
|
+
import {HealthController} from '../controllers/HealthController.js'
|
|
424
|
+
import {OrderController} from '../controllers/OrderController.js'
|
|
425
|
+
import {Inventory} from '../components/Inventory.js'
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* 服务的选项
|
|
429
|
+
* @param port HTTP 服务器的端口,默认取环境变量 PORT,没有时为 8080
|
|
430
|
+
* @param inventory 库存服务接收服务调用的地址,默认取环境变量 INVENTORY_URL,没有时为 http://localhost:3000
|
|
431
|
+
*/
|
|
432
|
+
export function config(port: number = Number(process.env.PORT ?? 8080), inventory: string = process.env.INVENTORY_URL ?? 'http://localhost:3000'): ApplicationOptions {
|
|
433
|
+
return {
|
|
434
|
+
id: 'orders',
|
|
435
|
+
name: 'orders',
|
|
436
|
+
mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
|
|
437
|
+
components: {
|
|
438
|
+
//库存服务的服务动作的代理,以及调用时使用的令牌
|
|
439
|
+
inventoryService: buildHTTPServiceProxy({url: inventory, headers: {Authorization: `Bearer ${process.env.INVENTORY_TOKEN ?? ''}`}}),
|
|
440
|
+
inventory: {class: Inventory},
|
|
441
|
+
entrypoint: buildEntrypoints({
|
|
442
|
+
controllers: [HealthController, OrderController],
|
|
443
|
+
http: expressHTTPEntrypoint({
|
|
444
|
+
port: port,
|
|
445
|
+
//结果和错误都是 {errno, errMsg, data},错误保留各自的状态码(400、409 等)
|
|
446
|
+
respond: jsonEnvelope(),
|
|
447
|
+
body: {limit: '1mb'},
|
|
448
|
+
server: {keepAliveTimeout: 60000, headersTimeout: 65000},
|
|
449
|
+
middlewares: [authenticate],
|
|
450
|
+
before: (app): void => void app.use(compression())
|
|
451
|
+
})
|
|
452
|
+
})
|
|
453
|
+
},
|
|
454
|
+
//代理在启动时创建:INVENTORY_URL 无效时,服务无法启动
|
|
455
|
+
bootstrap: ['inventoryService', 'entrypoint']
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
这是模板中的 `Config.ts`,改动如下:
|
|
461
|
+
|
|
462
|
+
- 新增 `inventoryService` 组件,也就是代理,带有库存服务的地址(`INVENTORY_URL`,默认为 `http://localhost:3000`),调用时在 `Authorization` 请求头中带上令牌(`INVENTORY_TOKEN`,即库存服务的 `API_TOKEN`);
|
|
463
|
+
- 新增 `inventory` 组件,由控制器注入;
|
|
464
|
+
- 用 `OrderController` 替换示例控制器;
|
|
465
|
+
- `jsonEnvelope()` 不再设置 `errorStatus`:错误保留各自的状态码(400、409、503);
|
|
466
|
+
- 请求体最多 1 MB(模板允许 100 MB),对订单来说足够了;
|
|
467
|
+
- `bootstrap: ['inventoryService', 'entrypoint']`:代理在启动时创建,所以 `INVENTORY_URL` 无效时,服务会立即停止,并给出 `The option "url" of "inventoryService" (HTTPServiceProxy) is invalid`。
|
|
468
|
+
|
|
469
|
+
模板中的中间件 `Authenticate.ts` 保留不变:订单服务设置了自己的 `API_TOKEN` 时,它的客户端必须带上这个令牌。
|
|
470
|
+
|
|
471
|
+
## 9. 运行
|
|
472
|
+
|
|
473
|
+
启动库存服务,把它的 HTTP 服务器放到 8081,让订单服务继续使用 8080:
|
|
474
|
+
|
|
475
|
+
```bash
|
|
476
|
+
cd inventory
|
|
477
|
+
PORT=8081 API_TOKEN=inventory-secret npm run dev
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
然后在另一个终端中启动订单服务,带上库存服务的令牌:
|
|
481
|
+
|
|
482
|
+
```bash
|
|
483
|
+
cd orders
|
|
484
|
+
INVENTORY_TOKEN=inventory-secret npm run dev
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
在第三个终端中:
|
|
488
|
+
|
|
489
|
+
```bash
|
|
490
|
+
curl -X POST localhost:8080/orders -H 'Content-Type: application/json' -d '{"items": [{"product": "desk", "quantity": 2}, {"product": "lamp", "quantity": 1}]}'
|
|
491
|
+
# {"errno":null,"errMsg":null,"data":{"id":"91de56de-9ca0-45d8-8197-cd2b2526dde5","items":[{"product":"desk","quantity":2},{"product":"lamp","quantity":1}]}}
|
|
492
|
+
|
|
493
|
+
curl -i -X POST localhost:8080/orders -H 'Content-Type: application/json' -d '{"items": [{"product": "desk", "quantity": 2}]}'
|
|
494
|
+
# HTTP/1.1 409 Conflict
|
|
495
|
+
# {"errno":"E_OUT_OF_STOCK","errMsg":"desk: 2 asked, 1 in stock","data":null}
|
|
496
|
+
|
|
497
|
+
curl -i -X POST localhost:8080/orders -H 'Content-Type: application/json' -d '{"items": [{"product": "chair", "quantity": 1}]}'
|
|
498
|
+
# HTTP/1.1 404 Not Found
|
|
499
|
+
# {"errno":"E_PRODUCT_NOT_FOUND","errMsg":"The product chair does not exist","data":null}
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
库存服务响应带有令牌的调用,拒绝其他调用:
|
|
503
|
+
|
|
504
|
+
```bash
|
|
505
|
+
curl localhost:3000 -H 'Authorization: Bearer inventory-secret' -H 'Content-Type: application/json' -d '{"resource": "stock", "act": "get", "product": "desk"}'
|
|
506
|
+
# {"success":true,"error":null,"payload":{"product":"desk","available":1}}
|
|
507
|
+
|
|
508
|
+
curl localhost:3000 -H 'Content-Type: application/json' -d '{"resource": "stock", "act": "get", "product": "desk"}'
|
|
509
|
+
# {"success":false,"error":{…,"statusCode":401,"message":"The caller is not authenticated","errMsg":"The caller is not authenticated","err":"UnauthorizedException","errno":"E_UNAUTHORIZED"},"payload":null}
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
错误带有异常的全部属性,这里省略了其中的 `stack`、`appId` 和 `appName`。停止库存服务(Ctrl+C),再下一个订单:
|
|
513
|
+
|
|
514
|
+
```bash
|
|
515
|
+
curl -i -X POST localhost:8080/orders -H 'Content-Type: application/json' -d '{"items": [{"product": "lamp", "quantity": 1}]}'
|
|
516
|
+
# HTTP/1.1 503 Service Unavailable
|
|
517
|
+
# {"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}
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
## 10. 测试
|
|
521
|
+
|
|
522
|
+
每个服务都有自己的测试,它们替换了模板中的测试。库存服务的测试启动服务,发送它的动作的消息,并读取响应的封套:
|
|
523
|
+
|
|
524
|
+
**`inventory/packages/app/src/tests/App.spec.ts`**
|
|
525
|
+
|
|
526
|
+
```typescript
|
|
527
|
+
import {after, before, describe, it} from 'node:test'
|
|
528
|
+
import assert from 'node:assert'
|
|
529
|
+
import {Application} from 'lakutata'
|
|
530
|
+
import {config} from '../config/Config.js'
|
|
531
|
+
|
|
532
|
+
const PORT: number = Number(process.env.TEST_PORT ?? 18085)
|
|
533
|
+
const SERVICE_PORT: number = PORT + 1
|
|
534
|
+
|
|
535
|
+
type Envelope = { success: boolean, error: { errno: string, errMsg: string } | null, payload?: any }
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* 调用一个服务动作,返回响应的封套
|
|
539
|
+
* @param message
|
|
540
|
+
* @param token
|
|
541
|
+
*/
|
|
542
|
+
async function call(message: Record<string, unknown>, token: string = 'test-token'): Promise<Envelope> {
|
|
543
|
+
const response: Response = await fetch(`http://127.0.0.1:${SERVICE_PORT}`, {
|
|
544
|
+
method: 'POST',
|
|
545
|
+
headers: {'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json'},
|
|
546
|
+
body: JSON.stringify(message)
|
|
547
|
+
})
|
|
548
|
+
return await response.json() as Envelope
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
describe('inventory', (): void => {
|
|
552
|
+
|
|
553
|
+
let app: Application
|
|
554
|
+
|
|
555
|
+
before(async (): Promise<void> => {
|
|
556
|
+
process.env.API_TOKEN = 'test-token'
|
|
557
|
+
app = await new Promise<Application>((resolve: (app: Application) => void): void => {
|
|
558
|
+
Application.run(config(PORT, SERVICE_PORT)).onLaunched((launched: Application): void => resolve(launched))
|
|
559
|
+
})
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
after(async (): Promise<void> => {
|
|
563
|
+
await app?.dispose()
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
it('answers the health check without authentication', async (): Promise<void> => {
|
|
567
|
+
assert.deepStrictEqual(await (await fetch(`http://127.0.0.1:${PORT}/health`)).json(), {errno: null, errMsg: null, data: {status: 'ok'}})
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
it('refuses the calls without the token', async (): Promise<void> => {
|
|
571
|
+
const refused: Envelope = await call({resource: 'stock', act: 'get', product: 'lamp'}, 'wrong-token')
|
|
572
|
+
assert.strictEqual(refused.success, false)
|
|
573
|
+
assert.strictEqual(refused.error?.errno, 'E_UNAUTHORIZED')
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
it('gives the stock of a product', async (): Promise<void> => {
|
|
577
|
+
assert.deepStrictEqual((await call({resource: 'stock', act: 'get', product: 'lamp'})).payload, {product: 'lamp', available: 10})
|
|
578
|
+
assert.strictEqual((await call({resource: 'stock', act: 'get', product: 'chair'})).error?.errno, 'E_PRODUCT_NOT_FOUND')
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
it('reserves all the items of an order, once', async (): Promise<void> => {
|
|
582
|
+
const message: Record<string, unknown> = {resource: 'stock', act: 'reserve', order: 'order-1', items: [{product: 'lamp', quantity: 2}, {product: 'desk', quantity: 1}]}
|
|
583
|
+
const levels: unknown = [{product: 'lamp', available: 8}, {product: 'desk', available: 2}]
|
|
584
|
+
assert.deepStrictEqual((await call(message)).payload, levels)
|
|
585
|
+
//重发这个调用,不会再预留任何库存
|
|
586
|
+
assert.deepStrictEqual((await call(message)).payload, levels)
|
|
587
|
+
assert.deepStrictEqual((await call({resource: 'stock', act: 'get', product: 'lamp'})).payload, {product: 'lamp', available: 8})
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
it('reserves nothing when an item is short', async (): Promise<void> => {
|
|
591
|
+
const refused: Envelope = await call({resource: 'stock', act: 'reserve', order: 'order-2', items: [{product: 'lamp', quantity: 1}, {product: 'desk', quantity: 5}]})
|
|
592
|
+
assert.strictEqual(refused.error?.errno, 'E_OUT_OF_STOCK')
|
|
593
|
+
assert.strictEqual(refused.error?.errMsg, 'desk: 5 asked, 2 in stock')
|
|
594
|
+
assert.deepStrictEqual((await call({resource: 'stock', act: 'get', product: 'lamp'})).payload, {product: 'lamp', available: 8})
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
it('refuses an invalid message', async (): Promise<void> => {
|
|
598
|
+
assert.strictEqual((await call({resource: 'stock', act: 'reserve', order: 'order-3', items: []})).error?.errno, 'E_INVALID_VALUE')
|
|
599
|
+
assert.strictEqual((await call({resource: 'stock', act: 'count'})).error?.errno, 'E_CONTROLLER_ACTION_NOT_FOUND')
|
|
600
|
+
})
|
|
601
|
+
})
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
订单服务的测试不需要库存服务,而是在同一个进程中启动一个库存服务的替身:一个运行在自己容器中的入口(见 [Objects](../Objects.md) 第 5 节),它的动作和库存服务的动作模式相同。一个进程只运行一个应用,这里是订单服务,所以替身不是应用。替身像库存服务一样检查令牌,最后两个测试先更换它的令牌,再停止它:
|
|
605
|
+
|
|
606
|
+
**`orders/packages/app/src/tests/App.spec.ts`**
|
|
607
|
+
|
|
608
|
+
```typescript
|
|
609
|
+
import {after, before, describe, it} from 'node:test'
|
|
610
|
+
import assert from 'node:assert'
|
|
611
|
+
import {Application, Container, Exception} from 'lakutata'
|
|
612
|
+
import {buildEntrypoints, Controller, Entrypoint, httpRequestOfContext, httpServiceEntrypoint, type ServiceContext} from 'lakutata/com/entrypoint'
|
|
613
|
+
import {Prefix, ServiceAction} from 'lakutata/decorator/ctrl'
|
|
614
|
+
import {config} from '../config/Config.js'
|
|
615
|
+
|
|
616
|
+
const PORT: number = Number(process.env.TEST_PORT ?? 18085)
|
|
617
|
+
const INVENTORY_PORT: number = PORT + 1
|
|
618
|
+
|
|
619
|
+
class UnauthorizedException extends Exception {
|
|
620
|
+
public errno: string = 'E_UNAUTHORIZED'
|
|
621
|
+
|
|
622
|
+
public readonly statusCode: number = 401
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
class OutOfStockException extends Exception {
|
|
626
|
+
public errno: string = 'E_OUT_OF_STOCK'
|
|
627
|
+
|
|
628
|
+
public readonly statusCode: number = 409
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* 库存服务的替身:模式相同的动作、它要求的令牌、库存中的 3 盏台灯,以及它预留过的订单
|
|
633
|
+
*/
|
|
634
|
+
@Prefix({pattern: {resource: 'stock'}})
|
|
635
|
+
class InventoryStandIn extends Controller {
|
|
636
|
+
public static token: string = 'inventory-token'
|
|
637
|
+
|
|
638
|
+
public static lamps: number = 3
|
|
639
|
+
|
|
640
|
+
public static readonly orders: string[] = []
|
|
641
|
+
|
|
642
|
+
@ServiceAction({act: 'reserve'})
|
|
643
|
+
public async reserve(input: { order: string, items: { product: string, quantity: number }[] }): Promise<unknown> {
|
|
644
|
+
const authorization: string | undefined = httpRequestOfContext(this.context as ServiceContext)?.headers.authorization
|
|
645
|
+
if (authorization !== `Bearer ${InventoryStandIn.token}`) throw new UnauthorizedException('The caller is not authenticated')
|
|
646
|
+
const quantity: number = input.items[0].quantity
|
|
647
|
+
if (quantity > InventoryStandIn.lamps) throw new OutOfStockException('lamp: {0} asked, {1} in stock', quantity, InventoryStandIn.lamps)
|
|
648
|
+
InventoryStandIn.lamps -= quantity
|
|
649
|
+
InventoryStandIn.orders.push(input.order)
|
|
650
|
+
return [{product: 'lamp', available: InventoryStandIn.lamps}]
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* 下一个订单,返回响应的状态码和 {errno, errMsg, data}
|
|
656
|
+
* @param body
|
|
657
|
+
*/
|
|
658
|
+
async function placeOrder(body: unknown): Promise<{ status: number, errno: string | null, errMsg: string | null, data: any }> {
|
|
659
|
+
const response: Response = await fetch(`http://127.0.0.1:${PORT}/orders`, {
|
|
660
|
+
method: 'POST',
|
|
661
|
+
headers: {'Authorization': 'Bearer test-token', 'Content-Type': 'application/json'},
|
|
662
|
+
body: JSON.stringify(body)
|
|
663
|
+
})
|
|
664
|
+
return {status: response.status, ...await response.json() as { errno: string | null, errMsg: string | null, data: any }}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
describe('orders', (): void => {
|
|
668
|
+
|
|
669
|
+
let app: Application
|
|
670
|
+
//替身运行在自己的容器中,和测试在同一个进程里
|
|
671
|
+
const inventory: Container = new Container()
|
|
672
|
+
|
|
673
|
+
before(async (): Promise<void> => {
|
|
674
|
+
await inventory.set(Entrypoint, buildEntrypoints({
|
|
675
|
+
controllers: [InventoryStandIn],
|
|
676
|
+
service: httpServiceEntrypoint({port: INVENTORY_PORT, host: '127.0.0.1'})
|
|
677
|
+
}))
|
|
678
|
+
process.env.API_TOKEN = 'test-token'
|
|
679
|
+
process.env.INVENTORY_TOKEN = 'inventory-token'
|
|
680
|
+
app = await new Promise<Application>((resolve: (app: Application) => void): void => {
|
|
681
|
+
Application.run(config(PORT, `http://127.0.0.1:${INVENTORY_PORT}`)).onLaunched((launched: Application): void => resolve(launched))
|
|
682
|
+
})
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
after(async (): Promise<void> => {
|
|
686
|
+
await app?.dispose()
|
|
687
|
+
await inventory.destroy()
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
it('places an order, its stock reserved by the inventory', async (): Promise<void> => {
|
|
691
|
+
const placed = await placeOrder({items: [{product: 'lamp', quantity: 2}]})
|
|
692
|
+
assert.strictEqual(placed.status, 200)
|
|
693
|
+
assert.deepStrictEqual(placed.data.items, [{product: 'lamp', quantity: 2}])
|
|
694
|
+
assert.deepStrictEqual(InventoryStandIn.orders, [placed.data.id])
|
|
695
|
+
assert.strictEqual(InventoryStandIn.lamps, 1)
|
|
696
|
+
})
|
|
697
|
+
|
|
698
|
+
it('answers the refusal of the inventory', async (): Promise<void> => {
|
|
699
|
+
const refused = await placeOrder({items: [{product: 'lamp', quantity: 5}]})
|
|
700
|
+
assert.strictEqual(refused.status, 409)
|
|
701
|
+
assert.strictEqual(refused.errno, 'E_OUT_OF_STOCK')
|
|
702
|
+
assert.strictEqual(refused.errMsg, 'lamp: 5 asked, 1 in stock')
|
|
703
|
+
})
|
|
704
|
+
|
|
705
|
+
it('refuses an invalid order without calling the inventory', async (): Promise<void> => {
|
|
706
|
+
const invalid = await placeOrder({items: []})
|
|
707
|
+
assert.strictEqual(invalid.status, 400)
|
|
708
|
+
assert.strictEqual(invalid.errno, 'E_INVALID_VALUE')
|
|
709
|
+
assert.strictEqual(InventoryStandIn.orders.length, 1)
|
|
710
|
+
})
|
|
711
|
+
|
|
712
|
+
it('answers 503 when the inventory refuses its token', async (): Promise<void> => {
|
|
713
|
+
InventoryStandIn.token = 'another-token'
|
|
714
|
+
const failed = await placeOrder({items: [{product: 'lamp', quantity: 1}]})
|
|
715
|
+
assert.strictEqual(failed.status, 503)
|
|
716
|
+
assert.strictEqual(failed.errno, 'E_INVENTORY_UNAVAILABLE')
|
|
717
|
+
assert.strictEqual(failed.errMsg, 'The inventory fails: The caller is not authenticated')
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
it('answers 503 when the inventory does not answer', async (): Promise<void> => {
|
|
721
|
+
await inventory.destroy()
|
|
722
|
+
const unavailable = await placeOrder({items: [{product: 'lamp', quantity: 1}]})
|
|
723
|
+
assert.strictEqual(unavailable.status, 503)
|
|
724
|
+
assert.strictEqual(unavailable.errno, 'E_INVENTORY_UNAVAILABLE')
|
|
725
|
+
})
|
|
726
|
+
})
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
```bash
|
|
730
|
+
cd inventory
|
|
731
|
+
npm test
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
```
|
|
735
|
+
app │ ✔ answers the health check without authentication
|
|
736
|
+
app │ ✔ refuses the calls without the token
|
|
737
|
+
app │ ✔ gives the stock of a product
|
|
738
|
+
app │ ✔ reserves all the items of an order, once
|
|
739
|
+
app │ ✔ reserves nothing when an item is short
|
|
740
|
+
app │ ✔ refuses an invalid message
|
|
741
|
+
app │ ℹ pass 6
|
|
742
|
+
✔ test ran in app
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
```bash
|
|
746
|
+
cd ../orders
|
|
747
|
+
npm test
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
```
|
|
751
|
+
app │ ✔ places an order, its stock reserved by the inventory
|
|
752
|
+
app │ ✔ answers the refusal of the inventory
|
|
753
|
+
app │ ✔ refuses an invalid order without calling the inventory
|
|
754
|
+
app │ ✔ answers 503 when the inventory refuses its token
|
|
755
|
+
app │ ✔ answers 503 when the inventory does not answer
|
|
756
|
+
app │ ℹ pass 5
|
|
757
|
+
✔ test ran in app
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
## 11. 在生产环境中
|
|
761
|
+
|
|
762
|
+
- 在每个项目中运行 `npm run docker:build`,用模板中的 `Dockerfile` 构建镜像。在镜像中,库存服务在 `PORT`(8080)上提供健康检查,在 `SERVICE_PORT`(3000)上接收调用。在容器网络中,订单服务通过 `INVENTORY_URL=http://inventory:3000` 访问库存服务;对外只公开订单服务的端口。
|
|
763
|
+
- 两个服务的令牌应来自同一份机密配置:库存服务的 `API_TOKEN`,以及订单服务的 `INVENTORY_TOKEN`。只有一方换了新令牌时,订单服务会以 503 响应。
|
|
764
|
+
- 库存保存在内存中,订单则根本没有保存:请把它们存到数据库中(见 [带数据库的 REST API](RestAPI.md))。那时把订单的 id 作为预留记录的唯一键,就能保证一个订单不会被预留两次。
|
|
765
|
+
- 其他传输方式也能提供同样的动作:Socket.IO、JSON-RPC(见 [ServiceCalls](../ServiceCalls.md))、NATS(见 [NATS](../NATS.md))或 gRPC(见 [GRPC](../GRPC.md)),每种都是 `service` 选项中的一个适配器。NATS 和 gRPC 的代理有同样的 `invoke()`,也抛出同样的 `ServiceInvokeException`:只需换用它们的构建函数注册 `inventoryService`,并改掉 `Inventory.ts` 中代理的类型,其余不变。
|