本文へスキップ
前のページへ戻る

ミライCRMの全体アーキテクチャと技術スタック

GitHubで編集

ミライCRMの全体アーキテクチャと技術スタック

こんにちは、ミライCRM CTOの四方田です。 ミライCRMの開発を始めてから一定の規模になり、コードベースもメンバーも増えてきました。新しく入ったメンバーや、社外のエンジニアの方に向けて、ミライCRMが何をするプロダクトで、どのような構成で動いていて、なぜその技術を選んでいるのかを紹介します!

目次

目次を開く

1. ミライCRMとは

ミライCRM は、「次世代AI LINE CRMツール」コンセプトにしたSaaS型のCRMプラットフォームです。

プロダクトとしてのコアバリューは 「EC × LINE 連携で売上向上を実現する」 こと。ECの購買データとLINE上のリアルタイム行動データをかけ合わせ、ユーザー一人ひとりに最適化されたコミュニケーションを実現することで、「一斉配信から脱却し、LINEを”売れるタッチポイント”に変える」というポジショニングを取っています。

EC事業者をメインのターゲットに、

一つのプロダクトで提供しています。システムとしてはマルチテナント構成(organization)で、テナントごとにLINE公式アカウント・ECストア・ユーザーが完全に分離された状態で動きます。

主な機能

機能はおおまかにメッセージ配信/顧客管理/LINE内コンテンツ/分析/AIによる業務効率化5領域に分かれています。

メッセージ配信

顧客管理

LINE内コンテンツ

分析

AIによる業務効率化

ミライCRMの大きな差別化ポイントが AIによる運用業務の自動化 です。

「専任のアナリストやデザイナーがいなくても、データドリブンなLINE配信運用を回せる」状態を作るのが狙いです。

これらの機能群を、EC運用のナレッジを持った専任チームによる導入後の伴走支援セットで提供している、というのがプロダクト全体としての立ち位置になります。

2. 全体アーキテクチャ

現状のシステム構成を、ざっくり次のように分けて説明します。

全体像をまず1枚にすると、次のような構成になっています。

flowchart LR
    subgraph Clients["Clients"]
        Tenant[テナント運用者]
        Customer[顧客<br/>LINE / Webサイト]
    end

    subgraph External["External Services"]
        LINE[LINE Messaging API]
        Ecforce[ecforce]
        Clerk[Clerk]
        Shopify[Shopify]
    end

    subgraph Edge["Cloudflare Edge"]
        CFW[Tracking Edge Workers]
        CFQueue[(Cloudflare Queues)]
    end

    subgraph GCP["Google Cloud"]
        Web[Web Frontend]
        LIFF[LIFF Web App]
        ShopifyApp[Shopify App]
        API[Main API Server]
        Broker[Webhook Broker]
        Worker[Temporal Worker]
        Agent[Analytics Agent]
        PubSub[(Cloud Pub/Sub)]
        Temporal[(Temporal Cloud)]
        PG[(PostgreSQL)]
        CH[(ClickHouse)]
        Redis[(Redis)]
    end

    Tenant --> Web
    Customer --> LIFF
    Customer --> CFW
    Tenant --> ShopifyApp
    Tenant --> Agent

    Web --> API
    LIFF --> API
    ShopifyApp --> API

    CFW --> CFQueue
    CFQueue --> CH
    LINE -- Webhook --> Broker
    Ecforce -- Webhook --> Broker
    Clerk -- Webhook --> Broker
    Broker -- publish --> PubSub
    Shopify -- ネイティブPub/Sub配送 --> PubSub

    PubSub --> API

    API --> PG
    API --> CH
    API --> Redis
    API --> Temporal
    Agent --> CH

    Temporal --> Worker
    Worker --> PG
    Worker --> CH
    Worker --> LINE

2.1 サービス構成

ミライCRMは単一のモノリシックなサーバーではなく、用途の異なる複数のサービスから構成されています。主なものは次の通りです。

サービス役割
Main API ServerメインのAPIサーバー。Connect RPCでフロントエンドからのリクエストを受ける
Webhook BrokerLINE / Clerk / ecforce などからのWebhookを受け、Pub/Subにルーティングする
Temporal Workerキャンペーン配信・データ同期などの長時間ワークフローを実行するワーカー
Web Frontendテナント向けの管理画面(Reactアプリ)
LIFF Web AppLINE上で動く顧客向け画面(LIFFアプリ)
Shopify AppShopify組み込みアプリ/Flowアクション拡張
Analytics AgentLINE × ECデータを横断で分析する、AI分析エージェント
Tracking Edge WorkersCloudflare Workers上で動くトラッキング系エッジ関数群

