🗺️

Google Maps Routes API移行ガイド【Directions API完全対応】

に公開

はじめに

2025年3月、GoogleはDirections API / Distance Matrix APIをレガシー指定しました。今後はRoutes API (computeRoutes, computeRouteMatrix) の使用が必須となります。

この記事では、実際のプロジェクトでRoutes APIへの移行を実施した際の実装例と、移行時に直面した課題と解決策を詳しく解説します。

想定読者

  • Directions API / Distance Matrix APIを使用している方
  • Routes APIへの移行を検討している方
  • Google Maps APIを使ったルート最適化機能を実装したい方

この記事で学べること

  • ✅ Directions API → Routes API (computeRoutes) の移行方法
  • ✅ Distance Matrix API → Routes API (computeRouteMatrix) の移行方法(ストリーミング対応)
  • ✅ Routes API共通クライアントの設計パターン
  • ✅ フィールドマスク・エラーハンドリングの実装方法

前提条件

この記事の内容を実践するには、以下の環境が必要です:

  • Node.js 18.x 以上
  • Google Maps API キー(Routes API有効化済み)
  • TypeScript 5.x 以上
# 環境確認
node -v
# v18.x 以上

なぜRoutes APIに移行する必要があるのか

レガシー指定の背景

Googleは2025年3月に以下のAPIをレガシー指定しました:

  • Directions API (/maps/api/directions/json)
  • Distance Matrix API (/maps/api/distancematrix/json)

今後はRoutes APIの使用が推奨されます:

  • computeRoutes (/routes.googleapis.com/directions/v2:computeRoutes)
  • computeRouteMatrix (/routes.googleapis.com/distanceMatrix/v2:computeRouteMatrix)

主な変更点

項目 旧API Routes API
HTTPメソッド GET POST
認証 Query param ?key=API_KEY Header X-Goog-Api-Key
フィールドマスク なし(全フィールド返却) 必須X-Goog-FieldMask
レスポンス形式 JSON(同期) JSON(同期)/ ストリーミング
座標形式 "37.419734,-122.0827784" {latLng: {latitude: 37.419734, longitude: -122.0827784}}

Routes API 共通クライアントの実装

まず、computeRoutescomputeRouteMatrix の共通処理を提供するクライアントクラスを実装します。

RoutesClient クラス

lib/google-maps/routes-client.ts
/**
 * Routes API 共通クライアント
 * computeRoutes と computeRouteMatrix の共通処理を提供
 */
export interface RoutesClientConfig {
  apiKey: string;
  baseUrl?: string;
}

export class RoutesClient {
  private apiKey: string;
  private baseUrl: string;

  constructor(config: RoutesClientConfig) {
    this.apiKey = config.apiKey;
    this.baseUrl = config.baseUrl || 'https://routes.googleapis.com';
  }

  /**
   * 共通ヘッダーを生成
   * X-Goog-Api-Key と X-Goog-FieldMask は必須
   */
  private getHeaders(fieldMask: string): Headers {
    return new Headers({
      'Content-Type': 'application/json',
      'X-Goog-Api-Key': this.apiKey,      // ← Query paramではなくHeader
      'X-Goog-FieldMask': fieldMask,      // ← 必須!
    });
  }

  /**
   * computeRoutes エンドポイント呼び出し
   */
  async computeRoutes(
    request: ComputeRoutesRequest,
    fieldMask?: string
  ): Promise<ComputeRoutesResponse> {
    // デフォルトのフィールドマスク
    const defaultFieldMask =
      'routes.duration,routes.distanceMeters,routes.polyline.encodedPolyline,routes.legs,routes.optimizedIntermediateWaypointIndex';
    const mask = fieldMask || defaultFieldMask;

    try {
      const response = await fetch(`${this.baseUrl}/directions/v2:computeRoutes`, {
        method: 'POST',  // ← GETではなくPOST
        headers: this.getHeaders(mask),
        body: JSON.stringify(request),
      });

      if (!response.ok) {
        const errorData = await response.json();
        throw new Error(
          `Routes API error (${errorData.error.code}): ${errorData.error.message}`
        );
      }

      const data: ComputeRoutesResponse = await response.json();

      if (!data.routes || data.routes.length === 0) {
        throw new Error('指定された地点間のルートが見つかりませんでした');
      }

      return data;
    } catch (error: any) {
      throw new Error(`ルート計算に失敗しました: ${error.message}`);
    }
  }

