newmo 技術ブログ

技術で地域をカラフルに

ブラウザで動作する地理空間データ処理ライブラリとして DuckDB-wasm を使い、 SQL を TypeScript で管理する仕組みを作る

newmo では、地図データや地理情報を扱う場面が多くあります。

たとえば、タクシーやライドシェアでは、営業区域のような営業していいエリアといった地理的な定義があります。 また、乗り入れ禁止区域のようなタクシーが乗り入れてはいけないエリアといった定義も必要になります。

これらの地理に関する定義は GeoJSON のような地理情報を扱うデータ形式で管理されることが多いです。 しかし、GeoJSONなどの定義をテキストとして手書きするのは困難です。 そのため、地図上に区域を作図するエディタやその定義した区域が正しいかをチェックするような管理ツールが必要です。 管理ツールは、ウェブアプリケーションとして作った方が利用できる環境が広がります。

このような地理情報は一度に扱うデータが多かったり、空間的な計算処理が必要になるため、専用の仕組みを使うことが多いです。 このような技術を、地理情報システム(GIS:Geographic Information System)と呼びます。

ブラウザやウェブ標準には、GIS処理を行う仕組みは特にないため、すでに安定したGISの実装があるサーバ側で処理することも多いです。 しかし、データを変更するたびに、クライアントからサーバにリクエストしてサーバ側で GIS の処理を行うと、変更の反映がリアルタイムにできないため体験が良くありません。 そのため、地図情報の編集ツールといったプレビューの表示はクライアント側でも地理空間データ処理をして、操作内容を即時に反映できると体験を向上させることができます。

これらを行うには、ブラウザで動作する地理空間データ処理を行うライブラリが必要です。

ブラウザで動作する地理空間データ処理ライブラリとして DuckDB-wasm を使う

ブラウザで地理空間データ処理を行うライブラリとして何を使うかを検討して、最終的にDuckDB-wasmを使うことにしました。

DuckDB-wasmは、名前の通りDuckDBの WebAssembly ビルドです。 この、DuckDB-wasm はブラウザや Node.js など Wasm を実行できる環境で、DuckDB を動かすことができます。

DuckDB にはSpatial Extensionというものがあり、地理空間データ処理を扱えます。

データベースでは、Postgres 拡張のPostGISや SQLite 拡張のSpatiaLiteなどがあります。 DuckDB のSpatial Extensionもこれと同様の地理空間データ処理を扱う拡張で、面白い点として WebAssembly で動く点があげられます。

PostGIS と DuckDB の Spatial Extension をおおまかに比較してみると次のようになります。 また、JavaScriptで実装された地理空間データ処理ライブラリである Turf.js との比較も併せてみます。

基本的なCategory DuckDB WASM PostGIS Turf
基本データ型 GEOMETRY, POINT_2D, LINESTRING_2D, POLYGON_2D geometry, geography Point, LineString, Polygonなど
空間演算 ST_Area, ST_Distance, ST_Union ST_Area, ST_Distance, ST_Union, ST_Buffer area, distance, union, buffer
空間関係 ST_Contains, ST_Within, ST_Touches ST_Contains, ST_Within, ST_Touches, ST_Crosses booleanContains, booleanWithin, booleanOverlap
座標変換 ST_Transform ST_Transform, ST_SetSRID transform
ジオメトリ生成 ST_MakePoint, ST_MakeLine ST_MakePoint, ST_MakeLine, ST_MakePolygon point, lineString, polygon
集計関数 ST_Extent, ST_Union ST_Extent, ST_Union, ST_Collect collect, combine
クラスタリング なし ST_ClusterDBSCAN, ST_ClusterKMeans clustersKmeans
測地線計算 ST_Distance_Spheroid ST_Distance_Spheroid, ST_Length_Spheroid geodesicDistance
GeoJSON 周りの比較 DuckDB WASM PostGIS Turf
GeoJSON 変換 ST_AsGeoJSON ST_AsGeoJSON feature, featureCollection
ジオメトリ → GeoJSON SELECT ST_AsGeoJSON(geom) SELECT ST_AsGeoJSON(geom) turf.feature(geometry)
プロパティ付き Feature json extension ST_AsGeoJSON(t.*)  turf.feature(geometry, properties)
FeatureCollection 生成 json extension json_build_object('type','FeatureCollection','features',json_agg(ST_AsGeoJSON(t.*)::json)) turf.featureCollection(features) 
CRS 指定 ST_Transform + ST_AsGeoJSON ST_Transform + ST_AsGeoJSON1 なし(WGS84 固定)
オプション 基本的な GeoJSON 出力のみ maxdecimaldigits, bbox, CRS 指定など bbox, id 指定可能
基本作成 ST_MakePolygon, ST_Polygon ST_MakePolygon, ST_Polygon, ST_PolygonFromText polygon, multiPolygon
Polygon 操作 DuckDB WASM PostGIS Turf
ポリゴン演算 ST_Union, ST_Intersection, ST_Difference ST_Union, ST_Intersection, ST_Difference, ST_3DUnion union, intersect, difference
空間分析 ST_Area, ST_Perimeter ST_Area, ST_Perimeter, ST_3DArea, ST_3DPerimeter area, perimeter
空間関係 ST_Contains, ST_Within, ST_Overlaps7 ST_Contains, ST_Within, ST_Overlaps, ST_3DIntersects booleanContains, booleanWithin, booleanOverlap
検証 ST_IsValid, ST_IsSimple ST_IsValid, ST_IsSimple, ST_IsValidReason isPolygon, isMultiPolygon
変換 ST_Transform ST_Transform, ST_Force3D transformScale, transformRotate
単純化関数 ST_Simplify ST_Simplify simplify,polygonSmooth
サポートしているデータ形式 DuckDB WASM PostGIS Turf
入力形式 GeoJSON, Shapefile, GeoPackage, KML, GML GeoJSON, Shapefile, GeoPackage, KML, GML, WKT, WKB GeoJSON のみ
出力形式 GeoJSON, WKT, WKB GeoJSON, KML, SVG, WKT, WKB GeoJSON のみ
GeoJSON 操作 ST_AsGeoJSON, ST_GeomFromGeoJSON ST_AsGeoJSON, ST_GeomFromGeoJSON feature, featureCollection10
KML 操作 ST_AsKML ST_AsKML, ST_GeomFromKML なし
WKT 操作 ST_AsText, ST_GeomFromText ST_AsText, ST_GeomFromText なし
データ読み込み ST_Read ST_Read, ST_AsBinary JSON.parse
データ書き出し ST_AsGeoJSON, ST_AsText ST_AsGeoJSON, ST_AsKML, ST_AsSVG JSON.stringify

おおまかに比較しても、DuckDB の Spatial Extension は PostGIS と同等の機能性を持っていることがわかります。 逆にTurf.jsは演算のみなので、データ形式の変換などについてはスコープ外となっていることもわかります。

ブラウザ上で動作する地理空間データ処理ライブラリを決めるために、このような機能比較、実装イメージ、ユースケース、サイズ、メリット/デメリットなどを書いた Design Doc を書いて議論しました。 その結果、DuckDB-wasm を使うことにしました。

📢 newmoでの Design Doc について

newmo のフロントエンドでは、大きなライブラリを導入する際には Design Doc を書いて議論してから決めることが多いです。 これについては、以前書いた One Version Rule を実践するためと、なぜそのライブラリを使っているのかという経緯が Design Doc(Architectural Decision Records としての役割)として残るためです。

この Design Doc とライブラリの管理などについては、yui_tang が 2024年11月23日(土曜) のJSConf JPで発表する予定です。

DuckDB-wasm + TypeScript で SQL を管理する

地理空間データ処理ライブラリとしてDuckDB-wasmを使うことにしました。 これは、クライアントサイドで SQL を書いて、クライアントサイドのWasm上で SQL を実行する必要があるということを意味しています。

今回は DuckDB のデータを永続化はせずに In-Memory DB として利用しているので、マイグレーションのような複雑な問題はありませんが、 それでも SQL を管理する方法は考える必要があります。

DuckDB-wasmはまだ新しいライブラリであるため、どのように扱うかのベストプラクティスが確立されていません。 そのため、今回 DuckDB-wasm を扱うにあたって、DuckDB で実行する SQL を TypeScript で管理する仕組みを作りました。

仕組みと言っても単純で、DuckDB で実行する SQL に型をつけて定義する Utility 関数を用意しただけです。

次のような SQL を実行できる関数を定義できる Utility 関数を提供しています。

  • defineQueryOne: 一つの結果を返すクエリを実行する関数を定義する
  • defineQueryMany: 複数の結果を返すクエリを実行する関数を定義する
  • defineQueryExec: 結果を返さないクエリを実行する関数を定義する
  • transformQuery: クエリの実行結果を変換して、変換した結果を返すようにするクエリのラッパー

また、どの関数も第一引数にDuckDBContextを受け取り、DuckDBContextは DuckDB と接続するための情報を持っています。

defineQuery.ts: SQLを管理するUtility関数のコード(クリックで開く)

import type { AsyncDuckDB, AsyncDuckDBConnection } from "@duckdb/duckdb-wasm";

/**
 * 構文エラーなのでクエリの書き方の問題がある
 */
export type DuckDBParserError = {
  type: "DuckDBParserError";
  message: string;
  query: string;
  cause: Error;
};
/**
 * 変換エラーなのでデータの問題がある
 */