2.2 マルチテナンシー

ミライCRMのマルチテナンシーは 共有モデル(Pool / Shared モデル)構築しています。テナントごとに別DBや別スキーマを切るのではなく、1つのデータベース・1つのスキーマを全テナントで共有し、各テーブルの org_id カラムでテナントを区別する式です。

このモデルを選んだ理由は次の通りです。

代わりに、共有モデルは テナントの取り違え事故が即セキュリティインシデントになるいうリスクを背負います。だからこそ、後述するRLSのような「アプリ層が間違ってもDB側で止める」防御を設計の中心に据えています。

具体的には、すべてのテナント固有テーブルに org_id カラムを持たせ、Row Level Security (RLS)テナント分離の最後の砦として有効にしています。リクエストごとに SET LOCAL app.org_id = ...セッション変数へ現在のテナントIDを設定し、各テーブルに張ったRLSポリシー (tenant_isolation_policy) で「自テナントの行しか見えない/書けない」状態を強制する形です。

これにより、 アプリケーションコードでうっかり WHERE org_id = ?書き忘れても、別テナントの行が漏れることはないいう、アプリケーション層の規律とは独立した防御層を持てています。 org_id入れ忘れたクエリは、そもそもPostgreSQLが拒否する」 状態にしておくことが、マルチテナントSaaSの安心の基盤になっています。

RLSをテストで強制する

ただし、「RLSポリシーを張る」のを忘れてしまっては元も子もありません。

そこで、ミライCRMでは public スキーマの全テーブルが、org_id カラム・RLS有効・tenant_isolation_policy3点を満たしていること」をテストとして強制 しています。マイグレーションを足したPRでこのテストが落ちれば、CIの段階で気づけるようになっています。

具体的には、PostgreSQLのカタログ (pg_tables / pg_policies / information_schema.columns) を直接覗きにいって、以下のような検査を行うGoテストを書いています。

func TestRLSEnabledOnAllRequiredTables(t *testing.T) {
	t.Parallel()

	// Arrange
	db := NewTestDB(t)
	ctx := context.Background()

	// Act - Query to get all tables with their RLS status and org_id column existence
	query := `
		WITH table_info AS (
			SELECT
				t.tablename,
				t.rowsecurity AS rls_enabled,
				EXISTS (
					SELECT 1
					FROM information_schema.columns c
					WHERE c.table_schema = 'public'
					AND c.table_name = t.tablename
					AND c.column_name = 'org_id'
				) AS has_org_id,
				EXISTS (
					SELECT 1
					FROM pg_policies p
					WHERE p.schemaname = 'public'
					AND p.tablename = t.tablename
					AND p.policyname = 'tenant_isolation_policy'
				) AS has_tenant_isolation_policy
			FROM pg_tables t
			WHERE t.schemaname = 'public'
			ORDER BY t.tablename
		)
		SELECT * FROM table_info;
	`

	rows, err := db.QueryContext(ctx, query)
	require.NoError(t, err)
	defer rows.Close()

	var failedTables []string
	var tablesChecked int

	// Assert
	for rows.Next() {
		var tableName string
		var rlsEnabled bool
		var hasOrgID bool
		var hasTenantIsolationPolicy bool

		err := rows.Scan(&tableName, &rlsEnabled, &hasOrgID, &hasTenantIsolationPolicy)
		require.NoError(t, err)

		// Skip excluded tables (e.g. organizations table itself)
		if rlsExcludedTables[tableName] {
			continue
		}

		tablesChecked++

		if !hasOrgID {
			failedTables = append(failedTables,
				"Table '"+tableName+"' is missing 'org_id' column")
		}
		if !rlsEnabled {
			failedTables = append(failedTables,
				"Table '"+tableName+"' does not have RLS enabled")
		}
		if !hasTenantIsolationPolicy {
			failedTables = append(failedTables,
				"Table '"+tableName+"' does not have 'tenant_isolation_policy'")
		}
	}

	require.NoError(t, rows.Err())

	if len(failedTables) > 0 {
		t.Errorf("RLS verification failed for the following tables:\n")
		for _, failure := range failedTables {
			t.Errorf("  - %s\n", failure)
		}
	}

	assert.Greater(t, tablesChecked, 0, "No tables were checked")
}

