Là thành viên của nhóm phần mềm tại Nevados, chúng tôi đang xây dựng nền tảng vận hành và giám sát cho Nevados All Terrain Tracker®. Máy theo dõi năng lượng mặt trời là một thiết bị định hướng tấm pin mặt trời về phía mặt trời. Mọi thiết bị theo dõi năng lượng mặt trời liên tục gửi thông tin trạng thái và số đọc, chẳng hạn như góc hiện tại, nhiệt độ, điện áp, v.v. đến nền tảng của chúng tôi và chúng tôi cần lưu trữ thông tin này để phân tích và trực quan hóa. Nếu trình theo dõi được định cấu hình để gửi dữ liệu cứ sau 5 giây, chúng tôi có 17.280 điểm dữ liệu trên mỗi trình theo dõi mỗi ngày, 518.400 điểm dữ liệu trên mỗi trình theo dõi mỗi tháng. Điều đó tổng hợp rất nhiều thông tin. Loại dữ liệu này được gọi là "dữ liệu chuỗi thời gian" và đối với tất cả các vấn đề phức tạp trong phần mềm, có một số giải pháp (Cơ sở dữ liệu chuỗi thời gian) cho nó. Những cái nổi tiếng nhất là InfluxDB và TimescaleDB. Đối với nền tảng của mình, chúng tôi đã quyết định hợp tác với TDEngine , một sản phẩm tương đối mới được tối ưu hóa cho các ứng dụng IoT và hoạt động với ngôn ngữ truy vấn SQL.
Có một số lập luận cho quyết định này: TDEngine
Trong bài viết này, chúng ta sẽ hướng dẫn thiết lập cơ sở dữ liệu và bảng TDEngine cũng như cách tạo lược đồ GraphQL cho phép chúng ta truy vấn dữ liệu từ nhiều máy khách và ứng dụng khác nhau.
Cách dễ nhất để bắt đầu với TDEngine là sử dụng dịch vụ đám mây của họ. Đi tới TDEngine và tạo một tài khoản. Họ có một số cơ sở dữ liệu công cộng mà chúng tôi có thể sử dụng, thật tuyệt vời để đưa ra bản demo hoặc thử nghiệm các truy vấn.
Nếu bạn muốn chạy TDEngine cục bộ, bạn có thể sử dụng Docker image và Telegraf để lấy dữ liệu từ nhiều nguồn khác nhau và gửi chúng đến cơ sở dữ liệu, chẳng hạn như thông tin hệ thống, thống kê ping, v.v.
version: '3.9' services: tdengine: restart: always image: tdengine/tdengine:latest hostname: tdengine container_name: tdengine ports: - 6030:6030 - 6041:6041 - 6043-6049:6043-6049 - 6043-6049:6043-6049/udp volumes: - data:/var/lib/taos telegraf: image: telegraf:latest links: - tdengine env_file: .env volumes: - ./telegraf.conf:/etc/telegraf/telegraf.conf
Xem tài liệu chính thức về cấu hình Telegraf và tài liệu TDEngine về Telegraf . Nói tóm lại, nó sẽ trông giống như thế này để kết nối với chủ đề MQTT:
[agent] interval = "5s" round_interval = true omit_hostname = true [[processors.printer]] [[outputs.http]] url = "http://127.0.0.1:6041/influxdb/v1/write?db=telegraf" method = "POST" timeout = "5s" username = "root" password = "taosdata" data_format = "influx" [[inputs.mqtt_consumer]] topics = [ "devices/+/trackers", ]
Thay vì thiết lập mọi thứ cục bộ và chờ cơ sở dữ liệu điền thông tin, chúng tôi sẽ sử dụng cơ sở dữ liệu công khai cho bài viết này, nơi chứa các hoạt động di chuyển của tàu từ 5 cảng lớn của Hoa Kỳ.
Theo mặc định, các bảng trong TDEngine có một lược đồ ẩn, có nghĩa là lược đồ này thích ứng với dữ liệu được ghi vào cơ sở dữ liệu. Điều này rất tốt cho việc khởi động, nhưng cuối cùng, chúng tôi muốn chuyển sang một lược đồ rõ ràng để tránh các vấn đề với dữ liệu đến. Một điều cần một chút thời gian để làm quen là khái niệm Super Tables của họ gọi tắt là "Stable"). Trong TDEngine có thẻ (khóa) và cột (dữ liệu). Đối với mỗi tổ hợp phím, một "bảng" sẽ được tạo. Tất cả các bảng được nhóm lại trong STable.
Nhìn vào cơ sở dữ liệu vessel
, họ có một STable tên là ais_data
chứa rất nhiều bảng. Thông thường, chúng tôi không muốn truy vấn trên cơ sở từng bảng mà luôn sử dụng STable để lấy dữ liệu tích lũy từ tất cả các bảng.
TDEngine có chức năng DESCRIBE
cho phép chúng ta kiểm tra lược đồ của bảng hoặc STable. ais_data
có lược đồ sau:
STable có hai khóa và sáu cột dữ liệu. Các phím là mmsi
và name
. Chúng ta có thể sử dụng các câu lệnh SQL thông thường để truy vấn dữ liệu:
SELECT ts, name, latitude, longitude FROM vessel.ais_data LIMIT 100; ts name latitude longitude 2023-08-11T22:07:02.419Z GERONIMO 37.921673 -122.40928 2023-08-11T22:21:48.985Z GERONIMO 37.921688 -122.40926 2023-08-11T22:25:08.784Z GERONIMO 37.92169 -122.40926 ...
Hãy nhớ rằng dữ liệu chuỗi thời gian thường rất lớn, vì vậy chúng ta phải luôn giới hạn tập kết quả. Có một số hàm cụ thể theo chuỗi thời gian mà chúng ta có thể sử dụng, chẳng hạn như PARTITION BY
để nhóm kết quả theo khóa và rất hữu ích để nhận các khóa riêng lẻ cập nhật mới nhất. Ví dụ:
SELECT last_row(ts, name, latitude, longitude) FROM vessel.ais_data PARTITION BY name; ts name latitude longitude 2023-09-08T13:09:34.951Z SAN SABA 29.375961 -94.86894 2023-09-07T18:05:01.230Z SELENA 33.678585 -118.1954 2023-09-01T17:23:24.145Z SOME TUESDAY 33.676563 -118.230606 ...
Tôi khuyên bạn nên đọc Tài liệu SQL của họ để biết thêm ví dụ. Trước khi chúng ta tiếp tục, hãy đi tới "Lập trình", "Node.js" và truy xuất các biến TDENGINE_CLOUD_URL
và TDENGINE_CLOUD_TOKEN
của bạn.
GraphQL ngày nay khá nổi tiếng và có rất nhiều bài viết hay về nó. Chúng tôi đã chọn công nghệ này vì chúng tôi thu thập và xử lý thông tin từ nhiều nguồn khác nhau và GraphQL cho phép chúng tôi kết hợp chúng thành một API một cách minh bạch.
Chúng tôi sẽ sử dụng khung Fastify tuyệt vời (hiện là lựa chọn mặc định cho các ứng dụng Node.js) và bộ điều hợp Mercurius . Các nhóm Mercurius và Fastify đã làm việc cùng nhau để mang lại trải nghiệm liền mạch và đó là một lựa chọn tuyệt vời cho các API GraphQL tập trung vào hiệu suất. GraphQL Nexus là một công cụ để xây dựng/tạo lược đồ và trình phân giải, vì vậy chúng ta không phải viết mọi thứ bằng tay.
Có một chút mã thiết lập, v.v. cần phải thực hiện, tôi sẽ bỏ qua ở đây. Bạn có thể tìm thấy ví dụ đầy đủ trên GitHub - tdengine-graphql-example .
Tôi muốn giải thích rõ hơn hai điều trong bài viết này:
TDEngine có thư viện Node.js cho phép chúng ta truy vấn cơ sở dữ liệu. Điều này giúp bạn dễ dàng kết nối và gửi truy vấn, tiếc là các phản hồi hơi khó xử lý. Vì vậy, chúng tôi đã viết một trình bao bọc nhỏ:
'use strict' import tdengine from '@tdengine/rest' import { tdEngineToken, tdEngineUrl } from '../config.js' import parseFields from 'graphql-parse-fields' const { options: tdOptions, connect: tdConnect } = tdengine tdOptions.query = { token: tdEngineToken } tdOptions.url = tdEngineUrl export default function TdEngine(log) { this.log = log const conn = tdConnect(tdOptions) this.cursor = conn.cursor() } TdEngine.prototype.fetchData = async function fetchData(sql) { this.log.debug('fetchData()') this.log.debug(sql) const result = await this.cursor.query(sql) const data = result.getData() const errorCode = result.getErrCode() const columns = result.getMeta() if (errorCode !== 0) { this.log.error(`fetchData() error: ${result.getErrStr()}`) throw new Error(result.getErrStr()) } return data.map((r) => { const res = {} r.forEach((c, idx) => { const columnName = columns[idx].columnName .replace(/`/g, '') .replace('last_row(', '') .replace(')', '') if (c !== null) { res[columnName] = c } }) return res }) }
Điều này trả về một đối tượng TDEngine có thể được chuyển vào ngữ cảnh GraphQL. Về cơ bản, chúng ta sẽ sử dụng hàm fetchData
nơi chúng ta có thể chuyển vào một truy vấn SQL và lấy lại kết quả dưới dạng một mảng đối tượng. TDEngine trả về siêu dữ liệu (cột), lỗi và dữ liệu riêng biệt. Chúng tôi sẽ sử dụng siêu dữ liệu để ánh xạ các cột vào danh sách đối tượng thông thường. Trường hợp đặc biệt ở đây là hàm last_row
. Các cột được trả về dưới dạng last_row(ts)
, last_row(name)
v.v. và chúng tôi muốn xóa phần last_row
để thuộc tính ánh xạ 1:1 tới lược đồ GraphQL. Điều này được thực hiện trong phần columnName.replace
.
Rất tiếc, không có trình tạo lược đồ như Postgraphile cho TDEngine và chúng tôi không muốn viết và duy trì một lược đồ GraphQL thuần túy, vì vậy, chúng tôi sẽ sử dụng Nexus.js để trợ giúp việc đó. Chúng ta sẽ bắt đầu với hai loại cơ bản: VesselMovement
và Timestamp
(là loại vô hướng). Timestamp
và TDDate
là hai loại khác nhau để hiển thị ngày dưới dạng dấu thời gian hoặc dưới dạng chuỗi ngày. Điều này rất hữu ích cho ứng dụng khách (và trong quá trình phát triển), vì nó có thể quyết định sử dụng định dạng nào. asNexusMethod
cho phép chúng ta sử dụng loại này làm hàm trong lược đồ VesselMovement
. Chúng ta có thể giải quyết TDDate
ngay tại đây trong định nghĩa kiểu để sử dụng giá trị dấu thời gian ts
ban đầu.
import { scalarType, objectType } from 'nexus' export const Timestamp = scalarType({ name: 'Timestamp', asNexusMethod: 'ts', description: 'TDEngine Timestamp', serialize(value) { return new Date(value).getTime() } }) export const TDDate = scalarType({ name: 'TDDate', asNexusMethod: 'tdDate', description: 'TDEngine Timestamp as Date', serialize(value) { return new Date(value).toJSON() } }) export const VesselMovement = objectType({ name: 'VesselMovement', definition(t) { t.ts('ts') t.tdDate('date', { resolve: (root) => root.ts }) t.string('mmsi') t.string('name') t.float('latitude') t.float('longitude') t.float('speed') t.float('heading') t.int('nav_status') } })
Đối với các loại chuỗi thời gian, chúng tôi sử dụng hậu tố Movement
hoặc Series
để phân tách rõ ràng các loại quan hệ và chuỗi thời gian trong giao diện.
Bây giờ chúng ta có thể xác định Truy vấn. Chúng ta sẽ bắt đầu với một truy vấn đơn giản để biết những chuyển động mới nhất từ TDEngine:
import { objectType } from 'nexus' export const GenericQueries = objectType({ name: 'Query', definition(t) { t.list.field('latestMovements', { type: 'VesselMovement', resolve: async (root, args, { tdEngine }, info) => { const fields = filterFields(info) return tdEngine.fetchData( `select last_row(${fields}) from vessel.ais_data partition by mmsi;` ) } }) } })
GraphiQL là một công cụ tuyệt vời để kiểm tra API và khám phá lược đồ, bạn có thể kích hoạt nó bằng cách chuyển graphiql.enabled = true
trong Mercurius. Với truy vấn, chúng ta có thể thấy những chuyển động mới nhất của các tàu được nhóm theo mmsi
. Tuy nhiên, chúng ta hãy đi xa hơn một chút. Một trong những ưu điểm lớn nhất của GraphQL đó là lớp trong suốt đối với máy khách hoặc ứng dụng. Chúng ta có thể lấy dữ liệu từ nhiều nguồn và kết hợp chúng vào cùng một lược đồ.
Thật không may, tôi không thể tìm thấy API dễ dàng/miễn phí với thông tin phong phú về tàu. Có Sinay , nhưng họ chỉ cung cấp name
, mmsi
và imo
trong phản hồi Tàu của họ (mà chúng tôi đã có trong TDEngine). Vì ví dụ này, chúng tôi giả định rằng chúng tôi không có name
trong cơ sở dữ liệu của mình và chúng tôi cần truy xuất nó từ Sinay. Với imo
chúng tôi cũng có thể truy vấn lượng khí thải CO2 của một tàu hoặc một API khác có thể được sử dụng để truy xuất hình ảnh, cờ hoặc thông tin khác, tất cả những thông tin này có thể được kết hợp trong loại Vessel
.
export const Vessel = objectType({ name: 'Vessel', definition(t) { t.string('mmsi') t.string('name') t.nullable.string('imo') t.list.field('movements', { type: 'VesselMovement' }) } })
Như bạn có thể thấy ở đây, chúng ta có thể bao gồm movements
của trường danh sách với dữ liệu chuỗi thời gian từ TDEngine. Chúng tôi sẽ thêm một truy vấn khác để tìm nạp thông tin về tàu và trình phân giải cho phép chúng tôi kết hợp dữ liệu từ TDEngine và Sinay:
t.field('vessel', { type: 'Vessel', args: { mmsi: 'String' }, resolve: async (root, args, { tdEngine }, info) => { const waiting = [ getVesselInformation(args.mmsi), tdEngine.fetchData( `select * from vessel.ais_data where mmsi = '${args.mmsi}' order by ts desc limit 10;` ) ] const results = await Promise.all(waiting) return { ...results[0][0], movements: results[1] } } })
🎉 và ở đây chúng tôi có API GraphQL đang hoạt động trả về các hàng từ TDEngine cho một tàu cụ thể mà chúng tôi yêu cầu. getVesselInformation()
là một trình bao bọc đơn giản để tìm nạp dữ liệu từ Sinay. Chúng tôi sẽ thêm kết quả TDEngine vào thuộc tính movements
và GraphQL sẽ xử lý phần còn lại và ánh xạ mọi thứ vào lược đồ.
Giống như bất kỳ cơ sở dữ liệu SQL nào, chúng ta cần cẩn thận với thông tin đầu vào của người dùng. Trong ví dụ trên, chúng tôi sử dụng trực tiếp đầu vào mmsi
, điều này làm cho truy vấn này dễ bị chèn SQL. Vì lợi ích của ví dụ này, hiện tại chúng ta sẽ bỏ qua điều này, nhưng trong các ứng dụng "thế giới thực", chúng ta phải luôn vệ sinh đầu vào của người dùng. Có một số thư viện nhỏ để vệ sinh chuỗi, trong hầu hết các trường hợp, chúng tôi chỉ dựa vào các số (phân trang, giới hạn, v.v.) và enum (thứ tự sắp xếp) mà GraphQL kiểm tra cho chúng tôi.
Cảm ơn Dmitry Zaets đã chỉ ra điều này!
Có một số điều nằm ngoài phạm vi của bài viết này nhưng tôi muốn đề cập ngắn gọn:
Khi chúng tôi bắt đầu dự án, Nexus.js là lựa chọn tốt nhất để tạo lược đồ GraphQL của chúng tôi. Mặc dù ổn định và có phần đầy đủ tính năng nhưng nó vẫn thiếu bảo trì và cập nhật. Có một trình tạo lược đồ GraphQL dựa trên plugin có tên là Pothos hiện đại hơn một chút và được duy trì tích cực. Nếu bạn đang bắt đầu một dự án mới, tôi khuyên bạn nên sử dụng Pothos thay vì Nexus.js.
Cảm ơn Mo Sattler đã chỉ ra điều này!
Như bạn có thể thấy trong trình phân giải Vessel
ở trên, cả hai nguồn dữ liệu đều được tìm nạp và xử lý ngay lập tức. Điều này có nghĩa là nếu truy vấn chỉ dành cho name
thì chúng tôi vẫn tìm nạp movements
cho phản hồi. Và nếu truy vấn chỉ dành cho movements
, chúng tôi vẫn lấy tên từ Sinay và có khả năng trả tiền cho yêu cầu.
Đó là một kiểu chống mẫu GraphQL và chúng tôi có thể cải thiện hiệu suất bằng cách sử dụng thông tin trường để chỉ tìm nạp dữ liệu được yêu cầu. Bộ giải quyết có thông tin trường làm đối số thứ tư, nhưng chúng khá khó làm việc. Thay vào đó, chúng ta có thể sử dụng graphql-parse-fields
để lấy một đối tượng đơn giản của các trường được yêu cầu và điều chỉnh logic của trình phân giải.
Trong các truy vấn mẫu của chúng tôi, chúng tôi sử dụng select *
để tìm nạp tất cả các cột từ cơ sở dữ liệu ngay cả khi chúng không cần thiết. Điều này rõ ràng là khá tệ và chúng ta có thể sử dụng cùng một trình phân tích cú pháp trường để tối ưu hóa các truy vấn sql:
export function filterFields(info, context) { const invalidFields = ['__typename', 'date'] const parsedFields = parseFields(info) const fields = context ? parsedFields[context] : parsedFields const filteredFields = Object.keys(fields).filter( (f) => !invalidFields.includes(f) ) return filteredFields.join(',') }
Hàm này trả về danh sách các trường được phân tách bằng dấu phẩy từ thông tin GraphQL.
const fields = filterFields(info) return tdEngine.fetchData( `select last_row(${fields}) from vessel.ais_data partition by mmsi;` )
Nếu chúng tôi yêu cầu ts
, latitude
và longitude
, truy vấn sẽ như thế này:
select last_row(ts, latitude, longitude) from vessel.ais_data partition by mmsi;
Chỉ với một vài cột trong bảng này, điều này có thể không quan trọng lắm, nhưng với nhiều bảng và truy vấn phức tạp hơn, điều này có thể tạo ra sự khác biệt lớn về hiệu suất ứng dụng.
TDEngine có một số tiện ích mở rộng cụ thể theo chuỗi thời gian nên được sử dụng để cải thiện hiệu suất. Ví dụ: để truy xuất mục nhập mới nhất, truy vấn SQL truyền thống:
SELECT ts, name, latitude, longitude FROM vessel.ais_data order by ts desc limit 1;
Mất 653 mili giây để thực thi, trong khi truy vấn "TDEngine" chỉ mất 145 mili giây:
SELECT last_row(ts, name, latitude, longitude) FROM vessel.ais_data;
Có các tùy chọn cấu hình cho mỗi bảng để tối ưu hóa cho các hàm Last_row/first_row và các cài đặt bộ đệm khác. Tôi khuyên bạn nên đọc tài liệu TDEngine .
Phiên bản đơn giản: Trong bài viết này, chúng tôi đã thiết lập cơ sở dữ liệu chuỗi thời gian TDEngine và xác định lược đồ GraphQL để cho phép các ứng dụng khách kết nối và truy vấn dữ liệu.
Còn nhiều điều hơn thế nữa. Chúng tôi có một dự án soạn sẵn để kết hợp dữ liệu chuỗi thời gian phức tạp với dữ liệu quan hệ trong một giao diện minh bạch. Tại Nevados, chúng tôi đang sử dụng PostgreSQL làm cơ sở dữ liệu chính và truy xuất dữ liệu chuỗi thời gian theo cách tương tự như trong ví dụ movement
ở trên. Đây là một cách tuyệt vời để kết hợp dữ liệu từ nhiều nguồn trong một API duy nhất. Một lợi ích khác là dữ liệu chỉ được tìm nạp khi được yêu cầu, điều này mang lại nhiều tính linh hoạt hơn cho ứng dụng khách. Cuối cùng nhưng không kém phần quan trọng, Lược đồ GraphQL hoạt động như một tài liệu và hợp đồng, vì vậy chúng ta có thể dễ dàng đánh dấu vào ô "Tài liệu API".
Nếu bạn có bất kỳ câu hỏi hoặc nhận xét nào , vui lòng liên hệ trên BlueSky hoặc tham gia thảo luận trên GitHub .
Cũng được xuất bản ở đây .