Nevados 소프트웨어 팀의 일원으로서 우리는 Nevados All Terrain Tracker®용 운영 및 모니터링 플랫폼을 구축하고 있습니다. 태양광 추적기는 태양광 패널의 방향을 태양을 향하게 하는 장치입니다. 모든 태양광 추적기는 현재 각도, 온도, 전압 등과 같은 상태 정보와 판독값을 지속적으로 플랫폼에 전송하며 분석 및 시각화를 위해 이 정보를 저장해야 합니다. 트래커가 5초마다 데이터를 전송하도록 구성된 경우 매일 트래커당 17,280개의 데이터 포인트, 매월 트래커당 518,400개의 데이터 포인트를 갖게 됩니다. 그것은 많은 정보를 요약합니다. 이러한 종류의 데이터를 "시계열 데이터"라고 하며 소프트웨어의 모든 복잡한 문제에 대해서는 여러 가지 솔루션(시계열 데이터베이스)이 있습니다. 가장 유명한 것은 InfluxDB와 TimescaleDB입니다. 우리 플랫폼의 경우 IoT 애플리케이션에 최적화되고 SQL 쿼리 언어와 함께 작동하는 비교적 새로운 제품인 TDEngine을 사용하기로 결정했습니다.
이 결정에 대해서는 여러 가지 주장이 있었습니다: TDEngine
이 문서에서는 TDEngine 데이터베이스 및 테이블 설정과 다양한 클라이언트 및 애플리케이션의 데이터를 쿼리할 수 있는 GraphQL 스키마를 작성하는 방법을 살펴보겠습니다.
TDEngine을 시작하는 가장 쉬운 방법은 클라우드 서비스를 사용하는 것입니다. TDEngine 으로 이동하여 계정을 만드세요. 그들은 우리가 사용할 수 있는 몇 가지 공개 데이터베이스를 가지고 있는데, 이는 데모를 구성하거나 쿼리를 실험하는 데 좋습니다.
TDEngine을 로컬에서 실행하려는 경우 Docker 이미지와 Telegraf를 사용하여 다양한 소스에서 데이터를 검색하고 시스템 정보, 핑 통계 등과 같은 데이터를 데이터베이스로 보낼 수 있습니다.
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
Telegraf 구성에 대한 공식 문서 와 Telegraf 의 TDEngine 문서를 확인하세요. 간단히 말해서 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", ]
모든 것을 로컬로 설정하고 데이터베이스가 정보로 채워질 때까지 기다리는 대신, 이 기사에서는 미국 5개 주요 항구의 선박 이동이 포함된 공개 데이터베이스를 사용하겠습니다.
기본적으로 TDEngine의 테이블에는 암시적 스키마가 있습니다. 이는 스키마가 데이터베이스에 기록된 데이터에 맞게 조정된다는 의미입니다. 이는 부트스트래핑에 적합하지만 결국에는 들어오는 데이터 관련 문제를 피하기 위해 명시적 스키마로 전환하려고 합니다. 익숙해지는 데 약간의 시간이 걸리는 것 중 하나는 Super Tables (줄여서 "STable") 개념입니다. TDEngine에는 태그(키)와 열(데이터)이 있습니다. 각 키 조합에 대해 "테이블"이 생성됩니다. 모든 테이블은 STable에 그룹화됩니다.
vessel
데이터베이스를 살펴보면 많은 테이블을 포함하는 ais_data
라는 STable이 하나 있습니다. 일반적으로 우리는 테이블 단위로 쿼리하는 것을 원하지 않지만 항상 STable을 사용하여 모든 테이블에서 누적된 데이터를 가져옵니다.
TDEngine에는 테이블이나 STable의 스키마를 검사할 수 있는 DESCRIBE
함수가 있습니다. ais_data
에는 다음과 같은 스키마가 있습니다.
STable에는 2개의 키와 6개의 데이터 열이 있습니다. 키는 mmsi
와 name
입니다. 일반 SQL 문을 사용하여 데이터를 쿼리할 수 있습니다.
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 ...
시계열 데이터는 일반적으로 매우 크기 때문에 결과 집합을 항상 제한해야 한다는 점을 명심하세요. 키별로 결과를 그룹화하고 최신 업데이트 개별 키를 가져오는 데 유용한 PARTITION BY
같이 사용할 수 있는 시계열별 기능이 몇 가지 있습니다. 예를 들어:
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 ...
더 많은 예제를 보려면 해당 SQL 설명서를 읽는 것이 좋습니다. 계속 진행하기 전에 "프로그래밍", "Node.js"로 이동하여 TDENGINE_CLOUD_URL
및 TDENGINE_CLOUD_TOKEN
변수를 검색하세요.
GraphQL은 요즘 꽤 잘 알려져 있고 이에 대한 좋은 기사도 많이 있습니다. 우리는 다양한 소스에서 정보를 수집하고 처리하기 위해 이 기술을 선택했으며 GraphQL을 사용하면 이를 단일 API로 투명하게 결합할 수 있습니다.
우리는 놀라운 Fastify 프레임워크(현재 Node.js 애플리케이션의 기본 선택)와 Mercurius 어댑터를 사용할 것입니다. Mercurius와 Fastify 팀은 원활한 경험을 위해 협력했으며 성능에 중점을 둔 GraphQL API를 선택하는 데 탁월한 선택입니다. GraphQL Nexus 는 스키마와 해석기를 구축/생성하는 도구이므로 모든 것을 직접 작성할 필요가 없습니다.
수행해야 할 약간의 설정 코드 등이 있는데 여기서는 건너뛰겠습니다. GitHub - tdengine-graphql-example 에서 전체 예제를 찾을 수 있습니다.
나는 이 기사에서 다소 구체적인 두 가지 사항에 대해 자세히 설명하고 싶습니다.
TDEngine에는 데이터베이스를 쿼리할 수 있는 Node.js 라이브러리가 있습니다. 이렇게 하면 쉽게 연결하고 쿼리를 보낼 수 있지만 안타깝게도 응답을 처리하기가 약간 어렵습니다. 그래서 우리는 작은 래퍼를 작성했습니다.
'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 }) }
이는 GraphQL 컨텍스트에 전달될 수 있는 TDEngine 객체를 반환합니다. 우리는 주로 SQL 쿼리를 전달하고 결과를 개체 배열로 다시 가져올 수 있는 fetchData
함수를 사용할 것입니다. TDEngine은 메타데이터(열), 오류 및 데이터를 별도로 반환합니다. 메타데이터를 사용하여 열을 일반 개체 목록에 매핑합니다. 여기서 특별한 경우는 last_row
함수입니다. 열은 last_row(ts)
, last_row(name)
등으로 반환되며 속성이 GraphQL 스키마에 1:1로 매핑되도록 last_row
부분을 제거하려고 합니다. 이는 columnName.replace
부분에서 수행됩니다.
불행하게도 TDEngine용 Postgraphile 과 같은 스키마 생성기가 없으며 우리는 순수한 GraphQL 스키마를 작성하고 유지하고 싶지 않으므로 이를 돕기 위해 Nexus.js를 사용할 것입니다. VesselMovement
및 Timestamp
(스칼라 유형)의 두 가지 기본 유형부터 시작하겠습니다. Timestamp
와 TDDate
날짜를 타임스탬프 또는 날짜 문자열로 표시하는 두 가지 유형입니다. 이는 사용할 형식을 결정할 수 있으므로 클라이언트 응용 프로그램(및 개발 중에)에 유용합니다. asNexusMethod
사용하면 VesselMovement
스키마에서 해당 유형을 함수로 사용할 수 있습니다. 원래 ts
타임스탬프 값을 사용하기 위해 유형 정의에서 바로 TDDate
확인할 수 있습니다.
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') } })
시계열 유형의 경우 인터페이스에서 관계형 유형과 시계열 유형을 명확하게 구분하기 위해 Movement
또는 Series
접미사를 사용합니다.
이제 쿼리를 정의할 수 있습니다. 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은 API를 테스트하고 스키마를 탐색하는 훌륭한 도구입니다. Mercurius에서 graphiql.enabled = true
를 전달하여 활성화할 수 있습니다. 쿼리를 통해 mmsi
별로 그룹화된 선박의 최신 움직임을 확인할 수 있습니다. 그래도 조금 더 나아가 보겠습니다. GraphQL의 가장 큰 장점 중 하나는 클라이언트나 애플리케이션에 투명한 레이어가 있다는 것입니다. 여러 소스에서 데이터를 가져와 동일한 스키마로 결합할 수 있습니다.
안타깝게도 광범위한 선박 정보가 포함된 쉽고 무료인 API를 찾을 수 없었습니다. Sinay 가 있지만 Vessel 응답(TDEngine에 이미 있음)에 name
, mmsi
및 imo
만 제공합니다. 예를 들어, 데이터베이스에 name
없고 Sinay에서 검색해야 한다고 가정합니다. imo
를 사용하면 선박의 CO2 배출량을 쿼리할 수도 있고 다른 API를 사용하여 이미지, 플래그 또는 기타 정보를 검색할 수도 있습니다. 이 모든 정보는 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' }) } })
여기에서 볼 수 있듯이 TDEngine의 시계열 데이터에 목록 필드 movements
포함할 수 있습니다. 선박 정보를 가져오기 위해 또 다른 쿼리를 추가하고 리졸버를 통해 TDEngine과 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] } } })
🎉 여기에 우리가 요청한 특정 선박에 대해 TDEngine에서 행을 반환하는 작동 중인 GraphQL API가 있습니다. getVesselInformation()
Sinay에서 데이터를 가져오는 간단한 래퍼입니다. TDEngine 결과를 movements
속성에 추가하면 GraphQL이 나머지를 처리하고 모든 것을 스키마에 매핑합니다.
모든 SQL 데이터베이스와 마찬가지로 사용자 입력에도 주의가 필요합니다. 위의 예에서는 mmsi
입력을 직접 사용하므로 이 쿼리가 SQL 주입에 취약해집니다. 예를 들어 지금은 이를 무시하겠지만 "실제" 애플리케이션에서는 항상 사용자 입력을 삭제해야 합니다. 문자열을 삭제하기 위한 여러 개의 작은 라이브러리가 있습니다. 대부분의 경우 GraphQL이 확인하는 숫자(페이지 매기기, 제한 등)와 열거형(정렬 순서)에만 의존합니다.
이 점을 지적해주신 Dmitry Zaets에게 감사드립니다!
이 기사의 범위를 벗어나는 몇 가지 사항이 있지만 간략하게 언급하고 싶습니다.
프로젝트를 시작했을 때 Nexus.js는 GraphQL 스키마를 생성하는 데 가장 적합한 선택이었습니다. 안정적이고 기능이 다소 완벽 하지만 유지 관리 및 업데이트가 부족합니다. 좀 더 현대적이고 적극적으로 유지 관리되는 Pothos 라는 플러그인 기반 GraphQL 스키마 빌더가 있습니다. 새로운 프로젝트를 시작한다면 Nexus.js 대신 Pothos를 사용하는 것이 좋습니다.
이 점을 지적해주신 Mo Sattler에게 감사드립니다!
위의 Vessel
해석기에서 볼 수 있듯이 두 데이터 소스 모두 즉시 가져와서 처리됩니다. 즉, 쿼리가 name
에 대한 것일 경우에도 응답에 대한 movements
가져옵니다. 그리고 쿼리가 movements
에만 해당되는 경우에도 Sinay에서 이름을 가져오고 잠재적으로 요청 비용을 지불합니다.
이는 GraphQL 안티 패턴이며 필드 정보를 사용하여 요청된 데이터만 가져오면 성능을 향상시킬 수 있습니다. Resolver는 필드 정보를 네 번째 인수로 사용하지만 작업하기가 꽤 어렵습니다. 대신, graphql-parse-fields
사용하여 요청된 필드의 간단한 객체를 얻고 확인자 논리를 조정할 수 있습니다.
예제 쿼리에서는 select *
사용하여 필요하지 않은 경우에도 데이터베이스에서 모든 열을 가져옵니다. 이것은 분명히 매우 나쁜 것이며 동일한 필드 파서를 사용하여 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(',') }
이 함수는 GraphQL 정보에서 쉼표로 구분된 필드 목록을 반환합니다.
const fields = filterFields(info) return tdEngine.fetchData( `select last_row(${fields}) from vessel.ais_data partition by mmsi;` )
ts
, latitude
및 longitude
요청하면 쿼리는 다음과 같습니다.
select last_row(ts, latitude, longitude) from vessel.ais_data partition by mmsi;
이 테이블에 몇 개의 열만 있으면 별 문제가 되지 않을 수 있지만, 테이블이 많고 쿼리가 복잡하면 애플리케이션 성능에 큰 차이가 생길 수 있습니다.
TDEngine에는 성능을 향상시키는 데 사용해야 하는 시계열별 확장 기능이 있습니다. 예를 들어 최신 항목을 검색하려면 기존 SQL 쿼리를 사용하세요.
SELECT ts, name, latitude, longitude FROM vessel.ais_data order by ts desc limit 1;
실행하는 데 653ms가 걸리는 반면 "TDEngine" 쿼리는 145ms만 걸립니다.
SELECT last_row(ts, name, latitude, longitude) FROM vessel.ais_data;
last_row/first_row 함수 및 기타 캐시 설정을 최적화하기 위한 각 테이블의 구성 옵션이 있습니다. TDEngine 문서를 읽어 보시기 바랍니다.
간단한 버전: 이 문서에서는 TDEngine 시계열 데이터베이스를 설정하고 클라이언트 애플리케이션이 데이터를 연결하고 쿼리할 수 있도록 GraphQL 스키마를 정의했습니다.
더 많은 것이 있습니다. 투명한 인터페이스에서 복잡한 시계열 데이터와 관계형 데이터를 결합하는 상용구 프로젝트가 있습니다. Nevados에서는 PostgreSQL을 기본 데이터베이스로 사용하고 있으며 위의 movement
예시와 동일한 방식으로 시계열 데이터를 검색합니다. 이는 여러 소스의 데이터를 단일 API로 결합하는 좋은 방법입니다. 또 다른 이점은 요청된 경우에만 데이터를 가져오므로 클라이언트 애플리케이션에 많은 유연성이 추가된다는 것입니다. 마지막으로, GraphQL 스키마는 문서 및 계약으로 작동하므로 "API 문서" 상자를 쉽게 선택할 수 있습니다.
질문이나 의견이 있는 경우 BlueSky에 문의하거나 GitHub의 토론에 참여하세요 .
여기에도 게시되었습니다.