  /**
   * Waypoint を LatLng 座標から生成
   */
  static createWaypointFromLatLng(lat: number, lng: number) {
    return {
      location: {
        latLng: {
          latitude: lat,
          longitude: lng,
        },
      },
    };
  }

  /**
   * Duration 文字列を秒数に変換
   * @param duration "123s" 形式の文字列
   */
  static parseDuration(duration: string): number {
    return parseInt(duration.replace('s', ''), 10);
  }
}

型定義

types/routes-api.ts
/**
 * Routes API: computeRoutes Request
 */
export interface ComputeRoutesRequest {
  origin: Waypoint;
  destination: Waypoint;
  intermediates?: Waypoint[];
  travelMode?: 'DRIVE' | 'WALK' | 'BICYCLE' | 'TRANSIT';
  routingPreference?: 'TRAFFIC_UNAWARE' | 'TRAFFIC_AWARE' | 'TRAFFIC_AWARE_OPTIMAL';
  polylineQuality?: 'HIGH_QUALITY' | 'OVERVIEW';
  polylineEncoding?: 'ENCODED_POLYLINE' | 'GEO_JSON_LINESTRING';
  departureTime?: string; // ISO 8601
  computeAlternativeRoutes?: boolean;
  languageCode?: string;
  units?: 'IMPERIAL' | 'METRIC';
  optimizeWaypointOrder?: boolean; // ← 簡易最適化用
}

export interface Waypoint {
  location?: {
    latLng?: {
      latitude: number;
      longitude: number;
    };
  };
  placeId?: string;
  address?: string;
}

export interface ComputeRoutesResponse {
  routes: Route[];
}

export interface Route {
  legs: RouteLeg[];
  distanceMeters: number;
  duration: string; // "123s"
  polyline: {
    encodedPolyline: string;
  };
  optimizedIntermediateWaypointIndex?: number[]; // ← 最適化後の順序
}

export interface RouteLeg {
  distanceMeters: number;
  duration: string; // "123s"
  startLocation: {
    latLng?: {
      latitude: number;
      longitude: number;
    };
  };
  endLocation: {
    latLng?: {
      latitude: number;
      longitude: number;
    };
  };
}

ルート最適化の実装(computeRoutes)

Directions APIの optimize:true に相当する機能を Routes API で実装します。

optimizeRoute 関数

lib/google-maps/directions-optimize.ts
/**
 * ルート最適化(Routes API computeRoutes使用)
 *
 * @param waypoints 地点の座標配列(2地点以上)
 * @param travelMode 移動手段
 * @returns 最適化結果
 */
