From e44cfd44ea54987f66190a9efa7b2c0b05ad8911 Mon Sep 17 00:00:00 2001 From: ZIHAN DAI <99155080+PDGGK@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:37:28 +1000 Subject: [PATCH 1/2] docs: add the ThingsBoard table-model integration page Adds a user-guide page for the iotdb-thingsboard-table module in apache/iotdb-extras: stock ThingsBoard storing telemetry and attributes in IoTDB 2.x table model, by placing the module and its runtime dependencies on ThingsBoard's classpath and setting a few properties. The page lists the exact jar set, including the ones that must NOT be copied because ThingsBoard bundles newer copies of them. Four new pages -- Thingsboard.md in latest-Table and Master/Table, English and Chinese -- plus the two sidebar groups and the four Ecosystem-Overview index rows. Structure follows the existing Spark-IoTDB page. The attributes selector is documented as not yet usable on a stock ThingsBoard: ThingsBoard registers its JPA attributes bean unconditionally and exposes no switch for it, so setting the selector currently fails startup. The fix is apache/iotdb-extras#125; the page says so and will be updated when a build containing it is released. --- src/.vuepress/sidebar/V2.0.x/en-Table.ts | 7 + src/.vuepress/sidebar/V2.0.x/zh-Table.ts | 7 + .../Ecosystem-Overview_apache.md | 4 +- .../Ecosystem-Integration/Thingsboard.md | 203 ++++++++++++++++++ .../Ecosystem-Overview_apache.md | 4 +- .../Ecosystem-Integration/Thingsboard.md | 203 ++++++++++++++++++ .../Ecosystem-Overview_apache.md | 4 +- .../Ecosystem-Integration/Thingsboard.md | 171 +++++++++++++++ .../Ecosystem-Overview_apache.md | 4 +- .../Ecosystem-Integration/Thingsboard.md | 171 +++++++++++++++ 10 files changed, 774 insertions(+), 4 deletions(-) create mode 100644 src/UserGuide/Master/Table/Ecosystem-Integration/Thingsboard.md create mode 100644 src/UserGuide/latest-Table/Ecosystem-Integration/Thingsboard.md create mode 100644 src/zh/UserGuide/Master/Table/Ecosystem-Integration/Thingsboard.md create mode 100644 src/zh/UserGuide/latest-Table/Ecosystem-Integration/Thingsboard.md diff --git a/src/.vuepress/sidebar/V2.0.x/en-Table.ts b/src/.vuepress/sidebar/V2.0.x/en-Table.ts index 73eee9bcd..13e763dd1 100644 --- a/src/.vuepress/sidebar/V2.0.x/en-Table.ts +++ b/src/.vuepress/sidebar/V2.0.x/en-Table.ts @@ -220,6 +220,13 @@ export const enSidebar = { { text: 'MyBatisPlus Generator', link: 'MyBatisPlus-Generator' }, ], }, + { + text: 'IoT Platform', + collapsible: true, + children: [ + { text: 'ThingsBoard', link: 'Thingsboard' }, + ], + }, ], }, { diff --git a/src/.vuepress/sidebar/V2.0.x/zh-Table.ts b/src/.vuepress/sidebar/V2.0.x/zh-Table.ts index a1f69708b..c8a643a89 100644 --- a/src/.vuepress/sidebar/V2.0.x/zh-Table.ts +++ b/src/.vuepress/sidebar/V2.0.x/zh-Table.ts @@ -220,6 +220,13 @@ export const zhSidebar = { { text: 'MyBatisPlus Generator', link: 'MyBatisPlus-Generator' }, ], }, + { + text: '物联网对接', + collapsible: true, + children: [ + { text: 'ThingsBoard', link: 'Thingsboard' }, + ], + }, ], }, { diff --git a/src/UserGuide/Master/Table/Ecosystem-Integration/Ecosystem-Overview_apache.md b/src/UserGuide/Master/Table/Ecosystem-Integration/Ecosystem-Overview_apache.md index 96d73ffc1..87d8850eb 100644 --- a/src/UserGuide/Master/Table/Ecosystem-Integration/Ecosystem-Overview_apache.md +++ b/src/UserGuide/Master/Table/Ecosystem-Integration/Ecosystem-Overview_apache.md @@ -41,4 +41,6 @@ The following documentation will help you quickly and comprehensively understand - Programming Framework - Spring Boot Starter [Spring Boot Starter](./Spring-Boot-Starter.md) - Mybatis Generator [Mybatis Generator](./Mybatis-Generator.md) - - MyBatisPlus Generator [MyBatisPlus Generator](./MyBatisPlus-Generator.md) \ No newline at end of file + - MyBatisPlus Generator [MyBatisPlus Generator](./MyBatisPlus-Generator.md) +- IoT Platform + - ThingsBoard [ThingsBoard](./Thingsboard.md) \ No newline at end of file diff --git a/src/UserGuide/Master/Table/Ecosystem-Integration/Thingsboard.md b/src/UserGuide/Master/Table/Ecosystem-Integration/Thingsboard.md new file mode 100644 index 000000000..10defefba --- /dev/null +++ b/src/UserGuide/Master/Table/Ecosystem-Integration/Thingsboard.md @@ -0,0 +1,203 @@ + + +# ThingsBoard + +## 1. Overview + +ThingsBoard is an open-source IoT platform for device management, data collection +and visualisation. It stores device telemetry, latest-value telemetry and entity +attributes through three storage SPIs, which allows the storage layer to be +replaced without changing the platform itself. + +`iotdb-thingsboard-table` implements those three SPIs on top of the IoTDB Table +Model, so a ThingsBoard deployment can keep its telemetry in IoTDB instead of +Cassandra or a relational database: + +| ThingsBoard SPI | Implementation | Purpose | +| --- | --- | --- | +| `TimeseriesDao` | `IoTDBTableTimeseriesDao` | Historical telemetry: batched writes, raw and time-bucketed aggregation reads, deletes | +| `TimeseriesLatestDao` | `IoTDBTableLatestDao` | Latest value per telemetry key | +| `AttributesDao` | `IoTDBTableAttributesDao` | Entity attributes, scoped by `SERVER_SCOPE` / `SHARED_SCOPE` / `CLIENT_SCOPE` | + +This page covers the **Table Model** integration, which runs against a stock +ThingsBoard release and is enabled by putting the module and its runtime +dependencies on the classpath and setting a few properties. The deployment +section below lists the exact set; it is not a single jar. +There is a separate, earlier integration for the **Tree Model**, described in +[ThingsBoard (Tree Model)](../../Tree/Ecosystem-Integration/Thingsboard.md); +that one stores data under `root.thingsboard` and requires an IoTDB-adapted +ThingsBoard build rather than a stock one. The two are independent — pick the one +that matches the data model you are using. + +Writes are batched through a bounded asynchronous queue into IoTDB tablets. +Reads cover both the raw path and the aggregation path: fixed-width millisecond +buckets use IoTDB's native `date_bin`, while calendar buckets +(`WEEK` / `WEEK_ISO` / `MONTH` / `QUARTER`) are walked per bucket so that +boundaries match ThingsBoard's own semantics in the timezone carried by each +query. + +## 2. Usage Steps + +### 2.1 Version Requirements + +* `IoTDB: 2.0.8` (Table Model) — the version the integration tests are executed + against (`apache/iotdb:2.0.8-standalone`). Other 2.x releases are untested. +* `ThingsBoard: 4.3.1.2` +* `JDK: >= 17` + +The module is compiled against the ThingsBoard 4.3.1.2 SPI surface. Because +ThingsBoard's `common/data` and `dao` artifacts are not published to Maven +Central, the module builds against a compile-only surface of the types it uses; +those classes are excluded from the packaged jar, so at runtime the real +ThingsBoard classes are used. + +### 2.2 Obtain the jar + +Build it from the `iotdb-extras` repository. The module sits behind an explicit +opt-in profile, so a plain reactor build does not include it: + +```bash +# from the apache/iotdb-extras repository root, with JDK 17+ +# https://github.com/apache/iotdb-extras +mvn -pl iotdb-thingsboard-table -am -P with-thingsboard clean package +``` + +The jar is produced under `iotdb-thingsboard-table/target/`. + +### 2.3 Deploy into ThingsBoard + +ThingsBoard must be installed **before** the module is enabled: its installer +resolves a `TsDatabaseSchemaService` bean that only the built-in backends +provide, so starting with the selectors of section 3 already set makes the +install step fail. + +1. Install and start IoTDB, see [IoTDB QuickStart](../QuickStart/QuickStart.md). +2. Install ThingsBoard normally, with none of the properties in section 3 set. +3. Put the module **and the IoTDB client's runtime dependencies** on + ThingsBoard's classpath. The module jar alone is not enough — it fails at + first session creation with `NoClassDefFoundError` on `ITableSessionPool`. + Collect the set with `dependency:copy-dependencies -DincludeScope=runtime`, + then remove the artifacts ThingsBoard already bundles, or its newer copies + are shadowed by the module's older ones. Against ThingsBoard 4.3.1.2 that is + nine — `antlr4-runtime`, `commons-codec`, `commons-io`, `commons-lang3`, + `httpclient`, `httpcore`, `lz4-java`, `snappy-java`, `zstd-jni` — plus + `commons-logging`, which ThingsBoard asks to have removed because it uses + `spring-jcl`. `antlr4-runtime` is the one that bites hardest: ThingsBoard + 4.3.1.2 ships 4.13.0 and the module brings 4.9.3, after which Spring Data + JPA's `HqlLexer` cannot deserialise its own grammar and startup fails. + Eleven jars remain. Re-derive that set against the ThingsBoard release you + are deploying to, and prefer a deployment that boots over a dependency diff: + the diff answers whether ThingsBoard *ships* an artifact, not whether it + *tolerates* one, and `commons-logging` is exactly where those differ. +4. How the classpath is extended depends on the installation. ThingsBoard runs + as a Spring Boot application: the Docker images launch through + `PropertiesLauncher` and already honour a `LOADER_PATH` entry, so placing the + jars in `/usr/share/thingsboard/extensions` is enough; the deb/rpm packages + execute the distribution jar directly. Consult ThingsBoard's own deployment + documentation for the installation method you use. +5. Add the configuration below to ThingsBoard's `thingsboard.yml`, or supply the + equivalent environment variables. +6. Restart ThingsBoard. On first start the module creates its database and tables + in IoTDB, unless that bootstrap is disabled. + +The module is a Spring Boot auto-configuration, so no component scanning or code +change is required on the ThingsBoard side. + +## 3. Configuration + +### 3.1 Activation + +Historical telemetry needs the timeseries selector plus the explicit opt-in: + +```Properties +# select this backend for historical telemetry +database.ts.type=iotdb-table +# explicit opt-in; required together with the selector above +iotdb.ts.experimental-raw-only=true +``` + +Latest-value telemetry needs its **own** selector in addition to those two. If it +is omitted, historical telemetry is stored in IoTDB while latest values stay on +ThingsBoard's default backend, with no error at startup: + +```Properties +database.ts_latest.type=iotdb-table +# required when the latest DAO is active: sticky-routing | disabled +iotdb.ts_latest.cluster_mode=sticky-routing +``` + +Entity attributes are a separate opt-in and are inert unless enabled. Unlike the +two timeseries selectors it is not independent of the host: ThingsBoard has no +attributes-backend switch of its own, so enabling this one changes ThingsBoard's +bean graph rather than only this module's. + +> **Do not enable this on a stock ThingsBoard yet.** ThingsBoard has no +> configuration switch of its own for attributes storage — its JPA attributes +> bean registers unconditionally — so setting the selector below makes startup +> fail on the module's conflict check. A fix is under review upstream as +> apache/iotdb-extras#125. It withdraws exactly one bean — ThingsBoard's own +> `jpaAttributeDao`, matched on both the bean name and the fully-qualified class +> name — and fails startup on any other competing `AttributesDao` rather than +> removing it. This note applies until that PR is merged and a build containing +> it is released. + + +```Properties +database.attributes.type=iotdb-table +# required when the attribute DAO is active: sticky-routing | disabled +iotdb.attributes.cluster_mode=sticky-routing +``` + +### 3.2 Connection and schema + +| Property | Default | Meaning | +| --- | --- | --- | +| `iotdb.host` / `iotdb.port` | `127.0.0.1` / `6667` | IoTDB node address | +| `iotdb.username` / `iotdb.password` | `root` / `root` | IoTDB credentials | +| `iotdb.database` | `thingsboard` | Target IoTDB database | +| `iotdb.session-pool-size` | `8` | Table session pool size | +| `iotdb.schema.bootstrap` | `true` | Create the database and tables on first start; set to `false` to manage the schema out of band | + +### 3.3 Cluster mode + +`iotdb.attributes.cluster_mode` and `iotdb.ts_latest.cluster_mode` must be set +explicitly when the corresponding DAO is active. Accepted values: + +* `sticky-routing` — writes for one identity are pinned to a single node +* `disabled` — single-node deployment, or best-effort convergence accepted + +Any other value, including leaving it empty, fails at startup rather than +silently. These write paths converge within a single JVM, so a multi-writer +cluster needs one of the two acknowledgements above. + +## 4. Known Limitations + +* Attribute and latest-overlay writes converge within a single JVM. A clustered + deployment must either pin each identity to one node (`sticky-routing`) or + explicitly accept best-effort convergence (`disabled`) — which is why the + cluster mode has to be stated rather than defaulted. +* The latest-value path is derived from the telemetry table, with a small overlay + for the latest-only write and delete paths that a pure derivation cannot + express. +* Table-level TTL is used for retention; see the module's + [user guide](https://github.com/apache/iotdb-extras/blob/master/iotdb-thingsboard-table/docs/user-guide.md) + for how it maps onto ThingsBoard's own retention settings. diff --git a/src/UserGuide/latest-Table/Ecosystem-Integration/Ecosystem-Overview_apache.md b/src/UserGuide/latest-Table/Ecosystem-Integration/Ecosystem-Overview_apache.md index 96d73ffc1..87d8850eb 100644 --- a/src/UserGuide/latest-Table/Ecosystem-Integration/Ecosystem-Overview_apache.md +++ b/src/UserGuide/latest-Table/Ecosystem-Integration/Ecosystem-Overview_apache.md @@ -41,4 +41,6 @@ The following documentation will help you quickly and comprehensively understand - Programming Framework - Spring Boot Starter [Spring Boot Starter](./Spring-Boot-Starter.md) - Mybatis Generator [Mybatis Generator](./Mybatis-Generator.md) - - MyBatisPlus Generator [MyBatisPlus Generator](./MyBatisPlus-Generator.md) \ No newline at end of file + - MyBatisPlus Generator [MyBatisPlus Generator](./MyBatisPlus-Generator.md) +- IoT Platform + - ThingsBoard [ThingsBoard](./Thingsboard.md) \ No newline at end of file diff --git a/src/UserGuide/latest-Table/Ecosystem-Integration/Thingsboard.md b/src/UserGuide/latest-Table/Ecosystem-Integration/Thingsboard.md new file mode 100644 index 000000000..c479850ad --- /dev/null +++ b/src/UserGuide/latest-Table/Ecosystem-Integration/Thingsboard.md @@ -0,0 +1,203 @@ + + +# ThingsBoard + +## 1. Overview + +ThingsBoard is an open-source IoT platform for device management, data collection +and visualisation. It stores device telemetry, latest-value telemetry and entity +attributes through three storage SPIs, which allows the storage layer to be +replaced without changing the platform itself. + +`iotdb-thingsboard-table` implements those three SPIs on top of the IoTDB Table +Model, so a ThingsBoard deployment can keep its telemetry in IoTDB instead of +Cassandra or a relational database: + +| ThingsBoard SPI | Implementation | Purpose | +| --- | --- | --- | +| `TimeseriesDao` | `IoTDBTableTimeseriesDao` | Historical telemetry: batched writes, raw and time-bucketed aggregation reads, deletes | +| `TimeseriesLatestDao` | `IoTDBTableLatestDao` | Latest value per telemetry key | +| `AttributesDao` | `IoTDBTableAttributesDao` | Entity attributes, scoped by `SERVER_SCOPE` / `SHARED_SCOPE` / `CLIENT_SCOPE` | + +This page covers the **Table Model** integration, which runs against a stock +ThingsBoard release and is enabled by putting the module and its runtime +dependencies on the classpath and setting a few properties. The deployment +section below lists the exact set; it is not a single jar. +There is a separate, earlier integration for the **Tree Model**, described in +[ThingsBoard (Tree Model)](../../latest/Ecosystem-Integration/Thingsboard.md); +that one stores data under `root.thingsboard` and requires an IoTDB-adapted +ThingsBoard build rather than a stock one. The two are independent — pick the one +that matches the data model you are using. + +Writes are batched through a bounded asynchronous queue into IoTDB tablets. +Reads cover both the raw path and the aggregation path: fixed-width millisecond +buckets use IoTDB's native `date_bin`, while calendar buckets +(`WEEK` / `WEEK_ISO` / `MONTH` / `QUARTER`) are walked per bucket so that +boundaries match ThingsBoard's own semantics in the timezone carried by each +query. + +## 2. Usage Steps + +### 2.1 Version Requirements + +* `IoTDB: 2.0.8` (Table Model) — the version the integration tests are executed + against (`apache/iotdb:2.0.8-standalone`). Other 2.x releases are untested. +* `ThingsBoard: 4.3.1.2` +* `JDK: >= 17` + +The module is compiled against the ThingsBoard 4.3.1.2 SPI surface. Because +ThingsBoard's `common/data` and `dao` artifacts are not published to Maven +Central, the module builds against a compile-only surface of the types it uses; +those classes are excluded from the packaged jar, so at runtime the real +ThingsBoard classes are used. + +### 2.2 Obtain the jar + +Build it from the `iotdb-extras` repository. The module sits behind an explicit +opt-in profile, so a plain reactor build does not include it: + +```bash +# from the apache/iotdb-extras repository root, with JDK 17+ +# https://github.com/apache/iotdb-extras +mvn -pl iotdb-thingsboard-table -am -P with-thingsboard clean package +``` + +The jar is produced under `iotdb-thingsboard-table/target/`. + +### 2.3 Deploy into ThingsBoard + +ThingsBoard must be installed **before** the module is enabled: its installer +resolves a `TsDatabaseSchemaService` bean that only the built-in backends +provide, so starting with the selectors of section 3 already set makes the +install step fail. + +1. Install and start IoTDB, see [IoTDB QuickStart](../QuickStart/QuickStart.md). +2. Install ThingsBoard normally, with none of the properties in section 3 set. +3. Put the module **and the IoTDB client's runtime dependencies** on + ThingsBoard's classpath. The module jar alone is not enough — it fails at + first session creation with `NoClassDefFoundError` on `ITableSessionPool`. + Collect the set with `dependency:copy-dependencies -DincludeScope=runtime`, + then remove the artifacts ThingsBoard already bundles, or its newer copies + are shadowed by the module's older ones. Against ThingsBoard 4.3.1.2 that is + nine — `antlr4-runtime`, `commons-codec`, `commons-io`, `commons-lang3`, + `httpclient`, `httpcore`, `lz4-java`, `snappy-java`, `zstd-jni` — plus + `commons-logging`, which ThingsBoard asks to have removed because it uses + `spring-jcl`. `antlr4-runtime` is the one that bites hardest: ThingsBoard + 4.3.1.2 ships 4.13.0 and the module brings 4.9.3, after which Spring Data + JPA's `HqlLexer` cannot deserialise its own grammar and startup fails. + Eleven jars remain. Re-derive that set against the ThingsBoard release you + are deploying to, and prefer a deployment that boots over a dependency diff: + the diff answers whether ThingsBoard *ships* an artifact, not whether it + *tolerates* one, and `commons-logging` is exactly where those differ. +4. How the classpath is extended depends on the installation. ThingsBoard runs + as a Spring Boot application: the Docker images launch through + `PropertiesLauncher` and already honour a `LOADER_PATH` entry, so placing the + jars in `/usr/share/thingsboard/extensions` is enough; the deb/rpm packages + execute the distribution jar directly. Consult ThingsBoard's own deployment + documentation for the installation method you use. +5. Add the configuration below to ThingsBoard's `thingsboard.yml`, or supply the + equivalent environment variables. +6. Restart ThingsBoard. On first start the module creates its database and tables + in IoTDB, unless that bootstrap is disabled. + +The module is a Spring Boot auto-configuration, so no component scanning or code +change is required on the ThingsBoard side. + +## 3. Configuration + +### 3.1 Activation + +Historical telemetry needs the timeseries selector plus the explicit opt-in: + +```Properties +# select this backend for historical telemetry +database.ts.type=iotdb-table +# explicit opt-in; required together with the selector above +iotdb.ts.experimental-raw-only=true +``` + +Latest-value telemetry needs its **own** selector in addition to those two. If it +is omitted, historical telemetry is stored in IoTDB while latest values stay on +ThingsBoard's default backend, with no error at startup: + +```Properties +database.ts_latest.type=iotdb-table +# required when the latest DAO is active: sticky-routing | disabled +iotdb.ts_latest.cluster_mode=sticky-routing +``` + +Entity attributes are a separate opt-in and are inert unless enabled. Unlike the +two timeseries selectors it is not independent of the host: ThingsBoard has no +attributes-backend switch of its own, so enabling this one changes ThingsBoard's +bean graph rather than only this module's. + +> **Do not enable this on a stock ThingsBoard yet.** ThingsBoard has no +> configuration switch of its own for attributes storage — its JPA attributes +> bean registers unconditionally — so setting the selector below makes startup +> fail on the module's conflict check. A fix is under review upstream as +> apache/iotdb-extras#125. It withdraws exactly one bean — ThingsBoard's own +> `jpaAttributeDao`, matched on both the bean name and the fully-qualified class +> name — and fails startup on any other competing `AttributesDao` rather than +> removing it. This note applies until that PR is merged and a build containing +> it is released. + + +```Properties +database.attributes.type=iotdb-table +# required when the attribute DAO is active: sticky-routing | disabled +iotdb.attributes.cluster_mode=sticky-routing +``` + +### 3.2 Connection and schema + +| Property | Default | Meaning | +| --- | --- | --- | +| `iotdb.host` / `iotdb.port` | `127.0.0.1` / `6667` | IoTDB node address | +| `iotdb.username` / `iotdb.password` | `root` / `root` | IoTDB credentials | +| `iotdb.database` | `thingsboard` | Target IoTDB database | +| `iotdb.session-pool-size` | `8` | Table session pool size | +| `iotdb.schema.bootstrap` | `true` | Create the database and tables on first start; set to `false` to manage the schema out of band | + +### 3.3 Cluster mode + +`iotdb.attributes.cluster_mode` and `iotdb.ts_latest.cluster_mode` must be set +explicitly when the corresponding DAO is active. Accepted values: + +* `sticky-routing` — writes for one identity are pinned to a single node +* `disabled` — single-node deployment, or best-effort convergence accepted + +Any other value, including leaving it empty, fails at startup rather than +silently. These write paths converge within a single JVM, so a multi-writer +cluster needs one of the two acknowledgements above. + +## 4. Known Limitations + +* Attribute and latest-overlay writes converge within a single JVM. A clustered + deployment must either pin each identity to one node (`sticky-routing`) or + explicitly accept best-effort convergence (`disabled`) — which is why the + cluster mode has to be stated rather than defaulted. +* The latest-value path is derived from the telemetry table, with a small overlay + for the latest-only write and delete paths that a pure derivation cannot + express. +* Table-level TTL is used for retention; see the module's + [user guide](https://github.com/apache/iotdb-extras/blob/master/iotdb-thingsboard-table/docs/user-guide.md) + for how it maps onto ThingsBoard's own retention settings. diff --git a/src/zh/UserGuide/Master/Table/Ecosystem-Integration/Ecosystem-Overview_apache.md b/src/zh/UserGuide/Master/Table/Ecosystem-Integration/Ecosystem-Overview_apache.md index a27cd20bd..7dd061f5a 100644 --- a/src/zh/UserGuide/Master/Table/Ecosystem-Integration/Ecosystem-Overview_apache.md +++ b/src/zh/UserGuide/Master/Table/Ecosystem-Integration/Ecosystem-Overview_apache.md @@ -35,4 +35,6 @@ IoTDB 生态集成打通时序数据全链路:通过数据采集实现设备 - 编程框架 - Spring Boot Starter [Spring Boot Starter](./Spring-Boot-Starter.md) - Mybatis Generator [Mybatis Generator](./Mybatis-Generator.md) - - MyBatisPlus Generator [MyBatisPlus Generator](./MyBatisPlus-Generator.md) \ No newline at end of file + - MyBatisPlus Generator [MyBatisPlus Generator](./MyBatisPlus-Generator.md) +- 物联网对接 + - ThingsBoard [ThingsBoard](./Thingsboard.md) \ No newline at end of file diff --git a/src/zh/UserGuide/Master/Table/Ecosystem-Integration/Thingsboard.md b/src/zh/UserGuide/Master/Table/Ecosystem-Integration/Thingsboard.md new file mode 100644 index 000000000..cbf372d0f --- /dev/null +++ b/src/zh/UserGuide/Master/Table/Ecosystem-Integration/Thingsboard.md @@ -0,0 +1,171 @@ + + +# ThingsBoard + +## 1. 功能概述 + +ThingsBoard 是一个开源物联网平台,用于设备管理、数据采集与可视化。它通过三个存储 SPI +分别存放设备时序数据、最新值时序数据和实体属性,因此可以在不改动平台本身的前提下替换存储层。 + +`iotdb-thingsboard-table` 在 IoTDB 表模型之上实现了这三个 SPI,使 ThingsBoard 部署可以把 +时序数据存进 IoTDB,而不是 Cassandra 或关系型数据库: + +| ThingsBoard SPI | 实现 | 用途 | +| --- | --- | --- | +| `TimeseriesDao` | `IoTDBTableTimeseriesDao` | 历史时序:批量写入、原始读与时间分桶聚合读、删除 | +| `TimeseriesLatestDao` | `IoTDBTableLatestDao` | 每个 telemetry key 的最新值 | +| `AttributesDao` | `IoTDBTableAttributesDao` | 实体属性,按 `SERVER_SCOPE` / `SHARED_SCOPE` / `CLIENT_SCOPE` 分域 | + +本页介绍的是**表模型**集成:它对接的是原版 ThingsBoard 发行版,把模块及其运行期依赖放上 +classpath 并配置若干属性即可启用。下面的部署一节列出了确切的 jar 集合,不是单独一个 jar。 +**树模型**另有一套更早的集成,见 +[ThingsBoard(树模型)](../../Tree/Ecosystem-Integration/Thingsboard.md);那一套把数据写在 +`root.thingsboard` 下,并且需要 IoTDB 适配版的 ThingsBoard 安装包而非原版。两者互相独立, +按你使用的数据模型选择即可。 + +写入经由一个有界异步队列批量落入 IoTDB tablet。读取覆盖原始路径和聚合路径:固定宽度的毫秒 +分桶使用 IoTDB 原生的 `date_bin`;日历分桶(`WEEK` / `WEEK_ISO` / `MONTH` / `QUARTER`) +则逐桶推进,以保证边界与 ThingsBoard 自身语义一致,并落在每个查询各自携带的时区上。 + +## 2. 使用步骤 + +### 2.1 版本要求 + +* `IoTDB: 2.0.8`(表模型)—— 集成测试实际运行的版本(`apache/iotdb:2.0.8-standalone`), + 其他 2.x 版本未经测试。 +* `ThingsBoard: 4.3.1.2` +* `JDK: >= 17` + +该模块是对着 ThingsBoard 4.3.1.2 的 SPI 编译的。由于 ThingsBoard 的 `common/data` 与 `dao` +构件未发布到 Maven Central,模块编译时使用的是其所需类型的编译期替身,这些类不会被打进产物 +jar,因此运行时使用的是真实的 ThingsBoard 类。 + +### 2.2 获取 jar + +从 `iotdb-extras` 仓库构建。该模块位于一个需显式开启的 profile 之后,普通的 reactor 构建不会 +包含它: + +```bash +# 在 apache/iotdb-extras 仓库根目录,使用 JDK 17+ +# https://github.com/apache/iotdb-extras +mvn -pl iotdb-thingsboard-table -am -P with-thingsboard clean package +``` + +产物 jar 位于 `iotdb-thingsboard-table/target/` 下。 + +### 2.3 部署到 ThingsBoard + +必须**先装好 ThingsBoard,再启用模块**:它的安装器会解析一个只有内置后端才提供的 +`TsDatabaseSchemaService` bean,因此在第 3 节的选择器已经打开的情况下启动,安装会失败。 + +1. 安装并启动 IoTDB,参见 [IoTDB 快速上手](../QuickStart/QuickStart.md)。 +2. 按常规方式装好 ThingsBoard,此时不要设置第 3 节的任何属性。 +3. 把模块**以及 IoTDB 客户端的运行期依赖**一起放上 ThingsBoard 的 classpath。只放模块 jar + 是不够的——首次创建会话时会抛 `ITableSessionPool` 的 `NoClassDefFoundError`。用 + `dependency:copy-dependencies -DincludeScope=runtime` 收集,然后**去掉 ThingsBoard 已经 + 自带的那些**,否则它更新的版本会被模块带的旧版本盖住。对 ThingsBoard 4.3.1.2 而言是九个 + ——`antlr4-runtime`、`commons-codec`、`commons-io`、`commons-lang3`、`httpclient`、 + `httpcore`、`lz4-java`、`snappy-java`、`zstd-jni`——外加 `commons-logging`,那个是 + ThingsBoard 自己要求移除的(它使用 `spring-jcl`)。其中 `antlr4-runtime` 后果最严重: + ThingsBoard 4.3.1.2 自带 4.13.0,模块带的是 4.9.3,之后 Spring Data JPA 的 `HqlLexer` + 无法反序列化自己的语法,启动直接失败。剩下十一个 jar。请对着你要部署的那个 ThingsBoard + 版本重新推导这个集合,并且**以能启动的部署为准,而不是以依赖比对为准**:比对回答的是 + ThingsBoard 是否*自带*某个构件,而不是它是否*容得下*某个构件,`commons-logging` 正是 + 两者分道扬镳的地方。 +4. 如何扩展 classpath 取决于安装方式。ThingsBoard 以 Spring Boot 应用运行:Docker 镜像通过 + `PropertiesLauncher` 启动并已支持 `LOADER_PATH`,把这些 jar 放进 + `/usr/share/thingsboard/extensions` 即可;deb/rpm 包则直接执行发行版 jar。请按你所用的 + 安装方式查阅 ThingsBoard 自身的部署文档。 +5. 将下方配置写入 ThingsBoard 的 `thingsboard.yml`,或提供等价的环境变量。 +6. 重启 ThingsBoard。首次启动时,模块会在 IoTDB 中创建所需的数据库与表,除非关闭了该引导。 + +该模块是一个 Spring Boot 自动配置,因此 ThingsBoard 侧无需组件扫描或代码改动。 + +## 3. 配置 + +### 3.1 激活 + +历史时序需要时序选择器加上显式开关: + +```Properties +# 选择本后端作为历史时序存储 +database.ts.type=iotdb-table +# 显式开关,必须与上面的选择器同时设置 +iotdb.ts.experimental-raw-only=true +``` + +最新值时序**另需**它自己的选择器。若遗漏,历史时序会存入 IoTDB,而最新值仍留在 ThingsBoard +的默认后端,且启动时不会报错: + +```Properties +database.ts_latest.type=iotdb-table +# 最新值 DAO 激活时必填:sticky-routing | disabled +iotdb.ts_latest.cluster_mode=sticky-routing +``` + +实体属性是单独的一项开关,未启用时该 DAO 不生效。与两个时间序列选择器不同,它并非 +独立于宿主:ThingsBoard 自身没有属性存储的配置开关,因此启用这一项会改动 +ThingsBoard 的 bean 图,而不只是本模块的。 + +> **暂时不要在原版 ThingsBoard 上启用这一项。** ThingsBoard 自身没有为属性存储提供配置开关 +> ——它的 JPA 属性 bean 是无条件注册的——因此设置下面这个选择器会让模块的冲突检查导致启动失败。 +> 相应的修复正在上游 apache/iotdb-extras#125 中等待评审。它只撤下一个 bean —— +> ThingsBoard 自己的 `jpaAttributeDao`,按 bean 名与全限定类名同时匹配 —— 其余任何 +> 竞争的 `AttributesDao` 一律不动,而是让启动失败。在该 PR 合入且包含它的构建发布之前, +> 本提示有效。 + +```Properties +database.attributes.type=iotdb-table +# 属性 DAO 激活时必填:sticky-routing | disabled +iotdb.attributes.cluster_mode=sticky-routing +``` + +### 3.2 连接与表结构 + +| 属性 | 默认值 | 含义 | +| --- | --- | --- | +| `iotdb.host` / `iotdb.port` | `127.0.0.1` / `6667` | IoTDB 节点地址 | +| `iotdb.username` / `iotdb.password` | `root` / `root` | IoTDB 凭据 | +| `iotdb.database` | `thingsboard` | 目标 IoTDB 数据库 | +| `iotdb.session-pool-size` | `8` | 表会话池大小 | +| `iotdb.schema.bootstrap` | `true` | 首次启动时创建数据库与表;若自行管理表结构则设为 `false` | + +### 3.3 集群模式 + +当对应 DAO 激活时,`iotdb.attributes.cluster_mode` 与 `iotdb.ts_latest.cluster_mode` 必须显式 +设置。可选值: + +* `sticky-routing` —— 同一 identity 的写入固定路由到单个节点 +* `disabled` —— 单节点部署,或已接受尽力而为的收敛 + +其他取值(包括留空)会在启动时直接失败,而不是静默通过。这两条写入路径只在单个 JVM 内收敛, +因此多写入者的集群部署需要上述两种确认之一。 + +## 4. 已知限制 + +* 属性写入与最新值覆盖层的写入只在单个 JVM 内收敛。集群部署必须二选一:把每个 identity 固定 + 到单个节点(`sticky-routing`),或显式接受尽力而为的收敛(`disabled`)—— 这正是集群模式必须 + 显式声明而非取默认值的原因。 +* 最新值路径由时序表派生而来,另有一个很小的覆盖层,用于承接纯派生无法表达的"只写最新值"与 + "只删最新值"路径。 +* 保留策略使用表级 TTL,其与 ThingsBoard 自身保留设置的对应关系,参见模块的 + [用户指南](https://github.com/apache/iotdb-extras/blob/master/iotdb-thingsboard-table/docs/user-guide.md)。 diff --git a/src/zh/UserGuide/latest-Table/Ecosystem-Integration/Ecosystem-Overview_apache.md b/src/zh/UserGuide/latest-Table/Ecosystem-Integration/Ecosystem-Overview_apache.md index a27cd20bd..7dd061f5a 100644 --- a/src/zh/UserGuide/latest-Table/Ecosystem-Integration/Ecosystem-Overview_apache.md +++ b/src/zh/UserGuide/latest-Table/Ecosystem-Integration/Ecosystem-Overview_apache.md @@ -35,4 +35,6 @@ IoTDB 生态集成打通时序数据全链路:通过数据采集实现设备 - 编程框架 - Spring Boot Starter [Spring Boot Starter](./Spring-Boot-Starter.md) - Mybatis Generator [Mybatis Generator](./Mybatis-Generator.md) - - MyBatisPlus Generator [MyBatisPlus Generator](./MyBatisPlus-Generator.md) \ No newline at end of file + - MyBatisPlus Generator [MyBatisPlus Generator](./MyBatisPlus-Generator.md) +- 物联网对接 + - ThingsBoard [ThingsBoard](./Thingsboard.md) \ No newline at end of file diff --git a/src/zh/UserGuide/latest-Table/Ecosystem-Integration/Thingsboard.md b/src/zh/UserGuide/latest-Table/Ecosystem-Integration/Thingsboard.md new file mode 100644 index 000000000..c24a26020 --- /dev/null +++ b/src/zh/UserGuide/latest-Table/Ecosystem-Integration/Thingsboard.md @@ -0,0 +1,171 @@ + + +# ThingsBoard + +## 1. 功能概述 + +ThingsBoard 是一个开源物联网平台,用于设备管理、数据采集与可视化。它通过三个存储 SPI +分别存放设备时序数据、最新值时序数据和实体属性,因此可以在不改动平台本身的前提下替换存储层。 + +`iotdb-thingsboard-table` 在 IoTDB 表模型之上实现了这三个 SPI,使 ThingsBoard 部署可以把 +时序数据存进 IoTDB,而不是 Cassandra 或关系型数据库: + +| ThingsBoard SPI | 实现 | 用途 | +| --- | --- | --- | +| `TimeseriesDao` | `IoTDBTableTimeseriesDao` | 历史时序:批量写入、原始读与时间分桶聚合读、删除 | +| `TimeseriesLatestDao` | `IoTDBTableLatestDao` | 每个 telemetry key 的最新值 | +| `AttributesDao` | `IoTDBTableAttributesDao` | 实体属性,按 `SERVER_SCOPE` / `SHARED_SCOPE` / `CLIENT_SCOPE` 分域 | + +本页介绍的是**表模型**集成:它对接的是原版 ThingsBoard 发行版,把模块及其运行期依赖放上 +classpath 并配置若干属性即可启用。下面的部署一节列出了确切的 jar 集合,不是单独一个 jar。 +**树模型**另有一套更早的集成,见 +[ThingsBoard(树模型)](../../latest/Ecosystem-Integration/Thingsboard.md);那一套把数据写在 +`root.thingsboard` 下,并且需要 IoTDB 适配版的 ThingsBoard 安装包而非原版。两者互相独立, +按你使用的数据模型选择即可。 + +写入经由一个有界异步队列批量落入 IoTDB tablet。读取覆盖原始路径和聚合路径:固定宽度的毫秒 +分桶使用 IoTDB 原生的 `date_bin`;日历分桶(`WEEK` / `WEEK_ISO` / `MONTH` / `QUARTER`) +则逐桶推进,以保证边界与 ThingsBoard 自身语义一致,并落在每个查询各自携带的时区上。 + +## 2. 使用步骤 + +### 2.1 版本要求 + +* `IoTDB: 2.0.8`(表模型)—— 集成测试实际运行的版本(`apache/iotdb:2.0.8-standalone`), + 其他 2.x 版本未经测试。 +* `ThingsBoard: 4.3.1.2` +* `JDK: >= 17` + +该模块是对着 ThingsBoard 4.3.1.2 的 SPI 编译的。由于 ThingsBoard 的 `common/data` 与 `dao` +构件未发布到 Maven Central,模块编译时使用的是其所需类型的编译期替身,这些类不会被打进产物 +jar,因此运行时使用的是真实的 ThingsBoard 类。 + +### 2.2 获取 jar + +从 `iotdb-extras` 仓库构建。该模块位于一个需显式开启的 profile 之后,普通的 reactor 构建不会 +包含它: + +```bash +# 在 apache/iotdb-extras 仓库根目录,使用 JDK 17+ +# https://github.com/apache/iotdb-extras +mvn -pl iotdb-thingsboard-table -am -P with-thingsboard clean package +``` + +产物 jar 位于 `iotdb-thingsboard-table/target/` 下。 + +### 2.3 部署到 ThingsBoard + +必须**先装好 ThingsBoard,再启用模块**:它的安装器会解析一个只有内置后端才提供的 +`TsDatabaseSchemaService` bean,因此在第 3 节的选择器已经打开的情况下启动,安装会失败。 + +1. 安装并启动 IoTDB,参见 [IoTDB 快速上手](../QuickStart/QuickStart.md)。 +2. 按常规方式装好 ThingsBoard,此时不要设置第 3 节的任何属性。 +3. 把模块**以及 IoTDB 客户端的运行期依赖**一起放上 ThingsBoard 的 classpath。只放模块 jar + 是不够的——首次创建会话时会抛 `ITableSessionPool` 的 `NoClassDefFoundError`。用 + `dependency:copy-dependencies -DincludeScope=runtime` 收集,然后**去掉 ThingsBoard 已经 + 自带的那些**,否则它更新的版本会被模块带的旧版本盖住。对 ThingsBoard 4.3.1.2 而言是九个 + ——`antlr4-runtime`、`commons-codec`、`commons-io`、`commons-lang3`、`httpclient`、 + `httpcore`、`lz4-java`、`snappy-java`、`zstd-jni`——外加 `commons-logging`,那个是 + ThingsBoard 自己要求移除的(它使用 `spring-jcl`)。其中 `antlr4-runtime` 后果最严重: + ThingsBoard 4.3.1.2 自带 4.13.0,模块带的是 4.9.3,之后 Spring Data JPA 的 `HqlLexer` + 无法反序列化自己的语法,启动直接失败。剩下十一个 jar。请对着你要部署的那个 ThingsBoard + 版本重新推导这个集合,并且**以能启动的部署为准,而不是以依赖比对为准**:比对回答的是 + ThingsBoard 是否*自带*某个构件,而不是它是否*容得下*某个构件,`commons-logging` 正是 + 两者分道扬镳的地方。 +4. 如何扩展 classpath 取决于安装方式。ThingsBoard 以 Spring Boot 应用运行:Docker 镜像通过 + `PropertiesLauncher` 启动并已支持 `LOADER_PATH`,把这些 jar 放进 + `/usr/share/thingsboard/extensions` 即可;deb/rpm 包则直接执行发行版 jar。请按你所用的 + 安装方式查阅 ThingsBoard 自身的部署文档。 +5. 将下方配置写入 ThingsBoard 的 `thingsboard.yml`,或提供等价的环境变量。 +6. 重启 ThingsBoard。首次启动时,模块会在 IoTDB 中创建所需的数据库与表,除非关闭了该引导。 + +该模块是一个 Spring Boot 自动配置,因此 ThingsBoard 侧无需组件扫描或代码改动。 + +## 3. 配置 + +### 3.1 激活 + +历史时序需要时序选择器加上显式开关: + +```Properties +# 选择本后端作为历史时序存储 +database.ts.type=iotdb-table +# 显式开关,必须与上面的选择器同时设置 +iotdb.ts.experimental-raw-only=true +``` + +最新值时序**另需**它自己的选择器。若遗漏,历史时序会存入 IoTDB,而最新值仍留在 ThingsBoard +的默认后端,且启动时不会报错: + +```Properties +database.ts_latest.type=iotdb-table +# 最新值 DAO 激活时必填:sticky-routing | disabled +iotdb.ts_latest.cluster_mode=sticky-routing +``` + +实体属性是单独的一项开关,未启用时该 DAO 不生效。与两个时间序列选择器不同,它并非 +独立于宿主:ThingsBoard 自身没有属性存储的配置开关,因此启用这一项会改动 +ThingsBoard 的 bean 图,而不只是本模块的。 + +> **暂时不要在原版 ThingsBoard 上启用这一项。** ThingsBoard 自身没有为属性存储提供配置开关 +> ——它的 JPA 属性 bean 是无条件注册的——因此设置下面这个选择器会让模块的冲突检查导致启动失败。 +> 相应的修复正在上游 apache/iotdb-extras#125 中等待评审。它只撤下一个 bean —— +> ThingsBoard 自己的 `jpaAttributeDao`,按 bean 名与全限定类名同时匹配 —— 其余任何 +> 竞争的 `AttributesDao` 一律不动,而是让启动失败。在该 PR 合入且包含它的构建发布之前, +> 本提示有效。 + +```Properties +database.attributes.type=iotdb-table +# 属性 DAO 激活时必填:sticky-routing | disabled +iotdb.attributes.cluster_mode=sticky-routing +``` + +### 3.2 连接与表结构 + +| 属性 | 默认值 | 含义 | +| --- | --- | --- | +| `iotdb.host` / `iotdb.port` | `127.0.0.1` / `6667` | IoTDB 节点地址 | +| `iotdb.username` / `iotdb.password` | `root` / `root` | IoTDB 凭据 | +| `iotdb.database` | `thingsboard` | 目标 IoTDB 数据库 | +| `iotdb.session-pool-size` | `8` | 表会话池大小 | +| `iotdb.schema.bootstrap` | `true` | 首次启动时创建数据库与表;若自行管理表结构则设为 `false` | + +### 3.3 集群模式 + +当对应 DAO 激活时,`iotdb.attributes.cluster_mode` 与 `iotdb.ts_latest.cluster_mode` 必须显式 +设置。可选值: + +* `sticky-routing` —— 同一 identity 的写入固定路由到单个节点 +* `disabled` —— 单节点部署,或已接受尽力而为的收敛 + +其他取值(包括留空)会在启动时直接失败,而不是静默通过。这两条写入路径只在单个 JVM 内收敛, +因此多写入者的集群部署需要上述两种确认之一。 + +## 4. 已知限制 + +* 属性写入与最新值覆盖层的写入只在单个 JVM 内收敛。集群部署必须二选一:把每个 identity 固定 + 到单个节点(`sticky-routing`),或显式接受尽力而为的收敛(`disabled`)—— 这正是集群模式必须 + 显式声明而非取默认值的原因。 +* 最新值路径由时序表派生而来,另有一个很小的覆盖层,用于承接纯派生无法表达的"只写最新值"与 + "只删最新值"路径。 +* 保留策略使用表级 TTL,其与 ThingsBoard 自身保留设置的对应关系,参见模块的 + [用户指南](https://github.com/apache/iotdb-extras/blob/master/iotdb-thingsboard-table/docs/user-guide.md)。 From 9d334cc1642eab53e8b879288badfadaa96b082d Mon Sep 17 00:00:00 2001 From: Zihan Dai <99155080+PDGGK@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:03:17 +1000 Subject: [PATCH 2/2] docs: make the ThingsBoard guide operator-focused --- .../img/thingsboard-table-attributes.png | Bin 0 -> 32078 bytes .../img/thingsboard-table-telemetry.png | Bin 0 -> 28693 bytes .../Ecosystem-Integration/Thingsboard.md | 303 +++++++++--------- .../Ecosystem-Integration/Thingsboard.md | 303 +++++++++--------- .../Ecosystem-Integration/Thingsboard.md | 236 +++++++------- .../Ecosystem-Integration/Thingsboard.md | 236 +++++++------- 6 files changed, 538 insertions(+), 540 deletions(-) create mode 100644 src/.vuepress/public/img/thingsboard-table-attributes.png create mode 100644 src/.vuepress/public/img/thingsboard-table-telemetry.png diff --git a/src/.vuepress/public/img/thingsboard-table-attributes.png b/src/.vuepress/public/img/thingsboard-table-attributes.png new file mode 100644 index 0000000000000000000000000000000000000000..f9e8eef6a511a0272e7188a003f207ed382f4c9c GIT binary patch literal 32078 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 z

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 MWK^Z=B+Wwp4=Sl`(f|Me literal 0 HcmV?d00001 diff --git a/src/.vuepress/public/img/thingsboard-table-telemetry.png b/src/.vuepress/public/img/thingsboard-table-telemetry.png new file mode 100644 index 0000000000000000000000000000000000000000..ea3067ae2719ddd5a163d7428b55283fc06ae71d GIT binary patch literal 28693 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 LR+6fcFnRxfBG8&w literal 0 HcmV?d00001 diff --git a/src/UserGuide/Master/Table/Ecosystem-Integration/Thingsboard.md b/src/UserGuide/Master/Table/Ecosystem-Integration/Thingsboard.md index 10defefba..47c73f907 100644 --- a/src/UserGuide/Master/Table/Ecosystem-Integration/Thingsboard.md +++ b/src/UserGuide/Master/Table/Ecosystem-Integration/Thingsboard.md @@ -7,9 +7,9 @@ to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at - + http://www.apache.org/licenses/LICENSE-2.0 - + Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY @@ -21,183 +21,176 @@ # ThingsBoard -## 1. Overview - -ThingsBoard is an open-source IoT platform for device management, data collection -and visualisation. It stores device telemetry, latest-value telemetry and entity -attributes through three storage SPIs, which allows the storage layer to be -replaced without changing the platform itself. - -`iotdb-thingsboard-table` implements those three SPIs on top of the IoTDB Table -Model, so a ThingsBoard deployment can keep its telemetry in IoTDB instead of -Cassandra or a relational database: - -| ThingsBoard SPI | Implementation | Purpose | -| --- | --- | --- | -| `TimeseriesDao` | `IoTDBTableTimeseriesDao` | Historical telemetry: batched writes, raw and time-bucketed aggregation reads, deletes | -| `TimeseriesLatestDao` | `IoTDBTableLatestDao` | Latest value per telemetry key | -| `AttributesDao` | `IoTDBTableAttributesDao` | Entity attributes, scoped by `SERVER_SCOPE` / `SHARED_SCOPE` / `CLIENT_SCOPE` | +The `iotdb-thingsboard-table` module lets a stock ThingsBoard deployment store +historical telemetry, latest telemetry, and entity attributes in the IoTDB Table +Model. This page follows the operator workflow: download, install, configure, +run, and verify. -This page covers the **Table Model** integration, which runs against a stock -ThingsBoard release and is enabled by putting the module and its runtime -dependencies on the classpath and setting a few properties. The deployment -section below lists the exact set; it is not a single jar. -There is a separate, earlier integration for the **Tree Model**, described in -[ThingsBoard (Tree Model)](../../Tree/Ecosystem-Integration/Thingsboard.md); -that one stores data under `root.thingsboard` and requires an IoTDB-adapted -ThingsBoard build rather than a stock one. The two are independent — pick the one -that matches the data model you are using. +This integration is independent of the earlier +[ThingsBoard Tree Model integration](../../Tree/Ecosystem-Integration/Thingsboard.md), +which requires an IoTDB-adapted ThingsBoard build. -Writes are batched through a bounded asynchronous queue into IoTDB tablets. -Reads cover both the raw path and the aggregation path: fixed-width millisecond -buckets use IoTDB's native `date_bin`, while calendar buckets -(`WEEK` / `WEEK_ISO` / `MONTH` / `QUARTER`) are walked per bucket so that -boundaries match ThingsBoard's own semantics in the timezone carried by each -query. +## 1. Download -## 2. Usage Steps +The tested combination is: -### 2.1 Version Requirements +| Component | Version | +| --- | --- | +| IoTDB | 2.0.8, Table Model | +| ThingsBoard | 4.3.1.2 | +| JDK | 17 or later | -* `IoTDB: 2.0.8` (Table Model) — the version the integration tests are executed - against (`apache/iotdb:2.0.8-standalone`). Other 2.x releases are untested. -* `ThingsBoard: 4.3.1.2` -* `JDK: >= 17` - -The module is compiled against the ThingsBoard 4.3.1.2 SPI surface. Because -ThingsBoard's `common/data` and `dao` artifacts are not published to Maven -Central, the module builds against a compile-only surface of the types it uses; -those classes are excluded from the packaged jar, so at runtime the real -ThingsBoard classes are used. - -### 2.2 Obtain the jar - -Build it from the `iotdb-extras` repository. The module sits behind an explicit -opt-in profile, so a plain reactor build does not include it: +This module is not yet included in an ASF release. Build it from the official +`apache/iotdb-extras` source at commit `275043ed` or later: ```bash -# from the apache/iotdb-extras repository root, with JDK 17+ -# https://github.com/apache/iotdb-extras -mvn -pl iotdb-thingsboard-table -am -P with-thingsboard clean package +git clone https://github.com/apache/iotdb-extras.git +cd iotdb-extras +git checkout 275043ed +mvn -P with-thingsboard -pl iotdb-thingsboard-table -am clean package +mvn -P with-thingsboard -pl iotdb-thingsboard-table -am \ + dependency:copy-dependencies -DincludeScope=runtime \ + -DoutputDirectory="$PWD/runtime-deps" ``` -The jar is produced under `iotdb-thingsboard-table/target/`. - -### 2.3 Deploy into ThingsBoard - -ThingsBoard must be installed **before** the module is enabled: its installer -resolves a `TsDatabaseSchemaService` bean that only the built-in backends -provide, so starting with the selectors of section 3 already set makes the -install step fail. - -1. Install and start IoTDB, see [IoTDB QuickStart](../QuickStart/QuickStart.md). -2. Install ThingsBoard normally, with none of the properties in section 3 set. -3. Put the module **and the IoTDB client's runtime dependencies** on - ThingsBoard's classpath. The module jar alone is not enough — it fails at - first session creation with `NoClassDefFoundError` on `ITableSessionPool`. - Collect the set with `dependency:copy-dependencies -DincludeScope=runtime`, - then remove the artifacts ThingsBoard already bundles, or its newer copies - are shadowed by the module's older ones. Against ThingsBoard 4.3.1.2 that is - nine — `antlr4-runtime`, `commons-codec`, `commons-io`, `commons-lang3`, - `httpclient`, `httpcore`, `lz4-java`, `snappy-java`, `zstd-jni` — plus - `commons-logging`, which ThingsBoard asks to have removed because it uses - `spring-jcl`. `antlr4-runtime` is the one that bites hardest: ThingsBoard - 4.3.1.2 ships 4.13.0 and the module brings 4.9.3, after which Spring Data - JPA's `HqlLexer` cannot deserialise its own grammar and startup fails. - Eleven jars remain. Re-derive that set against the ThingsBoard release you - are deploying to, and prefer a deployment that boots over a dependency diff: - the diff answers whether ThingsBoard *ships* an artifact, not whether it - *tolerates* one, and `commons-logging` is exactly where those differ. -4. How the classpath is extended depends on the installation. ThingsBoard runs - as a Spring Boot application: the Docker images launch through - `PropertiesLauncher` and already honour a `LOADER_PATH` entry, so placing the - jars in `/usr/share/thingsboard/extensions` is enough; the deb/rpm packages - execute the distribution jar directly. Consult ThingsBoard's own deployment - documentation for the installation method you use. -5. Add the configuration below to ThingsBoard's `thingsboard.yml`, or supply the - equivalent environment variables. -6. Restart ThingsBoard. On first start the module creates its database and tables - in IoTDB, unless that bootstrap is disabled. - -The module is a Spring Boot auto-configuration, so no component scanning or code -change is required on the ThingsBoard side. - -## 3. Configuration - -### 3.1 Activation - -Historical telemetry needs the timeseries selector plus the explicit opt-in: - -```Properties -# select this backend for historical telemetry +The second command intentionally produces an unfiltered dependency directory. +Section 2 lists the exact runtime jars to deploy; do not copy the directory as +a whole. + +## 2. Install + +ThingsBoard must be installed before the IoTDB selectors are enabled. Its +installer resolves a `TsDatabaseSchemaService` supplied by the built-in +backends, so enabling this module during installation makes the installation +fail. + +1. Install and start IoTDB. See [IoTDB QuickStart](../QuickStart/QuickStart.md). + + When IoTDB 2.0.8 and ThingsBoard run in separate containers, put them on the + same Docker network and make IoTDB advertise a reachable service name. The + `2.0.8-standalone` image otherwise binds these endpoints to `127.0.0.1`. + For an IoTDB service named `iotdb`, set: + + ```yaml + cn_seed_config_node: iotdb:10710 + dn_seed_config_node: iotdb:10710 + cn_internal_address: iotdb + dn_internal_address: iotdb + dn_rpc_address: iotdb + ``` + +2. Install ThingsBoard normally without any of the properties from section 3. +3. Create a deployment `lib/` directory containing the module's binary jar from + `iotdb-thingsboard-table/target/` and the ten runtime jars listed below from + `runtime-deps/`. Place all eleven jars on the ThingsBoard classpath. + +For the ThingsBoard Docker image, mount or copy the jars to +`/usr/share/thingsboard/extensions`; its `PropertiesLauncher` already includes +that directory through `LOADER_PATH`. Other installation formats may use a +different classpath extension mechanism. + +The deployment directory contains exactly eleven jars: the module, `iotdb-session`, +`isession`, `service-rpc`, `iotdb-thrift`, `iotdb-thrift-commons`, `libthrift`, +`pipe-api`, `tsfile`, `common`, and `xz`. Do not add the unfiltered Maven runtime +directory: it contains older copies of libraries already supplied by +ThingsBoard. In particular, `antlr4-runtime` 4.9.3 shadows ThingsBoard's 4.13.0 +copy and prevents Spring Data JPA from starting. + +## 3. Configure + +Add the properties for the storage paths you want to move to IoTDB. The +following example enables all three paths for a single-node deployment: + +```properties +# IoTDB connection +# Docker service name; use 127.0.0.1 only when both processes share a host +iotdb.host=iotdb +iotdb.port=6667 +iotdb.username=root +iotdb.password=root +iotdb.database=thingsboard +iotdb.session-pool-size=8 +iotdb.schema.bootstrap=true + +# Historical telemetry database.ts.type=iotdb-table -# explicit opt-in; required together with the selector above iotdb.ts.experimental-raw-only=true -``` - -Latest-value telemetry needs its **own** selector in addition to those two. If it -is omitted, historical telemetry is stored in IoTDB while latest values stay on -ThingsBoard's default backend, with no error at startup: -```Properties +# Latest telemetry database.ts_latest.type=iotdb-table -# required when the latest DAO is active: sticky-routing | disabled -iotdb.ts_latest.cluster_mode=sticky-routing +iotdb.ts_latest.cluster_mode=disabled + +# Entity attributes +database.attributes.type=iotdb-table +iotdb.attributes.cluster_mode=disabled ``` -Entity attributes are a separate opt-in and are inert unless enabled. Unlike the -two timeseries selectors it is not independent of the host: ThingsBoard has no -attributes-backend switch of its own, so enabling this one changes ThingsBoard's -bean graph rather than only this module's. +The historical and latest selectors are separate. Omitting +`database.ts_latest.type` leaves latest values on the original ThingsBoard +backend even when historical telemetry is stored in IoTDB. -> **Do not enable this on a stock ThingsBoard yet.** ThingsBoard has no -> configuration switch of its own for attributes storage — its JPA attributes -> bean registers unconditionally — so setting the selector below makes startup -> fail on the module's conflict check. A fix is under review upstream as -> apache/iotdb-extras#125. It withdraws exactly one bean — ThingsBoard's own -> `jpaAttributeDao`, matched on both the bean name and the fully-qualified class -> name — and fails startup on any other competing `AttributesDao` rather than -> removing it. This note applies until that PR is merged and a build containing -> it is released. +The example writes the connection defaults explicitly. Outside Docker, +`iotdb.host` defaults to `127.0.0.1`; the other defaults are port `6667`, +username/password `root`/`root`, database `thingsboard`, session-pool size `8`, +and schema bootstrap enabled. +Attributes require a build containing +[apache/iotdb-extras#125](https://github.com/apache/iotdb-extras/pull/125), +merged as commit `275043ed`. Earlier builds cannot enable the attributes +selector on stock ThingsBoard. -```Properties -database.attributes.type=iotdb-table -# required when the attribute DAO is active: sticky-routing | disabled -iotdb.attributes.cluster_mode=sticky-routing -``` +When latest telemetry or attributes are enabled, their `cluster_mode` must be +set explicitly: + +- `sticky-routing`: route writes for one identity to one node. +- `disabled`: single-node deployment, or explicitly accept best-effort + convergence. -### 3.2 Connection and schema +Any other value, including an empty value, stops startup with an explanatory +error. -| Property | Default | Meaning | -| --- | --- | --- | -| `iotdb.host` / `iotdb.port` | `127.0.0.1` / `6667` | IoTDB node address | -| `iotdb.username` / `iotdb.password` | `root` / `root` | IoTDB credentials | -| `iotdb.database` | `thingsboard` | Target IoTDB database | -| `iotdb.session-pool-size` | `8` | Table session pool size | -| `iotdb.schema.bootstrap` | `true` | Create the database and tables on first start; set to `false` to manage the schema out of band | +## 4. Run and verify -### 3.3 Cluster mode +1. Restart ThingsBoard after the bundle and configuration are in place. +2. Check the log for `IoTDB Table Mode session pool initialized`. With schema + bootstrap enabled, also check for `IoTDB Table Mode schema bootstrap + complete`. +3. Send test telemetry and attributes to a ThingsBoard device, then confirm + them on the device's **Latest telemetry** and **Attributes** pages. -`iotdb.attributes.cluster_mode` and `iotdb.ts_latest.cluster_mode` must be set -explicitly when the corresponding DAO is active. Accepted values: + ![ThingsBoard latest telemetry stored through the IoTDB Table Model backend](/img/thingsboard-table-telemetry.png) -* `sticky-routing` — writes for one identity are pinned to a single node -* `disabled` — single-node deployment, or best-effort convergence accepted + ![ThingsBoard client attributes stored through the IoTDB Table Model backend](/img/thingsboard-table-attributes.png) -Any other value, including leaving it empty, fails at startup rather than -silently. These write paths converge within a single JVM, so a multi-writer -cluster needs one of the two acknowledgements above. +4. Confirm the same values in IoTDB: -## 4. Known Limitations +```sql +USE thingsboard; -* Attribute and latest-overlay writes converge within a single JVM. A clustered - deployment must either pin each identity to one node (`sticky-routing`) or - explicitly accept best-effort convergence (`disabled`) — which is why the - cluster mode has to be stated rather than defaulted. -* The latest-value path is derived from the telemetry table, with a small overlay - for the latest-only write and delete paths that a pure derivation cannot - express. -* Table-level TTL is used for retention; see the module's +SELECT * +FROM telemetry +ORDER BY time DESC +LIMIT 10; + +SELECT * +FROM entity_attributes +ORDER BY time DESC +LIMIT 10; +``` + +## 5. Known limitations + +- IoTDB 2.0.8 and ThingsBoard 4.3.1.2 are the tested versions. Other 2.x or + ThingsBoard versions require separate verification. +- Attribute and latest-overlay writes converge within one JVM. A multi-writer + deployment must use sticky routing or explicitly accept best-effort + convergence. +- The latest-value path is derived from the telemetry table, with a small + overlay for latest-only writes and deletes. +- Retention uses IoTDB table-level TTL. See the module's [user guide](https://github.com/apache/iotdb-extras/blob/master/iotdb-thingsboard-table/docs/user-guide.md) - for how it maps onto ThingsBoard's own retention settings. + for its mapping to ThingsBoard retention settings. + +Design, source-build, migration, and compile-surface verification details remain +in the +[`iotdb-thingsboard-table` module](https://github.com/apache/iotdb-extras/tree/master/iotdb-thingsboard-table). diff --git a/src/UserGuide/latest-Table/Ecosystem-Integration/Thingsboard.md b/src/UserGuide/latest-Table/Ecosystem-Integration/Thingsboard.md index c479850ad..53f8cbaf2 100644 --- a/src/UserGuide/latest-Table/Ecosystem-Integration/Thingsboard.md +++ b/src/UserGuide/latest-Table/Ecosystem-Integration/Thingsboard.md @@ -7,9 +7,9 @@ to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at - + http://www.apache.org/licenses/LICENSE-2.0 - + Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY @@ -21,183 +21,176 @@ # ThingsBoard -## 1. Overview - -ThingsBoard is an open-source IoT platform for device management, data collection -and visualisation. It stores device telemetry, latest-value telemetry and entity -attributes through three storage SPIs, which allows the storage layer to be -replaced without changing the platform itself. - -`iotdb-thingsboard-table` implements those three SPIs on top of the IoTDB Table -Model, so a ThingsBoard deployment can keep its telemetry in IoTDB instead of -Cassandra or a relational database: - -| ThingsBoard SPI | Implementation | Purpose | -| --- | --- | --- | -| `TimeseriesDao` | `IoTDBTableTimeseriesDao` | Historical telemetry: batched writes, raw and time-bucketed aggregation reads, deletes | -| `TimeseriesLatestDao` | `IoTDBTableLatestDao` | Latest value per telemetry key | -| `AttributesDao` | `IoTDBTableAttributesDao` | Entity attributes, scoped by `SERVER_SCOPE` / `SHARED_SCOPE` / `CLIENT_SCOPE` | +The `iotdb-thingsboard-table` module lets a stock ThingsBoard deployment store +historical telemetry, latest telemetry, and entity attributes in the IoTDB Table +Model. This page follows the operator workflow: download, install, configure, +run, and verify. -This page covers the **Table Model** integration, which runs against a stock -ThingsBoard release and is enabled by putting the module and its runtime -dependencies on the classpath and setting a few properties. The deployment -section below lists the exact set; it is not a single jar. -There is a separate, earlier integration for the **Tree Model**, described in -[ThingsBoard (Tree Model)](../../latest/Ecosystem-Integration/Thingsboard.md); -that one stores data under `root.thingsboard` and requires an IoTDB-adapted -ThingsBoard build rather than a stock one. The two are independent — pick the one -that matches the data model you are using. +This integration is independent of the earlier +[ThingsBoard Tree Model integration](../../latest/Ecosystem-Integration/Thingsboard.md), +which requires an IoTDB-adapted ThingsBoard build. -Writes are batched through a bounded asynchronous queue into IoTDB tablets. -Reads cover both the raw path and the aggregation path: fixed-width millisecond -buckets use IoTDB's native `date_bin`, while calendar buckets -(`WEEK` / `WEEK_ISO` / `MONTH` / `QUARTER`) are walked per bucket so that -boundaries match ThingsBoard's own semantics in the timezone carried by each -query. +## 1. Download -## 2. Usage Steps +The tested combination is: -### 2.1 Version Requirements +| Component | Version | +| --- | --- | +| IoTDB | 2.0.8, Table Model | +| ThingsBoard | 4.3.1.2 | +| JDK | 17 or later | -* `IoTDB: 2.0.8` (Table Model) — the version the integration tests are executed - against (`apache/iotdb:2.0.8-standalone`). Other 2.x releases are untested. -* `ThingsBoard: 4.3.1.2` -* `JDK: >= 17` - -The module is compiled against the ThingsBoard 4.3.1.2 SPI surface. Because -ThingsBoard's `common/data` and `dao` artifacts are not published to Maven -Central, the module builds against a compile-only surface of the types it uses; -those classes are excluded from the packaged jar, so at runtime the real -ThingsBoard classes are used. - -### 2.2 Obtain the jar - -Build it from the `iotdb-extras` repository. The module sits behind an explicit -opt-in profile, so a plain reactor build does not include it: +This module is not yet included in an ASF release. Build it from the official +`apache/iotdb-extras` source at commit `275043ed` or later: ```bash -# from the apache/iotdb-extras repository root, with JDK 17+ -# https://github.com/apache/iotdb-extras -mvn -pl iotdb-thingsboard-table -am -P with-thingsboard clean package +git clone https://github.com/apache/iotdb-extras.git +cd iotdb-extras +git checkout 275043ed +mvn -P with-thingsboard -pl iotdb-thingsboard-table -am clean package +mvn -P with-thingsboard -pl iotdb-thingsboard-table -am \ + dependency:copy-dependencies -DincludeScope=runtime \ + -DoutputDirectory="$PWD/runtime-deps" ``` -The jar is produced under `iotdb-thingsboard-table/target/`. - -### 2.3 Deploy into ThingsBoard - -ThingsBoard must be installed **before** the module is enabled: its installer -resolves a `TsDatabaseSchemaService` bean that only the built-in backends -provide, so starting with the selectors of section 3 already set makes the -install step fail. - -1. Install and start IoTDB, see [IoTDB QuickStart](../QuickStart/QuickStart.md). -2. Install ThingsBoard normally, with none of the properties in section 3 set. -3. Put the module **and the IoTDB client's runtime dependencies** on - ThingsBoard's classpath. The module jar alone is not enough — it fails at - first session creation with `NoClassDefFoundError` on `ITableSessionPool`. - Collect the set with `dependency:copy-dependencies -DincludeScope=runtime`, - then remove the artifacts ThingsBoard already bundles, or its newer copies - are shadowed by the module's older ones. Against ThingsBoard 4.3.1.2 that is - nine — `antlr4-runtime`, `commons-codec`, `commons-io`, `commons-lang3`, - `httpclient`, `httpcore`, `lz4-java`, `snappy-java`, `zstd-jni` — plus - `commons-logging`, which ThingsBoard asks to have removed because it uses - `spring-jcl`. `antlr4-runtime` is the one that bites hardest: ThingsBoard - 4.3.1.2 ships 4.13.0 and the module brings 4.9.3, after which Spring Data - JPA's `HqlLexer` cannot deserialise its own grammar and startup fails. - Eleven jars remain. Re-derive that set against the ThingsBoard release you - are deploying to, and prefer a deployment that boots over a dependency diff: - the diff answers whether ThingsBoard *ships* an artifact, not whether it - *tolerates* one, and `commons-logging` is exactly where those differ. -4. How the classpath is extended depends on the installation. ThingsBoard runs - as a Spring Boot application: the Docker images launch through - `PropertiesLauncher` and already honour a `LOADER_PATH` entry, so placing the - jars in `/usr/share/thingsboard/extensions` is enough; the deb/rpm packages - execute the distribution jar directly. Consult ThingsBoard's own deployment - documentation for the installation method you use. -5. Add the configuration below to ThingsBoard's `thingsboard.yml`, or supply the - equivalent environment variables. -6. Restart ThingsBoard. On first start the module creates its database and tables - in IoTDB, unless that bootstrap is disabled. - -The module is a Spring Boot auto-configuration, so no component scanning or code -change is required on the ThingsBoard side. - -## 3. Configuration - -### 3.1 Activation - -Historical telemetry needs the timeseries selector plus the explicit opt-in: - -```Properties -# select this backend for historical telemetry +The second command intentionally produces an unfiltered dependency directory. +Section 2 lists the exact runtime jars to deploy; do not copy the directory as +a whole. + +## 2. Install + +ThingsBoard must be installed before the IoTDB selectors are enabled. Its +installer resolves a `TsDatabaseSchemaService` supplied by the built-in +backends, so enabling this module during installation makes the installation +fail. + +1. Install and start IoTDB. See [IoTDB QuickStart](../QuickStart/QuickStart.md). + + When IoTDB 2.0.8 and ThingsBoard run in separate containers, put them on the + same Docker network and make IoTDB advertise a reachable service name. The + `2.0.8-standalone` image otherwise binds these endpoints to `127.0.0.1`. + For an IoTDB service named `iotdb`, set: + + ```yaml + cn_seed_config_node: iotdb:10710 + dn_seed_config_node: iotdb:10710 + cn_internal_address: iotdb + dn_internal_address: iotdb + dn_rpc_address: iotdb + ``` + +2. Install ThingsBoard normally without any of the properties from section 3. +3. Create a deployment `lib/` directory containing the module's binary jar from + `iotdb-thingsboard-table/target/` and the ten runtime jars listed below from + `runtime-deps/`. Place all eleven jars on the ThingsBoard classpath. + +For the ThingsBoard Docker image, mount or copy the jars to +`/usr/share/thingsboard/extensions`; its `PropertiesLauncher` already includes +that directory through `LOADER_PATH`. Other installation formats may use a +different classpath extension mechanism. + +The deployment directory contains exactly eleven jars: the module, `iotdb-session`, +`isession`, `service-rpc`, `iotdb-thrift`, `iotdb-thrift-commons`, `libthrift`, +`pipe-api`, `tsfile`, `common`, and `xz`. Do not add the unfiltered Maven runtime +directory: it contains older copies of libraries already supplied by +ThingsBoard. In particular, `antlr4-runtime` 4.9.3 shadows ThingsBoard's 4.13.0 +copy and prevents Spring Data JPA from starting. + +## 3. Configure + +Add the properties for the storage paths you want to move to IoTDB. The +following example enables all three paths for a single-node deployment: + +```properties +# IoTDB connection +# Docker service name; use 127.0.0.1 only when both processes share a host +iotdb.host=iotdb +iotdb.port=6667 +iotdb.username=root +iotdb.password=root +iotdb.database=thingsboard +iotdb.session-pool-size=8 +iotdb.schema.bootstrap=true + +# Historical telemetry database.ts.type=iotdb-table -# explicit opt-in; required together with the selector above iotdb.ts.experimental-raw-only=true -``` - -Latest-value telemetry needs its **own** selector in addition to those two. If it -is omitted, historical telemetry is stored in IoTDB while latest values stay on -ThingsBoard's default backend, with no error at startup: -```Properties +# Latest telemetry database.ts_latest.type=iotdb-table -# required when the latest DAO is active: sticky-routing | disabled -iotdb.ts_latest.cluster_mode=sticky-routing +iotdb.ts_latest.cluster_mode=disabled + +# Entity attributes +database.attributes.type=iotdb-table +iotdb.attributes.cluster_mode=disabled ``` -Entity attributes are a separate opt-in and are inert unless enabled. Unlike the -two timeseries selectors it is not independent of the host: ThingsBoard has no -attributes-backend switch of its own, so enabling this one changes ThingsBoard's -bean graph rather than only this module's. +The historical and latest selectors are separate. Omitting +`database.ts_latest.type` leaves latest values on the original ThingsBoard +backend even when historical telemetry is stored in IoTDB. -> **Do not enable this on a stock ThingsBoard yet.** ThingsBoard has no -> configuration switch of its own for attributes storage — its JPA attributes -> bean registers unconditionally — so setting the selector below makes startup -> fail on the module's conflict check. A fix is under review upstream as -> apache/iotdb-extras#125. It withdraws exactly one bean — ThingsBoard's own -> `jpaAttributeDao`, matched on both the bean name and the fully-qualified class -> name — and fails startup on any other competing `AttributesDao` rather than -> removing it. This note applies until that PR is merged and a build containing -> it is released. +The example writes the connection defaults explicitly. Outside Docker, +`iotdb.host` defaults to `127.0.0.1`; the other defaults are port `6667`, +username/password `root`/`root`, database `thingsboard`, session-pool size `8`, +and schema bootstrap enabled. +Attributes require a build containing +[apache/iotdb-extras#125](https://github.com/apache/iotdb-extras/pull/125), +merged as commit `275043ed`. Earlier builds cannot enable the attributes +selector on stock ThingsBoard. -```Properties -database.attributes.type=iotdb-table -# required when the attribute DAO is active: sticky-routing | disabled -iotdb.attributes.cluster_mode=sticky-routing -``` +When latest telemetry or attributes are enabled, their `cluster_mode` must be +set explicitly: + +- `sticky-routing`: route writes for one identity to one node. +- `disabled`: single-node deployment, or explicitly accept best-effort + convergence. -### 3.2 Connection and schema +Any other value, including an empty value, stops startup with an explanatory +error. -| Property | Default | Meaning | -| --- | --- | --- | -| `iotdb.host` / `iotdb.port` | `127.0.0.1` / `6667` | IoTDB node address | -| `iotdb.username` / `iotdb.password` | `root` / `root` | IoTDB credentials | -| `iotdb.database` | `thingsboard` | Target IoTDB database | -| `iotdb.session-pool-size` | `8` | Table session pool size | -| `iotdb.schema.bootstrap` | `true` | Create the database and tables on first start; set to `false` to manage the schema out of band | +## 4. Run and verify -### 3.3 Cluster mode +1. Restart ThingsBoard after the bundle and configuration are in place. +2. Check the log for `IoTDB Table Mode session pool initialized`. With schema + bootstrap enabled, also check for `IoTDB Table Mode schema bootstrap + complete`. +3. Send test telemetry and attributes to a ThingsBoard device, then confirm + them on the device's **Latest telemetry** and **Attributes** pages. -`iotdb.attributes.cluster_mode` and `iotdb.ts_latest.cluster_mode` must be set -explicitly when the corresponding DAO is active. Accepted values: + ![ThingsBoard latest telemetry stored through the IoTDB Table Model backend](/img/thingsboard-table-telemetry.png) -* `sticky-routing` — writes for one identity are pinned to a single node -* `disabled` — single-node deployment, or best-effort convergence accepted + ![ThingsBoard client attributes stored through the IoTDB Table Model backend](/img/thingsboard-table-attributes.png) -Any other value, including leaving it empty, fails at startup rather than -silently. These write paths converge within a single JVM, so a multi-writer -cluster needs one of the two acknowledgements above. +4. Confirm the same values in IoTDB: -## 4. Known Limitations +```sql +USE thingsboard; -* Attribute and latest-overlay writes converge within a single JVM. A clustered - deployment must either pin each identity to one node (`sticky-routing`) or - explicitly accept best-effort convergence (`disabled`) — which is why the - cluster mode has to be stated rather than defaulted. -* The latest-value path is derived from the telemetry table, with a small overlay - for the latest-only write and delete paths that a pure derivation cannot - express. -* Table-level TTL is used for retention; see the module's +SELECT * +FROM telemetry +ORDER BY time DESC +LIMIT 10; + +SELECT * +FROM entity_attributes +ORDER BY time DESC +LIMIT 10; +``` + +## 5. Known limitations + +- IoTDB 2.0.8 and ThingsBoard 4.3.1.2 are the tested versions. Other 2.x or + ThingsBoard versions require separate verification. +- Attribute and latest-overlay writes converge within one JVM. A multi-writer + deployment must use sticky routing or explicitly accept best-effort + convergence. +- The latest-value path is derived from the telemetry table, with a small + overlay for latest-only writes and deletes. +- Retention uses IoTDB table-level TTL. See the module's [user guide](https://github.com/apache/iotdb-extras/blob/master/iotdb-thingsboard-table/docs/user-guide.md) - for how it maps onto ThingsBoard's own retention settings. + for its mapping to ThingsBoard retention settings. + +Design, source-build, migration, and compile-surface verification details remain +in the +[`iotdb-thingsboard-table` module](https://github.com/apache/iotdb-extras/tree/master/iotdb-thingsboard-table). diff --git a/src/zh/UserGuide/Master/Table/Ecosystem-Integration/Thingsboard.md b/src/zh/UserGuide/Master/Table/Ecosystem-Integration/Thingsboard.md index cbf372d0f..2b4c23e7e 100644 --- a/src/zh/UserGuide/Master/Table/Ecosystem-Integration/Thingsboard.md +++ b/src/zh/UserGuide/Master/Table/Ecosystem-Integration/Thingsboard.md @@ -7,9 +7,9 @@ to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at - + http://www.apache.org/licenses/LICENSE-2.0 - + Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY @@ -21,151 +21,157 @@ # ThingsBoard -## 1. 功能概述 +`iotdb-thingsboard-table` 模块让原版 ThingsBoard 可以把历史时序、最新值时序和实体属性 +存入 IoTDB 表模型。本页按操作者真正使用它的顺序说明:下载、安装、配置、运行与验证。 -ThingsBoard 是一个开源物联网平台,用于设备管理、数据采集与可视化。它通过三个存储 SPI -分别存放设备时序数据、最新值时序数据和实体属性,因此可以在不改动平台本身的前提下替换存储层。 +这套集成独立于较早的 +[ThingsBoard 树模型集成](../../Tree/Ecosystem-Integration/Thingsboard.md),后者需要使用 +IoTDB 适配版 ThingsBoard。 -`iotdb-thingsboard-table` 在 IoTDB 表模型之上实现了这三个 SPI,使 ThingsBoard 部署可以把 -时序数据存进 IoTDB,而不是 Cassandra 或关系型数据库: +## 1. 下载 -| ThingsBoard SPI | 实现 | 用途 | -| --- | --- | --- | -| `TimeseriesDao` | `IoTDBTableTimeseriesDao` | 历史时序:批量写入、原始读与时间分桶聚合读、删除 | -| `TimeseriesLatestDao` | `IoTDBTableLatestDao` | 每个 telemetry key 的最新值 | -| `AttributesDao` | `IoTDBTableAttributesDao` | 实体属性,按 `SERVER_SCOPE` / `SHARED_SCOPE` / `CLIENT_SCOPE` 分域 | +已经验证的版本组合如下: -本页介绍的是**表模型**集成:它对接的是原版 ThingsBoard 发行版,把模块及其运行期依赖放上 -classpath 并配置若干属性即可启用。下面的部署一节列出了确切的 jar 集合,不是单独一个 jar。 -**树模型**另有一套更早的集成,见 -[ThingsBoard(树模型)](../../Tree/Ecosystem-Integration/Thingsboard.md);那一套把数据写在 -`root.thingsboard` 下,并且需要 IoTDB 适配版的 ThingsBoard 安装包而非原版。两者互相独立, -按你使用的数据模型选择即可。 +| 组件 | 版本 | +| --- | --- | +| IoTDB | 2.0.8,表模型 | +| ThingsBoard | 4.3.1.2 | +| JDK | 17 或更高版本 | -写入经由一个有界异步队列批量落入 IoTDB tablet。读取覆盖原始路径和聚合路径:固定宽度的毫秒 -分桶使用 IoTDB 原生的 `date_bin`;日历分桶(`WEEK` / `WEEK_ISO` / `MONTH` / `QUARTER`) -则逐桶推进,以保证边界与 ThingsBoard 自身语义一致,并落在每个查询各自携带的时区上。 +本模块尚未进入 ASF Release。请从 `apache/iotdb-extras` 官方源码的 commit `275043ed` +或更新版本构建: -## 2. 使用步骤 +```bash +git clone https://github.com/apache/iotdb-extras.git +cd iotdb-extras +git checkout 275043ed +mvn -P with-thingsboard -pl iotdb-thingsboard-table -am clean package +mvn -P with-thingsboard -pl iotdb-thingsboard-table -am \ + dependency:copy-dependencies -DincludeScope=runtime \ + -DoutputDirectory="$PWD/runtime-deps" +``` -### 2.1 版本要求 +第二条命令会有意生成未经筛选的依赖目录。第 2 节列出了实际需要部署的运行期 jar;不要 +直接复制整个目录。 -* `IoTDB: 2.0.8`(表模型)—— 集成测试实际运行的版本(`apache/iotdb:2.0.8-standalone`), - 其他 2.x 版本未经测试。 -* `ThingsBoard: 4.3.1.2` -* `JDK: >= 17` +## 2. 安装 -该模块是对着 ThingsBoard 4.3.1.2 的 SPI 编译的。由于 ThingsBoard 的 `common/data` 与 `dao` -构件未发布到 Maven Central,模块编译时使用的是其所需类型的编译期替身,这些类不会被打进产物 -jar,因此运行时使用的是真实的 ThingsBoard 类。 +必须先安装 ThingsBoard,再启用 IoTDB 选择器。ThingsBoard 安装器会解析由内置后端提供的 +`TsDatabaseSchemaService`;若安装阶段已经启用本模块,安装会失败。 -### 2.2 获取 jar +1. 安装并启动 IoTDB,参见 [IoTDB 快速上手](../QuickStart/QuickStart.md)。 -从 `iotdb-extras` 仓库构建。该模块位于一个需显式开启的 profile 之后,普通的 reactor 构建不会 -包含它: + 若 IoTDB 2.0.8 与 ThingsBoard 分别运行在容器中,请把两者放入同一 Docker 网络,并让 + IoTDB 公布网络内可达的服务名。`2.0.8-standalone` 镜像默认把这些地址绑定到 + `127.0.0.1`。假设 IoTDB 服务名为 `iotdb`,请设置: -```bash -# 在 apache/iotdb-extras 仓库根目录,使用 JDK 17+ -# https://github.com/apache/iotdb-extras -mvn -pl iotdb-thingsboard-table -am -P with-thingsboard clean package -``` + ```yaml + cn_seed_config_node: iotdb:10710 + dn_seed_config_node: iotdb:10710 + cn_internal_address: iotdb + dn_internal_address: iotdb + dn_rpc_address: iotdb + ``` -产物 jar 位于 `iotdb-thingsboard-table/target/` 下。 - -### 2.3 部署到 ThingsBoard - -必须**先装好 ThingsBoard,再启用模块**:它的安装器会解析一个只有内置后端才提供的 -`TsDatabaseSchemaService` bean,因此在第 3 节的选择器已经打开的情况下启动,安装会失败。 - -1. 安装并启动 IoTDB,参见 [IoTDB 快速上手](../QuickStart/QuickStart.md)。 -2. 按常规方式装好 ThingsBoard,此时不要设置第 3 节的任何属性。 -3. 把模块**以及 IoTDB 客户端的运行期依赖**一起放上 ThingsBoard 的 classpath。只放模块 jar - 是不够的——首次创建会话时会抛 `ITableSessionPool` 的 `NoClassDefFoundError`。用 - `dependency:copy-dependencies -DincludeScope=runtime` 收集,然后**去掉 ThingsBoard 已经 - 自带的那些**,否则它更新的版本会被模块带的旧版本盖住。对 ThingsBoard 4.3.1.2 而言是九个 - ——`antlr4-runtime`、`commons-codec`、`commons-io`、`commons-lang3`、`httpclient`、 - `httpcore`、`lz4-java`、`snappy-java`、`zstd-jni`——外加 `commons-logging`,那个是 - ThingsBoard 自己要求移除的(它使用 `spring-jcl`)。其中 `antlr4-runtime` 后果最严重: - ThingsBoard 4.3.1.2 自带 4.13.0,模块带的是 4.9.3,之后 Spring Data JPA 的 `HqlLexer` - 无法反序列化自己的语法,启动直接失败。剩下十一个 jar。请对着你要部署的那个 ThingsBoard - 版本重新推导这个集合,并且**以能启动的部署为准,而不是以依赖比对为准**:比对回答的是 - ThingsBoard 是否*自带*某个构件,而不是它是否*容得下*某个构件,`commons-logging` 正是 - 两者分道扬镳的地方。 -4. 如何扩展 classpath 取决于安装方式。ThingsBoard 以 Spring Boot 应用运行:Docker 镜像通过 - `PropertiesLauncher` 启动并已支持 `LOADER_PATH`,把这些 jar 放进 - `/usr/share/thingsboard/extensions` 即可;deb/rpm 包则直接执行发行版 jar。请按你所用的 - 安装方式查阅 ThingsBoard 自身的部署文档。 -5. 将下方配置写入 ThingsBoard 的 `thingsboard.yml`,或提供等价的环境变量。 -6. 重启 ThingsBoard。首次启动时,模块会在 IoTDB 中创建所需的数据库与表,除非关闭了该引导。 - -该模块是一个 Spring Boot 自动配置,因此 ThingsBoard 侧无需组件扫描或代码改动。 +2. 按常规方式安装 ThingsBoard,此时不要设置第 3 节的任何属性。 +3. 创建部署用的 `lib/` 目录,从 `iotdb-thingsboard-table/target/` 复制模块的二进制 jar, + 再从 `runtime-deps/` 复制下方列出的 10 个运行期 jar。将这 11 个 jar 全部放到 + ThingsBoard classpath 上。 + +对于 ThingsBoard Docker 镜像,可将这些 jar 挂载或复制到 +`/usr/share/thingsboard/extensions`。镜像使用的 `PropertiesLauncher` 已通过 +`LOADER_PATH` 包含该目录。其他安装方式可能采用不同的 classpath 扩展机制。 + +部署目录必须正好包含 11 个 jar:模块本身,以及 `iotdb-session`、`isession`、 +`service-rpc`、`iotdb-thrift`、`iotdb-thrift-commons`、`libthrift`、`pipe-api`、 +`tsfile`、`common` 和 `xz`。不要直接放入 Maven 复制出的全部运行期依赖;其中包含 +ThingsBoard 已自带的旧版本冲突项。特别是 `antlr4-runtime` 4.9.3 会盖住 ThingsBoard 的 +4.13.0,导致 Spring Data JPA 无法启动。 ## 3. 配置 -### 3.1 激活 +按需要启用要迁移到 IoTDB 的存储路径。下面的单节点示例同时启用三条路径: -历史时序需要时序选择器加上显式开关: +```properties +# IoTDB 连接 +# Docker 服务名;仅当两个进程共享同一主机时使用 127.0.0.1 +iotdb.host=iotdb +iotdb.port=6667 +iotdb.username=root +iotdb.password=root +iotdb.database=thingsboard +iotdb.session-pool-size=8 +iotdb.schema.bootstrap=true -```Properties -# 选择本后端作为历史时序存储 +# 历史时序 database.ts.type=iotdb-table -# 显式开关,必须与上面的选择器同时设置 iotdb.ts.experimental-raw-only=true -``` -最新值时序**另需**它自己的选择器。若遗漏,历史时序会存入 IoTDB,而最新值仍留在 ThingsBoard -的默认后端,且启动时不会报错: - -```Properties +# 最新值时序 database.ts_latest.type=iotdb-table -# 最新值 DAO 激活时必填:sticky-routing | disabled -iotdb.ts_latest.cluster_mode=sticky-routing +iotdb.ts_latest.cluster_mode=disabled + +# 实体属性 +database.attributes.type=iotdb-table +iotdb.attributes.cluster_mode=disabled ``` -实体属性是单独的一项开关,未启用时该 DAO 不生效。与两个时间序列选择器不同,它并非 -独立于宿主:ThingsBoard 自身没有属性存储的配置开关,因此启用这一项会改动 -ThingsBoard 的 bean 图,而不只是本模块的。 +历史时序和最新值时序使用两个独立选择器。若遗漏 `database.ts_latest.type`,即使历史数据 +已经存入 IoTDB,最新值仍会留在 ThingsBoard 原来的后端中。 -> **暂时不要在原版 ThingsBoard 上启用这一项。** ThingsBoard 自身没有为属性存储提供配置开关 -> ——它的 JPA 属性 bean 是无条件注册的——因此设置下面这个选择器会让模块的冲突检查导致启动失败。 -> 相应的修复正在上游 apache/iotdb-extras#125 中等待评审。它只撤下一个 bean —— -> ThingsBoard 自己的 `jpaAttributeDao`,按 bean 名与全限定类名同时匹配 —— 其余任何 -> 竞争的 `AttributesDao` 一律不动,而是让启动失败。在该 PR 合入且包含它的构建发布之前, -> 本提示有效。 +示例显式写出了连接配置的默认值。在非 Docker 环境中,`iotdb.host` 默认为 +`127.0.0.1`;其他默认值为端口 `6667`、用户名/密码 `root`/`root`、数据库 +`thingsboard`、会话池大小 `8`,并默认启用表结构引导。 -```Properties -database.attributes.type=iotdb-table -# 属性 DAO 激活时必填:sticky-routing | disabled -iotdb.attributes.cluster_mode=sticky-routing -``` +属性路径要求构建中包含 +[apache/iotdb-extras#125](https://github.com/apache/iotdb-extras/pull/125),即上游 +commit `275043ed` 或更新版本。更早的构建无法在原版 ThingsBoard 上启用属性选择器。 + +启用最新值或属性时,必须显式设置对应的 `cluster_mode`: + +- `sticky-routing`:把同一 identity 的写入固定路由到一个节点。 +- `disabled`:单节点部署,或明确接受尽力而为的收敛。 -### 3.2 连接与表结构 +其他取值(包括空值)都会让启动停止并给出明确错误。 -| 属性 | 默认值 | 含义 | -| --- | --- | --- | -| `iotdb.host` / `iotdb.port` | `127.0.0.1` / `6667` | IoTDB 节点地址 | -| `iotdb.username` / `iotdb.password` | `root` / `root` | IoTDB 凭据 | -| `iotdb.database` | `thingsboard` | 目标 IoTDB 数据库 | -| `iotdb.session-pool-size` | `8` | 表会话池大小 | -| `iotdb.schema.bootstrap` | `true` | 首次启动时创建数据库与表;若自行管理表结构则设为 `false` | +## 4. 运行与验证 -### 3.3 集群模式 +1. 部署 jar 并完成配置后,重启 ThingsBoard。 +2. 在日志中确认出现 `IoTDB Table Mode session pool initialized`。若启用了表结构引导, + 还应出现 `IoTDB Table Mode schema bootstrap complete`。 +3. 向一台 ThingsBoard 测试设备发送时序和属性数据,然后在设备的“最新遥测”和“属性”页面 + 确认这些值。 -当对应 DAO 激活时,`iotdb.attributes.cluster_mode` 与 `iotdb.ts_latest.cluster_mode` 必须显式 -设置。可选值: + ![通过 IoTDB 表模型后端存储的 ThingsBoard 最新遥测](/img/thingsboard-table-telemetry.png) -* `sticky-routing` —— 同一 identity 的写入固定路由到单个节点 -* `disabled` —— 单节点部署,或已接受尽力而为的收敛 + ![通过 IoTDB 表模型后端存储的 ThingsBoard 客户端属性](/img/thingsboard-table-attributes.png) -其他取值(包括留空)会在启动时直接失败,而不是静默通过。这两条写入路径只在单个 JVM 内收敛, -因此多写入者的集群部署需要上述两种确认之一。 +4. 在 IoTDB 中查询同一批数据: -## 4. 已知限制 +```sql +USE thingsboard; -* 属性写入与最新值覆盖层的写入只在单个 JVM 内收敛。集群部署必须二选一:把每个 identity 固定 - 到单个节点(`sticky-routing`),或显式接受尽力而为的收敛(`disabled`)—— 这正是集群模式必须 - 显式声明而非取默认值的原因。 -* 最新值路径由时序表派生而来,另有一个很小的覆盖层,用于承接纯派生无法表达的"只写最新值"与 - "只删最新值"路径。 -* 保留策略使用表级 TTL,其与 ThingsBoard 自身保留设置的对应关系,参见模块的 +SELECT * +FROM telemetry +ORDER BY time DESC +LIMIT 10; + +SELECT * +FROM entity_attributes +ORDER BY time DESC +LIMIT 10; +``` + +## 5. 已知限制 + +- 已验证版本为 IoTDB 2.0.8 和 ThingsBoard 4.3.1.2。其他 IoTDB 2.x 或 ThingsBoard + 版本需要单独验证。 +- 属性写入和最新值覆盖层只在单个 JVM 内收敛。多写入者部署必须使用固定路由,或明确接受 + 尽力而为的收敛。 +- 最新值路径从时序表派生,并使用一个小型覆盖层承接仅写最新值和仅删最新值的路径。 +- 保留策略使用 IoTDB 表级 TTL;其与 ThingsBoard 保留设置的对应关系参见模块的 [用户指南](https://github.com/apache/iotdb-extras/blob/master/iotdb-thingsboard-table/docs/user-guide.md)。 + +设计、源码构建、迁移和编译接口验证细节保留在 +[`iotdb-thingsboard-table` 模块](https://github.com/apache/iotdb-extras/tree/master/iotdb-thingsboard-table) +中。 diff --git a/src/zh/UserGuide/latest-Table/Ecosystem-Integration/Thingsboard.md b/src/zh/UserGuide/latest-Table/Ecosystem-Integration/Thingsboard.md index c24a26020..ef51e6af0 100644 --- a/src/zh/UserGuide/latest-Table/Ecosystem-Integration/Thingsboard.md +++ b/src/zh/UserGuide/latest-Table/Ecosystem-Integration/Thingsboard.md @@ -7,9 +7,9 @@ to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at - + http://www.apache.org/licenses/LICENSE-2.0 - + Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY @@ -21,151 +21,157 @@ # ThingsBoard -## 1. 功能概述 +`iotdb-thingsboard-table` 模块让原版 ThingsBoard 可以把历史时序、最新值时序和实体属性 +存入 IoTDB 表模型。本页按操作者真正使用它的顺序说明:下载、安装、配置、运行与验证。 -ThingsBoard 是一个开源物联网平台,用于设备管理、数据采集与可视化。它通过三个存储 SPI -分别存放设备时序数据、最新值时序数据和实体属性,因此可以在不改动平台本身的前提下替换存储层。 +这套集成独立于较早的 +[ThingsBoard 树模型集成](../../latest/Ecosystem-Integration/Thingsboard.md),后者需要使用 +IoTDB 适配版 ThingsBoard。 -`iotdb-thingsboard-table` 在 IoTDB 表模型之上实现了这三个 SPI,使 ThingsBoard 部署可以把 -时序数据存进 IoTDB,而不是 Cassandra 或关系型数据库: +## 1. 下载 -| ThingsBoard SPI | 实现 | 用途 | -| --- | --- | --- | -| `TimeseriesDao` | `IoTDBTableTimeseriesDao` | 历史时序:批量写入、原始读与时间分桶聚合读、删除 | -| `TimeseriesLatestDao` | `IoTDBTableLatestDao` | 每个 telemetry key 的最新值 | -| `AttributesDao` | `IoTDBTableAttributesDao` | 实体属性,按 `SERVER_SCOPE` / `SHARED_SCOPE` / `CLIENT_SCOPE` 分域 | +已经验证的版本组合如下: -本页介绍的是**表模型**集成:它对接的是原版 ThingsBoard 发行版,把模块及其运行期依赖放上 -classpath 并配置若干属性即可启用。下面的部署一节列出了确切的 jar 集合,不是单独一个 jar。 -**树模型**另有一套更早的集成,见 -[ThingsBoard(树模型)](../../latest/Ecosystem-Integration/Thingsboard.md);那一套把数据写在 -`root.thingsboard` 下,并且需要 IoTDB 适配版的 ThingsBoard 安装包而非原版。两者互相独立, -按你使用的数据模型选择即可。 +| 组件 | 版本 | +| --- | --- | +| IoTDB | 2.0.8,表模型 | +| ThingsBoard | 4.3.1.2 | +| JDK | 17 或更高版本 | -写入经由一个有界异步队列批量落入 IoTDB tablet。读取覆盖原始路径和聚合路径:固定宽度的毫秒 -分桶使用 IoTDB 原生的 `date_bin`;日历分桶(`WEEK` / `WEEK_ISO` / `MONTH` / `QUARTER`) -则逐桶推进,以保证边界与 ThingsBoard 自身语义一致,并落在每个查询各自携带的时区上。 +本模块尚未进入 ASF Release。请从 `apache/iotdb-extras` 官方源码的 commit `275043ed` +或更新版本构建: -## 2. 使用步骤 +```bash +git clone https://github.com/apache/iotdb-extras.git +cd iotdb-extras +git checkout 275043ed +mvn -P with-thingsboard -pl iotdb-thingsboard-table -am clean package +mvn -P with-thingsboard -pl iotdb-thingsboard-table -am \ + dependency:copy-dependencies -DincludeScope=runtime \ + -DoutputDirectory="$PWD/runtime-deps" +``` -### 2.1 版本要求 +第二条命令会有意生成未经筛选的依赖目录。第 2 节列出了实际需要部署的运行期 jar;不要 +直接复制整个目录。 -* `IoTDB: 2.0.8`(表模型)—— 集成测试实际运行的版本(`apache/iotdb:2.0.8-standalone`), - 其他 2.x 版本未经测试。 -* `ThingsBoard: 4.3.1.2` -* `JDK: >= 17` +## 2. 安装 -该模块是对着 ThingsBoard 4.3.1.2 的 SPI 编译的。由于 ThingsBoard 的 `common/data` 与 `dao` -构件未发布到 Maven Central,模块编译时使用的是其所需类型的编译期替身,这些类不会被打进产物 -jar,因此运行时使用的是真实的 ThingsBoard 类。 +必须先安装 ThingsBoard,再启用 IoTDB 选择器。ThingsBoard 安装器会解析由内置后端提供的 +`TsDatabaseSchemaService`;若安装阶段已经启用本模块,安装会失败。 -### 2.2 获取 jar +1. 安装并启动 IoTDB,参见 [IoTDB 快速上手](../QuickStart/QuickStart.md)。 -从 `iotdb-extras` 仓库构建。该模块位于一个需显式开启的 profile 之后,普通的 reactor 构建不会 -包含它: + 若 IoTDB 2.0.8 与 ThingsBoard 分别运行在容器中,请把两者放入同一 Docker 网络,并让 + IoTDB 公布网络内可达的服务名。`2.0.8-standalone` 镜像默认把这些地址绑定到 + `127.0.0.1`。假设 IoTDB 服务名为 `iotdb`,请设置: -```bash -# 在 apache/iotdb-extras 仓库根目录,使用 JDK 17+ -# https://github.com/apache/iotdb-extras -mvn -pl iotdb-thingsboard-table -am -P with-thingsboard clean package -``` + ```yaml + cn_seed_config_node: iotdb:10710 + dn_seed_config_node: iotdb:10710 + cn_internal_address: iotdb + dn_internal_address: iotdb + dn_rpc_address: iotdb + ``` -产物 jar 位于 `iotdb-thingsboard-table/target/` 下。 - -### 2.3 部署到 ThingsBoard - -必须**先装好 ThingsBoard,再启用模块**:它的安装器会解析一个只有内置后端才提供的 -`TsDatabaseSchemaService` bean,因此在第 3 节的选择器已经打开的情况下启动,安装会失败。 - -1. 安装并启动 IoTDB,参见 [IoTDB 快速上手](../QuickStart/QuickStart.md)。 -2. 按常规方式装好 ThingsBoard,此时不要设置第 3 节的任何属性。 -3. 把模块**以及 IoTDB 客户端的运行期依赖**一起放上 ThingsBoard 的 classpath。只放模块 jar - 是不够的——首次创建会话时会抛 `ITableSessionPool` 的 `NoClassDefFoundError`。用 - `dependency:copy-dependencies -DincludeScope=runtime` 收集,然后**去掉 ThingsBoard 已经 - 自带的那些**,否则它更新的版本会被模块带的旧版本盖住。对 ThingsBoard 4.3.1.2 而言是九个 - ——`antlr4-runtime`、`commons-codec`、`commons-io`、`commons-lang3`、`httpclient`、 - `httpcore`、`lz4-java`、`snappy-java`、`zstd-jni`——外加 `commons-logging`,那个是 - ThingsBoard 自己要求移除的(它使用 `spring-jcl`)。其中 `antlr4-runtime` 后果最严重: - ThingsBoard 4.3.1.2 自带 4.13.0,模块带的是 4.9.3,之后 Spring Data JPA 的 `HqlLexer` - 无法反序列化自己的语法,启动直接失败。剩下十一个 jar。请对着你要部署的那个 ThingsBoard - 版本重新推导这个集合,并且**以能启动的部署为准,而不是以依赖比对为准**:比对回答的是 - ThingsBoard 是否*自带*某个构件,而不是它是否*容得下*某个构件,`commons-logging` 正是 - 两者分道扬镳的地方。 -4. 如何扩展 classpath 取决于安装方式。ThingsBoard 以 Spring Boot 应用运行:Docker 镜像通过 - `PropertiesLauncher` 启动并已支持 `LOADER_PATH`,把这些 jar 放进 - `/usr/share/thingsboard/extensions` 即可;deb/rpm 包则直接执行发行版 jar。请按你所用的 - 安装方式查阅 ThingsBoard 自身的部署文档。 -5. 将下方配置写入 ThingsBoard 的 `thingsboard.yml`,或提供等价的环境变量。 -6. 重启 ThingsBoard。首次启动时,模块会在 IoTDB 中创建所需的数据库与表,除非关闭了该引导。 - -该模块是一个 Spring Boot 自动配置,因此 ThingsBoard 侧无需组件扫描或代码改动。 +2. 按常规方式安装 ThingsBoard,此时不要设置第 3 节的任何属性。 +3. 创建部署用的 `lib/` 目录,从 `iotdb-thingsboard-table/target/` 复制模块的二进制 jar, + 再从 `runtime-deps/` 复制下方列出的 10 个运行期 jar。将这 11 个 jar 全部放到 + ThingsBoard classpath 上。 + +对于 ThingsBoard Docker 镜像,可将这些 jar 挂载或复制到 +`/usr/share/thingsboard/extensions`。镜像使用的 `PropertiesLauncher` 已通过 +`LOADER_PATH` 包含该目录。其他安装方式可能采用不同的 classpath 扩展机制。 + +部署目录必须正好包含 11 个 jar:模块本身,以及 `iotdb-session`、`isession`、 +`service-rpc`、`iotdb-thrift`、`iotdb-thrift-commons`、`libthrift`、`pipe-api`、 +`tsfile`、`common` 和 `xz`。不要直接放入 Maven 复制出的全部运行期依赖;其中包含 +ThingsBoard 已自带的旧版本冲突项。特别是 `antlr4-runtime` 4.9.3 会盖住 ThingsBoard 的 +4.13.0,导致 Spring Data JPA 无法启动。 ## 3. 配置 -### 3.1 激活 +按需要启用要迁移到 IoTDB 的存储路径。下面的单节点示例同时启用三条路径: -历史时序需要时序选择器加上显式开关: +```properties +# IoTDB 连接 +# Docker 服务名;仅当两个进程共享同一主机时使用 127.0.0.1 +iotdb.host=iotdb +iotdb.port=6667 +iotdb.username=root +iotdb.password=root +iotdb.database=thingsboard +iotdb.session-pool-size=8 +iotdb.schema.bootstrap=true -```Properties -# 选择本后端作为历史时序存储 +# 历史时序 database.ts.type=iotdb-table -# 显式开关,必须与上面的选择器同时设置 iotdb.ts.experimental-raw-only=true -``` -最新值时序**另需**它自己的选择器。若遗漏,历史时序会存入 IoTDB,而最新值仍留在 ThingsBoard -的默认后端,且启动时不会报错: - -```Properties +# 最新值时序 database.ts_latest.type=iotdb-table -# 最新值 DAO 激活时必填:sticky-routing | disabled -iotdb.ts_latest.cluster_mode=sticky-routing +iotdb.ts_latest.cluster_mode=disabled + +# 实体属性 +database.attributes.type=iotdb-table +iotdb.attributes.cluster_mode=disabled ``` -实体属性是单独的一项开关,未启用时该 DAO 不生效。与两个时间序列选择器不同,它并非 -独立于宿主:ThingsBoard 自身没有属性存储的配置开关,因此启用这一项会改动 -ThingsBoard 的 bean 图,而不只是本模块的。 +历史时序和最新值时序使用两个独立选择器。若遗漏 `database.ts_latest.type`,即使历史数据 +已经存入 IoTDB,最新值仍会留在 ThingsBoard 原来的后端中。 -> **暂时不要在原版 ThingsBoard 上启用这一项。** ThingsBoard 自身没有为属性存储提供配置开关 -> ——它的 JPA 属性 bean 是无条件注册的——因此设置下面这个选择器会让模块的冲突检查导致启动失败。 -> 相应的修复正在上游 apache/iotdb-extras#125 中等待评审。它只撤下一个 bean —— -> ThingsBoard 自己的 `jpaAttributeDao`,按 bean 名与全限定类名同时匹配 —— 其余任何 -> 竞争的 `AttributesDao` 一律不动,而是让启动失败。在该 PR 合入且包含它的构建发布之前, -> 本提示有效。 +示例显式写出了连接配置的默认值。在非 Docker 环境中,`iotdb.host` 默认为 +`127.0.0.1`;其他默认值为端口 `6667`、用户名/密码 `root`/`root`、数据库 +`thingsboard`、会话池大小 `8`,并默认启用表结构引导。 -```Properties -database.attributes.type=iotdb-table -# 属性 DAO 激活时必填:sticky-routing | disabled -iotdb.attributes.cluster_mode=sticky-routing -``` +属性路径要求构建中包含 +[apache/iotdb-extras#125](https://github.com/apache/iotdb-extras/pull/125),即上游 +commit `275043ed` 或更新版本。更早的构建无法在原版 ThingsBoard 上启用属性选择器。 + +启用最新值或属性时,必须显式设置对应的 `cluster_mode`: + +- `sticky-routing`:把同一 identity 的写入固定路由到一个节点。 +- `disabled`:单节点部署,或明确接受尽力而为的收敛。 -### 3.2 连接与表结构 +其他取值(包括空值)都会让启动停止并给出明确错误。 -| 属性 | 默认值 | 含义 | -| --- | --- | --- | -| `iotdb.host` / `iotdb.port` | `127.0.0.1` / `6667` | IoTDB 节点地址 | -| `iotdb.username` / `iotdb.password` | `root` / `root` | IoTDB 凭据 | -| `iotdb.database` | `thingsboard` | 目标 IoTDB 数据库 | -| `iotdb.session-pool-size` | `8` | 表会话池大小 | -| `iotdb.schema.bootstrap` | `true` | 首次启动时创建数据库与表;若自行管理表结构则设为 `false` | +## 4. 运行与验证 -### 3.3 集群模式 +1. 部署 jar 并完成配置后,重启 ThingsBoard。 +2. 在日志中确认出现 `IoTDB Table Mode session pool initialized`。若启用了表结构引导, + 还应出现 `IoTDB Table Mode schema bootstrap complete`。 +3. 向一台 ThingsBoard 测试设备发送时序和属性数据,然后在设备的“最新遥测”和“属性”页面 + 确认这些值。 -当对应 DAO 激活时,`iotdb.attributes.cluster_mode` 与 `iotdb.ts_latest.cluster_mode` 必须显式 -设置。可选值: + ![通过 IoTDB 表模型后端存储的 ThingsBoard 最新遥测](/img/thingsboard-table-telemetry.png) -* `sticky-routing` —— 同一 identity 的写入固定路由到单个节点 -* `disabled` —— 单节点部署,或已接受尽力而为的收敛 + ![通过 IoTDB 表模型后端存储的 ThingsBoard 客户端属性](/img/thingsboard-table-attributes.png) -其他取值(包括留空)会在启动时直接失败,而不是静默通过。这两条写入路径只在单个 JVM 内收敛, -因此多写入者的集群部署需要上述两种确认之一。 +4. 在 IoTDB 中查询同一批数据: -## 4. 已知限制 +```sql +USE thingsboard; -* 属性写入与最新值覆盖层的写入只在单个 JVM 内收敛。集群部署必须二选一:把每个 identity 固定 - 到单个节点(`sticky-routing`),或显式接受尽力而为的收敛(`disabled`)—— 这正是集群模式必须 - 显式声明而非取默认值的原因。 -* 最新值路径由时序表派生而来,另有一个很小的覆盖层,用于承接纯派生无法表达的"只写最新值"与 - "只删最新值"路径。 -* 保留策略使用表级 TTL,其与 ThingsBoard 自身保留设置的对应关系,参见模块的 +SELECT * +FROM telemetry +ORDER BY time DESC +LIMIT 10; + +SELECT * +FROM entity_attributes +ORDER BY time DESC +LIMIT 10; +``` + +## 5. 已知限制 + +- 已验证版本为 IoTDB 2.0.8 和 ThingsBoard 4.3.1.2。其他 IoTDB 2.x 或 ThingsBoard + 版本需要单独验证。 +- 属性写入和最新值覆盖层只在单个 JVM 内收敛。多写入者部署必须使用固定路由,或明确接受 + 尽力而为的收敛。 +- 最新值路径从时序表派生,并使用一个小型覆盖层承接仅写最新值和仅删最新值的路径。 +- 保留策略使用 IoTDB 表级 TTL;其与 ThingsBoard 保留设置的对应关系参见模块的 [用户指南](https://github.com/apache/iotdb-extras/blob/master/iotdb-thingsboard-table/docs/user-guide.md)。 + +设计、源码构建、迁移和编译接口验证细节保留在 +[`iotdb-thingsboard-table` 模块](https://github.com/apache/iotdb-extras/tree/master/iotdb-thingsboard-table) +中。