export type DuckDBConversionError = {
  type: "DuckDBConversionError";
  message: string;
  query: string;
  cause: Error;
};
/**
 * データがない場合のエラー
 */
export type DuckDBNoRowError = {
  type: "DuckDBNoRowError";
  message: string;
  query: string;
  cause: Error;
};
/**
 * その他のエラー
 */
export type DuckDBUnknownError = {
  type: "DuckDBUnknownError";
  message: string;
  query: string;
  cause: Error;
};
export type DuckDBSQLError =
  | DuckDBParserError
  | DuckDBConversionError
  | DuckDBNoRowError
  | DuckDBUnknownError;
/**
 * DuckDBのクエリに渡すContext
 */
export type DuckDBContext = {
  db: AsyncDuckDB;
  conn: AsyncDuckDBConnection;
};
/**
 * DuckDBのエラーをエラーオブジェクトにする
 */
export const translateDuckDbError = ({
  message,
  query,
  error,
}: {
  message: string;
  query: string;
  error: unknown;
}): DuckDBSQLError => {
  if (error instanceof Error) {
    if (error.message.includes("Parser Error")) {
      return {
        type: "DuckDBParserError",
        message,
        query,
        cause: error,
      };
    }
    if (error.message.includes("Conversion Error")) {
      return {
        type: "DuckDBConversionError",
        message,
        query,
        cause: error,
      };
    }
  }
  return {
    type: "DuckDBUnknownError",
    message,
    query,
    cause: error as Error,
  };
};

// Inputが {} の場合は、 Inputをoptionalにする
// keyof {} は never になるのを利用して判定している
// https://stackoverflow.com/questions/62403425/conditional-type-for-empty-objects
export type QueryFunction<Input, Output> = keyof Input extends never
  ? QueryFunctionWithOptionalArgs<Input, Output>
  : QueryFunctionWithArgsRequiredArgs<Input, Output>;
export type QueryFunctionWithArgsRequiredArgs<Input, Output> = (
  context: DuckDBContext,
  args: Input
) => Promise<
  | {
      ok: true;
      data: Output;
    }
  | {
      ok: false;
      errors: DuckDBSQLError[];
    }
>;
export type QueryFunctionWithOptionalArgs<Input, Output> = (
  context: DuckDBContext,
  args?: Input
) => Promise<
  | {
      ok: true;
      data: Output;
    }
  | {
      ok: false;
      errors: DuckDBSQLError[];
    }
>;
/**
 * 一つの結果を返すクエリを定義する
 * データが存在しないときは、DuckDBNoRowErrorのエラーを返す
 * @param name 関数名
 * @param sql クエリ
 * @example
 * ```ts
 * const selectId = defineFunction<{id: string}, { id: string }>({
 *   name: "selectId",
 *   query: () => `SELECT * FROM table WHERE id = ${id`,
 * });
 * const result = await selectId(context, { id: "1" });
 * console.log(result.data.id); // => "1"
 * const notFound = await selectId(context, { id: "2" });
 * console.log(notFound.ok); // => false
 * ```
 *
 */
export const defineQueryOne = <
  /**
   * クエリの引数
   * 引数がない場合は {} を指定する
   */
  Input,
  /**
   * クエリの実行結果で取得できるデータ型
   */
  Output
>({
  name,
  sql,
}: {
  name: string;
  sql: (args: Input) => string;
}): QueryFunction<Input, Output> => {
  const fn = async (context: DuckDBContext, args?: Input) => {
    const query = `-- name: ${name} :one
${sql(args ?? ({} as Input))}`;
    try {
      const q = await context.conn.prepare(query);
      const resultTable = await q.query(args);
      const firstData = resultTable.toArray()[0];
      if (!firstData) {
        return {
          ok: false,
          errors: [
            {
              type: "DuckDBNoRowError",
              message: `No row found: ${name}`,
              query,
            },
          ],
        };
      }
      return {
        ok: true,
        // それぞれのアイテムはPlainなオブジェクトではないので、spread syntaxでnon-enumerableなプロパティを落とす
        // 型には定義されてない、生のプロパティをできるだけ触れないようにする
        // TODO: JSON.parse(JSON.stringify(firstData)) なら全て落とせるが、パフォーマンスが悪い
        data: { ...firstData },
      };
    } catch (error: unknown) {
      return {
        ok: false,
        errors: [
          translateDuckDbError({
            message: `Failed to query: ${name}`,
            query,
            error,
          }),
        ],
      };
    }
  };
  // nameを関数名に設定する
  Object.defineProperty(fn, "name", { value: name, configurable: true });
  return fn as QueryFunction<Input, Output>;
};

/**
 * 複数の結果を返すクエリを定義する
 * @param name 関数名
 * @param sql クエリ
 * @example
 * ```ts
 * const selectAll = defineFunction<{id: string}, { id: string }>({
 *   name: "selectAll",
 *   query: "SELECT * FROM table",
 * });
 * const result = await selectAll(context, {});
 * console.log(result.data); // => [{ id: "1" }, { id: "2" }]
 * ```
 */
export const defineQueryMany = <
  /**
   * クエリの引数
   * 引数がない場合は {} を指定する
   */
  Input,
  /**
   * クエリの実行結果で取得できるデータ型(要素の型なので、[]は不要)
   */
  Output
>({
  name,
  sql,
}: {
  name: string;
  sql: (args: Input) => string;
}): QueryFunction<Input, Output[]> => {
  const fn = async (context: DuckDBContext, args?: Input) => {
    const query = `-- name: ${name} :many
${sql(args ?? ({} as Input))}`;
    try {
      const q = await context.conn.prepare(query);
      const resultTable = await q.query(args);
      return {
        ok: true,
        data: resultTable.toArray(),
      };
    } catch (error: unknown) {
      return {
        ok: false,
        errors: [
          translateDuckDbError({
            message: `Failed to query: ${name}`,
            query,
            error,
          }),
        ],
      };
    }
  };
  // nameを関数名に設定する
  Object.defineProperty(fn, "name", { value: name, configurable: true });
  return fn as QueryFunction<Input, Output[]>;
};

/**
 * 結果を返さないクエリを定義する
 * @param name 関数名
 * @param sql クエリ
 * @example
 * ```ts
 * const update = defineFunction<{id: string}, undefined>({
 *   name: "update",
 *   query: "UPDATE table SET id = $id",
 * });
 * const result = await update(context, { id: "1" });
 * console.log(result.ok); // => true
 * console.log(result.data); // => undefined
 * ```
 */
export const defineQueryExec = <
  /**
   * クエリの引数
   * 引数がない場合は {} を指定する
   */
  Input
>({
  name,
  sql,
}: {
  name: string;
  sql: (args: Input) => string;
}): QueryFunction<Input, undefined> => {
  const fn = async (context: DuckDBContext, args?: Input) => {
    const query = `-- name: ${name} :exec
${sql(args ?? ({} as Input))}`;
    try {
      const q = await context.conn.prepare(query);
      await q.query(args);
      return {
        ok: true,
        data: undefined,
      };
    } catch (error: unknown) {
      return {
        ok: false,
        errors: [
          translateDuckDbError({
            message: `Failed to query: ${name}`,
            query,
            error,
          }),
        ],
      };
    }
  };
  // nameを関数名に設定する
  Object.defineProperty(fn, "name", { value: name, configurable: true });
  return fn as QueryFunction<Input, undefined>;
};

/**
 * クエリの実行結果を変換して、変換した結果を返すようにするクエリのラッパー
 * @example
 * ```ts
 * const selectId = defineQueryOne<{id: string}, { id: string }>({
 *    name: "selectId",
 *    sql: ({ id }) => `SELECT * FROM table WHERE id = ${id}`,
 * });
 * const selectIdWithTransformed = transformQuery(selectId, (data) => {
 *   return {
 *     id: Number(data.id),
 *   }
 * });
 * const result = await selectIdWithTransformed(context, { id: "1" });
 * console.log(result.data.id); // => 1
 */
export const transformQuery = <TransformOutput, Input, Output>(
  query: QueryFunction<Input, Output>,
  transformFn: (data: Output) => TransformOutput
): QueryFunction<Input, TransformOutput> => {
  const fn = async (context: DuckDBContext, args?: Input) => {
    const result = await query(context, args ?? ({} as Input));
    if (!result.ok) {
      return result;
    }
    return {
      ok: true,
      data: transformFn(result.data),
    };
  };
  // nameを関数名に設定する
  Object.defineProperty(fn, "name", {
    value: `${query.name}WithTransformed`,
    configurable: true,
  });
  return fn as QueryFunction<Input, TransformOutput>;
};

これらの Utility 関数を使って、次のように SQL を実行する関数を定義できます。

import { defineQueryOne, defineQueryMany, defineQueryExec } from "./defineQuery.ts";

/**
 * DuckDBにSpatial拡張をインストールするクエリ
 */
export const installSpatialExtension = defineQueryExec({
  name: "installSpatialExtension",
  sql: () => `
    INSTALL spatial;
    LOAD spatial;
  `,
});
/**
 * テーブルを作成するクエリ
 */
export const createTable = defineQueryExec({
  name: "createTable",
  sql: () => `
    CREATE TABLE table (
      id STRING
      name STRING
    );
  `,
});
/**
 * idを指定してデータを取得するクエリ
 */