ポイントは次の3つです。

  1. 対象を「特定のテーブル」ではなく「全テーブル」にしている: 新しいテーブルを足したとき、テスト側で何も書き換えなくても自動的に検査対象になる。RLSを張り忘れた瞬間にテストが落ちる
  2. 検査をPostgreSQL自身に問い合わせている: スキーマ定義ファイルをパースするのではなく、pg_tables / pg_policiesいう実態に問い合わせるので、「マイグレーションは書いたがDDLが流れていない」のようなずれも検知できる
  3. 意図的に除外したいテーブルだけ rlsExcludedTables明示する: テナント横断のメタテーブルorganizations 自身など、ごく少数)はallowlistに入れる。「除外する判断は明示的に書かないと通らない」 のが大事で、暗黙的に抜けることを許さない

さらに、ポリシーの内容そのものがズレていないかを別テストで検証しています。

func TestRLSPolicyDefinition(t *testing.T) {
	t.Parallel()

	// Arrange
	db := NewTestDB(t)
	ctx := context.Background()

	// Act
	query := `
		SELECT tablename, policyname, permissive, roles, cmd, qual
		FROM pg_policies
		WHERE schemaname = 'public'
		AND policyname = 'tenant_isolation_policy'
		ORDER BY tablename;
	`

	rows, err := db.QueryContext(ctx, query)
	require.NoError(t, err)
	defer rows.Close()

	expectedQual := "(org_id = current_setting('app.org_id'::text))"
	policiesChecked := 0

	// Assert
	for rows.Next() {
		var tableName, policyName, permissive, cmd string
		var roles []byte
		var qual *string

		err := rows.Scan(&tableName, &policyName, &permissive, &roles, &cmd, &qual)
		require.NoError(t, err)

		if rlsExcludedTables[tableName] {
			continue
		}
		policiesChecked++

		assert.Equal(t, "tenant_isolation_policy", policyName, "Policy name mismatch for table %s", tableName)
		assert.Equal(t, "PERMISSIVE", permissive, "Policy should be PERMISSIVE for table %s", tableName)
		assert.Equal(t, "ALL", cmd, "Policy command should be ALL for table %s", tableName)
		if qual != nil {
			assert.Equal(t, expectedQual, *qual, "Policy qualification mismatch for table %s", tableName)
		}
	}

	require.NoError(t, rows.Err())
	assert.Greater(t, policiesChecked, 0, "No policies were checked")
}

こちらは「ポリシーの中身が (org_id = current_setting('app.org_id'::text))・PERMISSIVEで・ALL(SELECT/INSERT/UPDATE/DELETE全て)に効いていること」を全テーブル横断で検査します。万が一誰かが個別テーブルだけポリシーを間違えて作成してしまった、みたいな事故も拾えます。

「RLSを張る」というルールを人間の規律に頼らず、スキーマ自身を機械的にテストすることで、テナント分離のSLOを長期で維持できる仕組みにしています。

2.3 Webhook Broker を分離している理由

ミライCRMでは、LINE / ecforce / Clerk といった外部サービスからのWebhookを、Main API Server で直接受けるのではなく、Webhook Broker という独立した薄いサービスで一度受けてからPub/Subに流す 構成を取っています。役割としては「Webhookを受け取り、署名検証してPub/Subにpublishするだけ」のごく薄いサービスです。

お、ShopifyはWebhookの配送先として Google Cloud Pub/Sub をネイティブにサポートしているため、Brokerを経由せず直接 Pub/Sub にpublishしています。「Brokerを噛ませる理由(受信SLAの切り離し・スパイクの吸収)」を、Shopify側のマネージドな配送機構がそのまま肩代わりしてくれる形です。

flowchart LR
    LINE[LINE Messaging API]
    Ecforce[ecforce]
    Clerk[Clerk]
    Shopify[Shopify]

    Broker[Webhook Broker<br/>署名検証 / ルーティング]
    PubSub[(Cloud Pub/Sub)]
    API[Main API Server]
    Worker[Temporal Worker]

    LINE -- Webhook --> Broker
    Ecforce -- Webhook --> Broker
    Clerk -- Webhook --> Broker
    Shopify -- ネイティブPub/Sub配送 --> PubSub

    Broker -- publish --> PubSub
    PubSub --> API
    PubSub --> Worker

