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.
Files changed (117) hide show
  1. package/README.md +50 -30
  2. package/README.zh-CN.md +130 -0
  3. package/dist/cjs/exports/QueueComponent.d.ts +1 -0
  4. package/dist/cjs/exports/QueueComponent.js +20 -0
  5. package/dist/esm/exports/QueueComponent.js +1 -0
  6. package/dist/types/exports/QueueComponent.d.ts +1 -0
  7. package/doc/en/Application.md +107 -0
  8. package/doc/en/CLI.md +274 -0
  9. package/doc/en/Cache.md +379 -0
  10. package/doc/en/Command.md +110 -0
  11. package/doc/en/ContainerdIntegration.md +17 -16
  12. package/doc/en/Controllers.md +256 -0
  13. package/doc/en/DBus.md +291 -0
  14. package/doc/en/DTOValidation.md +72 -43
  15. package/doc/en/Database.md +188 -0
  16. package/doc/en/DatabaseDrivers.md +287 -0
  17. package/doc/en/Decorator.md +52 -40
  18. package/doc/en/DockerIntegration.md +7 -6
  19. package/doc/en/Entities.md +240 -0
  20. package/doc/en/Entrypoint.md +216 -0
  21. package/doc/en/Exceptions.md +174 -0
  22. package/doc/en/GRPC.md +251 -0
  23. package/doc/en/GettingStarted.md +165 -0
  24. package/doc/en/HTTP.md +507 -0
  25. package/doc/en/HelperFunctions.md +11 -11
  26. package/doc/en/I18n.md +119 -115
  27. package/doc/en/Injection.md +144 -0
  28. package/doc/en/Installers.md +280 -0
  29. package/doc/en/Logger.md +206 -0
  30. package/doc/en/MCP.md +293 -0
  31. package/doc/en/Middlewares.md +213 -0
  32. package/doc/en/Migrations.md +202 -0
  33. package/doc/en/MongoEmbedded.md +149 -0
  34. package/doc/en/Monitor.md +164 -0
  35. package/doc/en/Mutex.md +27 -25
  36. package/doc/en/NATS.md +246 -0
  37. package/doc/en/NativeAddons.md +212 -0
  38. package/doc/en/Objects.md +143 -0
  39. package/doc/en/Overview.md +62 -85
  40. package/doc/en/Packaging.md +193 -0
  41. package/doc/en/ProjectStructure.md +124 -0
  42. package/doc/en/Queue.md +269 -0
  43. package/doc/en/Schedules.md +217 -0
  44. package/doc/en/Security.md +10 -9
  45. package/doc/en/ServiceCalls.md +504 -0
  46. package/doc/en/Time.md +19 -20
  47. package/doc/en/UpgradingFrom1.md +435 -0
  48. package/doc/en/UpgradingFrom2.md +315 -0
  49. package/doc/en/Workspaces.md +189 -0
  50. package/doc/en/examples/BackgroundJobs.md +677 -0
  51. package/doc/en/examples/CommandLineTool.md +664 -0
  52. package/doc/en/examples/DesktopApp.md +809 -0
  53. package/doc/en/examples/DeviceService.md +607 -0
  54. package/doc/en/examples/MCPServer.md +653 -0
  55. package/doc/en/examples/README.md +16 -0
  56. package/doc/en/examples/RestAPI.md +490 -0
  57. package/doc/en/examples/ServiceCalls.md +829 -0
  58. package/doc/en/examples/WebApp.md +568 -0
  59. package/doc/zh/Application.md +75 -0
  60. package/doc/zh/CLI.md +250 -0
  61. package/doc/zh/Cache.md +328 -0
  62. package/doc/zh/Command.md +78 -0
  63. package/doc/zh/ContainerdIntegration.md +9 -9
  64. package/doc/zh/Controllers.md +228 -0
  65. package/doc/zh/DBus.md +257 -0
  66. package/doc/zh/DTOValidation.md +49 -19
  67. package/doc/zh/Database.md +175 -0
  68. package/doc/zh/DatabaseDrivers.md +261 -0
  69. package/doc/zh/Decorator.md +25 -18
  70. package/doc/zh/DockerIntegration.md +6 -6
  71. package/doc/zh/Entities.md +224 -0
  72. package/doc/zh/Entrypoint.md +193 -0
  73. package/doc/zh/Exceptions.md +158 -0
  74. package/doc/zh/GRPC.md +226 -0
  75. package/doc/zh/GettingStarted.md +148 -0
  76. package/doc/zh/HTTP.md +445 -0
  77. package/doc/zh/HelperFunctions.md +2 -2
  78. package/doc/zh/I18n.md +26 -26
  79. package/doc/zh/Injection.md +129 -0
  80. package/doc/zh/Installers.md +232 -0
  81. package/doc/zh/Logger.md +188 -0
  82. package/doc/zh/MCP.md +265 -0
  83. package/doc/zh/Middlewares.md +198 -0
  84. package/doc/zh/Migrations.md +178 -0
  85. package/doc/zh/MongoEmbedded.md +118 -0
  86. package/doc/zh/Monitor.md +145 -0
  87. package/doc/zh/Mutex.md +10 -10
  88. package/doc/zh/NATS.md +207 -0
  89. package/doc/zh/NativeAddons.md +180 -0
  90. package/doc/zh/Objects.md +119 -0
  91. package/doc/zh/Overview.md +46 -71
  92. package/doc/zh/Packaging.md +154 -0
  93. package/doc/zh/ProjectStructure.md +102 -0
  94. package/doc/zh/Queue.md +214 -0
  95. package/doc/zh/Schedules.md +193 -0
  96. package/doc/zh/Security.md +6 -6
  97. package/doc/zh/ServiceCalls.md +459 -0
  98. package/doc/zh/Time.md +2 -2
  99. package/doc/zh/UpgradingFrom1.md +347 -0
  100. package/doc/zh/UpgradingFrom2.md +230 -0
  101. package/doc/zh/Workspaces.md +150 -0
  102. package/doc/zh/examples/BackgroundJobs.md +618 -0
  103. package/doc/zh/examples/CommandLineTool.md +618 -0
  104. package/doc/zh/examples/DesktopApp.md +748 -0
  105. package/doc/zh/examples/DeviceService.md +546 -0
  106. package/doc/zh/examples/MCPServer.md +604 -0
  107. package/doc/zh/examples/README.md +14 -0
  108. package/doc/zh/examples/RestAPI.md +458 -0
  109. package/doc/zh/examples/ServiceCalls.md +765 -0
  110. package/doc/zh/examples/WebApp.md +520 -0
  111. package/package.json +46 -17
  112. package/doc/en/ActionPattern.md +0 -408
  113. package/doc/en/Core.md +0 -312
  114. package/doc/en/ORMIntegration.md +0 -341
  115. package/doc/zh/ActionPattern.md +0 -334
  116. package/doc/zh/Core.md +0 -256
  117. package/doc/zh/ORMIntegration.md +0 -319
