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
@@ -0,0 +1,546 @@
1
+ # 设备上的服务
2
+
3
+ 这个示例为小型 Linux 机器(例如树莓派或网关)构建一个服务:它每 10 秒读取一次机器的负载、内存和温度,在内存中保留最近一小时的读数,并通过 HTTP 提供这些读数。它从 `service` 模板及其 `installer` 功能开始,展示定时任务、自己编写的组件及其选项、lakutata 的监控器、用环境变量配置服务,以及把服务安装到机器上、让它随系统启动的安装包。
4
+
5
+ | 方法 | 路由 | 作用 |
6
+ |---|---|---|
7
+ | `GET` | `/readings?limit=10` | 取最近的读数,最新的在前 |
8
+ | `GET` | `/status` | 取服务的运行时长、读数条数,以及服务自身的 CPU 和内存占用 |
9
+ | `GET` | `/health` | 检查进程是否在响应(模板自带) |
10
+
11
+ ## 1. 创建项目
12
+
13
+ ```bash
14
+ npx @lakutata/cli create --name telemetry --template service --with installer --yes
15
+ cd telemetry
16
+ rm packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.ts
17
+ ```
18
+
19
+ 项目是一个工作区,服务在 `packages/app` 中(见 [ProjectStructure](../ProjectStructure.md))。`installer` 功能添加了制作安装包所需的内容:`packages/app/package.json` 中的 `lakutata.installer` 配置、`package:host` 和 `package:linux` 脚本、`Dockerfile.installer`、`@lakutata/builder`,以及 `packages/resources` 中的图标。服务不需要再安装其他包:定时任务和监控器都是 lakutata 自带的。这里还删掉了模板中的示例控制器。
20
+
21
+ ## 2. 传感器
22
+
23
+ **`packages/app/src/components/Sensors.ts`**
24
+
25
+ ```typescript
26
+ import os from 'node:os'
27
+ import {readFile} from 'node:fs/promises'
28
+ import {Component, DTO} from 'lakutata'
29
+ import {Configurable} from 'lakutata/decorator/di'
30
+
31
+ /**
32
+ * 传感器测得的数据
33
+ */
34
+ export type Measures = {
35
+ /**
36
+ * 机器最近一分钟的平均负载
37
+ */
38
+ load: number
39
+ /**
40
+ * 已用内存占机器内存的百分比
41
+ */
42
+ memory: number
43
+ /**
44
+ * CPU 温度(摄氏度),系统不提供时为 null
45
+ */
46
+ temperature: number | null
47
+ }
48
+
49
+ /**
50
+ * 机器的传感器:负载、内存和 CPU 温度。设备有自己的传感器(接在串口或 I2C 上)时,就由这个组件读取它们
51
+ */
52
+ export class Sensors extends Component {
53
+
54
+ /**
55
+ * 温度文件,单位是千分之一摄氏度(Linux 的 thermal zone)
56
+ */
57
+ @Configurable(DTO.String().required())
58
+ protected readonly temperatureFile: string
59
+
60
+ /**
61
+ * 读取传感器
62
+ */
63
+ public async read(): Promise<Measures> {
64
+ return {
65
+ load: Math.round(os.loadavg()[0] * 100) / 100,
66
+ memory: Math.round((1 - os.freemem() / os.totalmem()) * 1000) / 10,
67
+ temperature: await this.temperature()
68
+ }
69
+ }
70
+
71
+ /**
72
+ * CPU 温度;温度文件读不到时(macOS、Windows、没有 thermal zone 的机器)为 null
73
+ */
74
+ protected async temperature(): Promise<number | null> {
75
+ try {
76
+ return Math.round(Number(await readFile(this.temperatureFile, 'utf8')) / 100) / 10
77
+ } catch {
78
+ return null
79
+ }
80
+ }
81
+ }
82
+ ```
83
+
84
+ 组件是长期存在的对象,整个应用中只有一个(见 [Objects](../Objects.md))。这个组件:
85
+
86
+ - 读取最近一分钟的平均负载(`os.loadavg()`,在 Windows 上总是 0),以及已用内存所占的比例;
87
+ - 从 Linux 的 thermal zone 读取 CPU 温度:这是一个以千分之一摄氏度为单位的文件(`48312` 表示 48.3 °C)。机器上没有这个文件时(例如 Mac),温度为 `null`;
88
+ - 有一个选项 `temperatureFile`:创建组件时,`@Configurable` 会用声明的规则校验服务选项中给出的值。
89
+
90
+ 如果设备有自己的传感器(接在串口或 I2C 上),就由这个组件读取它们,服务的其他部分不用改。
91
+
92
+ ## 3. 读数
93
+
94
+ **`packages/app/src/components/Readings.ts`**
95
+
96
+ ```typescript
97
+ import {Component, DTO} from 'lakutata'
98
+ import {Configurable} from 'lakutata/decorator/di'
99
+ import {type Measures} from './Sensors.js'
100
+
101
+ /**
102
+ * 一条读数:传感器测得的数据,以及测量的时间
103
+ */
104
+ export type Reading = { time: string } & Measures
105
+
106
+ /**
107
+ * 最近的读数,保存在内存中:条数达到 size 后,新读数替换最旧的一条
108
+ */
109
+ export class Readings extends Component {
110
+
111
+ /**
112
+ * 最多保留多少条读数
113
+ */
114
+ @Configurable(DTO.Number().integer().min(1).required())
115
+ protected readonly size: number
116
+
117
+ protected readonly readings: Reading[] = []
118
+
119
+ /**
120
+ * 已保留的读数条数
121
+ */
122
+ public get count(): number {
123
+ return this.readings.length
124
+ }
125
+
126
+ /**
127
+ * 保存一条读数
128
+ * @param reading
129
+ */
130
+ public add(reading: Reading): void {
131
+ this.readings.push(reading)
132
+ if (this.readings.length > this.size) this.readings.shift()
133
+ }
134
+
135
+ /**
136
+ * 最近的读数,最新的在前
137
+ * @param limit
138
+ */
139
+ public latest(limit: number): Reading[] {
140
+ return this.readings.slice(-limit).reverse()
141
+ }
142
+ }
143
+ ```
144
+
145
+ - 读数保存在内存中,最多 `size` 条(和 `temperatureFile` 一样是组件的选项):新读数会替换最旧的一条。服务重启后读数就没有了,第 11 节介绍如何保留它们。
146
+ - 组件是单例:虽然每次执行和每个请求都有自己的控制器,但添加读数的定时任务和读取读数的 HTTP 动作用的是同一个组件实例。
147
+
148
+ ## 4. 输入
149
+
150
+ **`packages/app/src/dtos/ReadingsDTO.ts`**
151
+
152
+ ```typescript
153
+ import {DTO} from 'lakutata'
154
+ import {Expect} from 'lakutata/decorator/dto'
155
+
156
+ /**
157
+ * 要取最近多少条读数(查询字符串是文本,由 strict(false) 转换)
158
+ */
159
+ export class ReadingsDTO extends DTO {
160
+ @Expect(DTO.Number().integer().min(1).max(1000).strict(false).default(10))
161
+ public limit: number
162
+ }
163
+ ```
164
+
165
+ `limit` 是要取的读数条数:从 1 到 1000,默认为 10。查询字符串是文本:`.strict(false)` 把 `'3'` 转换为数字 3,不合法的值在动作运行之前就会被拒绝(见 [DTOValidation](../DTOValidation.md))。
166
+
167
+ ## 5. 控制器
168
+
169
+ **`packages/app/src/controllers/ReadingController.ts`**
170
+
171
+ ```typescript
172
+ import {Controller, type ScheduleContext} from 'lakutata/com/entrypoint'
173
+ import {type CpuMonitor, type MemoryMonitor} from 'lakutata/com/monitor'
174
+ import {GET, Schedule} from 'lakutata/decorator/ctrl'
175
+ import {Inject} from 'lakutata/decorator/di'
176
+ import {type Reading, type Readings} from '../components/Readings.js'
177
+ import {type Sensors} from '../components/Sensors.js'
178
+ import {ReadingsDTO} from '../dtos/ReadingsDTO.js'
179
+
180
+ export class ReadingController extends Controller {
181
+
182
+ @Inject('sensors')
183
+ protected readonly sensors: Sensors
184
+
185
+ @Inject('readings')
186
+ protected readonly readings: Readings
187
+
188
+ /**
189
+ * 服务自身的 CPU 和内存
190
+ */
191
+ @Inject('cpu')
192
+ protected readonly cpu: CpuMonitor
193
+
194
+ @Inject('memory')
195
+ protected readonly memory: MemoryMonitor
196
+
197
+ /**
198
+ * 启动时读取一次传感器,之后每隔 SAMPLE_EVERY(默认 10 秒)读取一次
199
+ */
200
+ @Schedule({every: process.env.SAMPLE_EVERY ?? '10s', immediate: true})
201
+ public async sample(): Promise<void> {
202
+ const context: ScheduleContext = this.context as ScheduleContext
203
+ this.readings.add({time: context.scheduledAt.toISOString(), ...await this.sensors.read()})
204
+ }
205
+
206
+ /**
207
+ * GET /readings?limit=10:最近的读数,最新的在前
208
+ * @param input
209
+ */
210
+ @GET('/readings', ReadingsDTO)
211
+ public async list(input: ReadingsDTO): Promise<Reading[]> {
212
+ return this.readings.latest(input.limit)
213
+ }
214
+
215
+ /**
216
+ * GET /status:服务自身的状态,由监控器从启动时开始测量
217
+ */
218
+ @GET('/status')
219
+ public async status(): Promise<{ uptime: number, readings: number, cpu: number, memory: number }> {
220
+ return {
221
+ uptime: Math.round(process.uptime()),
222
+ readings: this.readings.count,
223
+ //平均 CPU 使用率,以单个核心的百分比表示
224
+ cpu: this.cpu.statistics.usageAvg,
225
+ //常驻内存,单位为字节
226
+ memory: this.memory.statistics.physicalUsed
227
+ }
228
+ }
229
+ }
230
+ ```
231
+
232
+ - 一个控制器可以包含多种传输方式的动作(见 [Controllers](../Controllers.md)):`sample()` 是定时任务,`list()` 和 `status()` 是 HTTP 动作。`sample()` 的每次执行都像请求一样处理,都会创建一个新的控制器。
233
+ - `@Schedule({every, immediate: true})` 在启动时执行一次 `sample()`,之后每过一个间隔执行一次(见 [Schedules](../Schedules.md))。`this.context` 是本次执行的 `ScheduleContext`:其中的 `scheduledAt` 是本次执行的计划时间,用作读数的时间。
234
+ - 间隔是在定义这个类时(也就是加载它所在的模块时)从 `SAMPLE_EVERY` 读取的,所以取的是进程启动时的环境变量(来自 shell,或者安装后服务的环境变量文件)。如果值不是时长(例如没有单位的 `30`),服务在启动时就会停止:`SyntaxError: Invalid duration "30": such as "500ms", "30s", "5m", "1h30m" or "1d"`。
235
+ - 监控器测量的是服务本身,而不是机器(见 [Monitor](../Monitor.md)):`usageAvg` 是进程自启动以来的平均 CPU 使用率,以单个核心的百分比表示;`physicalUsed` 是进程的常驻内存,单位为字节。从这两个值可以看出服务在小型机器上是否足够轻量。
236
+
237
+ ## 6. 服务的选项
238
+
239
+ **`packages/app/src/config/Config.ts`**
240
+
241
+ ```typescript
242
+ import type {ApplicationOptions} from 'lakutata'
243
+ import {buildEntrypoints, scheduleEntrypoint} from 'lakutata/com/entrypoint'
244
+ import {expressHTTPEntrypoint, jsonEnvelope} from 'lakutata/adapter/express'
245
+ import {CpuMonitor, MemoryMonitor} from 'lakutata/com/monitor'
246
+ import compression from 'compression'
247
+ import {authenticate} from '../middlewares/Authenticate.js'
248
+ import {Readings} from '../components/Readings.js'
249
+ import {Sensors} from '../components/Sensors.js'
250
+ import {HealthController} from '../controllers/HealthController.js'
251
+ import {ReadingController} from '../controllers/ReadingController.js'
252
+
253
+ /**
254
+ * 服务的选项
255
+ * @param port HTTP 服务器的端口,默认取环境变量 PORT,没有时为 8080
256
+ * @param kept 保留的读数条数,默认取环境变量 READINGS_KEPT,没有时为 360(每 10 秒一条,正好一小时)
257
+ */
258
+ export function config(port: number = Number(process.env.PORT ?? 8080), kept: number = Number(process.env.READINGS_KEPT ?? 360)): ApplicationOptions {
259
+ return {
260
+ id: 'telemetry',
261
+ name: 'telemetry',
262
+ mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
263
+ components: {
264
+ //测量服务自身 CPU 和内存的监控器
265
+ cpu: {class: CpuMonitor},
266
+ memory: {class: MemoryMonitor},
267
+ sensors: {class: Sensors, temperatureFile: process.env.TEMPERATURE_FILE ?? '/sys/class/thermal/thermal_zone0/temp'},
268
+ readings: {class: Readings, size: kept},
269
+ entrypoint: buildEntrypoints({
270
+ controllers: [HealthController, ReadingController],
271
+ //执行 @Schedule 定时任务
272
+ schedule: scheduleEntrypoint(),
273
+ http: expressHTTPEntrypoint({
274
+ port: port,
275
+ //结果和错误都是 {errno, errMsg, data},状态码都是 200
276
+ respond: jsonEnvelope({errorStatus: 200}),
277
+ body: {limit: '1mb'},
278
+ server: {keepAliveTimeout: 60000, headersTimeout: 65000},
279
+ middlewares: [authenticate],
280
+ before: (app): void => void app.use(compression())
281
+ })
282
+ })
283
+ },
284
+ //监控器从启动时开始测量
285
+ bootstrap: ['cpu', 'memory', 'entrypoint']
286
+ }
287
+ }
288
+ ```
289
+
290
+ 这是模板中的 `Config.ts`,改动如下:
291
+
292
+ - 新增监控器 `cpu` 和 `memory`,并把它们列在 `bootstrap` 中,让它们从启动时开始测量;
293
+ - 新增 `sensors` 和 `readings` 组件及其选项;
294
+ - 用 `ReadingController` 替换示例控制器,并加上定时适配器 `scheduleEntrypoint()`,没有它,`@Schedule` 定时任务永远不会执行(见 [Entrypoint](../Entrypoint.md));
295
+ - 请求体的上限从 100 MB 改为 1 MB:这个 API 只读取查询字符串。
296
+
297
+ 服务通过环境变量配置:
298
+
299
+ | 变量 | 默认值 | 作用 |
300
+ |---|---|---|
301
+ | `PORT` | `8080` | HTTP API 的端口 |
302
+ | `SAMPLE_EVERY` | `10s` | 两次读数之间的间隔:`500ms`、`30s`、`5m` 这样的时长 |
303
+ | `READINGS_KEPT` | `360` | 保留多少条读数 |
304
+ | `TEMPERATURE_FILE` | `/sys/class/thermal/thermal_zone0/temp` | 温度文件 |
305
+ | `API_TOKEN` | 无 | 设置后,请求必须带上 `Authorization: Bearer <API_TOKEN>`(模板中的中间件) |
306
+ | `MODE` | `development` | 安装后的服务为 `production`;它优先于 `mode` 选项(见 [Application](../Application.md)) |
307
+
308
+ 值不合法时,服务在启动时就会停止,日志会说明原因:
309
+
310
+ ```bash
311
+ READINGS_KEPT=0 npm start
312
+ ```
313
+
314
+ ```
315
+ [2026-10-17T14:55:03.108Z] ERROR (telemetry): The service cannot start: The option "size" of "readings" (Readings) is invalid: "size" must be greater than or equal to 1
316
+ ```
317
+
318
+ ## 7. 安装包
319
+
320
+ **`packages/app/package.json`**
321
+
322
+ ```json
323
+ {
324
+ "name": "app",
325
+ "version": "0.0.1",
326
+ "description": "Reads the load, the memory and the temperature of the machine, and serves them over HTTP",
327
+ "author": "",
328
+ "license": "UNLICENSED",
329
+ "private": true,
330
+ "type": "module",
331
+ "main": "build/App.js",
332
+ "scripts": {
333
+ "clean": "lakutata clean",
334
+ "build": "tsc",
335
+ "rebuild": "npm run clean && npm run build",
336
+ "dev": "npm run rebuild && node build/App.js",
337
+ "start": "node build/App.js",
338
+ "test": "node --test \"build/tests/**/*.spec.js\"",
339
+ "package": "lakutata package installer"
340
+ },
341
+ "dependencies": {
342
+ "compression": "^1.8.1",
343
+ "express": "^5.2.1",
344
+ "lakutata": "^3.0.0"
345
+ },
346
+ "devDependencies": {
347
+ "@types/compression": "^1.8.1",
348
+ "@types/express": "^5.0.6"
349
+ },
350
+ "bin": {
351
+ "telemetry": "build/App.js"
352
+ },
353
+ "lakutata": {
354
+ "icon": "../resources/logo.png",
355
+ "installer": {
356
+ "name": "telemetry",
357
+ "service": {
358
+ "env": {
359
+ "PORT": "8080",
360
+ "SAMPLE_EVERY": "10s",
361
+ "READINGS_KEPT": "360"
362
+ },
363
+ "restart": "always"
364
+ },
365
+ "linux": {
366
+ "targets": [
367
+ "deb",
368
+ "rpm",
369
+ "tar.gz"
370
+ ]
371
+ }
372
+ }
373
+ }
374
+ }
375
+ ```
376
+
377
+ 这是 `installer` 功能生成的 `package.json`,加上了描述和服务的选项(见 [Installers](../Installers.md)):
378
+
379
+ - `bin` 给出程序名 `telemetry`,软件包会把它加入 PATH(Linux 上是 `/usr/bin/telemetry`);
380
+ - `description` 是软件包和服务的描述;
381
+ - `service` 把服务注册到系统中:Linux 上由 systemd 运行,macOS 上由 launchd 运行,Windows 上作为 Windows 服务运行。安装时启用并启动服务,升级时重启它,卸载时停止并禁用它;
382
+ - `service.env` 是服务的环境变量,写在它的环境变量文件中(见第 10 节);
383
+ - `restart: "always"` 让服务每次结束后都重新启动;默认值 `on-failure` 只在失败时重启;
384
+ - 服务以 `telemetry` 用户运行,这是安装时创建的系统用户;它的工作目录是 `/var/lib/telemetry`,数据可以写在这里;
385
+ - `linux.targets` 是要制作的 Linux 软件包:deb(Debian、Ubuntu、Raspberry Pi OS)、rpm(Fedora、Red Hat),以及一个 tar.gz 压缩包,压缩包只包含应用本身,不注册服务。
386
+
387
+ 软件包的维护者取自 `lakutata.installer.maintainer`,没有时取 `package.json` 的 `author`;两者都没有时,工具会给出警告。
388
+
389
+ ## 8. 运行
390
+
391
+ ```bash
392
+ SAMPLE_EVERY=2s npm run dev
393
+ ```
394
+
395
+ 在另一个终端中:
396
+
397
+ ```bash
398
+ curl 'localhost:8080/readings?limit=3'
399
+ # {"errno":null,"errMsg":null,"data":[{"time":"2026-10-17T14:50:33.497Z","load":1.94,"memory":75.8,"temperature":null},{"time":"2026-10-17T14:50:31.497Z","load":1.94,"memory":75.8,"temperature":null},{"time":"2026-10-17T14:50:29.497Z","load":1.94,"memory":75.9,"temperature":null}]}
400
+
401
+ curl localhost:8080/status
402
+ # {"errno":null,"errMsg":null,"data":{"uptime":6,"readings":3,"cpu":0.29407,"memory":117686272}}
403
+
404
+ curl 'localhost:8080/readings?limit=abc'
405
+ # {"errno":"E_INVALID_VALUE","errMsg":"\"limit\" must be a number","data":null}
406
+ ```
407
+
408
+ 服务启动时先取一条读数,之后每 2 秒一条。这里是在 Mac 上运行的,所以温度为 `null`;在有 thermal zone 的 Linux 机器上,它是 CPU 的温度(摄氏度)。
409
+
410
+ ## 9. 测试
411
+
412
+ 模板中的测试换成了针对读数的测试。它自己写一个 thermal zone 文件(42.5 摄氏度),然后启动服务,每 200 毫秒读取一次,只保留 3 条读数:
413
+
414
+ **`packages/app/src/tests/App.spec.ts`**
415
+
416
+ ```typescript
417
+ import {after, before, describe, it} from 'node:test'
418
+ import assert from 'node:assert'
419
+ import {mkdtemp, rm, writeFile} from 'node:fs/promises'
420
+ import os from 'node:os'
421
+ import path from 'node:path'
422
+ import {setTimeout as sleep} from 'node:timers/promises'
423
+ import {Application} from 'lakutata'
424
+
425
+ const PORT: number = Number(process.env.TEST_PORT ?? 18089)
426
+
427
+ /**
428
+ * 带上令牌向服务发一个请求,返回 {errno, errMsg, data}
429
+ * @param route
430
+ */
431
+ async function request(route: string): Promise<{ errno: string | null, data: any }> {
432
+ const response: Response = await fetch(`http://127.0.0.1:${PORT}${route}`, {headers: {Authorization: 'Bearer test-token'}})
433
+ return await response.json() as { errno: string | null, data: any }
434
+ }
435
+
436
+ describe('telemetry', (): void => {
437
+
438
+ let app: Application
439
+ let directory: string
440
+
441
+ before(async (): Promise<void> => {
442
+ //一个 42.5 摄氏度的 thermal zone
443
+ directory = await mkdtemp(path.join(os.tmpdir(), 'telemetry-'))
444
+ await writeFile(path.join(directory, 'temp'), '42500\n')
445
+ process.env.TEMPERATURE_FILE = path.join(directory, 'temp')
446
+ process.env.API_TOKEN = 'test-token'
447
+ //每 200 毫秒一条读数:间隔是在加载控制器时读取的,所以先设置 SAMPLE_EVERY,再导入选项
448
+ process.env.SAMPLE_EVERY = '200ms'
449
+ const {config} = await import('../config/Config.js')
450
+ app = await new Promise<Application>((resolve: (app: Application) => void): void => {
451
+ Application.run(config(PORT, 3)).onLaunched((launched: Application): void => resolve(launched))
452
+ })
453
+ })
454
+
455
+ after(async (): Promise<void> => {
456
+ await app?.dispose()
457
+ await rm(directory, {recursive: true, force: true})
458
+ })
459
+
460
+ it('refuses the requests without authentication', async (): Promise<void> => {
461
+ const response: Response = await fetch(`http://127.0.0.1:${PORT}/readings`)
462
+ assert.deepStrictEqual(await response.json(), {errno: 'E_UNAUTHORIZED', errMsg: 'The request is not authenticated', data: null})
463
+ })
464
+
465
+ it('samples the sensors on its schedule and keeps the latest readings', async (): Promise<void> => {
466
+ //启动时一条,之后每 200 毫秒一条,只保留最近的 3 条
467
+ await sleep(1000)
468
+ const {data: readings} = await request('/readings')
469
+ assert.strictEqual(readings.length, 3)
470
+ assert.ok(readings[0].time > readings[1].time && readings[1].time > readings[2].time)
471
+ for (const reading of readings) {
472
+ assert.strictEqual(reading.temperature, 42.5)
473
+ assert.ok(reading.load >= 0)
474
+ assert.ok(reading.memory > 0 && reading.memory <= 100)
475
+ }
476
+ assert.strictEqual((await request('/readings?limit=1')).data.length, 1)
477
+ })
478
+
479
+ it('refuses an invalid limit', async (): Promise<void> => {
480
+ assert.strictEqual((await request('/readings?limit=0')).errno, 'E_INVALID_VALUE')
481
+ })
482
+
483
+ it('gives the status of the service', async (): Promise<void> => {
484
+ const {data: status} = await request('/status')
485
+ assert.strictEqual(status.readings, 3)
486
+ assert.ok(status.cpu >= 0)
487
+ assert.ok(status.memory > 0)
488
+ })
489
+ })
490
+ ```
491
+
492
+ 设置好 `SAMPLE_EVERY` 之后才导入选项,因为间隔是在加载控制器的模块时读取的。`node --test` 让每个测试文件在单独的进程中运行,所以这里设置的环境变量不会影响其他测试文件。
493
+
494
+ ```bash
495
+ npm test
496
+ ```
497
+
498
+ ```
499
+ app │ ✔ refuses the requests without authentication
500
+ app │ ✔ samples the sensors on its schedule and keeps the latest readings
501
+ app │ ✔ refuses an invalid limit
502
+ app │ ✔ gives the status of the service
503
+ app │ ℹ pass 4
504
+ ✔ test ran in app
505
+ ```
506
+
507
+ ## 10. 安装到设备上
508
+
509
+ 安装包中是一个内嵌 Node.js 的可执行文件,它按制作时所在的系统和架构构建(见 [Packaging](../Packaging.md))。`installer` 功能的两个脚本把安装包写到 `dist/installer`:
510
+
511
+ - `npm run package:linux` 在 Docker 容器中(`Dockerfile.installer`)制作 Linux 软件包,在 macOS 和 Windows 上也能用;软件包的架构就是 Docker 的架构:在 Apple 芯片的 Mac 上是 arm64,与装 64 位系统的树莓派 4、5 相同。文件名由包名、版本和架构组成:`telemetry_0.0.1_arm64.deb`、`telemetry-0.0.1-1.aarch64.rpm` 和 `telemetry-0.0.1-linux-arm64.tar.gz`。要为 x64 机器制作,就给 Docker 指定对应的平台:`docker buildx build --platform linux/amd64 --file Dockerfile.installer --output type=local,dest=dist/installer .`
512
+ - `npm run package:host` 制作当前机器的安装包:在 Linux 上是同样的软件包;在 macOS 上是 `pkg` 安装包(launchd 服务)和 tar.gz 压缩包;在 Windows 上是 NSIS 和 MSI 安装程序(Windows 服务)以及 zip 压缩包。
513
+
514
+ 在运行 Debian 或 Raspberry Pi OS 的设备上:
515
+
516
+ ```bash
517
+ sudo apt install ./telemetry_0.0.1_arm64.deb
518
+ systemctl status telemetry # 已启用并启动
519
+ journalctl -u telemetry -f # 服务的日志
520
+ curl 'localhost:8080/readings?limit=1'
521
+ ```
522
+
523
+ 服务的环境变量文件 `/etc/telemetry/telemetry.env` 中包含 `MODE=production` 和 `package.json` 中的 `service.env`:
524
+
525
+ ```
526
+ # The environment of the service telemetry, read when it starts (systemctl restart telemetry)
527
+ MODE="production"
528
+ PORT="8080"
529
+ SAMPLE_EVERY="10s"
530
+ READINGS_KEPT="360"
531
+ ```
532
+
533
+ 要修改服务的配置,就编辑这个文件,然后重启服务:
534
+
535
+ ```bash
536
+ sudoedit /etc/telemetry/telemetry.env # 例如 SAMPLE_EVERY="30s"、API_TOKEN="…"
537
+ sudo systemctl restart telemetry
538
+ ```
539
+
540
+ 升级(用 `sudo apt install` 安装下一个版本)会保留你对环境变量文件的修改,并重启服务。`sudo apt remove telemetry` 会停止服务并删除应用,但保留环境变量文件和工作目录。
541
+
542
+ ## 11. 下一步
543
+
544
+ - 要在重启后保留读数,可以把它们存进服务工作目录中的 SQLite 数据库,使用相对路径(安装后,`readings.sqlite` 就是 `/var/lib/telemetry/readings.sqlite`),做法见 [带数据库的 REST API](RestAPI.md)。它的驱动 better-sqlite3 是原生插件,安装包会把它放在可执行文件旁边(见 [Packaging](../Packaging.md) 第 3 节)。
545
+ - 要把读数发送到服务器,可以在 `sample()` 中通过 [NATS](../NATS.md) 发布,或者把它们加入[队列](../Queue.md)。
546
+ - 在 Linux 设备上,服务还可以和系统打交道:通过 [D-Bus](../DBus.md) 调用系统的服务,或者用 [containerd](../ContainerdIntegration.md) 或 [Docker](../DockerIntegration.md) 运行容器。