あえてMain APIから切り出している理由は次の通りです。

要するに、Webhook Broker は「外部からのイベントを絶対に落とさない」という単一責務を引き受けて、Main APIをドメインロジックに集中させる ためのレイヤーです。実体としては薄いサービスですが、外部連携が増えるほどこの分離の効果が効いてきます。

3. 技術スタックの選定理由

まず、現在の主要な技術スタックを一覧にまとめます。

用途Technology / Service
API インターフェースConnect, Protobuf
フロントエンドTypeScript, React, TanStack Router, TanStack Query, connect-web, shadcn/ui, Tailwind CSS, Vitest, happy-dom, GCS
E2E テストPlaywright
API サーバーGo, connect-go, sqlc, Atlas, Cloud Run
ワークフローエンジンTemporal Cloud
データベースCloud SQL for PostgreSQL / ClickHouse
WAFCloud Armor
認証Clerk
DB踏み台サーバーGCE
IaCTerraform
ソースコード管理GitHub
CI / CDGitHub Actions, Blacksmith
ロギングGrafana Cloud
モニタリング / エラートラッキングGrafana Cloud, Sentry, Slack
分析基盤ClickHouse, dbt, lightdash
Feature Flag / プロダクトアナリティクスStatsig

ここからは、特に判断のあった主要レイヤーについて「なぜそれを選んだのか」の話をしていきます。

3.1 バックエンド: Go

Go

メインAPI・Webhook Broker・Temporal Worker といった主要なサーバーサイドはGoに統一しています。正直なところ私が一番経験のある得意な言語であったことが主な採用理由ですが、以下の点からもフィットしているなと感じています。

また開発当初の想定にはなかったですが、黒魔術が少なく可読性が高いので、AIコーディングでもその恩恵を感じています。

3.2 バックエンド: TypeScript(Cloudflare Workers / Analytics Agent)

Cloudflare Workers

メインのサーバーサイドはGoで揃えていますが、性質が大きく異なる2つのワークロードについては TypeScript書いています。

それぞれGoではなくTypeScriptを採用している理由は次の通りです。

Tracking Edge Workers

Analytics Agent

「Goに統一する」ことでアーキテクチャ全体を揃える価値より、ランタイム・エコシステムが寄っている領域は素直にそのエコシステムの言語で書く 方が、現状のチーム規模では結果として効率が良いと判断しています。

3.3 フロントエンド: React + TanStack Router

React TanStack

フロントエンドの選定は、ミライCRMの管理画面が 「ログイン後のテナント向け管理画面」いう性格を強く持つ前提で決めています。SEOや初期表示HTMLが要件にならないため、SSRなしのSPAで十分という判断です。

3.4 API: Connect RPC(gRPC互換)+ Protocol Buffers

Connect RPC

APIインターフェースには Connect RPC採用しています。connect-go でバックエンドを実装し、connect-es で生成したクライアントを Web / LIFF / Shopify アプリから利用しています。

Connect を選んだ理由:

「OpenAPIとgRPCの中間で、現実的に最も摩擦が少ないものを選ぶ」と考えたとき、Connectが最もハマりました。

3.5 データストア: PostgreSQL + ClickHouse + Redis

データの種類によってストアを使い分けています。

PostgreSQL(メインデータ)

PostgreSQL

ClickHouse(イベント・顧客プロフィール・セグメント配信のデータソース)

ClickHouse

ミライCRMにおけるClickHouseは、「裏側で運用者が分析に使うDB」ではなく、テナントの顧客向け機能そのものを駆動するオペレーショナルなデータソースして位置付けているのが特徴です。

具体的には、ClickHouseに次のようなデータを置いています。

そして、これらのデータは内部分析だけでなく、テナントが画面から実行するセグメント配信のクエリ先してリアルタイムに引かれます。「過去30日にカート追加したが未購入」「特定LPの閲覧履歴あり×特定商品の購入回数3回以上」のようなEC × LINEを横断した条件で対象顧客を絞り込み、配信ジョブの宛先リストとして即座に返す——というオペレーショナルな経路にClickHouseが直接乗っているのが、ミライCRMにおけるClickHouseの一番の特徴です。

