paint-brush
TDEngine 및 GraphQL을 사용하여 시계열 데이터베이스 생성~에 의해@patrickheneise
641 판독값
641 판독값

TDEngine 및 GraphQL을 사용하여 시계열 데이터베이스 생성

~에 의해 Patrick Heneise13m2023/10/22
Read on Terminal Reader

너무 오래; 읽다

이 문서에서는 TDEngine 데이터베이스 및 테이블 설정과 다양한 클라이언트 및 애플리케이션의 데이터를 쿼리할 수 있는 GraphQL 스키마를 작성하는 방법을 살펴보겠습니다.
featured image - TDEngine 및 GraphQL을 사용하여 시계열 데이터베이스 생성
Patrick Heneise HackerNoon profile picture
0-item
1-item

동기 부여 및 소개

Nevados 소프트웨어 팀의 일원으로서 우리는 Nevados All Terrain Tracker®용 운영 및 모니터링 플랫폼을 구축하고 있습니다. 태양광 추적기는 태양광 패널의 방향을 태양을 향하게 하는 장치입니다. 모든 태양광 추적기는 현재 각도, 온도, 전압 등과 같은 상태 정보와 판독값을 지속적으로 플랫폼에 전송하며 분석 및 시각화를 위해 이 정보를 저장해야 합니다. 트래커가 5초마다 데이터를 전송하도록 구성된 경우 매일 트래커당 17,280개의 데이터 포인트, 매월 트래커당 518,400개의 데이터 포인트를 갖게 됩니다. 그것은 많은 정보를 요약합니다. 이러한 종류의 데이터를 "시계열 데이터"라고 하며 소프트웨어의 모든 복잡한 문제에 대해서는 여러 가지 솔루션(시계열 데이터베이스)이 있습니다. 가장 유명한 것은 InfluxDB와 TimescaleDB입니다. 우리 플랫폼의 경우 IoT 애플리케이션에 최적화되고 SQL 쿼리 언어와 함께 작동하는 비교적 새로운 제품인 TDEngine을 사용하기로 결정했습니다.


이 결정에 대해서는 여러 가지 주장이 있었습니다: TDEngine

  • 오픈 소스입니다
  • IoT 애플리케이션에 최적화되어 있습니다.
  • 우리에게 익숙한 언어인 SQL을 사용합니다.
  • 관리형 서비스로 제공되므로 애플리케이션 구축에 집중할 수 있습니다.
  • Docker를 통해 로컬로 쉽게 실행할 수 있습니다.


이 문서에서는 TDEngine 데이터베이스 및 테이블 설정과 다양한 클라이언트 및 애플리케이션의 데이터를 쿼리할 수 있는 GraphQL 스키마를 작성하는 방법을 살펴보겠습니다.

TDEngine 시작하기

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 사용

기본적으로 TDEngine의 테이블에는 암시적 스키마가 있습니다. 이는 스키마가 데이터베이스에 기록된 데이터에 맞게 조정된다는 의미입니다. 이는 부트스트래핑에 적합하지만 결국에는 들어오는 데이터 관련 문제를 피하기 위해 명시적 스키마로 전환하려고 합니다. 익숙해지는 데 약간의 시간이 걸리는 것 중 하나는 Super Tables (줄여서 "STable") 개념입니다. TDEngine에는 태그(키)와 열(데이터)이 있습니다. 각 키 조합에 대해 "테이블"이 생성됩니다. 모든 테이블은 STable에 그룹화됩니다.

tdengine 클라우드 테이블을 보여주는 스크린샷


vessel 데이터베이스를 살펴보면 많은 테이블을 포함하는 ais_data 라는 STable이 하나 있습니다. 일반적으로 우리는 테이블 단위로 쿼리하는 것을 원하지 않지만 항상 STable을 사용하여 모든 테이블에서 누적된 데이터를 가져옵니다.


TDEngine에는 테이블이나 STable의 스키마를 검사할 수 있는 DESCRIBE 함수가 있습니다. ais_data 에는 다음과 같은 스키마가 있습니다.

TDEngine 테이블 스키마를 보여주는 스크린샷


STable에는 2개의 키와 6개의 데이터 열이 있습니다. 키는 mmsiname 입니다. 일반 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 ... 


tdengine 출력을 보여주는 스크린샷


더 많은 예제를 보려면 해당 SQL 설명서를 읽는 것이 좋습니다. 계속 진행하기 전에 "프로그래밍", "Node.js"로 이동하여 TDENGINE_CLOUD_URLTDENGINE_CLOUD_TOKEN 변수를 검색하세요.

Nexus.js, Fastify 및 Mercurius를 사용한 GraphQL

GraphQL은 요즘 꽤 잘 알려져 있고 이에 대한 좋은 기사도 많이 있습니다. 우리는 다양한 소스에서 정보를 수집하고 처리하기 위해 이 기술을 선택했으며 GraphQL을 사용하면 이를 단일 API로 투명하게 결합할 수 있습니다.


우리는 놀라운 Fastify 프레임워크(현재 Node.js 애플리케이션의 기본 선택)와 Mercurius 어댑터를 사용할 것입니다. Mercurius와 Fastify 팀은 원활한 경험을 위해 협력했으며 성능에 중점을 둔 GraphQL API를 선택하는 데 탁월한 선택입니다. GraphQL Nexus 는 스키마와 해석기를 구축/생성하는 도구이므로 모든 것을 직접 작성할 필요가 없습니다.


