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
@@ -1,14 +1,14 @@
1
- # Lakutata containerd 集成
1
+ # Lakutata Containerd 集成
2
2
 
3
- **containerd 组件**(`lakutata/com/containerd`,`@lakutata/containerd` 包)通过 [containerd](https://containerd.io) 的 gRPC API 管理一个 containerd 命名空间中的镜像、容器、CNI 网络和命名卷,不需要 Docker,也不需要 nerdctl。它面向只运行 containerd 的 Linux 机器,例如嵌入式设备:组件直接与 containerd 的套接字通信(gRPC 客户端用 TypeScript 编写,没有原生插件),并自己完成 nerdctl 在 containerd 之上所做的工作(从镜像仓库拉取、解包镜像层、生成容器的 OCI 规范、容器日志)。它的 API 与 Docker 组件一致([DockerIntegration](DockerIntegration.md))。全部选项见 `@lakutata/containerd` 的 README。
3
+ **containerd 组件**(`lakutata/com/containerd`,`@lakutata/containerd` 包)通过 [containerd](https://containerd.io) 的 gRPC API 管理一个 containerd 命名空间中的镜像、容器、CNI 网络和命名卷,不需要 Docker,也不需要 nerdctl。它面向只运行 containerd 的 Linux 机器,例如嵌入式设备:组件直接与 containerd 的套接字通信(gRPC 客户端用 TypeScript 编写,没有原生插件),并自己完成 nerdctl 在 containerd 之上所做的工作(从镜像仓库拉取、解包镜像层、生成容器的 OCI 规范、容器日志)。它的 API 沿用 Docker 组件的 API([DockerIntegration](DockerIntegration.md))。全部选项见 `@lakutata/containerd` 的 README。
4
4
 
5
- 它创建的容器就是 nerdctl 的容器:带有 nerdctl 的标签(`nerdctl/name`、`nerdctl/hostname` 等),因此 `nerdctl ps` 和 `ctr containers ls` 都能列出它们。
5
+ 它创建的容器与 nerdctl 创建的容器相同:带有 nerdctl 的标签(`nerdctl/name`、`nerdctl/hostname` 等),因此 `nerdctl ps` 和 `ctr containers ls` 都能列出它们。
6
6
 
7
7
  ## 1. 环境要求
8
8
 
9
9
  | 功能 | 需要 |
10
10
  |---|---|
11
- | 全部功能 | Linux,带 runc 的 containerd 1.6 或更高版本,Node.js 22 或更高版本;应用以 root 运行,或属于拥有 containerd 套接字的用户组 |
11
+ | 全部功能 | Linux,带 runc 的 containerd 1.6 或更高版本,Node.js `^22.13.0 || >=24.11.0`;应用以 root 运行,或属于拥有 containerd 套接字的用户组 |
12
12
  | CNI 网络(包括默认的 `bridge`)、端口发布 | CNI 插件(`bridge`、`portmap`、`host-local` 等,位于 `/opt/cni/bin`、`/usr/lib/cni` 或 `/usr/libexec/cni`,即 `cniPath` 选项)以及 iptables |
13
13
  | 带 `stdin` 的 `exec()`、分开的 `stdout`/`stderr`、终端 | 主机上的 `mkfifo`(没有时,`exec()` 把两种输出都放在 `stdout` 中) |
14
14
  | 构建 | BuildKit 守护进程(`buildkitd`),本地或远程 |
@@ -35,7 +35,7 @@ Application
35
35
  cgroupManager: 'cgroupfs',
36
36
  //容器的日志、hosts、resolv.conf、设置,以及命名卷
37
37
  dataRoot: '/var/lib/lakutata/containerd',
38
- //组件启动容器时,超过 10 MiB 的日志文件会被轮转(0:从不轮转)
38
+ //组件启动容器时,超过 10 MiB 的日志文件会被轮转(默认为 0:从不轮转)
39
39
  logMaxSize: 10 * 1024 * 1024,
40
40
  registryAuths: {'registry.example.com': {username: 'deploy', password: 'secret'}},
41
41
  //通过 HTTP 访问的镜像仓库
@@ -56,7 +56,7 @@ Application
56
56
  })
57
57
  ```
58
58
 
59
- 组件在创建时连接 containerd:`socketPath` 上没有 containerd 时,应用启动失败并抛出 `ContainerdConnectionException`。命名空间不存在时会被创建。每个组件只在一个命名空间中工作;要管理多个命名空间,就为每个命名空间声明一个组件。
59
+ 组件在创建时连接 containerd:`socketPath` 上没有 containerd 时,组件创建失败,抛出 `ContainerdConnectionException`;组件列在 `bootstrap` 里时,应用因此启动失败。命名空间不存在时会被创建。每个组件只在一个命名空间中工作;要管理多个命名空间,就为每个命名空间声明一个组件。
60
60
 
61
61
  ### 与 Docker 组件的不同
62
62
 
@@ -167,7 +167,7 @@ async function containers(containerd: Containerd): Promise<void> {
167
167
  //同时删除它的快照和状态
168
168
  await container.remove({force: true})
169
169
 
170
- //按名称、标识符或其前缀查找;按标签列出
170
+ //带有这些标签的容器;getContainer() 按名称、id 或 id 的前缀查找一个容器
171
171
  console.log(await containerd.listContainers({app: 'worker'}))
172
172
  }
173
173
  ```