package/doc/zh/HTTP.md ADDED
@@ -0,0 +1,445 @@
1
+ # Lakutata HTTP
2
+
3
+ HTTP 适配器用 Express 或 Fastify 提供控制器的 HTTP 动作(`@GET`、`@POST`……)。本指南介绍 HTTP 特有的内容:两个适配器及其选项、路由、请求如何变成动作的输入、响应、错误,以及 Web 框架本身的功能。控制器及其动作见 [Controllers](Controllers.md),中间件和访问控制见 [Middlewares](Middlewares.md),适配器的启动和停止见 [Entrypoint](Entrypoint.md)。
4
+
5
+ ## 1. 第一个 HTTP API
6
+
7
+ ```bash
8
+ npm install lakutata express
9
+ ```
10
+
11
+ ```typescript
12
+ import {Application, DTO, Exception} from 'lakutata'
13
+ import {buildEntrypoints, Controller, Result, type StatusResult} from 'lakutata/com/entrypoint'
14
+ import {DELETE, GET, POST} from 'lakutata/decorator/ctrl'
15
+ import {Expect} from 'lakutata/decorator/dto'
16
+ import {expressHTTPEntrypoint} from 'lakutata/adapter/express'
17
+
18
+ class OrderIdDTO extends DTO {
19
+ //路由参数是文本:strict(false) 把 '1' 转换成 1
20
+ @Expect(DTO.Number().integer().strict(false).required())
21
+ public id: number
22
+ }
23
+
24
+ class NewOrderDTO extends DTO {
25
+ @Expect(DTO.String().required())
26
+ public product: string
27
+
28
+ @Expect(DTO.Number().integer().min(1).default(1))
29
+ public quantity: number
30
+ }
31
+
32
+ class OrderNotFoundException extends Exception {
33
+ public errno: string = 'E_ORDER_NOT_FOUND'
34
+
35
+ public statusCode: number = 404
36
+ }
37
+
38
+ type Order = { id: number, product: string, quantity: number }
39
+
40
+ const orders: Map<number, Order> = new Map()
41
+
42
+ class OrderController extends Controller {
43
+ @POST('/orders', NewOrderDTO)
44
+ public async create(input: NewOrderDTO): Promise<StatusResult> {
45
+ const order: Order = {id: orders.size + 1, product: input.product, quantity: input.quantity}
46
+ orders.set(order.id, order)
47
+ return Result.status(201, order, {Location: `/orders/${order.id}`})
48
+ }
49
+
50
+ @GET('/orders/:id', OrderIdDTO)
51
+ public async get(input: OrderIdDTO): Promise<Order> {
52
+ const order: Order | undefined = orders.get(input.id)
53
+ if (!order) throw new OrderNotFoundException('Order {id} not found', {id: input.id})
54
+ return order
55
+ }
56
+
57
+ @DELETE('/orders/:id', OrderIdDTO)
58
+ public async remove(input: OrderIdDTO): Promise<void> {
59
+ orders.delete(input.id)
60
+ }
61
+ }
62
+
63
+ Application.run(() => ({
64
+ id: 'orders.app',
65
+ name: 'Orders',
66
+ components: {
67
+ entrypoint: buildEntrypoints({
68
+ controllers: [OrderController],
69
+ http: expressHTTPEntrypoint({port: 8080})
70
+ })
71
+ },
72
+ bootstrap: ['entrypoint']
73
+ }))
74
+ ```
75
+
76
+ ```text
77
+ $ curl -i -X POST localhost:8080/orders -H 'Content-Type: application/json' -d '{"product": "lamp"}'
78
+ HTTP/1.1 201 Created
79
+ Location: /orders/1
80
+
81
+ {"id":1,"product":"lamp","quantity":1}
82
+ $ curl localhost:8080/orders/1
83
+ {"id":1,"product":"lamp","quantity":1}
84
+ $ curl -i -X DELETE localhost:8080/orders/1
85
+ HTTP/1.1 204 No Content
86
+ $ curl -i localhost:8080/orders/1
87
+ HTTP/1.1 404 Not Found
88
+
89
+ {"errno":"E_ORDER_NOT_FOUND","errMsg":"Order 1 not found"}
90
+ $ curl -i -X POST localhost:8080/orders -H 'Content-Type: application/json' -d '{}'
91
+ HTTP/1.1 400 Bad Request
92
+
93
+ {"errno":"E_INVALID_VALUE","errMsg":"\"product\" is required"}
94
+ ```
95
+
96
+ ## 2. Express 还是 Fastify
97
+
98
+ 两个适配器提供同样的动作,返回同样的结果:
99
+
100
+ | | Express | Fastify |
101
+ |---|---|---|
102
+ | 适配器 | `lakutata/adapter/express` 中的 `expressHTTPEntrypoint()` | `lakutata/adapter/fastify` 中的 `fastifyHTTPEntrypoint()` |
103
+ | 需要安装 | `express`(4 或 5) | `fastify`(5) |
104
+
105
+ `expressHTTPEntrypoint()` 的选项:
106
+
107
+ | 选项 | 默认值 | 含义 |
108
+ |---|---|---|
109
+ | `port` | `80` | 端口;`0` 表示任选一个空闲端口。端口已被占用时,应用启动失败 |
110
+ | `host` | 所有网络接口 | 监听的地址,比如 `'127.0.0.1'` 表示只接受本机的请求 |
111
+ | `name` | | 适配器的名字,用来区分多个适配器 |
112
+ | `body` | `{limit: '1mb'}` | JSON 和 URL 编码请求体的大小上限(`'5mb'`,或者字节数);`false` 表示不解析请求体 |
113
+ | `server` | | Node.js HTTP 服务器的选项:`keepAliveTimeout`、`headersTimeout`、`requestTimeout`…… |
114
+ | `app` | 新建的应用 | 一个已有的 Express 应用,动作会加到它上面 |
115
+ | `input` | 查询参数和请求体 | 从请求构造输入(第 4 节) |
116
+ | `respond` | `JSONResponder` | 写出结果和错误(第 6 节) |
117
+ | `middlewares` | | 这个适配器上的动作使用的中间件,在入口的中间件之后执行 |
118
+ | `before` | | `(app, context) => …`:添加应用自己的中间件和路由(第 7 节) |
119
+ | `fallback` | | 响应动作路径之外的请求,比如 Web 界面的页面(第 7 节) |
120
+ | `stop` | | 适配器停止、服务器关闭之后调用 |
121
+ | `heartbeat` | `15000` | 每隔多久发送一条注释,让事件流保持连接,单位毫秒(第 5 节) |
122
+
123
+ `fastifyHTTPEntrypoint()` 的选项相同,只是没有 `body`、`server` 和 `app`,取而代之的是 `fastify`:一个 Fastify 实例,或者要创建的实例的选项(`bodyLimit`,默认 1 MiB;`trustProxy`;`connectionTimeout`、`keepAliveTimeout`、`requestTimeout`;`logger`……)。
124
+
125
+ 两者的不同之处:
126
+
127
+ | | Express | Fastify |
128
+ |---|---|---|
129
+ | URL 编码的表单请求体 | 会解析 | 以 415 拒绝 |
130
+ | `this.context.request` | Express 的请求(`ip`、`get()`、`hostname`……) | Node.js 的请求 |
131
+ | 通配符路由 | `/files/*path`:输入得到 `path: ['docs', 'a.txt']` | `/files/*`:输入得到 `'*': 'docs/a.txt'` |
132
+ | `GET`、`HEAD`、`POST`、`PUT`、`PATCH`、`DELETE`、`OPTIONS`、`TRACE` 之外的 HTTP 方法 | 直接支持 | 需要在 `before` 中用 `app.addHttpMethod()` 注册,否则启动失败 |
133
+ | 用 `fallback` 提供 Web 界面 | `staticUI()`(第 7 节) | 自己编写处理函数 |
134
+ | Web 框架自身错误的 `errno` | `E_UNKNOWN` | 它自己的错误码,比如 `FST_ERR_CTP_BODY_TOO_LARGE` |
135
+ | 应用停止时,传入的 Express 应用或 Fastify 实例 | 保持不变 | 被关闭 |
136
+
137
+ 应用停止时(见 [Entrypoint](Entrypoint.md) 第 4 节),适配器不再接受新的连接,并结束事件流;Fastify 还会对已打开的连接上仍然到达的请求返回 503。正在处理的请求可以在入口的 `shutdownTimeout` 内完成(超时后以 503 响应),之后最多再有一秒来发送响应,然后剩下的连接被关闭,WebSocket 连接也一样。
138
+
139
+ ## 3. 路由
140
+
141
+ | 装饰器 | 方法 |
142
+ |---|---|
143
+ | `@GET(route)`、`@POST`、`@PUT`、`@PATCH`、`@DELETE`、`@HEAD`、`@OPTIONS` | 与名字相同的方法 |
144
+ | `@HTTPAction(route, method)` | 一个方法(`'purge'`),或者一组方法(`['get', 'post']`) |
145
+
146
+ 它们接下来的参数是输入的 DTO 和动作的选项,两者都可以省略(见 [Controllers](Controllers.md) 第 3 节)。
147
+
148
+ ```typescript
149
+ import {DTO} from 'lakutata'
150
+ import {Controller} from 'lakutata/com/entrypoint'
151
+ import {GET, HTTPAction, Prefix} from 'lakutata/decorator/ctrl'
152
+ import {Expect} from 'lakutata/decorator/dto'
153
+
154
+ class SearchDTO extends DTO {
155
+ @Expect(DTO.String().required())
156
+ public keyword: string
157
+ }
158
+
159
+ @Prefix({route: '/api'})
160
+ class LibraryController extends Controller {
161
+ //GET /api/search?keyword=lamp,或者带请求体 {"keyword": "lamp"} 的 POST /api/search
162
+ @HTTPAction('/search', ['get', 'post'], SearchDTO)
163
+ public async search(input: SearchDTO): Promise<string[]> {
164
+ return [input.keyword]
165
+ }
166
+
167
+ //使用 Express 时,GET /api/files/docs/guide.pdf:input.path 是 ['docs', 'guide.pdf'](使用 Fastify 时:'/files/*' 和 input['*'])
168
+ @GET('/files/*path')
169
+ public async file(input: { path: string[] }): Promise<string> {
170
+ return input.path.join('/')
171
+ }
172
+ }
173
+ ```
174
+
175
+ - 路由使用 Web 框架的语法:在两个框架中,`:id` 都表示一个参数;通配符的写法不同(第 2 节)。
176
+ - `@Prefix({route})` 给控制器的所有路由加上前缀(见 [Controllers](Controllers.md) 第 6 节)。
177
+ - `GET` 动作也会响应同一路由的 `HEAD` 请求(不带响应体),除非这个路由有自己的 `HEAD` 动作。
178
+ - 如果请求的方法在它的路由上没有对应的动作,请求以 404(`E_ROUTE_NOT_FOUND`)响应,而不是 405。没有任何机制会自动响应 `OPTIONS`:在添加处理 CORS 的中间件之前,浏览器的预检请求都会失败(第 7 节)。
179
+
180
+ ## 4. 输入
181
+
182
+ 默认情况下,动作的输入由查询参数、请求体的属性和路由参数依次合并而成,后者覆盖前者:
183
+
184
+ ```text
185
+ POST /orders/7?id=1&note=a 请求体为 {"id": 5, "note": "b"} → {id: '7', note: 'b'}
186
+ ```
187
+
188
+ 之后由动作的 DTO 校验输入,并去掉 DTO 没有声明的键。`this.context.data` 是校验之前的输入,不包含路由参数。
189
+
190
+ - **文本。** 查询参数和路由参数的值都是字符串:数字或布尔值的校验规则需要 `.strict(false)`,才能接受 `'42'` 或 `'true'`。
191
+ - **查询参数。** 出现多次的键是一个数组(`?tag=a&tag=b` → `tag: ['a', 'b']`)。在 Express 5 和 Fastify 中,方括号不会形成嵌套:`?filter[status]=open` 得到的键是 `'filter[status]'`。
192
+ - **JSON 请求体。** 数组无法作为输入使用(Express 把它的元素放在以序号为名的键下,Fastify 忽略它):列表要放在一个对象里发送(`{"items": [...]}`)。
193
+ - **表单。** Express 会解析 URL 编码的请求体,方括号会形成嵌套(`a[b]=1` → `a: {b: '1'}`);Fastify 以 415 拒绝它们。两者都不解析 `multipart/form-data`,也就是文件上传(第 7 节)。
194
+ - **上限。** 请求体超过上限时以 413 拒绝,JSON 格式错误时以 400 拒绝。
195
+
196
+ `input` 选项从请求构造输入。比如,把客户端的地址和语言交给动作(只有声明了 `ip` 和 `language` 的 DTO 才会保留它们):
197
+
198
+ ```typescript
199
+ import {expressHTTPEntrypoint} from 'lakutata/adapter/express'
200
+ import type {Request} from 'express'
201
+
202
+ export const http = expressHTTPEntrypoint({
203
+ port: 8080,
204
+ input: (request: Request): Record<string, unknown> => ({
205
+ ...request.query,
206
+ ...request.body,
207
+ ip: request.ip,
208
+ language: request.get('accept-language')
209
+ })
210
+ })
211
+ ```
212
+
213
+ 路由参数总是会合并到它的结果上。使用 Express 时,动作也可以直接读取请求:`(this.context as HTTPContext).request as unknown as Request` 得到 Express 的请求,带有 `ip`、`get()` 以及中间件添加的属性。使用 Fastify 时,`this.context.request` 是 Node.js 的请求:要通过 `input` 把 `request.ip` 交给动作。
214
+
215
+ ## 5. 响应
216
+
217
+ - 返回值以 JSON 发送;`undefined` 以 204 响应。
218
+ - `Result.status(status, data?, headers?)` 发送状态码和响应头,可以带数据,也可以不带。
219
+ - `Result.file(source, options?)` 发送一个文件:文件路径(相对于工作目录)、`Buffer` 或者流。`filename` 让它作为下载发送;`contentType` 设置它的类型,否则根据文件名的扩展名推断;`etag` 发送 `ETag` 响应头,请求的 `If-None-Match` 与它相同时以 304 响应。
220
+ - `Result.events(producer)` 发送 Server-Sent Events。
221
+
222
+ ```typescript
223
+ import {Readable} from 'node:stream'
224
+ import {Controller, Result, type EventsResult, type FileResult, type StatusResult} from 'lakutata/com/entrypoint'
225
+ import {GET, POST} from 'lakutata/decorator/ctrl'
226
+
227
+ class ExportController extends Controller {
228
+ //一个下载
229
+ @GET('/exports/orders.csv')
230
+ public async orders(): Promise<FileResult> {
231
+ return Result.file(Readable.from(['id,product\n', '1,lamp\n']), {contentType: 'text/csv', filename: 'orders.csv'})
232
+ }
233
+
234
+ //一个任务的进度,直到任务结束或者客户端离开
235
+ @GET('/exports/progress')
236
+ public async progress(): Promise<EventsResult> {
237
+ return Result.events(async (send, signal: AbortSignal): Promise<void> => {
238
+ for (let percent: number = 0; percent <= 100 && !signal.aborted; percent += 25) {
239
+ send({percent: percent}, 'progress')
240
+ await new Promise((resolve: (value: unknown) => void): unknown => setTimeout(resolve, 1000))
241
+ }
242
+ })
243
+ }
244
+
245
+ //重定向
246
+ @POST('/logout')
247
+ public async logout(): Promise<StatusResult> {
248
+ return Result.status(303, undefined, {Location: '/'})
249
+ }
250
+ }
251
+ ```
252
+
253
+ 事件按照标准的格式写出:数据为 JSON(字符串按原样写出,每行一个 `data:`),给出了事件名和 id 时也一并写出:
254
+
255
+ ```text
256
+ id: 1
257
+ event: tick
258
+ data: {"n":1}
259
+
260
+ data: line one
261
+ data: line two
262
+ ```
263
+
264
+ - 每隔 `heartbeat` 毫秒发送一条注释(`: ping`),让代理不断开连接。
265
+ - 生产者函数返回或失败时,流就结束;失败时不会再发送任何内容,因为响应头已经发出。客户端离开或应用停止时,`signal` 会被中止。
266
+ - 生产者函数在请求的作用域销毁之后运行:它应该使用组件,而不是属于请求的提供者。
267
+
268
+ 动作也可以通过 `this.context.response`(Node.js 的响应)自己写出响应。动作发出响应头之后,适配器既不写出它的结果,也不写出它的错误。客户端在收到响应之前离开时,`this.signal` 会被中止,并且什么都不会写出。
269
+
270
+ ## 6. 错误
271
+
272
+ 错误以一个状态码和 `{errno, errMsg}` 响应:
273
+
274
+ - 状态码取错误本身及其原因(`cause`)中第一个客户端错误(4xx)的 `statusCode` 或 `status`;没有时,取错误自己在 400 到 599 之间的状态码;否则为 500;
275
+ - `errno` 和 `errMsg` 来自错误本身:`errno` 取它的 `errno`,没有时取 `code`,再没有时为 `E_UNKNOWN`;`errMsg` 取它的 `errMsg`,没有时取 `message`。所以 `new Error('Lookup failed', {cause: notFound})` 以其原因的 404 响应,内容是 `{"errno": "E_UNKNOWN", "errMsg": "Lookup failed"}`。
276
+
277
+ | 状态码 | `errno` | 什么时候 |
278
+ |---|---|---|
279
+ | 400 | `E_INVALID_VALUE` | DTO 拒绝了输入 |
280
+ | 400 | `E_UNKNOWN`(Express)、`FST_ERR_CTP_INVALID_JSON_BODY`(Fastify) | 请求体的 JSON 格式错误 |
281
+ | 403 | `E_ACCESS_DENY` | 访问控制拒绝了请求(见 [Middlewares](Middlewares.md)) |
282
+ | 404 | `E_ROUTE_NOT_FOUND` | 没有动作对应这个路由和方法 |
283
+ | 413 | `E_UNKNOWN`(Express)、`FST_ERR_CTP_BODY_TOO_LARGE`(Fastify) | 请求体超过上限 |
284
+ | 415 | `FST_ERR_CTP_INVALID_MEDIA_TYPE` | Fastify 不解析这种类型的请求体 |
285
+ | 503 | `E_ENTRYPOINT_STOPPED` | 应用正在停止 |
286
+ | 500 | 错误自己的 | 其他所有错误 |
287
+
288
+ 应用自己的异常可以给出自己的 `errno` 和 `statusCode`,比如第 1 节的 `OrderNotFoundException`。
289
+
290
+ **封套。** `jsonEnvelope()`(Express)和 `fastifyJSONEnvelope()`(Fastify)把每个响应包装成 `{errno, errMsg, data}`:
291
+
292
+ ```typescript
293
+ import {expressHTTPEntrypoint, jsonEnvelope} from 'lakutata/adapter/express'
294
+
295
+ export const http = expressHTTPEntrypoint({
296
+ port: 8080,
297
+ //{"errno": null, "errMsg": null, "data": {...}, "timestamp": 1792209357257}
298
+ respond: jsonEnvelope({actionErrorStatus: 200, extra: (): Record<string, unknown> => ({timestamp: Date.now()})})
299
+ })
300
+ ```
301
+
302
+ - 结果是 `{errno: null, errMsg: null, data}`;没有数据时,状态码为 200,`data` 为 `null`。
303
+ - 错误是 `{errno, errMsg, data: null}`,使用错误的状态码。`errorStatus` 设置所有错误的状态码,比如对从封套中读取错误的客户端设为 200。`actionErrorStatus` 只设置动作的错误(校验、访问控制、动作抛出的异常)的状态码:找不到路由,以及 Web 框架中间件的错误,仍然使用它们自己的状态码。
304
+ - `extra(request)` 给每个响应添加字段,排在 `errno`、`errMsg` 和 `data` 之后,不会覆盖这三个字段。
305
+
306
+ **自定义响应器**是一个有两个方法的对象,交给 `respond` 选项:`success(response, data, request)` 和 `error(response, error, request, origin)`。请求已经到达动作时,`origin` 是 `'action'`;在此之前就失败时(Web 框架的中间件、找不到路由),`origin` 是 `'request'`。适配器子路径中的 `statusOfError()` 和 `describeError()` 按上面的规则计算状态码和 `{errno, errMsg}`。文件和事件流不经过响应器。
307
+
308
+ ## 7. Web 框架的功能
309
+
310
+ `before` 钩子在添加动作的路由之前收到 Express 应用或 Fastify 实例。它添加的中间件、插件和路由排在前面:它自己的路由优先于路径相同的动作。它的第二个参数提供:
311
+
312
+ | 属性 | 是什么 |
313
+ |---|---|
314
+ | `module` | 入口所在的模块,用来获取对象:`await module.getObject('db')` |
315
+ | `server` | HTTP 服务器,此时还没有开始监听:可以把 WebSocket 服务器挂到它上面 |
316
+ | `routes` | 动作的路由,以及每个路由的方法 |
317
+ | `actionPrefixes`、`isActionPath(path)` | 动作路由的第一段,以及某个路径是否在它们之下 |
318
+
319
+ `before` 失败会让应用启动失败。使用 Express 时,请求体解析器在 `before` 之后才添加:`before` 中的中间件看不到解析后的请求体。
320
+
321
+ ```typescript
322
+ import compression from 'compression'
323
+ import cors from 'cors'
324
+ import {expressHTTPEntrypoint} from 'lakutata/adapter/express'
325
+ import type {Express, Request, Response} from 'express'
326
+
327
+ export const http = expressHTTPEntrypoint({
328
+ port: 8080,
329
+ before: (app: Express): void => {
330
+ //在同一台机器上的反向代理之后:request.ip 和 request.protocol 取自它的 X-Forwarded-* 请求头
331
+ app.set('trust proxy', 'loopback')
332
+ //响应浏览器的预检请求,并给响应加上 CORS 响应头
333
+ app.use(cors({origin: 'https://app.example.com'}))
334
+ app.use(compression())
335
+ app.get('/health', (_request: Request, response: Response): void => void response.send('ok'))
336
+ }
337
+ })
338
+ ```
339
+
340
+ 使用 Fastify 时,在 `async` 钩子中注册插件(`await app.register(fastifyCors, {origin: …})`,`fastifyCors` 来自 `@fastify/cors`),并用 `fastify: {trustProxy: true}` 信任代理。
341
+
342
+ **HTTPS。** 适配器只提供 HTTP。要提供 HTTPS,请在应用前面放一个反向代理(nginx、Caddy、负载均衡器),并像上面那样信任它。
343
+
344
+ ### 上传
345
+
346
+ 适配器不解析 `multipart/form-data`。使用 Express 时,在 `before` 中给这个路由加上 `multer` 之类的解析器,再把它解析出的文件放进输入:
347
+
348
+ ```typescript
349
+ import multer from 'multer'
350
+ import {DTO} from 'lakutata'
351
+ import {Controller} from 'lakutata/com/entrypoint'
352
+ import {POST} from 'lakutata/decorator/ctrl'
353
+ import {Expect} from 'lakutata/decorator/dto'
354
+ import {expressHTTPEntrypoint} from 'lakutata/adapter/express'
355
+ import type {Express, Request} from 'express'
356
+
357
+ class AvatarDTO extends DTO {
358
+ @Expect(DTO.String().required())
359
+ public user: string
360
+
361
+ //multer 的文件:{originalname, mimetype, size, buffer…}
362
+ @Expect(DTO.Object().required())
363
+ public file: Express.Multer.File
364
+ }
365
+
366
+ class AvatarController extends Controller {
367
+ @POST('/avatars', AvatarDTO)
368
+ public async upload(input: AvatarDTO): Promise<{ name: string, size: number }> {
369
+ return {name: input.file.originalname, size: input.file.buffer.length}
370
+ }
371
+ }
372
+
373
+ const upload: multer.Multer = multer({storage: multer.memoryStorage(), limits: {fileSize: 5 * 1024 * 1024}})
374
+
375
+ export const http = expressHTTPEntrypoint({
376
+ port: 8080,
377
+ before: (app: Express): void => {
378
+ //先解析 POST /avatars 的表单,请求再继续交给动作
379
+ app.post('/avatars', upload.single('file'))
380
+ },
381
+ input: (request: Request): Record<string, unknown> => ({...request.query, ...request.body, file: request.file})
382
+ })
383
+ ```
384
+
385
+ multer 的错误(比如文件超过上限,`LIMIT_FILE_SIZE`)没有状态码:它们以 500 响应。
386
+
387
+ 使用 Fastify 时,在 `before` 中以 `attachFieldsToBody: 'keyValues'` 注册 `@fastify/multipart`:表单的字段和文件就成为请求体的属性,也就进入了输入。文件是一个 `Buffer`(用 `DTO.Binary()` 声明),不带文件名;超过上限的文件以 413 拒绝。
388
+
389
+ ### Web 界面
390
+
391
+ `fallback` 响应动作路径之外的请求(见上文的 `isActionPath()`)。使用 Express 时,`staticUI()` 提供一个构建好的单页应用,比如 Vite 构建出的 `dist` 目录:
392
+
393
+ ```typescript
394
+ import {expressHTTPEntrypoint, staticUI} from 'lakutata/adapter/express'
395
+
396
+ export const http = expressHTTPEntrypoint({
397
+ port: 8080,
398
+ fallback: staticUI('./ui/dist')
399
+ })
400
+ ```
401
+
402
+ - 目录中的文件按原样发送。文件名中带有内容哈希的文件(`index-B2x9fQ1a.js`)由浏览器缓存一年(`assetsMaxAge`);其他文件在每次请求时重新验证。
403
+ - 请求页面的 `GET` 请求(接受 HTML,路径没有扩展名)如果没有对应的文件,就以应用的 `index.html` 响应,由应用的路由处理这个路径。`spa: false` 关闭这个行为,`index` 可以指定另一个文件。
404
+ - 其他没有对应文件的请求,比如缺失的脚本,以 404 响应。
405
+ - 动作路由之下的路径(`/api/…`)永远不会交给 `fallback`:这些路径上没有对应动作的请求以 404 响应。
406
+
407
+ 使用 Fastify 时,`fallback` 是你自己编写的处理函数 `(request, reply) => …`,它会收到动作路径之外的所有请求。
408
+
409
+ ## 8. 从 lakutata 2.x 迁移
410
+
411
+ 2.x 的 HTTP 入口函数(`buildHTTPEntrypoint`)仍然可以使用:它在入口的启动阶段被调用(此时它获取的对象,比如注入了入口的鉴权组件,都可以正常获取),并且会被等待,所以应用在它的服务器开始监听之后才算启动完成。迁移到 `expressHTTPEntrypoint` 时:
412
+
413
+ - 应用自己的中间件和路由(CORS、压缩、健康检查、鉴权、Web 界面)放进 `before` 钩子,钩子里用 `module.getObject()` 获取对象;请求体解析器用 `body` 选项(`{limit: '100mb'}`);服务器的 `keepAliveTimeout` 和 `headersTimeout` 用 `server` 选项;
414
+ - 从请求构造的输入(查询参数、请求体、会话中的值)用 `input` 选项;
415
+ - `{errno, errMsg, data}` 封套用 `jsonEnvelope()`。2.x 的应用通常对动作的错误返回 200,而对 Express 中间件的错误(`next(error)`,比如鉴权的 401)使用错误自己的状态码:`jsonEnvelope({actionErrorStatus: 200})` 的行为与此相同。如果用 `errorStatus: 200`,401 也会变成 200,遇到 401 就跳转到登录页的客户端就会失效;
416
+ - 应用封套中的其他字段(`timestamp`、`nonce`、请求 ID)用 `extra` 选项给出,它在每次响应时调用,结果和错误都一样。不需要为了保留这些字段而自己编写响应器。
417
+
418
+ ```typescript
419
+ import {buildEntrypoints} from 'lakutata/com/entrypoint'
420
+ import {expressHTTPEntrypoint, jsonEnvelope} from 'lakutata/adapter/express'
421
+ import type {Express, NextFunction, Request, Response} from 'express'
422
+
423
+ class Unauthorized extends Error {
424
+ public readonly errno: string = 'E_UNAUTHORIZED'
425
+
426
+ public readonly statusCode: number = 401
427
+ }
428
+
429
+ export const entrypoint = buildEntrypoints({
430
+ controllers: [],
431
+ http: expressHTTPEntrypoint({
432
+ port: 3000,
433
+ body: {limit: '100mb'},
434
+ before: async (app: Express): Promise<void> => {
435
+ app.get('/health', (_request: Request, response: Response): unknown => response.json({status: 'ok'}))
436
+ //请求的鉴权:它的错误保留自己的状态码(401)
437
+ app.use((request: Request, _response: Response, next: NextFunction): void => request.headers.authorization ? next() : next(new Unauthorized('Not logged in')))
438
+ },
439
+ //动作的错误在封套中以 200 返回,与 2.x 相同,并带上时间戳
440
+ respond: jsonEnvelope({actionErrorStatus: 200, extra: (): Record<string, unknown> => ({timestamp: Date.now()})})
441
+ })
442
+ })
443
+ ```
444
+
445
+ 使用 Fastify 时,`fastifyJSONEnvelope()` 接受同样的选项,鉴权写成 Fastify 的钩子。
@@ -118,7 +118,7 @@ console.log(UUID.validate(UUID.v4()))
118
118
  |---|---|