export async function optimizeRoute(
  waypoints: Array<{ lat: number; lng: number }>,
  travelMode: 'DRIVING' | 'WALKING' | 'BICYCLING' = 'DRIVING'
): Promise<OptimizationResult> {
  if (waypoints.length < 2) {
    throw new Error('Waypoints must have at least 2 points');
  }

  // Routes API クライアント初期化
  const client = new RoutesClient({
    apiKey: process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || ''
  });

  // travelMode を Routes API 形式に変換
  // DRIVING → DRIVE, WALKING → WALK, BICYCLING → BICYCLE
  const travelModeMap: Record<string, 'DRIVE' | 'WALK' | 'BICYCLE'> = {
    DRIVING: 'DRIVE',
    WALKING: 'WALK',
    BICYCLING: 'BICYCLE',
  };

  // リクエスト構築
  const request: ComputeRoutesRequest = {
    origin: RoutesClient.createWaypointFromLatLng(
      waypoints[0].lat,
      waypoints[0].lng
    ),
    destination: RoutesClient.createWaypointFromLatLng(
      waypoints[waypoints.length - 1].lat,
      waypoints[waypoints.length - 1].lng
    ),
    intermediates: waypoints.length > 2
      ? waypoints
          .slice(1, -1)
          .map((wp) => RoutesClient.createWaypointFromLatLng(wp.lat, wp.lng))
      : undefined,
    travelMode: travelModeMap[travelMode] || 'DRIVE',
    optimizeWaypointOrder: true, // ← 簡易最適化を有効化
    computeAlternativeRoutes: false,
    languageCode: 'ja',
    units: 'METRIC',
  };

  // computeRoutes 呼び出し
  const response = await client.computeRoutes(request);

  if (!response.routes || response.routes.length === 0) {
    throw new Error('指定された地点間のルートが見つかりませんでした');
  }

  const route = response.routes[0];

  // 最適化後の地点順序を取得
  const optimizedIntermediateIndices = route.optimizedIntermediateWaypointIndex || [];
  const fullOrder = [
    0, // 出発地
    ...optimizedIntermediateIndices.map((idx: number) => idx + 1), // 中間地点
    waypoints.length - 1, // 目的地
  ];

  // 総距離・総所要時間を計算
  const totalDistance = route.distanceMeters;
  const totalDuration = RoutesClient.parseDuration(route.duration);

  return {
    waypointOrder: fullOrder,
    totalDistance,
    totalDuration,
    polyline: route.polyline.encodedPolyline || '',
  };
}

戻り値の型定義:

export interface OptimizationResult {
  /** 最適化後の地点順序(元の配列のインデックス) */
  waypointOrder: number[];
  /** 総距離(メートル) */
  totalDistance: number;
  /** 総所要時間(秒) */
  totalDuration: number;
  /** ポリライン */
  polyline: string;
}

Directions API との比較

変更前(Directions API):

// GET リクエスト(Query param)
const url = new URL('https://maps.googleapis.com/maps/api/directions/json');
url.searchParams.append('origin', `${waypoints[0].lat},${waypoints[0].lng}`);
url.searchParams.append('destination', `${waypoints[waypoints.length - 1].lat},${waypoints[waypoints.length - 1].lng}`);
url.searchParams.append('waypoints', `optimize:true|${waypointsParam}`);
url.searchParams.append('key', API_KEY);

const response = await fetch(url.toString());

変更後(Routes API):

// POST リクエスト(JSON Body)
const request: ComputeRoutesRequest = {
  origin: RoutesClient.createWaypointFromLatLng(waypoints[0].lat, waypoints[0].lng),
  destination: RoutesClient.createWaypointFromLatLng(waypoints[waypoints.length - 1].lat, waypoints[waypoints.length - 1].lng),
  intermediates: waypoints.slice(1, -1).map((wp) =>
    RoutesClient.createWaypointFromLatLng(wp.lat, wp.lng)
  ),
  travelMode: 'DRIVE',
  optimizeWaypointOrder: true, // ← optimize:true の代わり
};

const response = await client.computeRoutes(request);

距離行列の取得(computeRouteMatrix)

Distance Matrix APIの代替として、Routes API の computeRouteMatrix を使用します。複数地点間の距離・時間を一度に取得できます。

重要:ストリーミングレスポンス

computeRouteMatrixServer-sent events(ストリーミング)形式でレスポンスを返します。

lib/google-maps/distance-matrix.ts
/**
 * 複数地点間の距離・時間を取得(Routes API computeRouteMatrix)
 * ストリーミングレスポンスを処理
 */