このアーキテクチャを選んだ理由は次の通りです。

テナントが画面操作のたびに数千万〜数億行のイベント/プロフィールをリアルタイムに絞り込む——という要件には、書き込みと集計の両方を同時にこなせる リアルタイム分析DB不可欠です。ミライCRMでは、その役割を担うストアとしてClickHouseをテナントの顧客向け機能の中核に据えています。

Incremental Materialized View で event log から「最新状態」を導出する

このオペレーショナル用途を成り立たせている最大の武器が、ClickHouseの Incremental Materialized View です。

ClickHouseのMaterialized Viewは、RDBMSの「定期的にREFRESHして更新するスナップショット」とは性質がまったく違います。ClickHouse公式ドキュメントの表現を借りれば、Materialized View は “a trigger that runs a query on blocks of data as they’re inserted into a table” — ソーステーブルへINSERTされるブロック単位起動し、そのブロックに対してだけクエリを走らせて結果テーブルに流し込んでいく インクリメンタルな差分集計パイプライン です。ReplacingMergeTree AggregatingMergeTree などのMergeTree系エンジンと組み合わせると、書き込みが続くなかでも「最新値」「集計値」が常に最新の状態へ畳み込まれ続けます。

具体例: 「顧客属性ログ」から「最新顧客属性」を導出する

ミライCRMでは、顧客の属性(例: 「会員ランク」「最終購入日」「累計購入金額」など)を、追記専用のイベントログと、最新値だけを持つテーブルの二段構成管理しています。

この2つを接続するのが「顧客属性集約MV」と呼んでいる Materialized View です。

CREATE MATERIALIZED VIEW customer_traits_mv TO customer_traits AS
SELECT
    org_id,
    customer_id,
    source,
    key,
    argMax(value, timestamp)      AS value,
    argMax(value_type, timestamp) AS value_type,
    argMax(timestamp, timestamp)  AS last_timestamp
FROM customer_trait_logs
GROUP BY org_id, customer_id, source, key;

客属性ログにINSERTブロックが入るたびに、そのブロックの中身に対してだけ argMax 集計が走り、結果が最新顧客属性テーブルに流し込まれます。MV内部の FROM 句も「テーブル全体」ではなく「今入ってきたブロック」に対して評価されるので、毎回フルテーブルを scan することはありません。最新顧客属性テーブルは ReplacingMergeTree(last_timestamp)して宣言しており、last_timestampバージョンカラムに使うことで、ブロックをまたいだ重複もバックグラウンドマージで最新のタイムスタンプのものに収束していきます。マージは非同期なので、読み出し時には未マージの重複が見えうる点を考慮し、参照側は FINALしくは argMax最新値を取り出すルールにしています。

flowchart LR
    Source[(LINE / Webトラッキング / ECイベント)]
    Logs[("顧客属性ログ<br/>MergeTree / append-only log")]
    MV{{顧客属性集約MV<br/>argMax by timestamp}}
    Latest[("最新顧客属性<br/>ReplacingMergeTree<br/>最新値のみ")]
    Segment[セグメント配信の絞り込みクエリ]

    Source -- 属性書き込みイベント --> Logs
    Logs -. INSERTブロックごとにtrigger .-> MV
    MV -- 集計結果を流し込む --> Latest
    Latest -- 最新値を読む --> Segment

結果として、

いう状態が、夜間バッチや別パイプラインを動かすことなく INSERTそのものによって維持され続ける わけです。セグメント配信で「会員ランクが Gold の顧客」を絞り込みたいときは、最新顧客属性テーブルを「ランク属性が Gold」という条件で引くだけで済み、生のイベントログを毎回 scan して argMax走らせる必要はありません。

同じパターンを別の用途にも展開している

「追記専用ログ → 最新状態テーブル」のパターンは、ミライCRMの様々な機能で使い回されています。

ポイントは次の2つです。

  1. 「バッチ集計ジョブ」を運用しなくていい: AirflowやCloud Schedulerで動く「夜間集計ジョブ」を一切持たず、INSERTそのものが派生テーブルを更新する。失敗・遅延・再実行といった夜間バッチ特有の運用コストが消える
  2. 「集計」と「紐付け (JOIN)」の両方をINSERT契機で宣言的に書ける: 「最新値への畳み込み(最新顧客属性 / 最新セグメント所属)」も「identity resolution 付きの転送(Webトラッキング → 顧客イベント)」も、同じMVの仕組みで表現できる。新しい派生テーブル・新しい結合先が必要になっても、別パイプラインや夜間ジョブを増やさずに済む