export const selectId = defineQueryOne<
  { id: string },
  { id: string; name: string }
>({
  name: "selectId",
  sql: ({ id }) => `SELECT * FROM table WHERE id = ${id}`,
});
/**
 * 全てのデータを取得するクエリ
 */
export const selectAll = defineQueryMany<{}, { id: string; name: string }>({
  name: "selectAll",
  sql: () => `SELECT * FROM table`,
});
/**
 * データを挿入するクエリ
 */
export const insert = defineQueryExec<{ id: string; name: string }>({
  name: "insert",
  sql: ({ id, name }) => `INSERT INTO table VALUES (${id}, ${name})`,
});

これらのクエリは次のように実行できます。 DuckDB-wasm の使い方については、公式ドキュメントも参照してください。

import * as duckdb from "@duckdb/duckdb-wasm";
import duckdb_wasm from "@duckdb/duckdb-wasm/dist/duckdb-mvp.wasm";
import duckdb_wasm_next from "@duckdb/duckdb-wasm/dist/duckdb-eh.wasm";
import { installSpatialExtension } from "./sql.ts";

const setupDuckDBForBrowser = async () => {
  const MANUAL_BUNDLES: duckdb.DuckDBBundles = {
    mvp: {
      mainModule: duckdb_wasm,
      mainWorker: new URL(
        "@duckdb/duckdb-wasm/dist/duckdb-browser-mvp.worker.js",
        import.meta.url
      ).toString(),
    },
    eh: {
      mainModule: duckdb_wasm_next,
      mainWorker: new URL(
        "@duckdb/duckdb-wasm/dist/duckdb-browser-eh.worker.js",
        import.meta.url
      ).toString(),
    },
  };
  const bundle = await duckdb.selectBundle(MANUAL_BUNDLES);
  const worker = new Worker(bundle.mainWorker!);
  const logger = new duckdb.ConsoleLogger();
  const db = new duckdb.AsyncDuckDB(logger, worker);
  await db.instantiate(bundle.mainModule, bundle.pthreadWorker);

  const conn = await db.connect();
  const duckDBContext = {
    conn,
    db,
  };
  const installedResult = await installSpatialExtension(duckDBContext);
  if (!installedResult.ok) {
    throw new Error("Failed to install spatial extension", {
      cause: installedResult.errors,
    });
  }
  return duckDBContext;
};

const duckDBContext = await setupDuckDBForBrowser();
// insert
const insertResult = await insert(duckDBContext, { id: "1", name: "name" });
if (!insertResult.ok) {
  console.error("Failed to insert", insertResult.errors);
  return;
}
// select
const selectResult = await selectId(duckDBContext, {
  id: insertResult.data.id,
});
if (!selectResult.ok) {
  console.error("Failed to select", selectResult.errors);
  return;
}
console.log("select", selectResult.data); // => { id: "1", name: "name" }
// select all
const selectAllResult = await selectAll(duckDBContext);
if (!selectAllResult.ok) {
  console.error("Failed to select all", selectAllResult.errors);
  return;
}
console.log("select all", selectAllResult.data); // => [{ id: "1", name: "name" }]

このdefineQuery*関数などのUtilityは200-300行程度の小さなUtilityですが、クエリを定義する側はシンプルにSQLを書くだけで良くなります。 SQLのInputとOutputはTypeScriptで型定義することで、クエリを実行する側は型安全にクエリを実行できます。

また、クエリの実行結果は{ ok: true, data: Output }または{ ok: false, errors: DuckDBSQLError[] }というResult型のような値を返すようになっています。 これは、エラーも値として返したほうが型安全にエラーハンドリングを書きやすいためです。

DuckDB-wasm のテストをNode.jsで動かす

DuckDB-wasm はブラウザで動作するライブラリですが、WebAssemblyなのでNode.jsでも動かすことができます。 DuckDBで行う処理は特にブラウザに依存はしていないので、Node.jsで動くとテストが簡単に動かせるようになります。

Node.js向けの公式のドキュメントがまだ整備されていないので、参考程度になりますが、次のようにNode.jsでもDuckDB-wasmを使うことができます。 次のテストコードでは、"@duckdb/duckdb-wasm/blocking"を使って、Node.jsでBlocking APIのDuckDBインスタンスを動かしています。

web-workerなどのNode.js向けのWeb Worker APIを使うと、ブラウザと同じ非同期APIのDuckDBを使うこともできます。 ただ、余計なライブラリが必要だったり、テスト目的ならBlocking APIでもあまり困らなかったので、"@duckdb/duckdb-wasm/blocking"を使っています。

import { createDuckDB, NODE_RUNTIME } from "@duckdb/duckdb-wasm/blocking";
import { createRequire } from "module";
import { dirname, resolve } from "path";
import * as duckdb from "@duckdb/duckdb-wasm";
import {
  defineQueryExec,
  defineQueryMany,
  defineQueryOne,
  type DuckDBSQLError,
  type DuckDBContext,
  transformQuery,
} from "./defineQuery.ts";
import { describe, it, expect } from "vitest";

const require = createRequire(import.meta.url);
const DUCKDB_DIST = dirname(require.resolve("@duckdb/duckdb-wasm"));

/**
 * create initialized duckDB for Node.js
 * @returns {Promise<void>}
 */
export async function setupDuckDBForNodejs(): Promise<DuckDBContext> {
  const DUCKDB_BUNDLES = {
    mvp: {
      mainModule: resolve(DUCKDB_DIST, "./duckdb-mvp.wasm"),
      mainWorker: resolve(DUCKDB_DIST, "./duckdb-node-mvp.worker.cjs"),
    },
    eh: {
      mainModule: resolve(DUCKDB_DIST, "./duckdb-eh.wasm"),
      mainWorker: resolve(DUCKDB_DIST, "./duckdb-node-eh.worker.cjs"),
    },
  };
  // SyncDBとして作成してしまう
  // .thenや.catchなどを使わなければ、特に違いは意識しなくていい
  // TODO: Syncは公式にサポートされているがAsyncはWebWorkerに依存しているため工夫が必要
  // https://github.com/duckdb/duckdb-wasm/blob/6fcc50318b3a0e6b4e30c78bfdda19b9f86f4012/packages/duckdb-wasm/test/index_node.ts#L56
  const logger = new duckdb.ConsoleLogger();
  const db = await createDuckDB(DUCKDB_BUNDLES, logger, NODE_RUNTIME);
  await db.instantiate();
  const conn = db.connect();
  // @ts-expect-error -- syncのものをasyncとして渡しているため
  const duckDBContext = {
    db,
    conn,
  } as DuckDBContext;
  // spatial extensionをインストール
  const installResult = await installSpatialExtension(duckDBContext);
  if (!installResult.ok) {
    throw new Error("Failed to install spatial extension", {
      cause: installResult.errors,
    });
  }
  return duckDBContext;
}

/**
 * クエリの実行結果が成功しているかAssertionする
 * 失敗してる時のログを出力する
 * @param result
 */
export function assertQueryResultOk(result: {
  ok: boolean;
  errors?: any[];
}): asserts result is { ok: true } {
  if (!result.ok) {
    const error = new Error(
      "Assertion failed: query result is not ok. expected result.ok is true",
    );
    console.error(error, {
      errors: result.errors,
    });
    throw error;
  }
}

const createTestTable = defineQueryExec<{}>({
  name: "createTestTable",
  sql: () => `
    CREATE TABLE test_table (
      id UUID PRIMARY KEY DEFAULT uuid(),
      name TEXT
    )
  `,
});
const insertTestItem = defineQueryOne<
  { name: string },
  {
    id: string;
  }
>({
  name: "insertTestItem",
  sql: ({ name }) => `
    INSERT INTO test_table (name) VALUES ('${name}')
    RETURNING id
  `,
});
const getTestItem = defineQueryOne<
  { id: string },
  { id: string; name: string }
>({
  name: "getTestItem",
  sql: ({ id }) => `
    SELECT id, name FROM test_table WHERE id = '${id}'
  `,
});

describe("DuckDB Utils", () => {
  describe("defineQueryOne", () => {
    it("should return one result", async () => {
      const duckDBContext = await setupDuckDBForNodejs();
      assertQueryResultOk(await createTestTable(duckDBContext));
      const insertResult = await insertTestItem(duckDBContext, {
        name: "test",
      });
      assertQueryResultOk(insertResult);
      const insertedId = insertResult.data.id;
      const result = await getTestItem(duckDBContext, {
        id: insertedId,
      });
      assertQueryResultOk(result);
      expect(result.data).toEqual({ id: insertedId, name: "test" });
    });
  });
});

これで定義したクエリのテストをNode.jsでも動かせるので、Unit Testなども簡単に書けるようになっています。

今後の展望

defineQuery*関数で発行されるSQLは、-- name: ${name} :oneのような形式コメントを入れていることに気づいた人もいるかもしれません。 これはsqlcを意識して作った仕組みであるため、defineQuery*関数もそれぞれsqlcのQuery annotationsに対応した形で作成しています。

sqlcは、SQLを書いてGoのコードやTypeScriptのコードを生成できるツールです。 現状のsqlcはDuckDBには対応していません。将来的には、SQLを書いてそのクエリを実行できるコードを生成するような仕組みに置き換えることも検討しています。