export async function getDistanceMatrix(
  origins: Array<{ lat: number; lng: number }>,
  destinations: Array<{ lat: number; lng: number }>,
  travelMode: 'DRIVING' | 'WALKING' | 'BICYCLING' = 'DRIVING'
): Promise<DistanceMatrixResult> {
  const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY;
  if (!apiKey) {
    throw new Error('Google Maps API key is not configured');
  }

  // 地点数の制限チェック(Routes APIは最大25x25)
  if (origins.length > 25 || destinations.length > 25) {
    throw new Error('地点数は最大25件までです');
  }

  // Routes API クライアント初期化
  const client = new RoutesClient({ apiKey });

  // travelMode を Routes API 形式に変換
  const travelModeMap: Record<string, 'DRIVE' | 'WALK' | 'BICYCLE'> = {
    DRIVING: 'DRIVE',
    WALKING: 'WALK',
    BICYCLING: 'BICYCLE',
  };

  // リクエスト構築
  const request: ComputeRouteMatrixRequest = {
    origins: origins.map((o) => ({
      waypoint: RoutesClient.createWaypointFromLatLng(o.lat, o.lng),
    })),
    destinations: destinations.map((d) => ({
      waypoint: RoutesClient.createWaypointFromLatLng(d.lat, d.lng),
    })),
    travelMode: travelModeMap[travelMode] || 'DRIVE',
    languageCode: 'ja',
    units: 'METRIC',
  };

  // computeRouteMatrix 呼び出し(ストリーミング)
  const elements: RouteMatrixElement[] = await client.computeRouteMatrix(request);

  // 距離行列を構築
  const matrix = buildDistanceMatrix(elements, origins.length, destinations.length);

  return { matrix };
}

ストリーミングレスポンスの処理

lib/google-maps/routes-client.ts
/**
 * computeRouteMatrix エンドポイント呼び出し(ストリーミング)
 */
async computeRouteMatrix(
  request: ComputeRouteMatrixRequest,
  fieldMask?: string
): Promise<RouteMatrixElement[]> {
  const defaultFieldMask = 'originIndex,destinationIndex,distanceMeters,duration,condition';
  const mask = fieldMask || defaultFieldMask;

  try {
    const response = await fetch(`${this.baseUrl}/distanceMatrix/v2:computeRouteMatrix`, {
      method: 'POST',
      headers: this.getHeaders(mask),
      body: JSON.stringify(request),
    });

    if (!response.ok) {
      const errorData = await response.json();
      throw new Error(
        `Routes API error (${errorData.error.code}): ${errorData.error.message}`
      );
    }

    // ストリーミングレスポンスの処理
    const reader = response.body?.getReader();
    if (!reader) {
      throw new Error('Failed to get response stream');
    }

    const decoder = new TextDecoder();
    const elements: RouteMatrixElement[] = [];

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      // チャンクをデコード
      const chunk = decoder.decode(value, { stream: true });
      const lines = chunk.split('\n').filter((line) => line.trim());

      for (const line of lines) {
        try {
          const element: RouteMatrixElement = JSON.parse(line);
          // originIndex と destinationIndex が存在する要素のみ追加
          if (
            element.originIndex !== undefined &&
            element.destinationIndex !== undefined
          ) {
            elements.push(element);
          }
        } catch (e) {
          // JSON パースエラーは無視(空行など)
          continue;
        }
      }
    }

    return elements;
  } catch (error: any) {
    throw new Error(`距離行列の取得に失敗しました: ${error.message}`);
  }
}

距離行列の構築

lib/google-maps/distance-matrix.ts
/**
 * ストリーミングで取得した RouteMatrixElement を距離行列に変換
 */
function buildDistanceMatrix(
  elements: RouteMatrixElement[],
  originsCount: number,
  destinationsCount: number
): (DistanceMatrixElement | null)[][] {
  // N×M の行列を初期化(すべて null)
  const matrix: (DistanceMatrixElement | null)[][] = Array.from(
    { length: originsCount },
    () => Array(destinationsCount).fill(null)
  );

  // ストリーミングで取得した要素を行列に配置
  for (const element of elements) {
    if (
      element.originIndex === undefined ||
      element.destinationIndex === undefined
    ) {
      continue;
    }

    const i = element.originIndex;
    const j = element.destinationIndex;

    // ルートが存在する場合のみ追加
    if (
      element.condition === 'ROUTE_EXISTS' &&
      element.distanceMeters !== undefined &&
      element.duration !== undefined
    ) {
      matrix[i][j] = {
        distance: element.distanceMeters,
        duration: RoutesClient.parseDuration(element.duration), // "123s" → 123
      };
    }
  }

  return matrix;
}