수행해야 할 약간의 설정 코드 등이 있는데 여기서는 건너뛰겠습니다. GitHub - tdengine-graphql-example 에서 전체 예제를 찾을 수 있습니다.


나는 이 기사에서 다소 구체적인 두 가지 사항에 대해 자세히 설명하고 싶습니다.

  1. TDEngine 쿼리 라이브러리
  2. Nexus를 사용한 GraphQL 스키마

TDEngine 쿼리 라이브러리

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 부분에서 수행됩니다.

GraphQL 스키마

불행하게도 TDEngine용 Postgraphile 과 같은 스키마 생성기가 없으며 우리는 순수한 GraphQL 스키마를 작성하고 유지하고 싶지 않으므로 이를 돕기 위해 Nexus.js를 사용할 것입니다. VesselMovementTimestamp (스칼라 유형)의 두 가지 기본 유형부터 시작하겠습니다. TimestampTDDate 날짜를 타임스탬프 또는 날짜 문자열로 표시하는 두 가지 유형입니다. 이는 사용할 형식을 결정할 수 있으므로 클라이언트 응용 프로그램(및 개발 중에)에 유용합니다. 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 출력을 보여주는 스크린샷


GraphiQL은 API를 테스트하고 스키마를 탐색하는 훌륭한 도구입니다. Mercurius에서 graphiql.enabled = true 를 전달하여 활성화할 수 있습니다. 쿼리를 통해 mmsi 별로 그룹화된 선박의 최신 움직임을 확인할 수 있습니다. 그래도 조금 더 나아가 보겠습니다. GraphQL의 가장 큰 장점 중 하나는 클라이언트나 애플리케이션에 투명한 레이어가 있다는 것입니다. 여러 소스에서 데이터를 가져와 동일한 스키마로 결합할 수 있습니다.


안타깝게도 광범위한 선박 정보가 포함된 쉽고 무료인 API를 찾을 수 없었습니다. Sinay 가 있지만 Vessel 응답(TDEngine에 이미 있음)에 name , mmsiimo 만 제공합니다. 예를 들어, 데이터베이스에 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] } } }) 


쿼리의 graphiql 출력을 보여주는 스크린샷

🎉 여기에 우리가 요청한 특정 선박에 대해 TDEngine에서 행을 반환하는 작동 중인 GraphQL API가 있습니다. getVesselInformation() Sinay에서 데이터를 가져오는 간단한 래퍼입니다. TDEngine 결과를 movements 속성에 추가하면 GraphQL이 나머지를 처리하고 모든 것을 스키마에 매핑합니다.

참고: SQL 주입

모든 SQL 데이터베이스와 마찬가지로 사용자 입력에도 주의가 필요합니다. 위의 예에서는 mmsi 입력을 직접 사용하므로 이 쿼리가 SQL 주입에 취약해집니다. 예를 들어 지금은 이를 무시하겠지만 "실제" 애플리케이션에서는 항상 사용자 입력을 삭제해야 합니다. 문자열을 삭제하기 위한 여러 개의 작은 라이브러리가 있습니다. 대부분의 경우 GraphQL이 확인하는 숫자(페이지 매기기, 제한 등)와 열거형(정렬 순서)에만 의존합니다.


이 점을 지적해주신 Dmitry Zaets에게 감사드립니다!

최적화

이 기사의 범위를 벗어나는 몇 가지 사항이 있지만 간략하게 언급하고 싶습니다.

Nexus.js의 정신적 후계자인 포토스

프로젝트를 시작했을 때 Nexus.js는 GraphQL 스키마를 생성하는 데 가장 적합한 선택이었습니다. 안정적이고 기능이 다소 완벽 하지만 유지 관리 및 업데이트가 부족합니다. 좀 더 현대적이고 적극적으로 유지 관리되는 Pothos 라는 플러그인 기반 GraphQL 스키마 빌더가 있습니다. 새로운 프로젝트를 시작한다면 Nexus.js 대신 Pothos를 사용하는 것이 좋습니다.


이 점을 지적해주신 Mo Sattler에게 감사드립니다!

필드 리졸버

위의 Vessel 해석기에서 볼 수 있듯이 두 데이터 소스 모두 즉시 가져와서 처리됩니다. 즉, 쿼리가 name 에 대한 것일 경우에도 응답에 대한 movements 가져옵니다. 그리고 쿼리가 movements 에만 해당되는 경우에도 Sinay에서 이름을 가져오고 잠재적으로 요청 비용을 지불합니다.


이는 GraphQL 안티 패턴이며 필드 정보를 사용하여 요청된 데이터만 가져오면 성능을 향상시킬 수 있습니다. Resolver는 필드 정보를 네 번째 인수로 사용하지만 작업하기가 꽤 어렵습니다. 대신, graphql-parse-fields 사용하여 요청된 필드의 간단한 객체를 얻고 확인자 논리를 조정할 수 있습니다.

SQL 쿼리 최적화

예제 쿼리에서는 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 , latitudelongitude 요청하면 쿼리는 다음과 같습니다.

 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의 토론에 참여하세요 .


여기에도 게시되었습니다.