現状のUtilityはInputとOutputの型定義が完全に手動ですが、これらのツールが対応されるとDBのスキーマからTypeScriptで型定義を生成できたりしてより効率的に開発できるようになるかもしれません。

今回紹介した実装では、SQLのエスケープやprepared statementは特に対応を書いていません。 これは、実行するSQLの対象がブラウザ上の一時的な計算のためのデータで、漏れたり変更しても問題ないデータであるためです(あくまでデータはそのブラウザ内の値で、ページ内に閉じています)。 まだDuckDB Wasmのprepared statementの挙動がまだおかしい部分もあるため、できるだけシンプルな仕組みにしたかったのもあります。

おそらく、今後prepared statementの対応や@vercel/postgresのようなTagged Functionを使ったエスケープが必要になるかもしれません。 そのため、この記事のコードを利用する場合は、この点に留意してください。

また、defineQuery*関数とは別にクエリの実行結果を変換できるtransformQuery関数を提供しています。 クエリを実行できる関数の定義と変換処理を分けたのは、将来的にはクエリを実行できる関数は自動生成する可能性があると思ったためです。 クエリの定義と変換処理を分けておくことで、クエリの定義だけを自動生成するような仕組みを作りやすくなります。

そのため、defineQuery*関数の中には、クエリの実行結果をあまり変換する処理は入れないようにしていて、シンプルな実行結果を返すだけの関数にしています。

まとめ

DuckDBのWebAssembly版であるDuckDB-wasmを使って、ブラウザ上で地理空間データ処理をするSQLの管理をする仕組みを作りました。

小さな仕組みですが、SQLはSQLとしてある程度独立したものとして定義できるようになり、型定義も明示的に書く必要があるのでTypeScriptからも扱いやすくなったと思います。 秩序なくアプリケーションのコードのSQLをベタがきしてしまうと、後から変更もできなくなってしまいます。 将来的には、SQLからコード生成をして、もっと安全で楽にDuckDB-wasmを使うような仕組みを作ることも検討しています。

宣伝

2024年11月16日(土曜)に開催されるTSKaigi Kansai 2024で、ブラウザで完結!DuckDB Wasmでタクシー地図情報を可視化というタイトルでスポンサーLTをするので、ぜひ聴きに来てください!

また、スポンサーブースでは、DuckDB-wasmの選定に使ったDesign Docや、今回のSQL管理の仕組みを議論したDesign Docなども展示する予定です

newmoでは地理情報システム(GIS:Geographic Information System)に興味のあるエンジニアを積極的に採用中です!

MonorepoでのTerraform運用を楽にする!tfactionを使ったGitHub Actions Workflowの構築

はじめに

newmoではGoogle Cloud等のリソース管理にTerraformを使っています。また、newmoではMonorepoを使って開発しています。 Monorepoについてここでは詳しく説明しませんが、バックエンドのGoのコードもフロントエンドのTypeScriptのコードもTerraformのコードもすべて同じGitHubのレポジトリで管理し開発を行っています。

TerraformのコードをMonorepoで管理することで、以下の要素を統一的に制御できるようになりました

  • CICDパイプライン
  • TerraformとProviderのバージョン
  • セキュリティポリシー
  • Lintルール
  • クラウドリソースの構成
  • パフォーマンスとコストの最適化

リソースをTerraformのコードで管理する場合に用意するGitHubでのWorkflowは一般的には以下のようなものになると思います。

  1. Terraformのコードを書いてPull Requestを作成する
  2. 自動的にTerraformのPlanが実行される
  3. TerraformのコードとPlanの結果をレビューして承認する
  4. Mainブランチにマージすると自動的にTerraform Applyが実行される

私もこれまで何度かこのようなWorkflowを作成して利用してきたことがあります。単純にPlanとApplyを実行するだけなら難しいことはあまりないのですが、Monorepoで今後広く長く使われるWorkflowということでもう少し要件が出てきました。

  • 複数のTerraform stateに関連するPull Requestは並列に実行したい
  • Terraform のPlan, Apply結果をPull Requestのコメントで通知したい
  • Applyに失敗したときに、リトライしたい
    • 一時的なエラーによりApplyが失敗するケースが多いため、リトライ機能は重要です
  • Terraform stateが進んでいる場合にはApplyをさせない
    • 他のPull Requestが先にマージされた場合にはstateが変わっているので、Plan結果とは違う変更が行われてしまうのを防ぎたい
    • GitHub の Require branches to be up to date before mergingで防ぐことができるが、Monorepoで有効にするとマージ待ちが多く発生してしまうため、有効にしたくない
  • terraform fmt, terraform validateに加えてTFLintやTrivyでコードのチェックをしたい

以上のような要件をすべて自分で実装するのは結構大変なので利用可能なツールを探していたところ、 tfaction を知りました。

tfactionとは

tfaction - GitHub Actions で良い感じの Terraform Workflow を構築 に概要が書かれていますが、自分が上で挙げていた要件を満たすようなGitHub ActionsのWorkflowを簡単に構築できるActionのセットです。

tfactionの主な機能と特徴

ここでは実際に私たちが利用している機能だけ紹介します。

1:高度なPlan/Apply管理

2: PlanとApplyの並列実行

GitHub Actions build matrixを利用したWorkflow

3: セキュリティとコード品質

4: local moduleのサポート

これらの機能をGitHub ActionsのWorkflowを書くだけで利用することができます。 AtlantisやHCP Terraformなどとちゃんと比較したわけではないのですが、tfactionでやりたいことが実現できています。

newmoでの利用

Monorepoにおけるディレクトリ構成

newmoではMonorepoの lib/terraform 以下にTerraform関連の設定、たとえばTerraform Moduleやtfactionの設定、TFLintのルールなどを置いていて、Terraformの実際のコードはいくつかのディレクトリに分かれています。 Terraform stateのサイズを管理可能な範囲に保つため、サービス単位とProvider単位でディレクトリを分割しています。

.
├── lib
│   └── terraform
│       ├── tfaction-root.yaml
│       ├── tflint.hcl
│       ├── modules
│       │   └── # 共通で使用するTerraformモジュール
│       └── templates
│           └── # 共通で使用するTerraformテンプレート
└── server
    ├── a
    │   └── terraform
    │       ├── dev
    │       │   ├── googlecloud
    │       │   └── cloudflare
    │       └── prod
    │           ├── googlecloud
    │           └── cloudflare
    └── b
        └── terraform
            ├── dev
            │   └── googlecloud
            └── prod
                └── googlecloud

tfactionを使ったTerraform Workflowの設定

GitHub ActionsのWorkflowの設定の例を以下に載せておきます。 かなりコードを省略しているのでこのままでは動きませんが、設定の雰囲気は伝わるかと思います。 バージョンも省略していますがこの設定の時点ではtfaction v1.7.0を利用していました。