Distance Matrix API との比較

変更前(Distance Matrix API):

// GET リクエスト(Query param)
const response = await fetch(
  `https://maps.googleapis.com/maps/api/distancematrix/json?` +
  `origins=${encodeURIComponent(originsStr)}&` +
  `destinations=${encodeURIComponent(destinationsStr)}&` +
  `mode=${travelMode.toLowerCase()}&` +
  `key=${apiKey}`
);

const data = await response.json(); // ← 同期レスポンス
const matrix = data.rows.map((row: any) =>
  row.elements.map((element: any) => ({
    distance: element.distance.value,
    duration: element.duration.value,
  }))
);

変更後(Routes API):

// POST リクエスト(JSON Body)
const request: ComputeRouteMatrixRequest = {
  origins: origins.map((o) => ({
    waypoint: RoutesClient.createWaypointFromLatLng(o.lat, o.lng),
  })),
  destinations: destinations.map((d) => ({
    waypoint: RoutesClient.createWaypointFromLatLng(d.lat, d.lng),
  })),
  travelMode: 'DRIVE',
};

// ストリーミングレスポンスを処理
const elements = await client.computeRouteMatrix(request);
const matrix = buildDistanceMatrix(elements, origins.length, destinations.length);

フィールドマスク戦略

Routes APIでは X-Goog-FieldMask ヘッダーが必須です。必要なフィールドのみを指定することで、レスポンスサイズを削減できます。

computeRoutes のフィールドマスク

// 推奨フィールドマスク
const fieldMask =
  'routes.duration,' +
  'routes.distanceMeters,' +
  'routes.polyline.encodedPolyline,' +
  'routes.legs,' +
  'routes.optimizedIntermediateWaypointIndex';

computeRouteMatrix のフィールドマスク

// 推奨フィールドマスク
const fieldMask =
  'originIndex,' +
  'destinationIndex,' +
  'distanceMeters,' +
  'duration,' +
  'condition';
フィールドマスク一覧

computeRoutes:

  • routes.duration - 総所要時間
  • routes.distanceMeters - 総距離
  • routes.polyline.encodedPolyline - ルートのポリライン
  • routes.legs - 区間詳細
  • routes.optimizedIntermediateWaypointIndex - 最適化後の地点順序
  • * - すべてのフィールド(デバッグ用)

computeRouteMatrix:

  • originIndex - 出発地インデックス
  • destinationIndex - 目的地インデックス
  • distanceMeters - 距離
  • duration - 所要時間
  • condition - ルート存在確認(ROUTE_EXISTS / ROUTE_NOT_FOUND
  • * - すべてのフィールド(デバッグ用)

エラーハンドリング

Routes APIのエラーレスポンス例:

{
  "error": {
    "code": 400,
    "message": "Invalid request",
    "status": "INVALID_ARGUMENT",
    "details": [
      {
        "@type": "type.googleapis.com/google.rpc.BadRequest",
        "fieldViolations": [
          {
            "field": "origin",
            "description": "origin is required"
          }
        ]
      }
    ]
  }
}

共通エラーコード

エラーコード 説明 対処
400 INVALID_REQUEST リクエストが無効 リクエストパラメータを確認
403 PERMISSION_DENIED APIキーの権限不足 API有効化、キー制限を確認
429 RESOURCE_EXHAUSTED レート制限超過 リトライロジック実装
503 UNAVAILABLE サービス一時停止 指数バックオフでリトライ

エラーハンドリング実装例

try {
  const response = await client.computeRoutes(request);
  return response;
} catch (error: any) {
  if (error.message.includes('PERMISSION_DENIED')) {
    throw new Error('Google Maps APIキーの設定を確認してください');
  }
  if (error.message.includes('RESOURCE_EXHAUSTED')) {
    throw new Error('APIリクエスト上限に達しました。しばらく待ってから再試行してください');
  }
  throw new Error(`ルート計算に失敗しました: ${error.message}`);
}

よくあるエラーと対策

1. 400 INVALID_REQUEST - フィールドマスクが未指定

エラーメッセージ:

Field mask is required

原因: X-Goog-FieldMask ヘッダーが未指定、または空文字列

対策:

// ❌ NG
headers: {
  'X-Goog-Api-Key': apiKey,
  // X-Goog-FieldMask が未指定
}

// ✅ OK
headers: {
  'X-Goog-Api-Key': apiKey,
  'X-Goog-FieldMask': 'routes.duration,routes.distanceMeters', // ← 必須
}

2. 403 PERMISSION_DENIED - API有効化エラー

エラーメッセージ:

Routes API has not been used in project XXXXX before or it is disabled

原因: Google Cloud Console で Routes API が有効化されていない

対策:

  1. Google Cloud Console にアクセス
  2. 「APIとサービス」→「ライブラリ」
  3. 「Routes API」を検索して有効化
  4. 5-10分待ってから再試行

3. computeRouteMatrix でデータが取得できない

症状: elements 配列が空、またはデータが欠損

原因: ストリーミングレスポンスの処理ミス(response.json() を使用している)

対策:

// ❌ NG - 同期JSONとして扱っている
const data = await response.json(); // ← ストリーミングでは動作しない

// ✅ OK - ReadableStream として処理
const reader = response.body?.getReader();
const decoder = new TextDecoder();
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  const chunk = decoder.decode(value, { stream: true });
  // チャンクをパース...
}