@@ -175,7 +175,7 @@ async function containers(containerd: Containerd): Promise<void> {
175
175
  - `devices` 在容器中创建主机的设备(单个设备,或某个目录下的所有设备,如 `/dev/snd`)。对于之后才插入的设备,绑定挂载它们所在的目录,并用 `deviceCgroupRules` 允许它们的设备号,与 Docker 的 `--device-cgroup-rule` 相同:`binds: [{hostPath: '/dev', containerPath: '/dev'}], deviceCgroupRules: ['c 188:* rwm']`。
176
176
  - 在 cgroup v2 上,容器有自己的 cgroup 命名空间(`cgroupns: 'host'` 则共享主机的)。
177
177
  - 容器的系统调用像 nerdctl 一样由 seccomp 过滤:使用 containerd 的默认配置,并放行容器 `capabilities` 所允许的系统调用(`capabilities: ['SYS_ADMIN']` 允许 `mount`、`unshare` 等)。`seccompProfile: 'unconfined'` 关闭过滤,给出文件路径则使用该 OCI 配置;特权容器不受限制。
178
- - `privileged`、`capabilities` / `capabilitiesDrop`、`readonlyRootfs`、`pidMode: 'host'`、`ipcMode: 'host'`、`sysctls`、`extraHosts`、`dns`、`shmSize`、`pidsLimit`、`cpuSet`、`cpuShares`、`memorySwap`、`OOMKillDisable` 与 Docker 的同名选项相同。
178
+ - `privileged`、`capabilities` / `capabilitiesDrop`、`readonlyRootfs`、`pidMode: 'host'`、`ipcMode: 'host'`、`sysctls`、`extraHosts`、`dns`、`shmSize`、`pidsLimit`、`cpuSet`、`cpuShares`、`memorySwap`、`OOMKillDisable` 与 Docker 的同名选项作用相同。
179
179
  - `update()` 修改资源和重启策略以外的设置时,只对组件创建的容器有效(组件把它们的设置保存在 `dataRoot` 中)。
180
180
 
181
181
  ### 卷
@@ -260,7 +260,7 @@ BuildKit 使用组件的镜像仓库凭据(或 `auth`)拉取基础镜像。
260
260
 
261
261
  ## 7. 平台与事件
262
262
 
263
- `containerd.supportedPlatforms()` 返回本机能运行的平台:本机平台,以及在 `binfmt_misc` 中注册的模拟器(QEMU、Rosetta)对应的平台。注册了模拟器后,就能运行其他平台的容器:先拉取该平台的镜像,再 `createContainer({image: 'alpine:3.20', platform: 'linux/amd64', cmd: ['uname', '-m']})`。
263
+ `containerd.supportedPlatforms()` 返回本机可以运行的平台:本机平台,以及在 `binfmt_misc` 中注册的模拟器(QEMU、Rosetta)对应的平台。注册了模拟器后,就能运行其他平台的容器:先拉取该平台的镜像,再 `createContainer({image: 'alpine:3.20', platform: 'linux/amd64', cmd: ['uname', '-m']})`。
264
264
 
265
265
  `containerd.events(signal)` 以流的形式返回命名空间中 containerd 的事件:
266
266
 
@@ -0,0 +1,228 @@
1
+ # Lakutata 控制器与动作
2
+
3
+ 控制器是一个类,它的方法用来响应请求。这些方法叫做**动作**:装饰器给动作一个地址(HTTP 路由、命令、服务模式、定时计划、消息队列),请求到达这个地址时,`entrypoint` 组件就调用它。同一个方法可以有多个地址:同一段代码可以同时通过 HTTP、命令行和服务传输方式响应请求,校验、中间件和访问控制都相同。
4
+
5
+ ## 1. 一个控制器
6
+
7
+ ```typescript
8
+ import {Application, DTO} from 'lakutata'
9
+ import {buildEntrypoints, Controller, httpServiceEntrypoint} from 'lakutata/com/entrypoint'
10
+ import {CLIAction, GET, POST, ServiceAction} from 'lakutata/decorator/ctrl'
11
+ import {Expect} from 'lakutata/decorator/dto'
12
+ import {expressHTTPEntrypoint} from 'lakutata/adapter/express'
13
+ import {commanderCLIEntrypoint} from 'lakutata/adapter/commander'
14
+
15
+ class OrderIdDTO extends DTO {
16
+ //路由参数是文本:strict(false) 会转换它(命令行会自行转换它的参数)
17
+ @Expect(DTO.Number().integer().strict(false).required())
18
+ public id: number
19
+ }
20
+
21
+ class CreateOrderDTO extends DTO {
22
+ @Expect(DTO.String().required())
23
+ public product: string
24
+
25
+ @Expect(DTO.Number().integer().min(1).default(1))
26
+ public quantity: number
27
+ }
28
+
29
+ class OrderController extends Controller {
30
+ //GET /orders/42、命令 "order 42"、服务消息 {cmd: 'order.get', id: 42}
31
+ @GET('/orders/:id', OrderIdDTO)
32
+ @CLIAction('order <id>', OrderIdDTO, {description: 'Show an order'})
33
+ @ServiceAction({cmd: 'order.get'}, OrderIdDTO)
34
+ public async get(input: OrderIdDTO): Promise<{ id: number, status: string }> {
35
+ return {id: input.id, status: 'paid'}
36
+ }
37
+
38
+ //带 JSON 请求体的 POST /orders
39
+ @POST('/orders', CreateOrderDTO)
40
+ public async create(input: CreateOrderDTO): Promise<CreateOrderDTO> {
41
+ return input
42
+ }
43
+ }
44
+
45
+ Application.run(() => ({
46
+ id: 'orders.app',
47
+ name: 'Orders',
48
+ components: {
49
+ entrypoint: buildEntrypoints({
50
+ controllers: [OrderController],
51
+ http: expressHTTPEntrypoint({port: 8080}),
52
+ service: httpServiceEntrypoint({port: 8081}),
53
+ cli: commanderCLIEntrypoint({bin: 'orders', noCommand: 'ignore'})
54
+ })
55
+ },
56
+ bootstrap: ['entrypoint']
57
+ }))
58
+ ```
59
+
60
+ ```text
61
+ $ curl localhost:8080/orders/42
62
+ {"id":42,"status":"paid"}
63
+ $ curl -X POST localhost:8080/orders -H 'Content-Type: application/json' -d '{"product":"tea","extra":1}'
64
+ {"product":"tea","quantity":1}
65
+ $ curl -X POST localhost:8081 -H 'Content-Type: application/json' -d '{"cmd":"order.get","id":42}'
66
+ {"success":true,"error":null,"payload":{"id":42,"status":"paid"}}
67
+ $ node dist/index.js order 42
68
+ {
69
+ "id": 42,
70
+ "status": "paid"
71
+ }
72
+ ```
73
+
74
+ - 控制器继承 `lakutata/com/entrypoint` 中的 `Controller`。
75
+ - 控制器列在 `buildEntrypoints` 的 `controllers` 选项里,和各传输方式的适配器放在一起(见 [Entrypoint](Entrypoint.md))。入口必须列在 `bootstrap` 里,否则它不会被启动。
76
+ - 没有对应的适配器时,传输方式的装饰器不起作用:上面如果没有 `cli`,`@CLIAction` 就会被忽略。
77
+
78
+ ## 2. 请求的处理过程
79
+
80
+ 1. 适配器收到请求,按地址查找动作。找不到时,传输方式会回复“找不到”(HTTP 是 404)。
81
+ 2. 为这个请求创建一个新的作用域。
82
+ 3. 执行中间件(见 [Middlewares](Middlewares.md))。
83
+ 4. 在这个作用域里创建控制器的一个新实例。
84
+ 5. 动作的 DTO 校验输入(第 4 节)。
85
+ 6. 访问控制检查请求(见 [Middlewares](Middlewares.md))。
86
+ 7. 用输入调用方法,方法的返回值就是响应(第 5 节)。
87
+ 8. 销毁作用域,以及在其中创建的对象。
88
+
89
+ 所以,一个控制器只存活一个请求的时间。它注入的 `@Scoped` 提供者(提供者默认就是这种)在一个请求里只创建一次,由参与这个请求的所有对象共享;组件则由整个应用共享。
90
+
91
+ 在方法里,有两个属性描述当前请求:
92
+
93
+ - `this.context` 是请求的上下文。它的 `type` 表示传输方式,它的类提供该传输方式的细节:
94
+
95
+ | 类 | `type` | 特有的属性 |
96
+ |---|---|---|
97
+ | `HTTPContext` | `'HTTP'` | `request`、`response`、`route`(声明时的路由)、`method` |
98
+ | `CLIContext` | `'CLI'` | `command`(声明时的命令) |
99
+ | `ServiceContext` | `'SERVICE'` | `clientId`、`input`(完整的消息) |
100
+ | `ScheduleContext` | `'SCHEDULE'` | `schedule`、`scheduledAt`、`run` |
101
+ | `QueueContext`(`lakutata/com/queue`) | `'QUEUE'` | 见 [Queue](Queue.md) |
102
+
103
+ 所有上下文都有 `data`(输入到达时的样子,尚未校验)和 `locale`(使用 I18n 中间件时,请求的语言)。
104
+ - `this.signal` 是一个 `AbortSignal`(它的类型允许 `undefined`,所以要写成 `this.signal?.`)。请求被中止时它会触发:客户端离开、gRPC 调用的截止时间已过,或者应用正在停止而 `shutdownTimeout` 已用完。触发之后,动作的结果会被丢弃;耗时长的动作应该检查它并提前结束。
105
+
106
+ ## 3. 动作装饰器
107
+
108
+ | 装饰器 | 地址 | 指南 |
109
+ |---|---|---|
110
+ | `@GET(route)`、`@POST`、`@PUT`、`@PATCH`、`@DELETE`、`@HEAD`、`@OPTIONS`、`@HTTPAction(route, methods)` | HTTP 路由 | [HTTP](HTTP.md) |
111
+ | `@CLIAction(command)` | 命令 | [CLI](CLI.md) |
112
+ | `@ServiceAction(pattern)` | 服务模式 | [ServiceCalls](ServiceCalls.md)、[GRPC](GRPC.md)、[NATS](NATS.md) |
113
+ | `@Schedule(schedule)` | 间隔或 cron 表达式 | [Schedules](Schedules.md) |
114
+ | `@QueueAction(queue)`(`lakutata/com/queue`) | 消息队列 | [Queue](Queue.md) |
115
+ | `@MCPTool(name)`(`@lakutata/mcp`)、`@DBusMethod(...)`(`@lakutata/dbus`) | MCP 工具、D-Bus 方法 | [MCP](MCP.md)、[DBus](DBus.md) |
116
+
117
+ 除非表中注明了其他子路径,前五行的装饰器都来自 `lakutata/decorator/ctrl`。它们依次接受地址、输入的 DTO 类和动作选项,DTO 和选项都可以省略。`@Schedule` 是例外:它不接受 DTO,因为定时任务没有输入。
118
+
119
+ 动作选项:
120
+
121
+ | 选项 | 默认值 | 含义 |
122
+ |---|---|---|
123
+ | `name` | `'控制器类名:方法名'` | 动作名,在同一传输方式中必须唯一 |
124
+ | `description` | | 说明:命令的帮助、动作列表中的描述 |
125
+ | `middlewares` | | 这个动作的中间件(见 [Middlewares](Middlewares.md)) |
126
+ | `acl` | `false` | 用入口的访问规则检查请求 |
127
+ | `rule` | | 这个动作自己的访问规则:`(context, input) => Promise<boolean>` |
128
+ | `groups` | | 动作所属的分组,访问规则和动作列表会用到(见 [Entrypoint](Entrypoint.md)) |
129
+
130
+ ## 4. 输入
131
+
132
+ 各传输方式从请求中组装输入:
133
+
134
+ | 传输方式 | 输入 |
135
+ |---|---|
136
+ | HTTP | 查询参数和请求体,路由参数覆盖在上面 |
137
+ | 命令行 | 参数和选项 |
138
+ | 服务 | 消息,去掉模式中的键 |
139
+ | 定时任务 | 无 |
140
+ | 消息队列 | 消息 |
141
+
142
+ 然后由动作的 DTO 校验:
143
+
144
+ - 查询参数和路由参数的值都是文本:要求数字或布尔值的规则需要 `.strict(false)`,才能转换 `'42'` 或 `'true'`。命令行则会自行把参数和选项转换成对应属性的类型(见 [CLI](CLI.md));
145
+ - 补上默认值,去掉 DTO 没有声明的键(上例中的 `extra`);
146
+ - 输入不合法时,请求以 `InvalidValueException` 结束,HTTP 返回状态码 400。消息指出第一个不合法的值:`"product" is required`。使用 I18n 中间件时,消息是请求的语言。
147
+
148
+ 方法收到的是校验后的输入,一个普通对象的副本:它不是 DTO 类的实例,DTO 类只为它提供类型。没有 DTO 的动作收到的是原样的输入,所有键都保留。
149
+
150
+ ## 5. 结果
151
+
152
+ 方法的返回值由传输方式发送回去:
153
+
154
+ | 传输方式 | 返回值 | `undefined` | 抛出的错误 |
155
+ |---|---|---|---|
156
+ | HTTP | JSON | 状态码 204 | 它的 `statusCode`(默认 500)和 `{errno, errMsg}` |
157
+ | 命令行 | 写到 stdout:文本原样输出,其他值输出为 JSON | 不输出 | stderr 上的 `error: <消息>`,退出码 1 |
158
+ | 服务 | `{success: true, error: null, payload}` | 没有 `payload` | `{success: false, error, payload: null}`,错误带着它的全部属性 |
159
+ | 定时任务 | 忽略 | | 交给 `onError`,或者写入日志 |
160
+
161
+ `lakutata/com/entrypoint` 中的 `Result` 可以更细地控制响应:
162
+
163
+ - `Result.status(status, data, headers)`:带着数据指定 HTTP 状态码和响应头;
164
+ - `Result.file(path)`(也可以是 `Buffer` 或流,选项有 `{filename, contentType, etag, headers}`):一个文件,给出 `filename` 时作为下载;
165
+ - `Result.events(producer)`:在 HTTP 上是 Server-Sent Events,在命令行上输出每个事件的数据。生产函数 `(send, signal) => …` 在请求的作用域销毁之后才运行,所以它应该使用组件,而不是请求作用域内的提供者。
166
+
167
+ 服务传输方式不能发送文件或事件:这样的结果会让请求失败。
168
+
169
+ ## 6. 前缀
170
+
171
+ 在控制器上加 `@Prefix`,可以给它所有动作的地址加上前缀:
172
+
173
+ ```typescript
174
+ import {DTO} from 'lakutata'
175
+ import {Controller} from 'lakutata/com/entrypoint'
176
+ import {CLIAction, GET, Prefix, ServiceAction} from 'lakutata/decorator/ctrl'
177
+ import {Expect} from 'lakutata/decorator/dto'
178
+
179
+ class UserIdDTO extends DTO {
180
+ @Expect(DTO.String().required())
181
+ public id: string
182
+ }
183
+
184
+ @Prefix({route: '/users', command: 'user', pattern: {resource: 'user'}})
185
+ class UserController extends Controller {
186
+ //GET /users/:id、命令 "user:get <id>"、消息 {resource: 'user', act: 'get', id}
187
+ @GET('/:id', UserIdDTO)
188
+ @CLIAction('get <id>', UserIdDTO)
189
+ @ServiceAction({act: 'get'}, UserIdDTO)
190
+ public async get(input: UserIdDTO): Promise<{ id: string }> {
191
+ return {id: input.id}
192
+ }
193
+ }
194
+ ```
195
+
196
+ - `route` 加在 HTTP 路由前面,`command` 加在命令前面(用冒号连接),`pattern` 合并进服务模式。同一个键在前缀和动作里取值不同会报错(`InvalidPrefixException`)。
197
+ - 定时任务没有前缀。
198
+ - 控制器使用离它最近的、声明了前缀的类的前缀:先看自己,没有再看父类。
199
+
200
+ ## 7. 继承
201
+
202
+ 控制器会继承父类的动作,所以通用的动作可以在基类里只写一次。注册的子类会在自己的前缀下提供这些动作:
203
+
204
+ ```typescript
205
+ import {Controller} from 'lakutata/com/entrypoint'
206
+ import {GET, Prefix} from 'lakutata/decorator/ctrl'
207
+
208
+ abstract class HealthController extends Controller {
209
+ @GET('/health')
210
+ public async health(): Promise<string> {
211
+ return 'ok'
212
+ }
213
+ }
214
+
215
+ //GET /v1/health
216
+ @Prefix({route: '/v1'})
217
+ class V1Controller extends HealthController {
218
+ }
219
+
220
+ //GET /v2/health
221
+ @Prefix({route: '/v2'})
222
+ class V2Controller extends HealthController {
223
+ }
224
+ ```
225
+
226
+ - 只注册子类(`controllers: [V1Controller, V2Controller]`)。如果父类和子类一起注册,而某个动作的地址相同,它们会冲突:后注册的生效。
227
+ - 子类可以重写继承来的方法:动作保持原来的地址,执行新的代码。在重写的方法上再加装饰器,会增加一个地址,继承来的地址仍然保留。
228
+ - 基类可以是抽象类。
package/doc/zh/DBus.md ADDED
@@ -0,0 +1,257 @@
1
+ # Lakutata D-Bus
2
+
3
+ D-Bus 是 Linux 桌面和系统服务使用的消息总线:同一台机器上的程序通过它调用彼此的方法、读取彼此的属性、监听彼此的信号。`@lakutata/dbus` 包把控制器方法作为 D-Bus 服务的方法提供出来,对外暴露对象、属性和信号,也可以作为客户端调用其他服务。它不属于 `lakutata`:
4
+
5
+ ```bash
6
+ npm install lakutata @lakutata/dbus
7
+ ```
8
+
9
+ 它需要一个 D-Bus 守护进程:Linux 机器上的会话总线或系统总线,或者用 `dbus-daemon` 启动的私有总线(用于测试;在 macOS 上可以使用 Homebrew 的 `dbus` 包)。
10
+
11
+ ## 1. 一个服务
12
+
13
+ ```typescript
14
+ import {Application, DTO} from 'lakutata'
15
+ import {buildEntrypoints, Controller} from 'lakutata/com/entrypoint'
16
+ import {Expect, Return} from 'lakutata/decorator/dto'
17
+ import {dbusEntrypoint, DBusInterface, DBusMethod} from '@lakutata/dbus'
18
+
19
+ class ConfigureDTO extends DTO {
20
+ @Expect(DTO.String().required())
21
+ public sn: string
22
+
23
+ @Expect(DTO.Number().integer().min(0).optional())
24
+ public retries?: number
25
+ }
26
+
27
+ @DBusInterface('com.example.Manager', {path: '/com/example/Manager'})
28
+ class ManagerController extends Controller {
29
+ //没有参数,返回一个字符串(s)
30
+ @DBusMethod('Ping', {returns: 's'})
31
+ public async ping(): Promise<string> {
32
+ return 'pong'
33
+ }
34
+
35
+ //一个 a{sv} 参数,返回一个 a{sv} 字典(@Return 的类型)
36
+ @DBusMethod('Configure', ConfigureDTO)
37
+ @Return(ConfigureDTO)
38
+ public async configure(input: ConfigureDTO): Promise<ConfigureDTO> {
39
+ return input
40
+ }
41
+ }
42
+
43
+ Application.run(() => ({
44
+ id: 'devices.app',
45
+ name: 'Devices',
46
+ components: {
47
+ entrypoint: buildEntrypoints({
48
+ controllers: [ManagerController],
49
+ dbus: dbusEntrypoint({bus: 'session', service: 'com.example.DeviceService'})
50
+ })
51
+ },
52
+ bootstrap: ['entrypoint']
53
+ }))
54
+ ```
55
+
56
+ ```text
57
+ $ gdbus call --session --dest com.example.DeviceService --object-path /com/example/Manager --method com.example.Manager.Ping
58
+ ('pong',)
59
+ $ gdbus call --session --dest com.example.DeviceService --object-path /com/example/Manager --method com.example.Manager.Configure "{'sn': <'A1'>, 'retries': <3>}"
60
+ ({'sn': <'A1'>, 'retries': <3>},)
61
+ ```
62
+
63
+ - `@DBusInterface(name, {path})` 让控制器成为一个接口,在某个对象路径上提供。路径相同的多个控制器,是同一个对象的多个接口。
64
+ - `@DBusMethod(name, DTO?, options?)` 让控制器的方法成为接口的方法。它的选项是所有动作共有的选项,外加结果的类型 `returns`(第 3 节)。
65
+ - 每个对象还实现了 `org.freedesktop.DBus.Properties`、`Introspectable` 和 `Peer`;根对象 `/` 实现了 `ObjectManager`。
66
+ - 在使用 systemd 的机器上,`busctl --user call com.example.DeviceService /com/example/Manager com.example.Manager Ping` 与第一条 `gdbus` 命令的作用相同。
67
+
68
+ ## 2. 适配器
69
+
70
+ | 选项 | 默认值 | 含义 |
71
+ |---|---|---|
72
+ | `service` | 必填 | 服务的公认名称:由字母、数字和 `_` 组成的若干段,以点分隔 |
73
+ | `bus` | `'session'` | `'session'`(`DBUS_SESSION_BUS_ADDRESS` 中的地址)、`'system'`、一个地址(`'unix:path=…'`、`'tcp:host=…,port=…'`),或者私有总线的套接字路径 |
74
+ | `replaceExisting` | `false` | 在名称的持有者允许时,从它那里接管名称 |
75
+ | `allowReplacement` | `false` | 允许其他服务接管名称 |
76
+ | `decode` | `{int64: 'number'}` | 参数如何解码(第 3 节) |
77
+ | `name`、`middlewares` | | 适配器的名字,以及它的调用使用的中间件,在入口的中间件之后执行 |
78
+
79
+ - 应用启动时,适配器连接总线并获取名称。连不上总线(对于 `'session'`,没有设置 `DBUS_SESSION_BUS_ADDRESS` 也一样),或者名称已被其他连接持有,都会让启动失败。
80
+ - D-Bus 允许服务名称中有 `-`,但这个适配器不允许:服务名称是错误名称的前缀,而错误名称中不能有 `-`。
81
+ - 在系统总线上,需要 `/etc/dbus-1/system.d/` 中的策略文件允许进程持有这个名称。
82
+ - 设置 `allowReplacement` 时,用 `replaceExisting` 接管名称的服务从此收到所有调用。被替换的应用仍在运行,但没有名称,在另一个服务停止后也拿不回名称。
83
+ - 应用停止时,适配器先释放名称(新的调用以 `org.freedesktop.DBus.Error.ServiceUnknown` 失败),等正在处理的调用结束,再断开连接。
84
+
85
+ ## 3. 参数与结果
86
+
87
+ **输入。** 带 DTO 的方法接受一个参数:一个变体字典(`a{sv}`),包含 DTO 的各个属性;没有 DTO 的方法不接受参数。输入与其他传输方式一样,经过中间件、校验和访问控制。变体可以是任何类型,所以字符串字典(`a{ss}`)也能被接受。
88
+
89
+ | D-Bus 类型 | JavaScript 值 |
90
+ |---|---|
91
+ | `s`、`o` | 字符串 |
92
+ | `y`、`n`、`i`、`u`、`d` | 数字 |
93
+ | `x`、`t`(64 位) | 安全整数时为数字,否则为 bigint |
94
+ | `b` | 布尔值 |
95
+ | `as`、`ai`…… | 数组 |
96
+ | `a{…}` | 对象 |
97
+ | `(…)` | 数组 |
98
+
99
+ 64 位整数解码为数字,这样 `DTO.Number()` 才能接受它们;`decode: {int64: 'bigint'}` 把它们保留为 bigint,供 `DTO.BigInt()` 使用。
100
+
101
+ **结果**的类型是 `returns` 选项:`'s'` 这样的签名,或者一个 DTO(以 `a{sv}` 发送)。没有 `returns` 时,类型取自 `@Return` 的校验规则:
102
+
103
+ | `@Return` 的校验规则 | D-Bus 类型 |
104
+ |---|---|
105
+ | 对象、DTO | `a{sv}` |
106
+ | 字符串 | `s` |
107
+ | 布尔值 | `b` |
108
+ | 整数(`.integer()`) | `x` |
109
+ | 其他数字 | `d` |
110
+ | bigint | `x` |
111
+ | 数组 | `av` |
112
+ | 其他 | `v` |
113
+
114
+ - 既没有 `returns` 也没有 `@Return` 时,方法没有结果。
115
+ - 日期以 ISO 字符串发送,对象和数组中的 `null` 和 `undefined` 会被去掉:变体没有 null。结果为 `a{sv}` 的方法什么都不返回时,发送 `{}`。
116
+ - 结果为单个类型(`'s'`、`'i'`……)的方法必须返回一个值:返回 `undefined` 会让调用失败。
117
+ - `Map`、`Buffer` 以及 `dbus-sdk` 的带类型的值(`new DBusUInt32(7)`)按原样发送。
118
+
119
+ ## 4. 对象、属性与信号
120
+
121
+ 接口的路径可以是一个具体路径(`/com/example/Manager`),它的对象在适配器启动时导出;也可以是一个模板(`/com/example/Devices/{sn}`),由应用通过 `DBusObjectManager` 添加和移除它的对象。路径中的变量会合并到方法的输入中,覆盖客户端发送的值。
122
+
123
+ ```typescript
124
+ import {Component, DTO} from 'lakutata'
125
+ import {Controller} from 'lakutata/com/entrypoint'
126
+ import {Expect, Return} from 'lakutata/decorator/dto'
127
+ import {Inject} from 'lakutata/decorator/di'
128
+ import {
129
+ DBusInterface,
130
+ DBusMethod,
131
+ DBusObjectManager,
132
+ defineDBusProperty,
133
+ defineDBusSignal,
134
+ type DBusPropertyWrite
135
+ } from '@lakutata/dbus'
136
+
137
+ class DeviceSnDTO extends DTO {
138
+ @Expect(DTO.String().required())
139
+ public sn: string
140
+ }
141
+
142
+ class StatusDTO extends DTO {
143
+ @Expect(DTO.String().required())
144
+ public sn: string
145
+
146
+ @Expect(DTO.Boolean().required())
147
+ public online: boolean
148
+ }
149
+
150
+ export const DeviceChanged = defineDBusSignal<StatusDTO>('Changed', StatusDTO)
151
+ export const DeviceState = defineDBusProperty<string>('State', 's', {initial: 'idle'})
152
+ export const DeviceLevel = defineDBusProperty<number>('Level', 'i', {writable: true, initial: 1, schema: DTO.Number().integer().min(0).max(10)})
153
+
154
+ @DBusInterface('com.example.Device', {path: '/com/example/Devices/{sn}', signals: [DeviceChanged], properties: [DeviceState, DeviceLevel]})
155
+ export class DeviceController extends Controller {
156
+ //输入中的 sn 取自对象路径
157
+ @DBusMethod('Status', DeviceSnDTO)
158
+ @Return(StatusDTO)
159
+ public async status(input: DeviceSnDTO): Promise<StatusDTO> {
160
+ return {sn: input.sn, online: true} as StatusDTO
161
+ }
162
+ }
163
+
164
+ //在应用的 components 中声明
165
+ export class Devices extends Component {
166
+ @Inject(DBusObjectManager)
167
+ protected readonly manager: DBusObjectManager
168
+
169
+ protected async init(): Promise<void> {
170
+ this.manager.on('propertyWritten', (write: DBusPropertyWrite): void => {
171
+ if (write.property === DeviceLevel) console.log(`${write.path}: level ${write.value}`)
172
+ })
173
+ }
174
+
175
+ public plug(sn: string): void {
176
+ const path: string = `/com/example/Devices/${sn}`
177
+ this.manager.addObject(path)
178
+ this.manager.setPropertyValue(path, DeviceState, 'running')
179
+ this.manager.emitSignal(path, DeviceChanged, {sn: sn, online: true} as StatusDTO)
180
+ }
181
+
182
+ public unplug(sn: string): void {
183
+ this.manager.removeObject(`/com/example/Devices/${sn}`)
184
+ }
185
+ }
186
+ ```
187
+
188
+ - `DBusObjectManager` 是每个应用都有的组件:不需要声明,直接注入(`@Inject(DBusObjectManager)`)即可。在适配器启动之前添加的对象,会在它启动时导出。客户端通过根对象的 `InterfacesAdded` 和 `InterfacesRemoved` 信号得知对象的添加和移除。
189
+ - 属性为每个对象保存一个值,在设置之前是它的 `initial` 值。`setPropertyValue()` 用属性的 `schema` 校验值,并告诉客户端值已变化(`PropertiesChanged`)。
190
+ - 客户端可以写入 `writable` 属性:值经过校验(错误交给客户端)后保存,并发出 `propertyWritten`。需要传到设备上、或者校验通过后仍可能失败的写入,更适合做成方法,这样错误能交给调用方。
191
+ - `emitSignal()` 用信号的 DTO 校验数据,并返回这个路径上是否有对象发送了它。
192
+ - 属性和信号的签名在定义时就会检查。
193
+
194
+ ## 5. 错误
195
+
196
+ 方法抛出的错误,包括校验和访问控制的错误,会作为 D-Bus 错误连同消息一起交给调用方:
197
+
198
+ | 抛出的错误 | D-Bus 错误名称 |
199
+ |---|---|
200
+ | 带 `errno` 的异常(`E_OFFLINE`) | `com.example.DeviceService.E_OFFLINE` |
201
+ | 含有点的 `errno`(`org.example.Device.Offline`) | `org.example.Device.Offline` |
202
+ | 不以字母开头的 `errno`(`500`、`_HIDDEN`) | `com.example.DeviceService.E500`、`….E_HIDDEN` |
203
+ | 没有 `errno` 的错误(`Error`、`TypeError`) | `com.example.DeviceService.Error`、`….TypeError` |
204
+
205
+ 其他情况在到达任何动作之前就被响应:对象不存在时为 `org.freedesktop.DBus.Error.UnknownObject`,方法不存在时为 `UnknownMethod`,参数类型不对时为 `InvalidArgs`;没有应用持有这个名称时,总线以 `ServiceUnknown` 响应。
206
+
207
+ ## 6. 上下文与访问控制
208
+
209
+ 在 D-Bus 方法中,`this.context` 是一个 `DBusContext`:它的 `type` 是 `'DBUS'`(`DBUS_CONTEXT_TYPE`),`path` 是被调用对象的路径,`interface` 和 `member` 指明方法,`data` 是收到的字典,未经校验。
210
+
211
+ 入口的访问规则适用于以 `acl: true` 声明的方法,前提是规则的 `supportContextTypes()` 包含 `DBUS_CONTEXT_TYPE`(见 [Middlewares](Middlewares.md))。上下文不提供调用方的身份:访问规则无法检查调用方,这是系统总线上的总线策略要做的事。
212
+
213
+ ## 7. 客户端
214
+
215
+ `DBusClientComponent` 在创建时连接总线,用来调用服务、读取它们的属性、监听它们的信号:
216
+
217
+ ```typescript
218
+ import {Application} from 'lakutata'
219
+ import {DBusClientComponent, type DBusRemoteInterface} from '@lakutata/dbus'
220
+
221
+ Application
222
+ .run(() => ({
223
+ id: 'client.app',
224
+ name: 'Client',
225
+ components: {
226
+ dbus: {class: DBusClientComponent, busAddress: process.env.DBUS_SESSION_BUS_ADDRESS!, callTimeout: 5000}
227
+ }
228
+ }))
229
+ .onLaunched(async (app: Application): Promise<void> => {
230
+ const dbus: DBusClientComponent = await app.getObject('dbus')
231
+ //带签名的调用:输出参数组成的数组
232
+ const [status] = await dbus.invoke({
233
+ service: 'com.example.DeviceService',
234
+ objectPath: '/com/example/Devices/A1',
235
+ interface: 'com.example.Device',
236
+ method: 'Status',
237
+ signature: 'a{sv}',
238
+ args: [{sn: 'A1'}]
239
+ })
240
+ console.log(status)
241
+ //根据对象的内省信息构建的接口
242
+ const device: DBusRemoteInterface = await dbus.interface('com.example.DeviceService', '/com/example/Devices/A1', 'com.example.Device')
243
+ console.log(await device.property.State.get())
244
+ device.signal.on('Changed', (data: unknown): void => console.log(data))
245
+ })
246
+ ```
247
+
248
+ | 选项 | 默认值 | 含义 |
249
+ |---|---|---|
250
+ | `busAddress` | 必填 | 总线的地址(`'unix:path=/var/run/dbus/system_bus_socket'`),不能是 `'session'`,也不能是路径 |
251
+ | `timeout` | 20 秒 | 连接最多可以用多长时间,单位毫秒 |
252
+ | `callTimeout` | 无 | 调用最多等待回复多长时间,单位毫秒;单次调用可以给出自己的 `timeout` |
253
+ | `decode` | `{int64: 'number'}` | 回复和信号如何解码 |
254
+
255
+ - `invoke()` 得到输出参数组成的数组;接口的方法(`device.method.Status()`)直接得到值本身。
256
+ - 调用失败时,得到一个 `Error`,它的 `name` 是 D-Bus 错误名称(`com.example.DeviceService.E_OFFLINE`),没有 `errno`。
257
+ - 应用停止时,组件断开连接。