name: CI Terraform Plan
on:
  pull_request:
    paths:
      - lib/terraform/**
      - server/**/terraform/**

concurrency:
  group: ${{ github.workflow }}--${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true

env:
  AQUA_CONFIG: "${{ github.workspace }}/lib/terraform/aqua.yaml"
  TFACTION_CONFIG: "${{ github.workspace }}/lib/terraform/tfaction-root.yaml"

jobs:
  setup:
    timeout-minutes: 10
    outputs:
      targets: ${{ steps.list-targets.outputs.targets }}

    steps:
      - uses: actions/checkout
      - uses: aquaproj/aqua-installer
      - uses: suzuki-shunsuke/tfaction/list-targets
        id: list-targets

  plan:
    name: "terraform plan (${{ matrix.target.target }})"
    timeout-minutes: 30
    needs: setup

    # skip if targets is empty
    if: join(fromJSON(needs.setup.outputs.targets), '') != ''

    strategy:
      fail-fast: false
      matrix:
        target: ${{ fromJSON(needs.setup.outputs.targets) }}

    env:
      TFACTION_TARGET: ${{ matrix.target.target }}
      TFLINT_CONFIG_FILE: "${{ github.workspace }}/lib/terraform/tflint.hcl"
      TRIVY_SEVERITY: HIGH,CRITICAL

    steps:
      - uses: actions/checkout
        with:
          sparse-checkout: |
            .github/actions
            lib/terraform
            ${{ matrix.target.working_directory }}

      - uses: aquaproj/aqua-installer
      - uses: suzuki-shunsuke/tfaction/get-target-config
      - run: github-comment exec -- github-comment hide

      - uses: suzuki-shunsuke/tfaction/setup

      - uses: suzuki-shunsuke/tfaction/test

      - uses: suzuki-shunsuke/tfaction/plan

      - name: Playbook
        if: failure()
        uses: ./.github/actions/playbook
        with:
          message: |
            # ${{ github.job }} が失敗しました

            ## 影響
            - このままでは Terraform applyができません

            ## 調査方法
            - Jobの落ちてるステップのエラーログを確認してください

            ## 修正方法
            - Jobをretryすることで解決する場合もあります
            - 分からない場合は Platform Teamメンバー に聞いてください

最後のstepにあるPlaybookについては GitHub ActionsのJobが落ちたときに何をするべきかを記述するPlaybookの仕組みを作って運用している話 - newmo 技術ブログ の記事で詳しく紹介しています。

tfactionについてはほとんどデフォルトの設定のまま使っています。

tfaction-root.yamlの設定の一部

# 高速化のためparallelism を50に変える (defaultは10)
env:
  TF_CLI_ARGS_plan: "-parallelism=50"
  TF_CLI_ARGS_apply: "-parallelism=50"

# lintの有効化(デフォルトで有効)
tflint:
  enabled: true
trivy:
  enabled: true

TFLintの設定は、まだ特別なものはないです。

plugin "terraform" {
  enabled = true
  preset  = "recommended"
}

plugin "google" {
    enabled = true
    version = "0.28.0"
    source  = "github.com/terraform-linters/tflint-ruleset-google"
}

rule "terraform_naming_convention" {
  enabled = true
}

今後の改善

newmoのTerraform Workflowにおける今後の改善点として考えているものには以下のようなものがあります。

TerraformやTerraform providerのバージョンを統一する

monorepo内でのパッケージのバージョンを1つだけに統一するOne Version Ruleをpnpm catalogで実装する - newmo 技術ブログ で紹介したように、newmoではできる限りMonorepo内のTerraformやTerraform Providerのversionを統一したいと考えています。Terraformとしてterraform.tfに記述するバージョンを外から指定する機能は自分が知る限りないので、Terragrunt などのツールを試してみるかもしれません。

セルフサービス化

現在はPlatform teamが一元的にコードレビューを行っていますが、開発チームの安全で自律的な運用に向けて以下の取り組みを検討しています

  • Terraform Moduleより高度な抽象化
  • さまざまなセキュリティポリシーの自動チェック
  • TerraformやProviderの自動アップデート

Workflowの高速化

サービスやPlatformの成長とともに、Workflowのstepが増えたりTerraform stateが大きくなったりしてTerraform Workflowにかかる時間も増加していくことが予想されます。 今はまだ問題になることはないですが、Terraform Workflowに要する時間を計測して継続的に改善していきたいと考えています。

OpenTofu?

移行することは考えていないですが、気になっています。

ちなみにtfactionはOpenTofuもTerraguntもサポートしているようなのでその点も安心です。

まとめ

以上のようにnewmoではMonorepoにおいてtfactionを利用してTerraform Workflowを構築しています。 特に以下の点で効果を実感しています:

  • 運用しやすいシンプルなWorkflowの設定
  • Workflowの自動化による運用効率の向上
  • セキュリティとコード品質の確保
  • インフラ変更の安全性向上

GitHub ActionsでのTerraform Workflow構築を検討されている組織にとって、tfactionは優れた選択肢となるでしょう。 以前はいくつかnewmoのレポジトリにフィットしない部分もあったのですが、作者の suzuki-shunsuke さんに相談してv1.6.0に入れてもらった改善によって以前より快適に使えるようになりました。

書いた人: tjun

OpenTelemetry Collectorを使ったCloud Run to Datadogの実装パターン

newmoでは現在アプリケーションサーバーをCloud Runで動かし、Datadogを利用してサービスの監視をすることを考えています。 複数のCloud Runサービスからメトリクス、トレース、そしてログをDatadogへ送信する方法としていくつかのパターンが考えられます。 Datadogへメトリクスやトレース、ログを送る方法としてDatadog Agentを使う方法が一般的ですが将来のための柔軟性や拡張性を考えてOpenTelemetry Collectorを利用することを検討しました。この記事では、検討した構成案を紹介します。

(2025/05/21 に、現在の情報で更新しました)

はじめに

Datadog公式ドキュメントのOpenTelemetryのページでは、OpenTelemetryのテレメトリデータをDatadogへ送信する方法を紹介しています。 大きく分けてDatadog Agentを使う方法と、OpenTelemetry Collectorを使う方法があります。

docs.datadoghq.com

実装パターン

Cloud RunからDatadogへテレメトリデータを送信する方法として、以下のような実装パターンがあります。

1. アプリケーションから直接送信する

Datadog のライブラリを使って、直接送信することができます。 この方式はインフラ観点で管理するリソースが少ないというメリットがあります。しかし、アプリケーションの実装と密結合になってしまう、Envoyなど自分たちが実装していないサービスを動かす際に利用できない、などの課題があります。また、将来的には OpenTelemetry Protocol(OTLP)でも直接送信することができるようになると思いますが、まだ完全にはサポートされていません。

2. 同じコンテナでDatadog Agentを動かす

Datadogが公式にサポートしている方式です。

Datadogのserverless-initがアプリケーションのプロセスをwrapすることでメトリクスやトレース、ログを自動的に収集するものです。 すべてのCloud Runサービスのコンテナに設定が必要となります。

3. サイドカーとして動かす

Cloud Runでサイドカーが使えるようになったので、サイドカーで収集してDatadogへ送信することができます。サイドカーにもいくつかやり方があります。

  1. Datadog Agentをサイドカーとして動かす
  2. OpenTelemetry Collectorをサイドカーとして動かす
  3. Cloud RunのためのDatadog サイドカーを使う(2024年10月7日時点ではPrivate Beta)

アプリケーションコンテナからサイドカーに送り、サイドカーからDatadogに送信するという方式です。アプリケーションと分けてCollectorやAgenetを管理することができます。 各Cloud Runサービスごとにサイドカーの設定をする必要があります。 ログについては、サイドカーとの共有Volumeを作ってそこに書くか、OTLP(HTTP)でサイドカーへ送信することでOpenTelemetry Collectorが受け取ることができます。

最後のDatadog サイドカーは少し前に発表されていましたが、まだ公開されていないので今回の比較には入れていません。参考: Automatically instrument your Google Cloud Run services with the Datadog sidecar

4. Cloud RunサービスとしてOpenTelemetry Collectorを動かす

OpenTelemetry Collectorを独立したCloud Runサービスとして動かすこともできます。 Cloud Runサービスが一つ増えることにはなりますが、各Cloud Runサービスで追加のリソースはなくOpenTelemetry CollectorへOTLPで送信するだけになります。

newmoでは、Cloud Runサービスごとに設定を持たず1箇所に集約してPlatformチームで管理できる点と、OpenTelemetry Collectorの拡張性などの利点を考慮して、上記4のOpenTelemetry CollectorをCloud Runサービスとして動かす方式を採用してみることにしました。

OpenTelemetry Collectorの設定

各Cloud RunサービスからOpenTelemetry CollectorへOTLP(gRPC)でメトリクスやトレースを送信します。 ログについては、サービスがOTLPでログを送信する以前にエラーを出して停止してしまう場合もログを拾いたい、またCloud Runのリクエストログも合わせて取得したいということで、少し工夫して Cloud RunがCloud Loggingに出すログをLog RouterでPub/Subへ送り、OpenTelemetry Collectorの Pub/Sub receiverを使って取得する、という方式にしました。

Logの収集

設定は以下を参考にしました。

実際の設定例

receivers:
  otlp:
    protocols:
      grpc:
  googlecloudpubsub:
    subscription: projects/${env:GOOGLE_CLOUD_PROJECT}/subscriptions/${env:LOGGING_SUBSCRIPTION}
    encoding: googlecloudlogentry_encoding

processors:
  batch:
    # Datadog APM Intake limit is 3.2MB. Let's make sure the batches do not
    # go over that.
    send_batch_max_size: 1000
    send_batch_size: 100
    timeout: 10s
  memory_limiter:
    # drop metrics if memory usage gets too high
    check_interval: 1s
    limit_percentage: 65
    spike_limit_percentage: 20

connectors:
  datadog/connector: # DatadogのAPM Trace metricsのための設定

exporters:
  datadog/exporter:
    api:
      key: ${env:DD_API_KEY}
      site: api.datadoghq.com

extensions:
  googlecloudlogentry_encoding:
    handle_json_payload_as: "json"
    handle_proto_payload_as: "json"

service:
  extensions: [googlecloudlogentry_encoding]
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [datadog/connector, datadog/exporter]
    metrics:
      receivers: [datadog/connector, otlp]
      processors: [batch, memory_limiter]
      exporters: [datadog/exporter]
    logs:
      receivers: [googlecloudpubsub]
      processors: [batch]
      exporters: [datadog/exporter]

こんな感じで設定して、メトリクス、トレース、ログを収集してDatadogへ送信することができていました。 長くなるので省略しますがtrace_idのフィールドがRequestログとApplicationログでズレていたりするのを揃えたり、など細かい調整も入れています。

OpenTelemetry Collectorのビルド

OpenTelemetry Collector は otel/opentelemetry-collector-contrib という全部入りのImageを使うこともできるのですが、必要なコンポーネントだけ入れたものを OpenTelemetry Collector Builder (ocb) を使ってビルドすることが推奨されています。 現在 googlecloudpubsub receiverで encoding: cloud_logging でログを読むとときどきクラッシュする問題があるため、とりあえず修正を手元で当ててビルドすることで回避しています。 この問題はversion v0.126.0では解消しています。

ocbに渡す設定例

dist:
  name: otelcol
  description: Custom OpenTelemetry Collector
  output_path: /app

receivers:
  - gomod: go.opentelemetry.io/collector/receiver/otlpreceiver v0.126.0
  - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/receiver/googlecloudpubsubreceiver v0.126.0
processors:
  - gomod: go.opentelemetry.io/collector/processor/batchprocessor v0.126.0
  - gomod: go.opentelemetry.io/collector/processor/memorylimiterprocessor v0.126.0
exporters:
  - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/exporter/datadogexporter v0.126.0
connectors:
  - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/connector/datadogconnector v0.126.0
extensions:
  - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/extension/encoding/googlecloudlogentryencodingextension v0.126.0

これで必要なものを入れたImageを作成できます。

ハマったところ

ログ収集まわりのデバッグでログのフィールドの確認のためにOpenTelemetry Collectorのデバッグログを数行を出したところ、そのログをOpenTelemetry Collectorが拾って1行のログに対して数行ログが出るループができてログが爆発しました。ログ周りのデバッグ時は、OpenTelemetry Collector自身のログをExcludeする設定をしておいたほうが安全かもしれません。

まとめ

newmoでは、今後のOpenTelemetry Collectorの拡張性への期待から、OpenTelemetry CollectorをCloud Runサービスとして動かしてDatadogへテレメトリデータを送信しています。 まだ使い始めたばかりで送っているデータも多くないためパフォーマンスやコストは問題となっていませんが、今後もっと利用が増えていくと見直しが必要になる部分もあるかもしれません。 OpenTelemetry Collectorを使うのは初めてなので、改善できる部分や他の事例などがありましたら気軽に教えてもらえると助かります。

書いた人: tjun

newmoインターンがgqlparserにプルリクエストを投げた話

こんにちは。8月からnewmoでインターンをしている堀之内(@horinouchi09)と申します。

nemwoではバックエンドエンジニアとして、ビジネスドメインのAPIの開発やプラットフォームエンジニアリングのタスクなど多岐にわたってプロダクト開発に携わっています。

Go言語での開発は未経験からのスタートでしたが、バックエンドエンジニアのitoさんをはじめ多くの方々にサポートしていただき、楽しく開発ができています!

さて、今回は私がnewmoでのインターンを通して人生初のOSS Contributeをした話をします。

newmoの開発スタイル

newmoではクライアントからサーバーのAPIを呼び出すスタイルとしてGraphQLを採用しています(詳しくはこちらをご覧ください)。

GraphQLのディレクティブ

GraphQLに関する詳しい説明は省略しますが、GraphQLでは以下のようなスキーマを定義することでAPIを明示することができます。

# スキーマ定義
type Query {
  passenger: Passenger!
}

type Passenger {
  passengerId: String!
  name: String!
  nickname: String! @deprecated(reason: "old")
}

先ほどのスキーマ定義の例にあった@deprecated はディレクティブと呼ばれるものです。

GraphQLでは、ディレクティブをスキーマ定義のフィールドなどに付与することで情報を追加することができます。

例えば、先ほどの例の@deprecated は、Passengerのnicknameフィールドが非推奨であることを意味するGraphQLの標準仕様で定義されているディレクティブです。

さらに、@deprecatedのような標準で用意されているディレクティブに加えて、newmoでは独自のディレクティブを定義しています。

@validateStringというディレクティブがその一例です。

# 例
input DriverInformationInput {
  driverId: String! @validateString(format: UUID)
}

ここでの@validateString はクラアントから送られてくるdriverIdのバリデーションルールを明示できるディレクティブです。

ディレクティブとして定義することでdriverIdがUUIDの形式であることがクライアントからも明白になり仕様が明確になります。さらにnewmoではgqlgenというGo用のGraphQLコード生成ツールにプラグインを追加することでバリデーションのコードも自動で生成されるようにしています。

コード生成の実装としては、gqlgenが提供しているGenerateCodeというhookを利用してプラグインを書き、バリデーションの実装も含まれたresolverのコードを自動で生成されるようにしています。 

import "github.com/99designs/gqlgen/api"

// gqlgenによるコード生成部分
if err := api.Generate(
    cfg,
    api.AddPlugin(plugin.New()), // pluginの追加
); err != nil {
    return fmt.Errorf("failed to generate files: %w", err)
}
// pluginに実装しているGenerateCodeの例
func (m *Plugin) GenerateCode(data *codegen.Data) error {
    if !data.Config.Resolver.IsDefined() {
        return nil
    }
 
    // 具体的なロジック
    // 内部では"github.com/99designs/gqlgen/codegen/templates"のRenderでGoのファイルを生成しています。
    return m.generateSingleFile(data)
}

認可用のディレクティブの導入

APIにはそれぞれの認可用のトークンが必要です。しかし、現状のスキーマからはどのAPIがどのような認可情報が必要かわかりません。また、認可チェックを手書きで実装する必要があり漏れが発生しうる、などの問題がありました。

そこで認可用のディレクティブ@authorizationを定義することで表現力を高めつつ、そのようなディレクティブを元に認可チェックのコードも自動生成をしようと考えました。

@authorizationの仕様

type Query {
    Driver: Driver! @authorization(userType: hoge)
}

@authorizationは上記の例のようにQueryやMutationのフィールドに付与することで認可情報を表現するようにしたいと考えました。

しかし、このままでは全てのQueryやMutationのフィールドに対して@authorizationを付与していかなければなりません。また、newmoでは各スキーマの大半のAPIが同一の認可情報を必要としていたので、スキーマのデフォルトの認可情報を定義できた方が合理的です。

そこで以下のようにschemaに対して@authorizationを付与することにしました。

extend schema @authorization(userType: hoge)

type Query {
    Driver: Driver!
}

このように定義して、@authorizationのないDriver もuserTypeがhogeであることが必要になるものとして実装を進めます。

gqlgenによるコード生成

ディレクティブの仕様が決まれば、次は@authorizationに沿ったコードを生成するgqlgenのプラグインの実装です。

新たなファイルを生成するには、以下のCodeGeneratorというインターフェイスを満たすプラグインを作り、GenrateCode内でロジックを書くことになります。

type CodeGenerator interface {
    GenerateCode(cfg *codegen.Data) error
}

ここでのcodegen.Dataは以下のような構造体です。

この中でschemaに付与したディレクティブが入ってそうなSchema *ast.Schema は以下のような構造体でした。

type Schema struct {
    Query            *Definition
    Mutation         *Definition
    Subscription     *Definition

    Types      map[string]*Definition
    Directives map[string]*DirectiveDefinition

    PossibleTypes map[string][]*Definition
    Implements    map[string][]*Definition

    Description string

    Comment *CommentGroup
}

しかし、このSchemaという構造体の中にはschemaに付与されたディレクティブが入っていませんでした(この中のDirectives はスキーマ全体で定義されているディレクティブを指していて、schemaに付与されたディレクティブを格納している訳ではありません)。

gqlgenの利用するDataにschema上のディレクティブが入っていないのでは、プラグインとして先ほどの仕様を満たすコード生成を実装することができません。

gqlparserの修正

gqlgenはgqlparserというGraphQLスキーマをパースする別リポジトリのOSSに依存しています。

特に前述したSchema *ast.Schemaには、gqlparserのLoadSchemaという関数を用いて定義されたスキーマが落としこまれています。gqlparserのLoadSchemaの中で使われているvalidatorのLoadSchemaは以下のような関数です。

func LoadSchema(inputs ...*Source) (*Schema, error) {
    sd, err := parser.ParseSchemas(inputs...)
    if err != nil {
        return nil, gqlerror.WrapIfUnwrapped(err)
    }
    return ValidateSchemaDocument(sd)
}

よくよくgqlparserを読み込んでいくと、ParserSchemaではschemaに付与したディレクティブをきちんとParseしていましたが、ValidateSchemaDocument 内で捨てられていることがわかりました。

そこで、以下のようにSchemaDirectivesというフィールドを追加しValidateSchemaDocument 内で付け足すように変更するプルリクエストを投げることにしました。

type Schema struct {
    Query            *Definition
    Mutation         *Definition
    Subscription     *Definition
    SchemaDirectives DirectiveList // このフィールドを追加!

    Types      map[string]*Definition
    Directives map[string]*DirectiveDefinition

    PossibleTypes map[string][]*Definition
    Implements    map[string][]*Definition

    Description string

    Comment *CommentGroup
}

実際のプルリクエストがこちらです。

https://github.com/vektah/gqlparser/pull/318/files

こちらのプルリクエストがマージされ、実際にnewmoのgqlgenはSchemaに付与したディレクティブを元にコード生成ができるようになりました。

終わりに

このような背景で人生初のOSS Contributeに成功することができました!

newmoでは周りのエンジニアの方々が当たり前のようにOSSにプルリクエストを投げているので、自分自身の意識も変わってきている感覚があります。

newmoはビジネスドメインの開発にスピード感がある一方で、生産性を高めるためのツールの整備や将来の負債にならないような設計などにも力を入れており、インターンとして勉強できることが大変多いです。

今後もnewmoのプロダクト開発に少しでも力になり、「移動で地域をカラフルに」を実現できるように邁進します!


newmoではエンジニアを積極的に採用中です!キャリアサイトはこちら↓ careers.newmo.me

YAPC::Hakodate 2024 に参加&学生支援ランチでLTしました! #yapcjapan

YAPC::Hakodate 2024に社員4名が参加&学生向けにLTを実施しました

こんにちは。newmoのソフトウェアエンジニアの @tenntenn です。

2024年10月5日に開催されたYAPC::Hakodate 2024にて、今年はnewmoからエンジニア4名が参加。さらに、学生支援スポンサーとして学生向けにLTをさせていただきました。

このブログでは、LTの登壇資料や補足情報、参加した社員の感想などをシェアします。

 

学生支援スポンサーランチLT

 

参加された学生のみなさんに向けて、tenntennが入社した理由についてお話しました。自分の身の回りの課題を自分で解決したいという思いを語りました。

参加したnewmoエンジニアたち

当日はnewmoからこの4名が参加しました!左から、 @kamipo, genkey6@shugenshugen, @tenntenn, @yaotti

公式バッグに封入したチラシの一面

東京からクリアファイルとステッカーを持ち込み、お話した方に直接お渡ししたりも。

参加した人の感想

@kamipo

今回のYAPC期間中にフォロワーが70人増えました!たくさんお話してくれたひとたちありがとうございます!また次のYAPCでたくさんお話しましょう!

 

genkey6(@shugenshugen)

YAPC初参加勢でしたが、前夜祭から2日間参加させていただき、皆さんの熱量に圧倒されました!当日は主にセッション巡りをしていたのですが、どの発表も濃密でとても勉強になりました。

来年は登壇含めて盛り上げていけるようにチャレンジしたいです!

 

@yaotti

交流させてくださった皆さん、運営スタッフや登壇された方々、ありがとうございました!YAPC::* は10年ぶりの参加でしたが、面白く楽しい時間を過ごすことができました。

newmoとしてもスポンサーに加えて登壇などでコミュニティ貢献していけるように頑張りますー!

 

おわりに

私も今回が初めてのYAPC参加でした。普段参加しているGoコミュニティとはまた違った雰囲気で楽しかったです。

(苫小牧から函館は遠いですが)住んでいる北海道での開催だったので嬉しかったです!はこだて未来大学のキャンパスも素晴らしく、こういう雰囲気で学べる学生の皆さんは羨ましいなと感じました。

次回は地元(宮崎)のある九州(福岡)で計画中ということなので、そちらも楽しみです!

 

newmoではエンジニアを積極的に採用中です!キャリアサイトはこちら↓

careers.newmo.me

 

newmoでは新卒採用も行っています!興味ある方は長期インターン(ソフトウェアエンジニア)にぜひご応募ください。詳しくは↓のページをご覧ください。

careers.newmo.me

 

2024年10月28日(月)に「地域交通の未来を創る 新卒採用説明会」というイベントを開催します。興味のある方はぜひお越しください。

 

地域交通の未来を創る 新卒採用説明会

イベントの詳細確認や参加登録は以下のフォームから行えます。

https://forms.gle/g1jK6BVxi4Ev8pW19

Google Cloud PAMを使った権限昇格の仕組みと、Terraformでloopをネストする方法

PAM(Privileged Access Manager)とは

Google CloudのPrivileged Access Manager(PAM)という機能をご存知でしょうか。

詳しくは 新しい Privileged Access Manager を使用して常時オンの特権からオンデマンド アクセスに移行 | Google Cloud 公式ブログ に書かれています。

簡単な方法で、必要なときにのみ、必要な期間だけ、必要なアクセス権を正確に取得できるようにすることで、最小権限の原則を実現するのに役立ちます。PAM は、常時オンの常設特権から、ジャストインタイム(JIT)、時間制限付き、承認ベースのアクセス昇格を備えたオンデマンドの特権アクセスに移行できるようにすることで、リスクを軽減します。

常に強い権限を持つ運用にしていると、アカウントが乗っ取られた場合のリスクや開発環境だと思って作業したら本番環境だった、みたいなオペレーションミスのリスクがあります。普段は必要最低限の権限にしておいてPAMを使って権限昇格する運用にすると、権限を取得するためには他の人からの承認が必要となり、また時間経過とともに自動的に権限が元に戻るので、リスクを減らすことができます。 PAMを使わずTerraformなどで権限昇格の設定を書いて承認をもらって権限を取得する、という運用もできますが、時間制限で自動的に権限が消えること、また申請して権限昇格するまでのリードタイムを考えるとPAMのような仕組みがあると便利です。

つまり、普段は必要最低限の権限だけ持っておいて必要なときだけPAMを使って権限昇格して一時的に必要な権限を取得するようにすることで、セキュリティと利便性のバランスを取ることができます。

PAM利用の流れ

PAMでは、Entitlementという事前に作成した権限に対して申請を行い、指定した承認者が承認・却下を行います。 newmoではSlackにPAMの申請の通知を流し、Platform teamが承認を行う運用にしています。

PAMの利用

PAMを使えるように設定をする

PAMを利用するためには、事前にEntitlementを作っておく必要があります。 Entitlementでは以下のような項目を指定します。

  • 申請できるアカウント(user, groupなど)
  • 承認できるアカウント(user, groupなど)
  • 申請する権限
  • emailの通知先
  • 権限付与できる最大の時間(3時間など)

申請可能な権限の数だけEntitlementを各Google Cloudのprojectで作成しておく必要があります。そこでPAMのためのTerraform moduleを作って各Projectで設定することにしました。しかし一つ課題がありました。

Terraformのloopをネストする

PAMのためのEntitlementを必要となる権限に対して用意するにあたって、複数の権限をセットにして扱いたいことがありました。 たとえば、 roles/secretmanager.secretVersionAdder の権限だけを付与してもGoogle Cloud コンソールから作業するためには roles/secretmanager.viewer も必要となる、みたいなことがあるため、それぞれの権限を別で申請しなくても良いように権限セットを1つにまとめたEntitlementを作成しておきたいです。 そのためにはTerraformにおいて 権限セットごとにEntitlementを作成する というloopと 権限セットに複数の権限を入れる というloopが必要になるのですが、Terraformでは for_eachはネストできないという課題があります。

そこで今回はmoduleの中でmoduleを利用することで対応することにしました。以下のように書くことができます。

pam-module では、role setの数だけpam-internalを作ります

module "pam-internal" {
  source     = "./pam-internal"
  project_id = var.project_id

  for_each    = merge(local.default_role_sets, var.role_sets)
  name        = each.key
  roles       = each.value
  slack_email = data.google_secret_manager_secret_version_access.pam_slack_email.secret_data
}

pam-internal moduleでは、一つのentitlementにrole set内のroleの数だけblockを作ります

resource "google_privileged_access_manager_entitlement" "entitlements" {
  entitlement_id       = var.name
  max_request_duration = "18000s" # 5 hours
  parent               = "projects/${var.project_id}"

  privileged_access { 
    gcp_iam_access {
      dynamic "role_bindings" {
        for_each = toset(var.roles)  # roleの数だけ作成
        content {
          role = role_bindings.value
        }
      }
      resource      = "//cloudresourcemanager.googleapis.com/projects/${var.project_id}"
      resource_type = "cloudresourcemanager.googleapis.com/Project"
    }
  }

  eligible_users {
    principals = [
      "group:abc@example.com" # 申請できるgroup
    ]
  }

  approval_workflow {
    manual_approvals {
      steps {
        approvers {
          principals = [
            "group:platform@example.com".  # platform teamが承認
          ]
        }
      }
    }
  }
}

実際に利用するところでは以下のような感じで指定できます。

module "pam" {
  source = "..../modules/pam"

  project_id  = "newmo-example"

  role_sets = {
    "bigquery-data-editor" = [
      "roles/bigquery.user",
      "roles/bigquery.dataEditor",
    ]
    "bigquery-data-viewer" = [
      "roles/bigquery.user",
      "roles/bigquery.dataViewer",
    ]
    "secret-manager-secret-version-adder" = [
      "roles/secretmanager.viewer",
      "roles/secretmanager.secretVersionAdder",
    ]
    "storage-object-viewer" = [
      "roles/storage.objectViewer",
    ]
  }  
}

(ここで挙げたコードは多くの部分を省略しているので、そのままでは動きません)

これを各projectで設定することで、Entitlementの準備ができました。 あとはPAMを利用するように必要な権限を付与しておけば、ドキュメントのとおりにGoogle Cloudコンソールから申請や承認が可能です。

使ってみた感想

以前に似たような仕組みを作ったことがあったのですが、Google Cloudの標準の機能として提供されたのはうれしいです。 課題としては、EmailをSlackに飛ばしている場合かなり見にくく、申請なのか承認された通知なのかExpireされた通知なのか、よく見ないと分からない点があります。公式にSlack通知をサポートしてもらえるとうれしいです。

参考

書いた人: tjun

まずはイテレータ(range over func)の仕様を学ぼう - Goのイテレータ深堀りNight

はじめに

こんにちは。newmoでソフトウェアエンジニアをやっている@tenntennです。 本稿では、2024年9月24日(火)にファインディ株式会社主催の「Goのイテレータ深堀りNight」というイベントで登壇してきましたので、その報告と内容について紹介します。

findy.connpass.com

「Goのイテレータ深堀りNight」は、2024年8月にリリースされたGo1.23の機能の1であるrange over func(通称イテレータ)について、6人の登壇者がさまざまな角度で10分のライトニングトーク(LT)を行うイベントです。筆者は、トップバッターということで「まずはイテレータ(range over func)の仕様を学ぼう 」という発表を行いました。

登壇に用いた資料は次のリンクから閲覧ができます。

docs.google.com

イテレータの導入経緯

イテレータの導入経緯

イテレータは単独で導入を検討されていた機能ではありません。Go1.18でジェネリクス(型パラメータ)が導入される際の議論からうまれた機能です。図のように、Go1.18ではジェネリクス(型パラメータ)について言語仕様の追加や宣言済み識別子(anyとcomparable)の追加は行われたものの、標準ライブラリはgoパッケージなどを除いて公開されているAPIはほとんど更新されませんでした。

Goコミュニティにおいて、ジェネリクスを使ったコードの経験がある程度貯まるまでは、標準ライブラリへに追加しないという理由で、slicesパッケージやmapsパッケージはx/expパッケージ以下で実験的に公開されていました。実際にslicesパッケージとmapsパッケージの一部の機能は変更され、constraintsパッケージは公開されず、一部の機能がcmpパッケージとして標準ライブラリに追加されました。

そして、Go1.21でついにslicesパッケージとmapsパッケージは標準ライブラリに追加されました。しかし、maps.Keys関数やmaps.Values関数など、スライスを返す関数は、x/exp/mapsパッケージでは提供されていたものの、標準ライブラリとしては追加されませんでした。これらの関数はスライスで返すのではなく、新たに議論が始まっていたイテレータ型として返すべきだという議論になったからです。

次のリンクから当時の議論を追うことができるでしょう。

github.com

github.com

有志が作成しているGoの歴史をまとめているサイトgolang.design/historyによれば、イテレータに関連する提案はかなり前からされていたようです。

golang.design

議論が活発になったのは、ジェネリクスがGo1.18でリリースされ、次のGitHub Discussionが作成されてからでしょう。

github.com

このDiscussionでは、次のようなインタフェースを満たす型の値がfor range文でNextメソッドを呼び出されていくことでイテレーションができるというものでした。

// Iter supports iterating over a sequence of values of type `E`.
type Iter[E any] interface {
    // Next returns the next value in the iteration if there is one,
    // and reports whether the returned value is valid.
    // Once Next returns ok==false, the iteration is over,
    // and all subsequent calls will return ok==false.
    Next() (elem E, ok bool)
}

この議論は後方互換性の観点からインタフェースでイテレータの仕組みを提供するのは難しいという結論になり、次に示したDiscussionにあるように関数を用いた方法に議論が移っていきました。

github.com

そして議論の末、ついにGo1.23でイテレータ(range over func)がリリースされました。しかし、型パラメータの時と同じように標準ライブラリの変更は最低限になっており、slicesパッケージやmapsパッケージにイテレータを返す関数が少し追加されただけです。

それでもGopher(Goのユーザ)たち念願のmaps.Keys関数が導入されました。なお、maps.Keys関数はfor range文でマップ型の値をイテレーションしてキーを取得した場合と同様にキーの並び順がランダムになるため、次のようにslices.Sorted関数かslices.SortedFunc関数を使用すると良いでしょう。

m := map[string]int{"dog": 1, "cat": 99}
fmt.Println(slices.Sorted(maps.Keys(m))

型パラメータのおさらい

型パラメータのおさらい

このようにイテレータは型パラメータ(ジェネリクス)と密接に関係した機能です。型パラメータを知らずにイテレータを理解することは難しいため、本登壇ではイテレータの説明の前に型パラメータについて、図のように簡単におさらいをしました。

パラメータという言葉は、ここでは「外から変更できるもの」という意味で使用しています。日頃からよく使う関数のパラメータ(引数)と型パラメータを対比させて解説しました。

関数パラメータ(引数)を使用しない場合、関数内で使用する値は関数内で宣言した定数や変数、リテラル表記した値を使用する必要があり、関数の外から変更できません。そこで関数に引数を設けることで外から値を指定できるようになります。また、次のように関数宣言に記述する引数のことを特に仮引数(parameter)と呼び、F(10)のように実際に指定した引数のことを実引数(argument)と呼びます。

func F(n int) {
    fmt.Println(n)
}

関数の引数を値のパラメータだとすると、外部から変更できるものを型にしたものが型パラメータになります。 たとえば、次のような関数において、引数の型を外部から指定できるようにしたい場合を考えます。

func G(v int) {
   fmt.Println(v)
}

次のように記述すると、引数の型を外部から指定できます。この時、Tは型パラメータと呼ばれ、anyは型制約(type constraint)となります。 型制約は、値に対する型のようなもので型パラメータTとして受け入れる型の条件でインタフェースを指定します。 なお、anyはどんな型でも受け付けることを表しています。 比喩的に表現すると、値に対する型や型パラメータに対する型制約は、求人における募集要項のようなものだと考えると良いでしょう。 条件に示したこと以上の働きはできないので、適切な条件を指定する必要がある点が似ています。

func G[T any](v T) {
   fmt.Println(v)
}

また、G[int](10)のように実際に指定した型のことを型引数(type argument)と呼びます。 関数における値の引数の仮引数・実引数は、型パラメータにおける型パラメータと型引数に対応しています。

さらにジェネリクス(型パラメータ)について学びたい場合は、公式チュートリアルや筆者のスライドを参考にすると良いでしょう。

docs.google.com

言語仕様のアップデート

イテレータ(range over func)

図のように、Go1.23ではfor range文のrange expressionに関数が使えるようになりました。どんな関数でも良いわけではなく、引数にyield関数と呼ばれる関数を取るような3種類の関数です。それぞれの違いは引数にとるyield関数の引数の個数になり、0個、1個、2個の3種類になります。yield関数はfor range文のボディに処理を移す役割があるため、これらの数および型はfor range文の左辺の識別子(または式)に一致しています。

range over funcと他のrange over

図のようにGo1.22までは、range expressionで扱える式の型は、配列、配列へのポインタ、スライス、文字列、チャネル(双方向、受信専用)、整数(Go1.22で導入)でした。これらの値に対するイテレーションは、型ごとに決められています。たとえば、スライスや配列であれば先頭から順にアクセスし、マップであればランダムな順番でアクセスします。そして、range over func(イテレータ)によって、任意のデータ構造を任意のアルゴリズムでシーケンシャルにアクセスできるようにするになりました。

重要な3つの仕様

図のようにイテレータには3つの重要な仕様があります。1つめは、for range文が実行される際に、range expressionに指定した関数が1回だけ実行される点です。イテレーションのアルゴリズムは、この関数に移譲されると考えると良いでしょう。2つめは、yield関数を呼ぶとfor range文のボディに処理が移る点です。for range文のボディが1回だけ実行され、yield関数はリターンされます。3つめは、breakなどでfor range文のイテレーションが途中で終了した場合、yield関数はfalseを返す点です。

簡単なイテレータの例を次に示します。Alphabetはアルファベット列を生成するようなイテレータです。 イテレータとは関係ありませんが、rune型は整数なのでfor c := 'A'; c <= 'Z'; c++で、c'A'から'Z'まで変化していきます。

func Alphabet(yield func(rune) bool) {
    for c := 'A'; c <= 'Z'; c++ { // 'A', 'B', 'C', …
        if !yield(c) { return }
    }
}

func usage() {
    // ABC
    for c := range Alphabet {
        fmt.Printf("%c", c)
        if c == 'C' { break }
    }
}

標準ライブラリの変更点

標準ライブラリの変更点

Go1.23では言語仕様の変更の他に、iterパッケージが導入されました。iterパッケージでは、図のようにSeq[V]型とSeq2[K, V]型が提供されています。 この2つの型のおかげで、次のようにイテレータをわかりやすく表記できるようになっています。

func Map[X, Y any](seq iter.Seq[X], f func(X) Y) iter.Seq[Y] {
    return func(yield func(Y) bool) {
        for x := range seq { if !yield(f(x)) { break } }
    }
}

func usage() {
    seq := Map(slices.Values([]int{10, 20}), func(x int) string {
        return fmt.Sprintf("0x%x", x)
    })

    // 0xa
    // 0x14
    for v := range seq { fmt.Println(v) }
}

Map関数は、あるiter.Seq[X]型のイテレータをiter.Seq[Y]型に変換する関数です。各要素は第2引数の関数fによって変換されます。 なお、slices.Values関数は、Go1.23で導入されたスライスの値からなるイテレータを生成する関数です。

注意点

注意点

図のように、yieldが宣言済み識別子(組み込み型)や予約語ではありません。ちなみに宣言済み識別子が増えることはありますが、予約語が増えたことはありません。 予約語は識別子にできない語なので、予約語を追加する場合は後方互換に気を使う必要があるからです。 Go1.21で互換性に関する機能の拡張が行われたため、予約語を追加しても後方互換性がなくなる可能性は低いですが、気軽に増やせるものではないでしょう。

また、yield関数がfalseを返した場合、さらに yield 関数を呼ぶとパニックが発生します。そのため、戻り値を if 文などでハンドリングしておく必要があります。

for range 文のrange expressionに値が nil の関数を指定した場合、パニックが発生します。これは通常の関数呼び出しの場合と同じ挙動になっています。標準ライブラリでは、引数でイテレータを受け取る場合でも nil かどうかのチェックは特別行っていないようです。一方で戻り値でイテレータを返す場合には、 nil を返さないように作られています。

おわりに

本稿では、Go1.23で導入されたイテレータについて、その仕様に紹介しました。標準ライブラリで本格的に活用されるのはGo1.24になるようですが、それまでにぜひ触ってみましょう。

PR: newmoではエンジニアを募集しています! 興味がある方は、次の採用情報をご覧ください。

careers.newmo.me