4. 429 RESOURCE_EXHAUSTED - レート制限超過

エラーメッセージ:

Quota exceeded for quota metric 'Elements per day'

原因: 1日あたりの API 使用量上限に到達

対策:

  • キャッシュを実装してAPI呼び出し回数を削減
  • Google Cloud Console で割当量を確認・増加申請
  • フィールドマスクで必要最小限のフィールドのみ取得

キャッシュについて

Routes APIのレスポンスをキャッシュすることで、API使用量を削減できます。

基本的な実装パターン

export async function getDistanceMatrix(
  origins: Array<{ lat: number; lng: number }>,
  destinations: Array<{ lat: number; lng: number }>,
  travelMode: 'DRIVING' | 'WALKING' | 'BICYCLING' = 'DRIVING'
): Promise<DistanceMatrixResult> {
  // キャッシュキー生成(座標 + 移動手段でユニークに)
  const cacheKey = `distance-matrix:${JSON.stringify({origins, destinations, travelMode})}`;

  // キャッシュチェック
  const cached = getCache(cacheKey);
  if (cached) {
    return cached;
  }

  // API呼び出し
  const result = await fetchDistanceMatrix(origins, destinations, travelMode);

  // キャッシュに保存
  setCache(cacheKey, result, TTL);

  return result;
}

まとめ

この記事では、Google Maps Routes APIへの移行方法を実例とともに解説しました:

  • Directions API → Routes API (computeRoutes) の移行
    • GET → POST + JSON Body形式に変更
    • optimizeWaypointOrder: true でルート最適化
    • Header認証(X-Goog-Api-Key
  • Distance Matrix API → Routes API (computeRouteMatrix) の移行
    • ストリーミングレスポンスの処理(ReadableStream
    • 距離行列の構築
  • Routes API共通クライアントの設計
    • 共通ヘッダー(X-Goog-Api-Key, X-Goog-FieldMask
    • エラーハンドリング
  • フィールドマスク
    • 必須ヘッダー X-Goog-FieldMask
    • 必要なフィールドのみを指定
  • キャッシュの考え方
    • API使用量の削減

次のステップ

参考資料


Delivroute について

Delivrouteは、配送ルート最適化を簡単に行えるWebアプリケーションです。

  • 🗺️ 最大10地点のルート最適化: Google Maps Routes APIで最適な配送順序を自動計算
  • 簡単CSVインポート: CSVファイルから一括で配送先を登録
  • 💰 クレジット制: 無料プラン(月3回)、Proプラン(月60回)で気軽に利用可能

👉 サービスはこちら: https://delivroute.com


この記事が役に立ったら、ぜひいいね👍やコメント💬をお願いします!

Discussion