119
119
  | `delay(ms)` | 等待指定的毫秒数 |
120
120
  | `gracefulExit` | 执行完清理函数后退出进程 |
121
- | `pathExists(path)`、`isPath(text)` | 文件或目录是否存在;字符串是否是路径的写法 |
121
+ | `pathExists(path)`、`isPath(text)` | 文件或目录是否存在;字符串看起来是否像路径 |
122
122
  | `Glob(patterns, options?)`、`GlobSync`、`isGlobString` | 匹配 glob 模式的文件;字符串是否是 glob 模式 |
123
123
  | `isPromise`、`isPromiseLike`、`isAbortError` | 值是否是 Promise 或 thenable;错误是否是 Node.js 的中止错误 |
124
124
  | `isSymbol`、`isNativeFunction`、`isHTML`、`isXML` | 类型判断 |
@@ -137,4 +137,4 @@ console.log(Statistics.median(latencies), Statistics.quantile(latencies, 0.9))
137
137
  //13.5 90
138
138
  ```
139
139
 
140
- 类型判断函数(`Is*`)不会抛出异常,而是返回 `false`;其他函数抛出标准的 JavaScript 错误(`RangeError`、`TypeError`),`templating()` 还会抛出 `MissingValueError`。
140
+ 类型判断函数(`is…`)不会抛出异常,而是返回 `false`;其他函数抛出标准的 JavaScript 错误(`RangeError`、`TypeError`),`templating()` 还会抛出 `MissingValueError`。
package/doc/zh/I18n.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # 国际化(i18n)
2
2
 
3
- `lakutata/com/i18n`(`@lakutata/i18n` 包)按**键名**管理各语言的消息:代码里写键名,每种语言在自己的文件里给出这个键的句子。消息使用 [ICU MessageFormat](https://unicode-org.github.io/icu/userguide/format_parse/messages/) 的语法,数字、货币、日期和复数由 Node.js 内置的 `Intl` 按语言格式化,不依赖第三方库。
3
+ `lakutata/com/i18n`(`@lakutata/i18n` 包)按**键名**管理各语言的消息:代码里写键名,每种语言在自己的文件里给出这个键对应的文字。消息使用 [ICU MessageFormat](https://unicode-org.github.io/icu/userguide/format_parse/messages/) 的语法,数字、货币、日期和复数由 Node.js 内置的 `Intl` 按语言格式化,不依赖第三方库。
4
4
 
5
5
  ## 翻译文件
6
6
 
@@ -23,7 +23,7 @@ locales/
23
23
 
24
24
  JSON 的嵌套就是键名的层级:上面的键是 `order.count` 和 `order.created`。键名的每一段都不能包含 `.`。
25
25
 
26
- 也可以一种语言一个目录,目录里的文件名(和子目录名)作为键名的前缀:
26
+ 一种语言也可以是一个目录,目录里的文件名(和子目录名)作为键名的前缀:
27
27
 
28
28
  ```