「イベントは1回入れるだけで、セグメント配信にも分析にも、必要な切り口の集計結果が常に最新で手元にある」という状態を作れること——これがミライCRMでClickHouseを選んだ最大の理由です。

私たちがCRMの中心でもあるイベントをClickHouseを活用してどのように扱っているかについてはまた別途詳細記事でも話せればと思っています!

Redis(キャッシュ/セッション)

Redis

3.6 SQL → Go の橋渡し: sqlc

sqlc

PostgreSQL とのアプリケーション層のやり取りには sqlc採用しています。SQL クエリを書いておくと、コード生成コマンドで型付きのGoコードに変換される形です。

選定の理由は次の通りです。

「ORMの黒魔術とdatabase/sql型安全性のなさ」のちょうど中間を取れていて、長期保守を前提にしたバックエンドの選択として現状もっとも納得感があります。

3.7 非同期処理: Cloud Pub/Sub と Temporal の使い分け

Google Cloud Pub/Sub Temporal

非同期処理は Pub/Sub と Temporal二段構えです。これは意図的に使い分けています。

flowchart LR
    subgraph Inputs["入力"]
        Webhook[LINE / Shopify / ecforce / Clerk<br/>Webhook]
        Track[Tracking events]
        UI[配信開始ボタン<br/>同期API初期化]
    end

    subgraph Light["軽量・ステートレス → Pub/Sub"]
        PubSub[(Cloud Pub/Sub)]
        Subs["Subscribers<br/>(at-least-once / idempotent)"]
        CH[(ClickHouse)]
        PG1[(PostgreSQL)]
    end

    subgraph Heavy["長時間・多段階・要リトライ → Temporal"]
        Temporal[(Temporal Cloud)]
        WF["Workflow<br/>キャンペーン配信 / 大量同期"]
        Acts["Activities<br/>(LINE送信 / Shopify fetch)"]
        PG2[(PostgreSQL)]
        LINEAPI[LINE Messaging API]
    end

    Webhook --> PubSub
    Track --> PubSub
    PubSub --> Subs
    Subs --> CH
    Subs --> PG1

    UI --> Temporal
    Temporal --> WF
    WF --> Acts
    Acts --> LINEAPI
    Acts --> PG2

Cloud Pub/Sub を使うケース:

Temporal を使うケース:

LINEへの一斉配信は、対象顧客が数万件あり、レート制限を考慮しつつ送信し、途中でAPIエラーがあれば該当の顧客だけリトライする、という処理が要求されます。これを「Pub/Sub + 自前で進捗テーブル」で組むと、プログレスの正しさを担保するのが想像以上に大変で、結局自前のワークフローエンジンを書くことになります。Temporalはこの問題をワークフローのコードがそのまま耐久性のある実行履歴になる形で解決してくれるので、運用上もっとも信頼性が高いという判断です。

3.8 認証: Clerk

Clerk

認証・組織管理には Clerk使っています。

「認証は買って、ドメインに集中する」という典型的な選択です。

3.9 エッジ: Cloudflare Workers

Cloudflare Workers

Webトラッキングやリンククリックのトラッキングには Cloudflare Workers を使用しています。

「重い処理はGCPのCloud Run、薄くて広いトラッキングはCloudflare Workers」と役割を分けています。

3.10 インフラ: Google Cloud / Cloud Run

Google Cloud

インフラは GCP に統一しています。

「マネージドに寄せられるところは全部寄せて、ドメインのコードを書く時間を最大化する」のが基本方針です。

4. これから

ここまでが2026年5月時点のミライCRMの全体像です。今後は、

いった向で、引き続きアーキテクチャを進化させていく予定です。

技術選定のひとつひとつには「なぜこれを選んだか」の理由があり、その背景を共有することで、新しく入ってくるメンバーが既存のコードに納得感を持って手を入れられるようになると考えています。気になる箇所や深掘りしたいテーマがあれば、続編として個別の技術トピックの記事も書いていきます。


GitHubで編集
この記事を共有する