lakutata 3.0.0 → 3.1.1
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 +511 -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 +63 -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 +508 -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/WebRTC.md +1364 -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/Meeting.md +573 -0
- package/doc/en/examples/README.md +17 -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 +446 -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 +47 -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 +462 -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/WebRTC.md +1049 -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/Meeting.md +524 -0
- package/doc/zh/examples/README.md +15 -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,462 @@
|
|
|
1
|
+
# Lakutata 服务调用
|
|
2
|
+
|
|
3
|
+
服务动作(`@ServiceAction`)通过消息而不是 URL 来调用:消息是一个对象,其中一部分键用来选择动作,称为**模式**,其余的键就是输入。同一组动作可以同时通过多种传输方式提供:本指南介绍的普通 HTTP、Socket.IO、JSON-RPC 2.0(基于 HTTP、WebSocket 或标准输入输出)和 Electron 的 IPC,以及在各自指南中介绍的 gRPC、NATS 和 WebRTC 的数据通道。控制器及其动作见 [Controllers](Controllers.md),入口及其适配器见 [Entrypoint](Entrypoint.md)。
|
|
4
|
+
|
|
5
|
+
## 1. 第一个服务
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import {Application, DTO, Exception} from 'lakutata'
|
|
9
|
+
import {buildEntrypoints, Controller, httpServiceEntrypoint} from 'lakutata/com/entrypoint'
|
|
10
|
+
import {ServiceAction} from 'lakutata/decorator/ctrl'
|
|
11
|
+
import {Expect} from 'lakutata/decorator/dto'
|
|
12
|
+
|
|
13
|
+
class NewOrderDTO extends DTO {
|
|
14
|
+
@Expect(DTO.String().required())
|
|
15
|
+
public product: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
class OrderIdDTO extends DTO {
|
|
19
|
+
@Expect(DTO.Number().integer().required())
|
|
20
|
+
public id: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class OrderNotFoundException extends Exception {
|
|
24
|
+
public errno: string = 'E_ORDER_NOT_FOUND'
|
|
25
|
+
|
|
26
|
+
public statusCode: number = 404
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type Order = { id: number, product: string }
|
|
30
|
+
|
|
31
|
+
const orders: Map<number, Order> = new Map()
|
|
32
|
+
|
|
33
|
+
class OrderController extends Controller {
|
|
34
|
+
//消息 {resource: 'order', act: 'create', product: 'lamp'}:输入是 {product: 'lamp'}
|
|
35
|
+
@ServiceAction({resource: 'order', act: 'create'}, NewOrderDTO)
|
|
36
|
+
public async create(input: NewOrderDTO): Promise<Order> {
|
|
37
|
+
const order: Order = {id: orders.size + 1, product: input.product}
|
|
38
|
+
orders.set(order.id, order)
|
|
39
|
+
return order
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@ServiceAction({resource: 'order', act: 'get'}, OrderIdDTO)
|
|
43
|
+
public async get(input: OrderIdDTO): Promise<Order> {
|
|
44
|
+
const order: Order | undefined = orders.get(input.id)
|
|
45
|
+
if (!order) throw new OrderNotFoundException('Order {id} not found', {id: input.id})
|
|
46
|
+
return order
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
Application.run(() => ({
|
|
51
|
+
id: 'orders.app',
|
|
52
|
+
name: 'Orders',
|
|
53
|
+
components: {
|
|
54
|
+
entrypoint: buildEntrypoints({
|
|
55
|
+
controllers: [OrderController],
|
|
56
|
+
//POST http://localhost:3000/,请求体为 JSON 格式的消息
|
|
57
|
+
service: httpServiceEntrypoint({port: 3000})
|
|
58
|
+
})
|
|
59
|
+
},
|
|
60
|
+
bootstrap: ['entrypoint']
|
|
61
|
+
}))
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```text
|
|
65
|
+
$ curl localhost:3000 -H 'Content-Type: application/json' -d '{"resource": "order", "act": "create", "product": "lamp"}'
|
|
66
|
+
{"success":true,"error":null,"payload":{"id":1,"product":"lamp"}}
|
|
67
|
+
$ curl localhost:3000 -H 'Content-Type: application/json' -d '{"resource": "order", "act": "get", "id": 1}'
|
|
68
|
+
{"success":true,"error":null,"payload":{"id":1,"product":"lamp"}}
|
|
69
|
+
$ curl localhost:3000 -H 'Content-Type: application/json' -d '{"resource": "order", "act": "get", "id": 9}'
|
|
70
|
+
{"success":false,"error":{…,"message":"Order 9 not found","errMsg":"Order 9 not found","err":"OrderNotFoundException","errno":"E_ORDER_NOT_FOUND"},"payload":null}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
错误带有异常的所有属性(第 3 节)。
|
|
74
|
+
|
|
75
|
+
## 2. 模式
|
|
76
|
+
|
|
77
|
+
如果消息包含模式的所有键,并且值相同,消息就与这个模式匹配。然后从消息中去掉模式的键,剩下的就是动作的输入。以下面这些动作为例:
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
import {Controller} from 'lakutata/com/entrypoint'
|
|
81
|
+
import {ServiceAction} from 'lakutata/decorator/ctrl'
|
|
82
|
+
|
|
83
|
+
class OrderController extends Controller {
|
|
84
|
+
@ServiceAction({resource: 'order', act: 'get'})
|
|
85
|
+
public async get(input: Record<string, unknown>): Promise<unknown> {
|
|
86
|
+
return input
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
//资源 "order" 的其他消息
|
|
90
|
+
@ServiceAction({resource: 'order'})
|
|
91
|
+
public async other(input: Record<string, unknown>): Promise<unknown> {
|
|
92
|
+
return input
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
@ServiceAction({resource: 'order', act: 'audit.*'})
|
|
96
|
+
public async audit(input: Record<string, unknown>): Promise<unknown> {
|
|
97
|
+
return input
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
@ServiceAction({res: {name: 'user'}, act: 'get'})
|
|
101
|
+
public async user(input: Record<string, unknown>): Promise<unknown> {
|
|
102
|
+
return input
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
@ServiceAction({version: 2, act: 'info'})
|
|
106
|
+
public async info(): Promise<string> {
|
|
107
|
+
return 'v2'
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
| 消息 | 动作 | 输入 |
|
|
113
|
+
|---|---|---|
|
|
114
|
+
| `{resource: 'order', act: 'get', id: 1}` | `get` | `{id: 1}` |
|
|
115
|
+
| `{resource: 'order', act: 'list', page: 1}` | `other` | `{act: 'list', page: 1}` |
|
|
116
|
+
| `{resource: 'order', act: 'audit.read', who: 'ada'}` | `audit` | `{who: 'ada'}` |
|
|
117
|
+
| `{res: {name: 'user', extra: 1}, act: 'get', q: 1}` | `user` | `{res: {extra: 1}, q: 1}` |
|
|
118
|
+
| `{version: '2', act: 'info'}` | `info` | `{}` |
|
|
119
|
+
| `{resource: 'invoice'}` | 无匹配的动作:`ControllerActionNotFoundException` | |
|
|
120
|
+
|
|
121
|
+
- 有多个模式匹配时,最具体的那个胜出,也就是键最多的那个。
|
|
122
|
+
- 模式中的字符串值可以是通配符:`*` 匹配任意多个字符,`?` 匹配一个字符。
|
|
123
|
+
- 值按文本比较:模式 `{version: 2}` 与 `version: '2'` 匹配。
|
|
124
|
+
- 模式可以嵌套对象,最多两层;嵌套对象的键逐个比较。
|
|
125
|
+
- `@Prefix({pattern})` 把一个模式合并到控制器所有动作的模式中(见 [Controllers](Controllers.md) 第 6 节)。
|
|
126
|
+
- 没有任何模式匹配的消息,以 `ControllerActionNotFoundException`(`E_CONTROLLER_ACTION_NOT_FOUND`)失败。
|
|
127
|
+
- 消息是 JSON,值保留各自的类型:与 HTTP 请求的查询参数不同,期望数字的校验规则不会把 `"2"` 转换成数字。
|
|
128
|
+
|
|
129
|
+
## 3. 响应
|
|
130
|
+
|
|
131
|
+
HTTP 服务、Socket.IO 和 Electron 以封套响应;JSON-RPC 使用它自己的响应格式(第 4 节)。
|
|
132
|
+
|
|
133
|
+
```text
|
|
134
|
+
{"success": true, "error": null, "payload": <结果>}
|
|
135
|
+
{"success": false, "error": <错误>, "payload": null}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
- 动作什么都不返回时,没有 `payload`:`{"success": true, "error": null}`。
|
|
139
|
+
- `error` 是错误所有自身属性的副本:`message`、`stack`、`errno`、`errMsg`、`statusCode`、`err`(它的类名)、`appId`、`appName`,以及异常类自己定义的属性。客户端收到的是动作抛出的完整错误。
|
|
140
|
+
- `Result.status(status, data)` 发送它的数据。文件和事件流(`Result.file()`、`Result.events()`)无法发送:调用会失败。
|
|
141
|
+
|
|
142
|
+
调用服务的应用通过代理读取封套:HTTP 服务用 `HTTPServiceProxy`(第 4 节),NATS 和 gRPC 传输方式也各有代理。代理返回 `payload`,或者抛出带有远端错误属性的 `ServiceInvokeException`。其他客户端(例如浏览器中的页面)自己读取封套,比如:
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
type Envelope<T> = { success: boolean, error: { message?: string, errno?: string } | null, payload: T }
|
|
146
|
+
|
|
147
|
+
export async function call<T>(message: Record<string, unknown>): Promise<T> {
|
|
148
|
+
const response: Response = await fetch('http://localhost:3000/', {
|
|
149
|
+
method: 'POST',
|
|
150
|
+
headers: {'Content-Type': 'application/json'},
|
|
151
|
+
body: JSON.stringify(message)
|
|
152
|
+
})
|
|
153
|
+
const envelope: Envelope<T> = await response.json() as Envelope<T>
|
|
154
|
+
//把错误的属性(errno、statusCode……)复制到一个 Error 上
|
|
155
|
+
if (!envelope.success) throw Object.assign(new Error(envelope.error?.message ?? 'The call failed'), envelope.error)
|
|
156
|
+
return envelope.payload
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## 4. 传输方式
|
|
161
|
+
|
|
162
|
+
| 适配器 | 导入自 | 需要安装 | 请求的形式 | 推送的事件 |
|
|
163
|
+
|---|---|---|---|---|
|
|
164
|
+
| `httpServiceEntrypoint()` | `lakutata/com/entrypoint` | | 以 JSON 格式 `POST` 消息 | Server-Sent Events |
|
|
165
|
+
| `socketIOServiceEntrypoint()` | `lakutata/adapter/socket.io` | `socket.io`(客户端需要 `socket.io-client`) | 带确认回调的 emit | Socket.IO 事件 |
|
|
166
|
+
| `jsonRPCServiceEntrypoint()` | `lakutata/adapter/jsonrpc` | WebSocket 需要 `ws` | JSON-RPC 2.0 请求 | 通知(WebSocket、stdio) |
|
|
167
|
+
| `electronIPCServiceEntrypoint()` | `lakutata/adapter/electron` | (Electron) | `ipcRenderer.invoke()` | IPC 消息 |
|
|
168
|
+
| `grpcServiceEntrypoint()` | `@lakutata/grpc/service` | `@lakutata/grpc` | 见 [GRPC](GRPC.md) | gRPC 流 |
|
|
169
|
+
| `natsServiceEntrypoint()` | `@lakutata/nats` | `@lakutata/nats` | 见 [NATS](NATS.md) | NATS 消息 |
|
|
170
|
+
| `webrtcServiceEntrypoint()` | `@lakutata/webrtc` | `@lakutata/webrtc` | 数据通道上的 JSON-RPC 2.0 请求,见 [WebRTC](WebRTC.md) | 通知 |
|
|
171
|
+
|
|
172
|
+
每个适配器还接受 `name`,用来区分多个适配器,以及 `middlewares`,在入口的中间件之后执行(见 [Middlewares](Middlewares.md))。
|
|
173
|
+
|
|
174
|
+
### HTTP 服务
|
|
175
|
+
|
|
176
|
+
`httpServiceEntrypoint()` 通过普通 HTTP 提供动作:适合工具(curl、Postman)、脚本,以及开发阶段的 Web 界面。Electron 模板在开发时就为此添加了它。
|
|
177
|
+
|
|
178
|
+
| 选项 | 默认值 | 含义 |
|
|
179
|
+
|---|---|---|
|
|
180
|
+
| `port` | `3000` | 端口;`0` 表示任选一个空闲端口 |
|
|
181
|
+
| `host` | 所有网络接口 | 监听的地址 |
|
|
182
|
+
| `path` | `'/'` | 请求的路径;事件在它下面的 `<path>/events` |
|
|
183
|
+
| `limit` | `10485760`(10 MiB) | 请求体的最大字节数 |
|
|
184
|
+
| `listening` | | `(server) => …`,服务器开始监听后调用 |
|
|
185
|
+
|
|
186
|
+
- 请求是发往 `path` 的 `POST`,请求体是 JSON 格式的消息。无论动作的结果如何,都以 200 和封套响应。请求体不是 JSON 时以 400 响应,超过 `limit` 时以 413 响应,其他方法或路径以 404 响应;这些响应也带有封套。
|
|
187
|
+
- CORS 响应头允许任何来源,允许的方法是 `GET`、`POST` 和 `OPTIONS`,并允许任何请求头,例如 `Authorization`。
|
|
188
|
+
- 客户端在 `id` 请求头或 `id` 查询参数中给出自己的 id(第 5 节)。
|
|
189
|
+
- `GET <path>/events` 以 Server-Sent Events 的形式推送发给这个客户端的事件。第一个事件 `client` 给出客户端的 id(请求没有给出时为随机 id);之后每个事件带有它的名字,数据是它的参数组成的 JSON 数组。每 15 秒发送一条注释,保持连接。
|
|
190
|
+
|
|
191
|
+
```text
|
|
192
|
+
event: client
|
|
193
|
+
data: ["client-1"]
|
|
194
|
+
|
|
195
|
+
event: progress
|
|
196
|
+
data: [50,"half"]
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
在浏览器中用 `EventSource` 读取:
|
|
200
|
+
|
|
201
|
+
```typescript
|
|
202
|
+
const events: EventSource = new EventSource('http://localhost:3000/events?id=client-1')
|
|
203
|
+
events.addEventListener('progress', (event: MessageEvent): void => console.log(JSON.parse(event.data)))
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
其他应用通过 `HTTPServiceProxy` 调用这个服务,它是一个由 `buildHTTPServiceProxy()` 注册的组件。下面调用第 1 节的订单服务:
|
|
207
|
+
|
|
208
|
+
```typescript
|
|
209
|
+
import {Application} from 'lakutata'
|
|
210
|
+
import {buildHTTPServiceProxy, type HTTPServiceProxy, ServiceInvokeException} from 'lakutata/com/entrypoint'
|
|
211
|
+
|
|
212
|
+
Application
|
|
213
|
+
.run(() => ({
|
|
214
|
+
id: 'shop.app',
|
|
215
|
+
name: 'Shop',
|
|
216
|
+
components: {
|
|
217
|
+
orders: buildHTTPServiceProxy({url: 'http://localhost:3000/'})
|
|
218
|
+
}
|
|
219
|
+
}))
|
|
220
|
+
.onLaunched(async (app: Application): Promise<void> => {
|
|
221
|
+
const orders: HTTPServiceProxy = await app.getObject<HTTPServiceProxy>('orders')
|
|
222
|
+
//{id: 1, product: 'lamp'}
|
|
223
|
+
console.log(await orders.invoke({resource: 'order', act: 'create', product: 'lamp'}))
|
|
224
|
+
try {
|
|
225
|
+
//最多等待 5 秒
|
|
226
|
+
await orders.invoke({resource: 'order', act: 'get', id: 9}, 5000)
|
|
227
|
+
} catch (error) {
|
|
228
|
+
//动作的错误,带有它的各个属性:E_ORDER_NOT_FOUND
|
|
229
|
+
if (error instanceof ServiceInvokeException) console.log(error.errno)
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
| 选项 | 默认值 | 含义 |
|
|
235
|
+
|---|---|---|
|
|
236
|
+
| `url` | | 服务的地址,包括它的适配器的 `path` |
|
|
237
|
+
| `headers` | | 每次调用和事件流都会带上的请求头,例如 `Authorization` |
|
|
238
|
+
| `clientId` | 随机的 UUID | 这个客户端的 id(第 5 节) |
|
|
239
|
+
| `reconnectDelay` | `1000` | 事件流断开后,隔多少毫秒重新打开 |
|
|
240
|
+
|
|
241
|
+
- `invoke(message, timeout)` 发送消息并返回 `payload`。超时默认为 10 分钟;超时后放弃这次调用,服务端会中止动作的 signal。
|
|
242
|
+
- 动作失败时抛出 `ServiceInvokeException`,带有它的错误的各个属性(第 3 节)。服务没有给出响应时抛出 `ServiceUnavailableException`(`E_SERVICE_UNAVAILABLE`,状态码 503):无法连接到服务,服务在超时之前没有响应,或者它的响应不是封套(例如服务前面的代理返回的错误页面)。
|
|
243
|
+
- `on(event, listener)`、`once()` 和 `off()` 监听服务推送给这个客户端的事件。第一个监听器会打开事件流。事件流断开后,隔 `reconnectDelay` 重新打开;断开期间推送的事件会丢失。
|
|
244
|
+
|
|
245
|
+
### Socket.IO
|
|
246
|
+
|
|
247
|
+
`socketIOServiceEntrypoint()` 通过 Socket.IO 提供动作:适合浏览器,以及保持连接的客户端。
|
|
248
|
+
|
|
249
|
+
| 选项 | 默认值 | 含义 |
|
|
250
|
+
|---|---|---|
|
|
251
|
+
| `port` | `3000` | 它自己的服务器的端口;`0` 表示任选一个空闲端口 |
|
|
252
|
+
| `host` | 所有网络接口 | 它自己的服务器的地址 |
|
|
253
|
+
| `event` | `'message'` | 请求使用的事件 |
|
|
254
|
+
| `namespace` | `'/'` | 请求使用的命名空间 |
|
|
255
|
+
| `options` | | Socket.IO 服务器的选项:`cors`、`path`、`maxHttpBufferSize`…… |
|
|
256
|
+
| `server` | | 共用的 HTTP 服务器,或者返回它的函数(见下文) |
|
|
257
|
+
| `io` | | 一个已有的 Socket.IO 服务器,按原样使用 |
|
|
258
|
+
| `listening` | | `(io, server) => …`,适配器就绪后调用 |
|
|
259
|
+
|
|
260
|
+
```typescript
|
|
261
|
+
import {io, type Socket} from 'socket.io-client'
|
|
262
|
+
|
|
263
|
+
//客户端 id 为 client-1(第 5 节)
|
|
264
|
+
const socket: Socket = io('http://localhost:3000', {auth: {id: 'client-1'}})
|
|
265
|
+
socket.on('progress', (percent: number): void => console.log(`${percent}%`))
|
|
266
|
+
//{success: true, error: null, payload: {id: 1, product: 'lamp'}},5 秒内没有应答时抛出错误
|
|
267
|
+
console.log(await socket.timeout(5000).emitWithAck('message', {resource: 'order', act: 'get', id: 1}))
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
- 请求就是以消息为数据 emit `event` 事件,并附带一个接收封套的回调。不带回调的 emit 仍然会执行动作。
|
|
271
|
+
- 来自其他来源的浏览器需要 `options: {cors: {origin: …}}`。
|
|
272
|
+
- `lakutata/adapter/socket.io` 中的 `socketOfContext(this.context)` 返回发出请求的 socket,比如用来把它加入一个房间。
|
|
273
|
+
|
|
274
|
+
Socket.IO 可以与 HTTP 适配器共用端口:把 `expressHTTPEntrypoint` 的 `before` 钩子收到的服务器交给它。适配器按选项的顺序启动,所以要把 `http` 写在 `service` 前面:
|
|
275
|
+
|
|
276
|
+
```typescript
|
|
277
|
+
import type {Server} from 'node:http'
|
|
278
|
+
import {buildEntrypoints} from 'lakutata/com/entrypoint'
|
|
279
|
+
import {expressHTTPEntrypoint} from 'lakutata/adapter/express'
|
|
280
|
+
import {socketIOServiceEntrypoint} from 'lakutata/adapter/socket.io'
|
|
281
|
+
|
|
282
|
+
let httpServer: Server | undefined
|
|
283
|
+
|
|
284
|
+
export const entrypoint = buildEntrypoints({
|
|
285
|
+
controllers: [],
|
|
286
|
+
http: expressHTTPEntrypoint({port: 8080, before: (_app, context): void => void (httpServer = context.server)}),
|
|
287
|
+
//HTTP 动作和 Socket.IO 都在 8080 端口上
|
|
288
|
+
service: socketIOServiceEntrypoint({server: (): Server => httpServer!})
|
|
289
|
+
})
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
停止时,适配器断开所有 socket,共用的服务器留给它的所有者处理。
|
|
293
|
+
|
|
294
|
+
### JSON-RPC 2.0
|
|
295
|
+
|
|
296
|
+
`jsonRPCServiceEntrypoint()` 通过 JSON-RPC 2.0 提供动作:适合用其他语言编写的客户端、支持这个协议的工具,以及通过标准输入输出调用它的父进程。
|
|
297
|
+
|
|
298
|
+
| 选项 | 默认值 | 含义 |
|
|
299
|
+
|---|---|---|
|
|
300
|
+
| `transport` | `'http'` | `'http'`(每条消息一个 `POST`)、`'websocket'`(需要 `ws`)或 `'stdio'` |
|
|
301
|
+
| `port`、`host`、`path` | `3000`、所有网络接口、`'/'` | HTTP 或 WebSocket 的服务器 |
|
|
302
|
+
| `limit` | `10485760`(10 MiB) | 消息的最大字节数 |
|
|
303
|
+
| `methodField` | `'method'` | 模式中接收方法名的键 |
|
|
304
|
+
| `positionalParams` | | 输入中接收数组形式参数的键;不设置时,拒绝数组形式的参数 |
|
|
305
|
+
| `toPattern` | | `(method, params) => message`:自己构造消息,取代上面两个选项 |
|
|
306
|
+
| `framing` | `'newline'` | stdio 上:每行一条消息,或者 `'content-length'`(带消息头,与 Language Server Protocol 相同) |
|
|
307
|
+
| `input`、`output` | `process.stdin`、`process.stdout` | stdio 使用的流 |
|
|
308
|
+
| `end` | `'application'` | stdio 的输入结束时:`'application'` 在正在处理的请求都得到响应后退出应用,`'none'` 什么都不做,或者一个函数 |
|
|
309
|
+
| `heartbeat` | `30000` | WebSocket 的 ping 间隔,单位毫秒;`0` 表示不发送 |
|
|
310
|
+
| `listening` | | `(server) => …`,HTTP 或 WebSocket 的服务器开始监听后调用 |
|
|
311
|
+
|
|
312
|
+
请求的方法名和参数构成消息:`{"method": "order.get", "params": {"id": 1}}` 就是消息 `{method: 'order.get', id: 1}`,所以动作要在模式中声明方法名:
|
|
313
|
+
|
|
314
|
+
```typescript
|
|
315
|
+
import {Controller} from 'lakutata/com/entrypoint'
|
|
316
|
+
import {ServiceAction} from 'lakutata/decorator/ctrl'
|
|
317
|
+
|
|
318
|
+
class OrderRPCController extends Controller {
|
|
319
|
+
//{"jsonrpc": "2.0", "id": 1, "method": "order.get", "params": {"id": 1}}
|
|
320
|
+
@ServiceAction({method: 'order.get'})
|
|
321
|
+
public async get(input: { id: number }): Promise<{ id: number }> {
|
|
322
|
+
return {id: input.id}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
- 结果是响应中的 `result`;动作什么都不返回时为 `null`。
|
|
328
|
+
- 不带 `id` 的请求是通知:动作会执行,但不发送任何响应(HTTP 上为 204)。批量请求(请求组成的数组)以响应组成的数组回答。
|
|
329
|
+
- 错误是带有错误码的 `error`:
|
|
330
|
+
|
|
331
|
+
| 错误码 | 什么时候 |
|
|
332
|
+
|---|---|
|
|
333
|
+
| `-32700` | 消息不是 JSON |
|
|
334
|
+
| `-32600` | 消息不是 JSON-RPC 2.0 请求,或者批量请求为空 |
|
|
335
|
+
| `-32601` | 没有动作与请求匹配 |
|
|
336
|
+
| `-32602` | 输入没有通过 DTO 的校验,或者参数是数组而没有设置 `positionalParams` |
|
|
337
|
+
| `-32000` | 动作的其他错误,完整的错误放在 `data` 中 |
|
|
338
|
+
| `-32001` | 应用正在停止 |
|
|
339
|
+
|
|
340
|
+
- 在 HTTP 上,请求是发往 `path` 的 `POST`,以 200 响应;其他方法或路径以 404 响应。CORS 响应头允许任何来源和任何请求头。
|
|
341
|
+
- 在 WebSocket 上,每个文本帧是一条消息。推送给客户端的事件以通知的形式发送:`{"jsonrpc": "2.0", "method": "<事件>", "params": [<参数>]}`。
|
|
342
|
+
|
|
343
|
+
**在 stdio 上**,应用是一个子进程:父进程把请求写到它的标准输入,从它的标准输出读取响应和事件。标准输出属于协议:应用的日志要写到标准错误,也不能调用 `console.log()`。
|
|
344
|
+
|
|
345
|
+
```typescript
|
|
346
|
+
import {Application} from 'lakutata'
|
|
347
|
+
import {buildEntrypoints, Controller} from 'lakutata/com/entrypoint'
|
|
348
|
+
import {ServiceAction} from 'lakutata/decorator/ctrl'
|
|
349
|
+
import {Logger} from 'lakutata/com/logger'
|
|
350
|
+
import {jsonRPCServiceEntrypoint} from 'lakutata/adapter/jsonrpc'
|
|
351
|
+
|
|
352
|
+
class ToolController extends Controller {
|
|
353
|
+
@ServiceAction({method: 'sum'})
|
|
354
|
+
public async sum(input: { values: number[] }): Promise<number> {
|
|
355
|
+
return input.values.reduce((total: number, value: number): number => total + value, 0)
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
Application.run(() => ({
|
|
360
|
+
id: 'tool.app',
|
|
361
|
+
name: 'Tool',
|
|
362
|
+
components: {
|
|
363
|
+
//标准输出属于协议
|
|
364
|
+
log: {class: Logger, destinations: [process.stderr]},
|
|
365
|
+
entrypoint: buildEntrypoints({
|
|
366
|
+
controllers: [ToolController],
|
|
367
|
+
service: jsonRPCServiceEntrypoint({transport: 'stdio'})
|
|
368
|
+
})
|
|
369
|
+
},
|
|
370
|
+
bootstrap: ['entrypoint']
|
|
371
|
+
}))
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
```text
|
|
375
|
+
$ echo '{"jsonrpc": "2.0", "id": 1, "method": "sum", "params": {"values": [1, 2, 3]}}' | node build/App.js
|
|
376
|
+
{"jsonrpc":"2.0","id":1,"result":6}
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
输入结束、请求都得到响应之后,应用就会退出(`end`)。
|
|
380
|
+
|
|
381
|
+
### Electron
|
|
382
|
+
|
|
383
|
+
`electronIPCServiceEntrypoint()` 在 Electron 应用的主进程中,向它的窗口提供动作。
|
|
384
|
+
|
|
385
|
+
| 选项 | 默认值 | 含义 |
|
|
386
|
+
|---|---|---|
|
|
387
|
+
| `channel` | `'lakutata:service'` | 请求使用的 IPC 通道;事件使用 `<channel>:event`,订阅使用 `<channel>:subscribe` |
|
|
388
|
+
| `ipcMain` | Electron 的 `ipcMain` | 要使用的 `ipcMain`,测试时可以传入替身 |
|
|
389
|
+
|
|
390
|
+
页面通过窗口的预加载脚本调用主进程:
|
|
391
|
+
|
|
392
|
+
```typescript
|
|
393
|
+
import {contextBridge, ipcRenderer, type IpcRendererEvent} from 'electron'
|
|
394
|
+
|
|
395
|
+
contextBridge.exposeInMainWorld('service', {
|
|
396
|
+
//得到封套,从不抛出错误
|
|
397
|
+
invoke: (message: Record<string, unknown>, clientId: string): Promise<unknown> => ipcRenderer.invoke('lakutata:service', message, clientId),
|
|
398
|
+
//推送给页面的事件:事件名,以及参数组成的数组
|
|
399
|
+
subscribe: (clientId: string, listener: (event: string, args: unknown[]) => void): void => {
|
|
400
|
+
ipcRenderer.on('lakutata:service:event', (_event: IpcRendererEvent, name: string, args: unknown[]): void => listener(name, args))
|
|
401
|
+
ipcRenderer.send('lakutata:service:subscribe', clientId)
|
|
402
|
+
}
|
|
403
|
+
})
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
- 每个页面在订阅和调用时给出自己的客户端 id(`crypto.randomUUID()`);页面在窗口中重新加载后,新的 id 取代原来的 id。没有 id 的页面收不到事件。
|
|
407
|
+
- `lakutata/adapter/electron` 中的 `ipcEventOfContext(this.context)` 返回请求的 IPC 事件,用来找到发出请求的窗口。
|
|
408
|
+
- `lakutata create` 的 Electron 模板包含同样形式的预加载脚本,并为页面提供了 `window.ipc`,它的 `invoke()` 返回结果,或者抛出错误。
|
|
409
|
+
|
|
410
|
+
## 5. 客户端与事件
|
|
411
|
+
|
|
412
|
+
保持连接的传输方式可以向客户端推送事件。客户端通过它给出的 id 来识别:
|
|
413
|
+
|
|
414
|
+
| 传输方式 | 客户端 id |
|
|
415
|
+
|---|---|
|
|
416
|
+
| HTTP 服务 | 请求的 `id` 请求头或 `id` 查询参数;事件流没有给出时为随机 id |
|
|
417
|
+
| Socket.IO | 连接的 `auth.id`,其次是 `id` 请求头,再次是 `id` 查询参数,否则为 socket 的 id |
|
|
418
|
+
| 基于 HTTP 的 JSON-RPC | 请求的 `id` 请求头或 `id` 查询参数;收不到事件 |
|
|
419
|
+
| 基于 WebSocket 的 JSON-RPC | 连接的 `id` 请求头或 `id` 查询参数,否则为随机 id |
|
|
420
|
+
| 基于 stdio 的 JSON-RPC | `'stdio'` |
|
|
421
|
+
| Electron | 页面在订阅或调用时给出的 id |
|
|
422
|
+
| WebRTC | 调用方在房间里的成员 id |
|
|
423
|
+
|
|
424
|
+
动作从 `this.context.clientId` 读取它的客户端 id。`entrypoint` 组件负责推送事件:
|
|
425
|
+
|
|
426
|
+
- `notify(clientId, event, ...args)` 把事件发给一个客户端,通过这个客户端连接着的每种传输方式发送,返回是否送达;
|
|
427
|
+
- `broadcast(event, ...args)` 把事件发给所有已连接的客户端,返回客户端的数量。
|
|
428
|
+
|
|
429
|
+
```typescript
|
|
430
|
+
import {Controller, Entrypoint, ServiceContext} from 'lakutata/com/entrypoint'
|
|
431
|
+
import {ServiceAction} from 'lakutata/decorator/ctrl'
|
|
432
|
+
import {Inject} from 'lakutata/decorator/di'
|
|
433
|
+
|
|
434
|
+
class TaskController extends Controller {
|
|
435
|
+
@Inject('entrypoint')
|
|
436
|
+
protected readonly entrypoint: Entrypoint
|
|
437
|
+
|
|
438
|
+
@ServiceAction({cmd: 'start'})
|
|
439
|
+
public async start(): Promise<void> {
|
|
440
|
+
const clientId: string | undefined = (this.context as ServiceContext).clientId
|
|
441
|
+
//任务完成时通知发起它的客户端;任务开始时通知所有客户端
|
|
442
|
+
if (clientId) setTimeout((): void => void this.entrypoint.notify(clientId, 'done', {task: 1}), 1000)
|
|
443
|
+
this.entrypoint.broadcast('started', {task: 1})
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
客户端这样收到 `notify(clientId, 'progress', 50, 'half')`:
|
|
449
|
+
|
|
450
|
+
| 传输方式 | 收到的内容 |
|
|
451
|
+
|---|---|
|
|
452
|
+
| HTTP 服务 | 事件流中的 `event: progress` 和 `data: [50,"half"]` |
|
|
453
|
+
| Socket.IO | `socket.on('progress', (percent, label) => …)` |
|
|
454
|
+
| JSON-RPC(WebSocket、stdio) | `{"jsonrpc": "2.0", "method": "progress", "params": [50, "half"]}` |
|
|
455
|
+
| Electron | 通过上面预加载脚本中的 `subscribe` 收到 `('progress', [50, 'half'])` |
|
|
456
|
+
| WebRTC | `PeerServiceProxy` 的 `proxy.on('progress', (percent, label) => …)` |
|
|
457
|
+
|
|
458
|
+
## 6. 停止与离开
|
|
459
|
+
|
|
460
|
+
- 应用停止时,新的请求以 `EntrypointStoppedException`(`E_ENTRYPOINT_STOPPED`;JSON-RPC 中错误码为 `-32001`)失败,正在处理的请求有入口的 `shutdownTimeout` 来完成(见 [Entrypoint](Entrypoint.md) 第 4 节)。
|
|
461
|
+
- 客户端在收到响应之前离开时(socket 断开、窗口关闭、HTTP 连接关闭、数据通道关闭),它的请求的 `this.signal` 会被中止。
|
|
462
|
+
- 传输方式不限制调用的时间:客户端自己设置超时,比如上文的 `socket.timeout(5000)`。
|
package/doc/zh/Time.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Lakutata 时间(Time)
|
|
2
2
|
|
|
3
|
-
`Time` 是带时区的 `Date`,提供类似 moment 的 API。它由 `lakutata`(以及 `@lakutata/utils
|
|
3
|
+
`Time` 是带时区的 `Date`,提供类似 moment 的 API。它由 `lakutata`(以及 `@lakutata/utils`)导出,没有任何依赖:时区数据来自运行时(`Intl`),所以时区规则取决于 Node.js 的版本。
|
|
4
4
|
|
|
5
5
|
```typescript
|
|
6
6
|
import {Time} from 'lakutata'
|
|
@@ -17,7 +17,7 @@ time.isBetween('2024-01-01', '2024-12-31', 'day', '[]') // true
|
|
|
17
17
|
|
|
18
18
|
- `Time` 就是 `Date`:凡是接受 `Date` 的地方都可以使用它(`DTO.Date()`、ORM 字段、`JSON.stringify`)。
|
|
19
19
|
- 返回 `Time` 的方法都返回新实例:`add`、`subtract`、`startOf`、`endOf`、`set`、各设置方法(`hours(5)`、`month(0)` 等)、`timezone(zone)`、`clone()`,调用它们的实例不会改变。从 `Date` 继承的方法(`getHours()`、`setHours()` 等)按进程的时区而不是实例的时区读取和修改时间,而且设置方法会修改实例本身:请使用 `Time` 的方法。
|
|
20
|
-
- 构造参数可以是 `Time
|
|
20
|
+
- 构造参数可以是 `Time`(连同它的时区)、`Date`、毫秒时间戳、日期字符串(纯数字字符串视为时间戳;无效字符串抛出 `RangeError`),或默认时区的本地时间字段 `[年, 月, 日, 时, 分, 秒, 毫秒]`。不传参数(或传 `null`)表示当前时间。不带偏移的字符串按默认时区读取。
|
|
21
21
|
- 无效的时间(数组中的字段超出范围)不会抛出异常:它的 `format()` 返回 `'Invalid date'`。
|
|
22
22
|
|
|
23
23
|
## 时区
|