29
29
  locales/
@@ -82,7 +82,7 @@ const i18n = buildI18nOptions({
82
82
  | `defaultLocale` | 默认语言,默认 `en` |
83
83
  | `fallback` | 某种语言缺少消息时,接着去哪些语言里找(`{'zh-TW': 'zh-CN'}`,或一个列表) |
84
84
  | `messages` | 在代码里直接给出的消息(按语言),在目录之后加载,同名的键会覆盖;只用它时不需要 `directory` |
85
- | `missing` | 缺少消息或参数时:`'throw'` 抛出异常;`'key'` 返回键名(参数原样保留为 `{name}`)并发出一次警告。默认抛出,`NODE_ENV=production` 时返回键名 |
85
+ | `missing` | 缺少消息或参数时怎么办:`'throw'` 抛出异常;`'key'` 返回键名(缺少的参数原样保留为 `{name}`),第一次出现时发出警告。默认 `'throw'`,`NODE_ENV=production` 时默认 `'key'` |
86
86
  | `currency` | 消息和参数都没有指定货币时使用的货币(ISO 4217) |
87
87
  | `timeZone` | 日期和时间的时区(IANA) |
88
88
  | `validationMessages` | 是否注册支持的语言的内置校验消息,默认 `true`(见“校验错误的翻译”) |
@@ -106,7 +106,7 @@ class OrderService extends Provider {
106
106
 
107
107
  | 方法 | 说明 |
108
108
  |---|---|
109
- | `t(key, params?, locale?)` | 某种语言下的消息,用参数格式化;不传语言时使用默认语言 |
109
+ | `t(key, params?, locale?)` | 某个键在某种语言下的消息,用参数格式化;不传语言时使用默认语言 |
110
110
  | `has(key, locale?)` | 某种语言(或它回退到的语言)是否有这个键 |
111
111
  | `format(message, params?, locale?)` | 格式化一条不在翻译文件里的 ICU 消息 |
112
112
  | `translator(locale)` | 得到绑定某种语言的翻译器(`ITranslator`),之后不用每次都传语言 |
@@ -114,7 +114,7 @@ class OrderService extends Provider {
114
114
  | `addMessages(locale, messages)` | 运行时添加某种语言的消息,同名的键会覆盖 |
115
115
  | `getLocales()`、`getDefaultLocale()`、`getKeys(locale)`、`getTimeZone()` | 支持的语言、默认语言、某种语言自己的键、时区 |
116
116
 
117
- `t()`、`has()`、`format()` 和 `translator()` 不做语言协商:不支持的语言按原样查找,然后回退。`addMessages()` 返回一个 Promise,在新语言的校验消息注册完成后完成(消息本身在它返回后即可通过 `t()` 取得)。
117
+ `t()`、`has()`、`format()` 和 `translator()` 不做语言协商:不支持的语言按原样查找,然后回退。`addMessages()` 返回一个 Promise,新语言的校验消息注册完成后它才完成;而新消息本身在 `addMessages()` 返回后就能通过 `t()` 取得。
118
118
 
119
119
  ### 按请求的语言回复
120
120
 
@@ -157,7 +157,7 @@ Application.run(() => ({
157
157
  }))
158
158
  ```
159
159
 
160
- 请求要的语言按以下顺序读取,再和支持的语言协商(见下文),都不支持时用默认语言:
160
+ 请求所要的语言按下表的顺序读取,再和支持的语言协商(见“语言的协商与回退”),都不支持时用默认语言:
161
161
 
162
162
  | 入口 | 来源(优先的在前) |
163
163
  |---|---|
@@ -189,7 +189,7 @@ Application.run(() => ({
189
189
  }
190
190
  ```
191
191
 
192
- - 没有配置 `I18nMiddleware` 时使用 `Translator` 会抛出 `I18nTranslatorException`。
192
+ - 没有配置 `I18nMiddleware` 时,调用 `Translator` 的方法会抛出 `I18nTranslatorException`。
193
193
  - 翻译器(`Translator` 和 `translator(locale)` 得到的 `ITranslator`)有 `locale`、`t(key, params)`、`has(key)`、`format(message, params)`、`number(value, options)` 和 `date(value, options)`。
194
194
  - 自己处理语言时,可以用 `parseAcceptLanguage(header)` 解析 `Accept-Language`,用 `environmentLocales()` 读取环境变量里的语言(`zh_CN.UTF-8` 会转换为 `zh-CN`),再交给 `negotiate()`。
195
195
  - 不在请求里的地方(定时任务、单例组件),用组件的 `t(key, params, locale)` 或 `translator(locale)`。
@@ -222,7 +222,7 @@ Application.run(() => ({
222
222
  i18n: buildI18nOptions({messages: {'zh-CN': zhCN}}),
223
223
  entrypoint: buildEntrypoints({
224
224
  controllers: [GreetController],
225
- //命令执行时,Controller 注入的 Translator 也使用同一种语言
225
+ //执行命令的 Controller 注入的 Translator 也使用同一种语言
226
226
  middlewares: [I18nMiddleware],
227
227
  //TOOL_LANG=zh-CN tool --help,或 LANG=zh_CN.UTF-8
228
228
  cli: commanderCLIEntrypoint({bin: 'tool', translate: commanderTranslator({environment: 'TOOL_LANG'})})
@@ -251,22 +251,22 @@ Application.run(() => ({
251
251
  - 键名(默认前缀 `cli.`,可用 `prefix` 选项修改):
252
252
  - 程序:`description`;命令:`commands.<命令>.description`、`commands.<命令>.arguments.<参数>`、`commands.<命令>.options.<属性>`;
253
253
  - 帮助:`help.usage`、`help.arguments`、`help.options`、`help.commands`、`help.helpOption`、`help.helpCommand`、`help.versionOption`;
254
- - 提示:`help.default`(`default: {value}`)、`help.choices`(`choices: {choices}`)、`help.hints`(` ({hints})`,提示在说明后的格式)、`help.separator`(`, `)、`help.negate`(`Turn off {flag}`);
254
+ - 提示:`help.default`(`default: {value}`)、`help.choices`(`choices: {choices}`)、`help.hints`(` ({hints})`,说明后面附加提示的格式)、`help.separator`(`, `)、`help.negate`(`Turn off {flag}`);
255
255
  - 提问:`prompt.json`(`Not a JSON object`)。
256
256
  - 没有消息的文字使用代码里的英文原文,所以只需要为其他语言提供消息。
257
- - 语言在适配器启动时确定一次:依次为 `environment` 选项指定的环境变量、环境(`LANGUAGE`、`LC_ALL`、`LC_MESSAGES`、`LANG`),最后是进程的语言,再与支持的语言协商。
258
- - 中文、日文、韩文的宽字符按两列计算,帮助的长行在字符之间换行,标点不放在行首。
257
+ - 语言在适配器启动时确定一次:依次读取 `environment` 选项指定的环境变量、`LANGUAGE`、`LC_ALL`、`LC_MESSAGES`、`LANG`,最后是进程的语言,再与支持的语言协商。
258
+ - 中文、日文、韩文的宽字符按两列计算;帮助里的长行可以在这些字符之间换行,并且不会让标点出现在行首。
259
259
  - 也可以直接给 `translate` 一个函数:`translate: () => (text) => messages[text.key] ?? text.text`。
260
260
 
261
261
  ## 校验错误的翻译
262
262
 
263
- 配置了 `I18nMiddleware` 时,请求里的校验失败的消息都使用请求的语言:请求参数(`@HTTPAction`、`@CLIAction`、`@ServiceAction` 的 DTO),请求中调用的方法的参数和返回值(`@Accept`、`@Return`),以及代码里直接调用的 `DTO.validate`、`DTO.validateAsync`。
263
+ 配置了 `I18nMiddleware` 时,请求里校验失败的消息都使用请求的语言:请求参数(`@HTTPAction`、`@CLIAction`、`@ServiceAction` 的 DTO),请求中调用的方法的参数和返回值(`@Accept`、`@Return`),以及代码里直接调用的 `DTO.validate`、`DTO.validateAsync`。
264
264
 
265
265
  框架内置了 20 种语言的校验消息,组件启动时为它支持的每种语言注册(只加载用到的语言):
266
266
 
267
267
  `en`、`zh-CN`、`zh-TW`、`ja`、`ko`、`fr`、`de`、`es`、`pt-BR`、`it`、`nl`、`pl`、`ru`、`uk`、`tr`、`vi`、`th`、`id`、`hi`、`ar`
268
268
 
269
- 支持的语言使用最接近的内置语言:`zh`、`zh-Hans`、`zh-SG` 使用 `zh-CN`,`zh-Hant`、`zh-HK`、`zh-MO` 使用 `zh-TW`,其他语言按语言部分匹配(`pt-PT` 使用 `pt-BR`,`fr-CA` 使用 `fr`)。没有内置消息的语言使用它回退到的语言的,最后是英文。
269
+ 支持的语言使用最接近的内置语言:`zh`、`zh-Hans`、`zh-SG` 使用 `zh-CN`,`zh-Hant`、`zh-HK`、`zh-MO` 使用 `zh-TW`,其他语言按语言部分匹配(`pt-PT` 使用 `pt-BR`,`fr-CA` 使用 `fr`)。没有内置消息的语言,使用它回退到的语言的内置消息,最后是英文。
270
270
 
271
271
  应用可以在翻译里覆盖或补充校验消息:写在 `validation` 下,键是校验的错误码,消息使用校验的模板语法(`{{#label}}`、`{{#limit}}`),不是 ICU:
272
272
 
@@ -281,13 +281,13 @@ Application.run(() => ({
281
281
 
282
282
  - 某种语言的校验消息依次叠加:回退到的语言的内置消息和应用消息,再是它自己的内置消息和应用消息,后面的覆盖前面的。
283
283
  - `validationMessages: false` 不注册内置消息,只使用应用的 `validation` 消息。
284
- - 请求参数先按原来的方式校验;只有校验失败时,才按请求的语言再校验一次,取得该语言的消息。校验通过的请求不受任何影响。
285
- - 少数消息里有校验引擎填入的英文内容,例如排序方向(`ascending`)、引用错误的原因、自定义断言的消息,这些部分在各语言里仍是英文。
284
+ - 请求参数照常校验;只有校验失败时,才按请求的语言再校验一次,取得这种语言的消息。校验通过的请求不受任何影响。
285
+ - 少数消息里有校验库自己填入的英文内容,例如排序方向(`ascending`)、引用错误的原因、自定义断言的消息,这些部分在各语言里仍是英文。
286
286
  - 不使用 i18n 组件时,也可以直接用 `lakutata` 的函数:`loadValidationMessages(language)` 加载某种语言的内置消息,`registerValidationMessages(language, messages)` 注册,之后校验时用 `{errors: {language}}` 选项指定语言,或者用 `runInValidationLanguage(language, fn)` 让一段代码(包括其中的异步调用)里的所有校验都使用该语言。
287
287
 
288
288
  ## 异常消息的翻译
289
289
 
290
- 配置了 `I18nMiddleware` 时,请求中抛出的异常如果在翻译里有对应的消息,它的消息会换成请求的语言。键名是 `errors.<异常的 errno>`,消息用异常的模板参数格式化:
290
+ 配置了 `I18nMiddleware` 时,如果请求中抛出的异常在翻译里有对应的消息,它的消息会换成请求的语言。键名是 `errors.<异常的 errno>`,消息用异常的模板参数格式化:
291
291
 
292
292
  ```typescript
293
293
  import {Exception} from 'lakutata'
@@ -305,14 +305,14 @@ throw new OrderNotFoundException('Order {id} not found', {id: 7})
305
305
 
306
306
  - 中文请求得到 `订单 7 不存在`,英文请求得到原来的 `Order 7 not found`;`errno` 等其他属性不变。
307
307
  - 逐个传入的参数(`new XxxException('{0} of {1}', a, b)`)在消息里用 `{0}`、`{1}` 引用。
308
- - 没有对应消息的异常保持原样;消息无法格式化时(比如缺少参数)也保持原样,并发出一次警告。
308
+ - 在翻译里没有对应消息的异常保持原样;消息无法格式化时(比如缺少参数)也保持原样,并发出警告。
309
309
  - 不在请求里的地方,可以用 `exceptionParams(exception)`(来自 `lakutata`)取得异常的参数,自己翻译。
310
310
 
311
311
  ## 语言的协商与回退
312
312
 
313
- **协商**(`negotiate`):依次看请求方的每一种语言,先找完全相同的标签,再找只有语言部分的标签(`en-GB` → `en`),再找同一语言的其他地区(`pt-PT` → `pt-BR`,按支持的语言的顺序);都找不到时使用默认语言。
313
+ **协商**(`negotiate`):依次尝试请求方的每一种语言,先找完全相同的标签,再找只有语言部分的标签(`en-GB` → `en`),再找同一语言的其他地区(`pt-PT` → `pt-BR`,按支持的语言的顺序);都找不到时使用默认语言。
314
314
 
315
- **回退**:在某种语言里找不到消息时,依次查找:它在 `fallback` 里配置的语言、去掉最后一段的标签(`zh-Hant-TW` → `zh-Hant` → `zh`)、默认语言。消息总是按**请求的语言**格式化:回退到英文的消息,在德语请求里数字仍然是 `1.000`。
315
+ **回退**:在某种语言里找不到消息时,依次查找:它在 `fallback` 里配置的语言、去掉最后一段的标签(`zh-Hant-TW` → `zh-Hant` → `zh`)、默认语言。消息总是按**请求的语言**格式化:德语请求回退到英文消息时,数字仍然写成 `1.000`。
316
316
 
317
317
  ## 消息语法
318
318
 
@@ -331,7 +331,7 @@ throw new OrderNotFoundException('Order {id} not found', {id: 7})
331
331
 
332
332
  ## 键名的类型
333
333
 
334
- 应用可以声明键名和它们的参数,之后写错键名或漏掉参数时编译就会报错。`lakutata i18n types` 根据默认语言的消息生成这份声明(见下文),也可以手写:
334
+ 应用可以声明键名和它们的参数,这样写错键名或漏掉参数时,编译就会报错。`lakutata i18n types` 根据默认语言的消息生成这份声明(见下文),也可以手写:
335
335
 
336
336
  ```typescript
337
337
  //src/i18n.d.ts
@@ -353,18 +353,18 @@ declare module 'lakutata/com/i18n' {
353
353
 
354
354
  | 命令 | 作用 |
355
355
  |---|---|
356
- | `lakutata i18n check` | 以默认语言(`--default`,默认 `en`)为准检查各语言的消息。错误:ICU 语法错误、默认语言没有的键、默认消息没有的参数、未知的校验错误码或不属于该错误码的变量;警告:缺少的消息(会回退)、没有用到的参数、和默认语言相同(可能还没翻译)。有错误时退出码非 0,`--strict` 时警告也算失败,适合放进 CI |
356
+ | `lakutata i18n check` | 以默认语言(`--default`,默认 `en`)为准检查各语言的消息。错误:ICU 语法错误、默认语言没有的键、默认消息没有的参数、未知的校验错误码或不属于该错误码的变量;警告:缺少的消息(会回退)、没有用到的参数、与默认语言相同的消息(可能还没翻译)。有错误时退出码非 0,加上 `--strict` 时警告也算失败,适合放进 CI |
357
357
  | `lakutata i18n types` | 根据默认语言的消息生成键名和参数的类型声明(`--out`,默认 `src/i18n.d.ts`);应用从 `@lakutata/i18n` 引入时加 `--module @lakutata/i18n` |
358
- | `lakutata i18n add <语言>` | 以默认语言的消息为模板新建一种语言(按默认语言的布局建文件或目录,不复制 `validation` 部分,新语言使用自己的内置校验消息),之后翻译它们 |
358
+ | `lakutata i18n add <语言>` | 以默认语言的消息为模板新建一种语言,供之后翻译(按默认语言的布局建文件或目录,不复制 `validation` 部分,新语言使用自己的内置校验消息) |
359
359
 
360
- 这些检查也可以在代码里使用:`loadCatalogs(directories)`、`checkCatalogs(catalogs, defaultLocale)`、`generateMessageTypes(catalogs, defaultLocale)`。
360
+ 这些检查也可以作为函数在代码里调用:`loadCatalogs(directories)`、`checkCatalogs(catalogs, defaultLocale)`、`generateMessageTypes(catalogs, defaultLocale)`。
361
361
 
362
362
  ## 异常
363
363
 
364
- | 异常 | 时机 |
364
+ | 异常 | 什么时候 |
365
365
  |---|---|
366
366
  | `I18nCatalogException` | 翻译文件或配置无效:JSON 有误、文件名不是语言标签、目录不存在、别名无法解析 |
367
- | `I18nMessageSyntaxException` | 消息的语法错误 |
367
+ | `I18nMessageSyntaxException` | 消息有语法错误 |
368
368
  | `I18nMissingKeyException` | 找不到键(`missing: 'throw'`) |
369
369
  | `I18nMissingParameterException` | 缺少参数(`missing: 'throw'`),或货币类消息没有可用的货币 |
370
- | `I18nTranslatorException` | 没有配置 `I18nMiddleware` 时,或在请求之外使用 `Translator` |
370
+ | `I18nTranslatorException` | 没有配置 `I18nMiddleware`,或者在请求之外使用 `Translator` |