Google Maps Platform と Google Cloud でフルスタックの店舗検索アプリを作成する

1. はじめに

概要

地図に掲載したい場所が多数あり、その場所がどこにあるか、どの場所に行けばよいかをユーザーに知らせたいとします。一般的な例として、次のようなものがあります。

  • 小売店のウェブサイトに設置する店舗検索
  • 今度の選挙の投票所を示した地図
  • 電池のリサイクル回収所などの特殊な場所のディレクトリ

作成するアプリの概要

この Codelab では、特殊な場所のライブ データフィードからデータを取得し、ユーザーの出発点に最も近い場所を見つけるスポット検索アプリを作成します。このフルスタックのスポット検索アプリでは、店舗拠点が 25 か所に制限されている簡単な店舗検索アプリよりも多くの場所を処理できます。

2ece59c64c06e9da.png

学習する内容

この Codelab では、重要な技術的コンセプトの学習に専念できるよう、オープン データセットを使用して多数の店舗拠点について事前入力されたメタデータをシミュレートします。

  • Maps JavaScript API: カスタマイズしたウェブ上のマップに多数の場所を表示します。
  • GeoJSON: 場所に関するメタデータを保存する形式
  • プレイス オートコンプリート: ユーザーがより早く正確な出発地を指定できるようにします。
  • Go: アプリケーションのバックエンドの開発に使用するプログラミング言語。バックエンドはデータベースとやりとりを行い、クエリ結果を JSON 形式でフロントエンドに送り返します。
  • App Engine: ウェブアプリのホスティング用

要件

  • HTML および JavaScript の基本的な知識
  • Google アカウント

2. 準備

次のセクションのステップ 3 で、この Codelab の Maps JavaScript APIPlaces APIDistance Matrix API を有効にします。

Google Maps Platform の利用を始める

Google Maps Platform を初めて使用する場合は、Google Maps Platform スタートガイドを参照するか、再生リスト「Getting Started with Google Maps Platform」を視聴して、以下の手順を行ってください。

  1. 請求先アカウントを作成します。
  2. プロジェクトを作成します。
  3. Google Maps Platform の API と SDK(前セクションに記載のもの)を有効化します。
  4. API キーを生成します。

Cloud Shell をアクティブにする

この Codelab では、Cloud Shell(Google Cloud で実行するコマンドライン環境)を使用します。この環境を使用すると、Google Cloud で実行しているプロダクトとリソースにアクセスできるため、ウェブブラウザからプロジェクトを完全にホストして実行できます。

Cloud Console から Cloud Shell を有効にするには、[Cloud Shell をアクティブにする] 89665d8d348105cd.png をクリックします(プロビジョニングと対象環境への接続にはそれほど時間はかかりません)。

5f504766b9b3be17.png

クリックすると、紹介インタースティシャルが表示された後、ブラウザの下部に新しいシェルが表示されます。

d3bb67d514893d1f.png

プロジェクトを確認する

Cloud Shell が接続されると、認証が完了していることと、設定時に選択したプロジェクト ID にプロジェクトがすでに設定されていることがわかります。

$ gcloud auth list
Credentialed Accounts:
ACTIVE  ACCOUNT
  *     <myaccount>@<mydomain>.com
$ gcloud config list project
[core]
project = <YOUR_PROJECT_ID>

なんらかの理由でプロジェクトが設定されていない場合は、次のコマンドを実行します。

gcloud config set project <YOUR_PROJECT_ID>

AppEngine Flex API を有効にする

AppEngine Flex API は、Cloud Console から手動で有効にする必要があります。こうすることで、API を有効にするだけでなく、ユーザーに代わって Google サービス(SQL データベースなど)とやりとりを行う認証アカウントである AppEngine フレキシブル環境サービス アカウントも作成されます。

3. Hello World

バックエンド: Go での Hello World アプリの作成

Cloud Shell インスタンスで、まずこの Codelab のベースとなる Go App Engine Flex アプリを作成します。

Cloud Shell のツールバーで [エディタを開く] ボタンをクリックし、コードエディタを新しいタブで開きます。このウェブベースのコードエディタを使用すると、Cloud Shell インスタンス内で簡単にファイルを編集できます。

b63f7baad67b6601.png

次に、[新しいウィンドウで開く] アイコンをクリックして、エディタとターミナルを新しいタブに移動します。

3f6625ff8461c551.png

新しいタブの下部にあるターミナルで、新しい austin-recycling ディレクトリを作成します。

mkdir -p austin-recycling && cd $_

次に、すべてが正常に機能することを確認するための簡単な Go App Engine アプリを作成します。Hello World!

austin-recycling ディレクトリが左側のエディタのフォルダ リストにも表示されます。austin-recycling ディレクトリに app.yaml というファイルを作成します。次の内容を app.yaml ファイルに追加します。

app.yaml

runtime: go
env: flex

manual_scaling:
  instances: 1
resources:
  cpu: 1
  memory_gb: 0.5
  disk_size_gb: 10

この構成ファイルでは、Go Flex ランタイムを使用するように App Engine アプリケーションを設定します。このファイルの設定項目の背景情報については、Google App Engine Go スタンダード環境のドキュメントをご覧ください。

次に、app.yaml ファイルとともに main.go ファイルを作成します。

main.go

package main

import (
        "fmt"
        "log"
        "net/http"
        "os"
)

func main() {
        http.HandleFunc("/", handle)
        port := os.Getenv("PORT")
        if port == "" {
                port = "8080"
        }
        log.Printf("Listening on port %s", port)
        if err := http.ListenAndServe(":"+port, nil); err != nil {
                log.Fatal(err)
        }
}

func handle(w http.ResponseWriter, r *http.Request) {
        if r.URL.Path != "/" {
                http.NotFound(w, r)
                return
        }
        fmt.Fprint(w, "Hello world!")
}

ここで少し時間を取り、このコードの機能を大まかに理解しておくことが重要です。ポート 8080 でリッスンする http サーバーを起動し、パス "/" に一致する HTTP リクエストのハンドラ関数を登録する main というパッケージを定義しました。

handler と呼ばれるこのハンドラ関数は、テキスト文字列 "Hello, world!" を書き込みます。このテキストがブラウザに送り返され、読めるようになります。以降のステップでは、簡単なハードコードされた文字列ではなく、GeoJSON データを返すハンドラを作成します。

これらの手順を実行すると、エディタは次のようになります。

2084fdd5ef594ece.png

テストする

このアプリケーションをテストするには、Cloud Shell インスタンス内で App Engine 開発サーバーを実行します。Cloud Shell コマンドラインに戻り、次のように入力します。

go run *.go

数行のログ出力が表示されます。これは、localhost のポート 8080 でリッスンしている Hello World ウェブアプリを使って、Cloud Shell インスタンスで開発サーバーを実行していることを示しています。このアプリでウェブブラウザのタブを開くには、Cloud Shell ツールバーで [ウェブでプレビュー] ボタンを押し、[プレビューのポート: 8080] を選択します。

4155fc1dc717ac67.png

このメニュー項目をクリックすると、ウェブブラウザに、App Engine 開発サーバーから配信された「Hello, World!」という文字列が表示された新しいタブが開きます。

次のステップでは、このアプリにオースティン市のリサイクルに関するデータを追加して、データの可視化を開始します。

4. 現在のデータを取得する

GIS 世界の共通語「GeoJSON」

前の手順で、後で GeoJSON データをウェブブラウザにレンダリングするハンドラを Go コードで作成すると述べましたが、GeoJSON とは何でしょうか。

地理情報システム(GIS)の世界では、地理エンティティに関する知識をコンピュータ システム間で通信できなければなりません。Google マップのデータは人間にとって読みやすい形で表示されていますが、コンピュータでは通常、より消化しやすい形式のデータが好まれます。

GeoJSON は、テキサス州オースティン市のリサイクル回収所の座標など、地理的データ構造をエンコードするための形式です。GeoJSON は、インターネット技術特別調査委員会によって制定された RFC7946 という標準を基に標準化されています。GeoJSON は JSON(JavaScript Object Notation)を基準に定義されており、JSON 自体も、JavaScript を標準化した組織である Ecma International によって標準化された ECMA-404 を基に標準化されています。

つまり、GeoJSON は、地理情報を伝えるために広くサポートされている転送形式です。この Codelab では、次のように GeoJSON を使用します。

  • リクエストするデータをフィルタリングするため、Go パッケージを使用してオースティン市のデータを GIS 固有の内部データ構造に解析します。
  • リクエストしたデータをウェブサーバーとウェブブラウザ間の転送用にシリアル化します。
  • JavaScript ライブラリを使用して、レスポンスをマップ上のマーカーに変換します。

これにより、コードを入力する手間が省けます。これは、データ ストリームをメモリ内表現に変換するためにパーサーとジェネレータを記述する必要がないためです。

データを取得する

テキサス州オースティン市の Open Data Portal では、公共リソースに関する地理空間情報を一般公開しています。この Codelab では、リサイクル回収所に関するデータセットを可視化します。

Maps JavaScript API のデータレイヤーを使用してレンダリングされた地図上のマーカーを使ってデータを視覚化します。

まず、オースティン市の GeoJSON データをアプリにダウンロードします。

  1. Cloud Shell インスタンスのコマンドライン ウィンドウで、[CTRL] + [C] と入力し、サーバーをシャットダウンします。
  2. austin-recycling ディレクトリ内に data ディレクトリを作成し、そのディレクトリに移動します。
mkdir -p data && cd data

次に、curl を使用してリサイクル回収所の位置情報を取得します。

curl "https://data.austintexas.gov/resource/qzi7-nx8g.geojson" -o recycling-locations.geojson

最後に、親ディレクトリに移動します。

cd ..

5. 位置情報をマッピングする

まず、これから構築する、単なる Hello World アプリを超えたロバストなアプリを反映するように app.yaml ファイルを更新します。

app.yaml

runtime: go
env: flex

handlers:
- url: /
  static_files: static/index.html
  upload: static/index.html
- url: /(.*\.(js|html|css))$
  static_files: static/\1
  upload: static/.*\.(js|html|css)$
- url: /.*
  script: auto

manual_scaling:
  instances: 1
resources:
  cpu: 1
  memory_gb: 0.5
  disk_size_gb: 10

この app.yaml 構成は、//*.js/*.css/*.html に対するリクエストを一連の静的ファイルに送信します。つまり、アプリの静的 HTML コンポーネントは、Go アプリではなく App Engine のファイル配信インフラストラクチャによって直接配信されます。これにより、サーバーの負荷が軽減され、配信速度が向上します。

次に、Go でアプリケーションのバックエンドを構築します。

バックエンドを構築する

すでにお気づきかもしれませんが、app.yaml ファイルでは、GeoJSON ファイルを公開しません。これは、後のステップで高度な機能を構築できるよう、GeoJSON は Go のバックエンドによって処理、送信されるためです。次のように main.go ファイルを変更します。

main.go

package main

import (
        "fmt"
        "io/ioutil"
        "log"
        "net/http"
        "os"
        "path/filepath"
)

var GeoJSON = make(map[string][]byte)

// cacheGeoJSON loads files under data into `GeoJSON`.
func cacheGeoJSON() {
        filenames, err := filepath.Glob("data/*")
        if err != nil {
                log.Fatal(err)
        }

        for _, f := range filenames {
                name := filepath.Base(f)
                dat, err := ioutil.ReadFile(f)
                if err != nil {
                        log.Fatal(err)
                }
                GeoJSON[name] = dat
        }
}

func main() {
        // Cache the JSON so it doesn't have to be reloaded every time a request is made.
        cacheGeoJSON()

        // Request for data should be handled by Go.  Everything else should be directed
        // to the folder of static files.
        http.HandleFunc("/data/dropoffs", dropoffsHandler)
        http.Handle("/", http.FileServer(http.Dir("./static/")))

        // Open up a port for the webserver.
        port := os.Getenv("PORT")
        if port == "" {
                port = "8080"
        }
        log.Printf("Listening on port %s", port)

        if err := http.ListenAndServe(":"+port, nil); err != nil {
                log.Fatal(err)
        }
}

func helloHandler(w http.ResponseWriter, r *http.Request) {
        // Writes Hello, World! to the user's web browser via `w`
        fmt.Fprint(w, "Hello, world!")
}

func dropoffsHandler(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-type", "application/json")
        w.Write(GeoJSON["recycling-locations.geojson"])
}

Go のバックエンドにはすでに有益な機能があり、AppEngine インスタンスが起動後すぐにすべての位置情報をキャッシュしています。これにより、各ユーザーが更新するたびにバックエンドでディスクからファイルを読み取る必要がなくなるため、時間を節約できます。

フロントエンドを構築する

まず、すべての静的アセットを格納するフォルダを作成します。プロジェクトの親フォルダから static フォルダを作成します。

mkdir -p static && cd static

このフォルダに 3 つのファイルを作成します

  • index.html には、1 ページで構成される店舗検索アプリのすべての HTML を格納します。
  • style.css には、スタイル設定を格納します。
  • app.js は、GeoJSON の取得、Maps API の呼び出し、カスタム マップへのマーカーの配置を行います。

これら 3 つのファイルを作成し、static/ に配置します。

style.css

html,
body {
  height: 100%;
  margin: 0;
  padding: 0;
}

body {
  display: flex;
}

#map {
  height: 100%;
  flex-grow: 4;
  flex-basis: auto;
}

index.html

<html>
  <head>
    <title>Austin recycling drop-off locations</title>
    <link rel="stylesheet" type="text/css" href="style.css" />
    <script src="app.js"></script>

    <script
      defer
    src="https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY&v=weekly&libraries=places&callback=initialize&solution_channel=GMP_codelabs_fullstackstorelocator_v1_a"
    ></script>
  </head>

  <body>
    <div id="map"></div>
    <!-- Autocomplete div goes here -->
  </body>
</html>

head 要素の script タグの src URL に特に注意してください。

  • プレースホルダ テキスト「YOUR_API_KEY」を、設定で生成した API キーに置き換えます。Cloud Console の [API とサービス] -> [認証情報] ページにアクセスして API キーを取得するか、新しいキーを生成できます。
  • URL にパラメータ callback=initialize. が含まれていることを確認します。次に、そのコールバック関数を含む JavaScript ファイルを作成します。ここで、アプリがバックエンドから位置情報を読み込み、Maps API に送信し、その結果を使用して地図上にカスタム ロケーションをマークし、これがウェブページに美しくレンダリングされます。
  • パラメータ libraries=places は Places Library を読み込みます。このライブラリは、後で追加する住所のオートコンプリートなどの機能で必要になります。

app.js

let distanceMatrixService;
let map;
let originMarker;
let infowindow;
let circles = [];
let stores = [];
// The location of Austin, TX
const AUSTIN = { lat: 30.262129, lng: -97.7468 };

async function initialize() {
  initMap();

  // TODO: Initialize an infoWindow

  // Fetch and render stores as circles on map
  fetchAndRenderStores(AUSTIN);

  // TODO: Initialize the Autocomplete widget
}

const initMap = () => {
  // TODO: Start Distance Matrix service

  // The map, centered on Austin, TX
  map = new google.maps.Map(document.querySelector("#map"), {
    center: AUSTIN,
    zoom: 14,
    // mapId: 'YOUR_MAP_ID_HERE',
    clickableIcons: false,
    fullscreenControl: false,
    mapTypeControl: false,
    rotateControl: true,
    scaleControl: false,
    streetViewControl: true,
    zoomControl: true,
  });
};

const fetchAndRenderStores = async (center) => {
  // Fetch the stores from the data source
  stores = (await fetchStores(center)).features;

  // Create circular markers based on the stores
  circles = stores.map((store) => storeToCircle(store, map));
};

const fetchStores = async (center) => {
  const url = `/data/dropoffs`;
  const response = await fetch(url);
  return response.json();
};

const storeToCircle = (store, map) => {
  const [lng, lat] = store.geometry.coordinates;
  const circle = new google.maps.Circle({
    radius: 50,
    strokeColor: "#579d42",
    strokeOpacity: 0.8,
    strokeWeight: 5,
    center: { lat, lng },
    map,
  });

  return circle;
};

このコードは、店舗の場所を地図上にレンダリングします。これまで作成した内容をテストするには、コマンドラインから親ディレクトリに戻ります。

cd ..

戻ったら、次のコマンドを使用してアプリを再び開発モードで実行します。

go run *.go

以前と同じようにプレビューします。次のような、小さな緑色の丸が描かれた地図が表示されます。

58a6680e9c8e7396.png

すでにマップ上に位置情報がレンダリングされていますが、Codelab はまだ半分しか完了していません。では次に、インタラクティブ機能を追加します。

6 オンデマンドで詳細を表示する

地図上のマーカーのクリック イベントに応答する

地図上に多数のマーカーを表示できるようになったら、次は、ユーザーがマーカーをクリックした際に、その場所に関する情報(事業名、住所など)を確認できるようにする必要があります。Google マップのマーカーをクリックすると通常表示される情報ウィンドウの名前は、情報ウィンドウです。

infoWindow オブジェクトを作成します。次のコードを initialize 関数に追加し、コメント行「// TODO: Initialize an info window」を置き換えます。

app.js - initialize

  // Add an info window that pops up when user clicks on an individual
  // location. Content of info window is entirely up to us.
  infowindow = new google.maps.InfoWindow();

fetchAndRenderStores 関数定義をわずかに変更したバージョンに置き換えます。storeToCircle を呼び出すように最後の行を変更し、引数 infowindow を追加します。

app.js - fetchAndRenderStores

const fetchAndRenderStores = async (center) => {
  // Fetch the stores from the data source
  stores = (await fetchStores(center)).features;

  // Create circular markers based on the stores
  circles = stores.map((store) => storeToCircle(store, map, infowindow));
};

storeToCircle 定義に、infowindow を 3 つ目の引数として追加します。

app.js - storeToCircle

const storeToCircle = (store, map, infowindow) => {
  const [lng, lat] = store.geometry.coordinates;
  const circle = new google.maps.Circle({
    radius: 50,
    strokeColor: "#579d42",
    strokeOpacity: 0.8,
    strokeWeight: 5,
    center: { lat, lng },
    map,
  });
  circle.addListener("click", () => {
    infowindow.setContent(`${store.properties.business_name}<br />
      ${store.properties.address_address}<br />
      Austin, TX ${store.properties.zip_code}`);
    infowindow.setPosition({ lat, lng });
    infowindow.setOptions({ pixelOffset: new google.maps.Size(0, -30) });
    infowindow.open(map);
  });
  return circle;
};

上記の新しいコードでは、地図上の店舗マーカーがクリックされるたびに、選択した店舗の情報とともに infoWindow が表示されます。

サーバーがまだ稼働している場合は、停止して再起動します。地図ページを更新して、地図上のマーカーをクリックしてみてください。次のような情報ウィンドウが開き、事業名と住所が表示されます。

1af0ab72ad0eadc5.png

7. ユーザーの開始位置を取得する

店舗検索の使用目的は通常、現在地または出発予定の住所から最も近い店舗を見つけることです。プレイス オートコンプリート検索バーを追加して、ユーザーが出発住所を簡単に入力できるようにしましょう。プレイス オートコンプリートは、他の Google 検索バーでのオートコンプリートと同じように機能しますが、Google Maps Platform のすべての Place から予測されるという点が異なります。

ユーザー入力フィールドを作成する

再び style.css を編集して、オートコンプリート検索バーと検索結果を表示する関連サイドパネルのスタイルを追加します。CSS のスタイルを更新する間、マップとともに店舗情報をリストとして表示するサイドバーのスタイルも追加します。

このコードは、ファイルの末尾に追加します。

style.css

#panel {
  height: 100%;
  flex-basis: 0;
  flex-grow: 0;
  overflow: auto;
  transition: all 0.2s ease-out;
}

#panel.open {
  flex-basis: auto;
}

#panel .place {
  font-family: "open sans", arial, sans-serif;
  font-size: 1.2em;
  font-weight: 500;
  margin-block-end: 0px;
  padding-left: 18px;
  padding-right: 18px;
}

#panel .distanceText {
  color: silver;
  font-family: "open sans", arial, sans-serif;
  font-size: 1em;
  font-weight: 400;
  margin-block-start: 0.25em;
  padding-left: 18px;
  padding-right: 18px;
}

/* Styling for Autocomplete search bar */
#pac-card {
  background-color: #fff;
  border-radius: 2px 0 0 2px;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
  box-sizing: border-box;
  font-family: Roboto;
  margin: 10px 10px 0 0;
  -moz-box-sizing: border-box;
  outline: none;
}

#pac-container {
  padding-top: 12px;
  padding-bottom: 12px;
  margin-right: 12px;
}

#pac-input {
  background-color: #fff;
  font-family: Roboto;
  font-size: 15px;
  font-weight: 300;
  margin-left: 12px;
  padding: 0 11px 0 13px;
  text-overflow: ellipsis;
  width: 400px;
}

#pac-input:focus {
  border-color: #4d90fe;
}

#pac-title {
  color: #fff;
  background-color: #acbcc9;
  font-size: 18px;
  font-weight: 400;
  padding: 6px 12px;
}

.hidden {
  display: none;
}

オートコンプリートの検索バーとスライドアウト パネルは、どちらも必要になるまでは非表示になっています。

index.html 内の "<!-- Autocomplete div goes here --> というコメントを次のコードに置き換えて、オートコンプリート ウィジェットの div を準備します。この編集中に、スライドアウト パネル用の div も追加します。

index.html

     <div id="panel" class="closed"></div>
     <div class="hidden">
      <div id="pac-card">
        <div id="pac-title">Find the nearest location</div>
        <div id="pac-container">
          <input
            id="pac-input"
            type="text"
            placeholder="Enter an address"
            class="pac-target-input"
            autocomplete="off"
          />
        </div>
      </div>
    </div>

次に、app.js の最後に次のコードを追加して、オートコンプリート ウィジェットを地図に追加する関数を定義します。

app.js

const initAutocompleteWidget = () => {
  // Add search bar for auto-complete
  // Build and add the search bar
  const placesAutoCompleteCardElement = document.getElementById("pac-card");
  const placesAutoCompleteInputElement = placesAutoCompleteCardElement.querySelector(
    "input"
  );
  const options = {
    types: ["address"],
    componentRestrictions: { country: "us" },
    map,
  };
  map.controls[google.maps.ControlPosition.TOP_RIGHT].push(
    placesAutoCompleteCardElement
  );
  // Make the search bar into a Places Autocomplete search bar and select
  // which detail fields should be returned about the place that
  // the user selects from the suggestions.
  const autocomplete = new google.maps.places.Autocomplete(
    placesAutoCompleteInputElement,
    options
  );
  autocomplete.setFields(["address_components", "geometry", "name"]);
  map.addListener("bounds_changed", () => {
    autocomplete.setBounds(map.getBounds());
  });

  // TODO: Respond when a user selects an address
};

このコードは、オートコンプリートの候補を住所のみに制限し(プレイス オートコンプリートは施設名や管理場所にも一致するため)、また返される住所を米国内の住所のみに制限します。この任意の仕様を追加すると、住所を絞り込むために入力する必要のある文字数が少なくなります。

その後、作成したオートコンプリート div を地図の右上に移動し、レスポンスに含める各 Place に関して返すフィールドを指定します。

最後に、initialize 関数の最後で initAutocompleteWidget 関数を呼び出して、「// TODO: Initialize the Autocomplete widget」というコメントを置き換えます。

app.js - initialize

 // Initialize the Places Autocomplete Widget
 initAutocompleteWidget();

次のコマンドを実行してサーバーを再起動し、プレビューを更新します。

go run *.go

地図の右上にオートコンプリート ウィジェットが表示されます。このウィジェットでは、地図に表示されている領域に合わせて、入力内容と一致する米国内の住所が表示されます。

58e9bbbcc4bf18d1.png

ユーザーが出発点の住所を選択したときに地図を更新する

次に、ユーザーがオートコンプリート ウィジェットの候補から選択した住所に基づいて、店舗までの距離が計算されるようにする必要があります。

app.jsinitAutocompleteWidget の最後に次のコードを追加し、コメント「// TODO: Respond when a user selects an address」を置き換えます。

app.js - initAutocompleteWidget

  // Respond when a user selects an address
  // Set the origin point when the user selects an address
  originMarker = new google.maps.Marker({ map: map });
  originMarker.setVisible(false);
  let originLocation = map.getCenter();
  autocomplete.addListener("place_changed", async () => {
    // circles.forEach((c) => c.setMap(null)); // clear existing stores
    originMarker.setVisible(false);
    originLocation = map.getCenter();
    const place = autocomplete.getPlace();

    if (!place.geometry) {
      // User entered the name of a Place that was not suggested and
      // pressed the Enter key, or the Place Details request failed.
      window.alert("No address available for input: '" + place.name + "'");
      return;
    }
    // Recenter the map to the selected address
    originLocation = place.geometry.location;
    map.setCenter(originLocation);
    map.setZoom(15);
    originMarker.setPosition(originLocation);
    originMarker.setVisible(true);

    // await fetchAndRenderStores(originLocation.toJSON());
    // TODO: Calculate the closest stores
  });

このコードによりリスナーが追加され、ユーザーがいずれかの候補をクリックすると、選択された住所が地図の中心となり、距離計算の基準としての起点が設定されます。距離計算は後のステップで実装します。

サーバーを停止して再起動し、プレビューを更新して、オートコンプリート検索バーに住所を入力した後、入力した住所を中心に地図が再表示されることを確認します。

8. Cloud SQL を使用したスケーリング

店舗検索としてなかなかうまく機能するようになってきました。ここでは、アプリで使用する位置情報が 100 か所近くしかないことを利用して、ファイルから繰り返し読み取る代わりに、バックエンドのメモリに読み込んでいます。では、処理する規模が異なる場合はどうでしょうか。広い地域に分散している数百個の場所(または世界中に数千)がある場合、すべての場所をメモリに保持することは最適な手段ではなくなります。また、ゾーンを個々のファイルに分割すれば、それに伴って新たな問題が生まれます。

そこで有効なのが、データベースから位置情報を読み込む方法です。このステップでは、GeoJSON ファイル内のすべての位置情報を Cloud SQL データベースに移行し、リクエストが届くたびに、ローカル キャッシュからではなくデータベースから結果を取得するように Go バックエンドを更新します。

PostGres データベースで Cloud SQL インスタンスを作成する

Cloud SQL インスタンスは Google Cloud Console から作成できますが、gcloud ユーティリティを使用するとコマンドラインからさらに簡単に作成できます。Cloud Shell で次のコマンドを使用して、Cloud SQL インスタンスを作成します。

gcloud sql instances create locations \
--database-version=POSTGRES_12 \
--tier=db-custom-1-3840 --region=us-central1
  • 引数 locations は、このインスタンスの Cloud SQL に付ける名前です。
  • tier フラグは、便利な定義済みマシンの中から選択するための方法です。
  • db-custom-1-3840 は、作成されるインスタンスが vCPU 1 つと約 3.75 GB のメモリを持つ必要があることを示します。

Cloud SQL インスタンスは、デフォルトのユーザー postgres で PostGresSQL データベースを使用して作成、初期化されます。このユーザーのパスワードは、まだ設定されていません。ログインする前に、設定する必要があります。

次のコマンドを使用してパスワードを設定します。

gcloud sql users set-password postgres \
    --instance=locations --prompt-for-password

プロンプトが表示されたら、選択したパスワードを入力します。

PostGIS 拡張機能を有効にする

PostGIS は、標準化されたタイプの地理空間データの格納を容易にする PostGresSQL の拡張機能です。通常の状況では、PostGIS をデータベースに追加するには、インストール プロセスを一から行わなければなりません。Cloud SQL でサポートされている PostGresSQL の拡張機能の 1 つであるため、その必要はありません。

Cloud Shell ターミナルで、ユーザー postgres としてログインし、データベース インスタンスに接続します。

gcloud sql connect locations --user=postgres --quiet

作成したパスワードを入力します。次に、postgres=> コマンド プロンプトで PostGIS 拡張機能を追加します。

CREATE EXTENSION postgis;

正常に実行されると、次のように、CREATE EXTENSION という出力が表示されます。

コマンド出力例

CREATE EXTENSION

最後に、postgres=> コマンド プロンプトで終了コマンドを入力して、データベース接続を終了します。

\q

地理データをデータベースにインポートする

次に、位置情報データを GeoJSON ファイルから新しいデータベースにインポートする必要があります。

このプロセスは、インターネット上のツールを利用して簡単に自動化できます。ここでは、地理空間データを格納するための一般的な形式間の変換を行う ogr2ogr というツールを使用します。自動化に使用できるオプションの 1 つとして、GeoJSON 形式から SQL ダンプファイルへの変換があります。変換したら、SQL ダンプファイルを使用してデータベース用のテーブルと列を作成し、そこに GeoJSON ファイル内のすべてのデータを読み込みます。

SQL ダンプファイルを作成する

まず、ogr2ogr をインストールします。

sudo apt-get install gdal-bin

次に、ogr2ogr を使用して SQL ダンプファイルを作成します。このファイルにより、austinrecycling という名前のテーブルが作成されます。

ogr2ogr --config PG_USE_COPY YES -f PGDump datadump.sql \
data/recycling-locations.geojson -nln austinrecycling

上記のコマンドは、austin-recycling フォルダからの実行に基づいています。別のディレクトリから実行する必要がある場合は、datarecycling-locations.geojson が格納されているディレクトリのパスに置き換えます。

リサイクル回収所の位置情報をデータベースに取り込む

最後のコマンドが完了すると、コマンドを実行したディレクトリに datadump.sql, ファイルが作成されます。このファイルを開くと、100 行を超える SQL 行があり、テーブル austinrecycling が作成されて、位置情報が入力されています。

次に、次のコマンドを使用してデータベースへの接続を開き、そのスクリプトを実行します。

gcloud sql connect locations --user=postgres --quiet < datadump.sql

スクリプトが正常に実行されると、出力行の最後の数行は次のようになります。

コマンド出力例

ALTER TABLE
ALTER TABLE
ATLER TABLE
ALTER TABLE
COPY 103
COMMIT
WARNING: there is no transaction in progress
COMMIT

Cloud SQL を使用するように Go バックエンドを更新する

すべてのデータがデータベースに格納されたところで、次はコードを更新します。

位置情報を送信するようにフロントエンドを更新する

フロントエンドに対するごく小規模な更新から始めましょう。大規模な処理にも対応できるアプリを作成しているため、クエリが実行されるたびに位置情報を 1 つずつ毎回フロントエンドに送信するのではなく、ユーザー求めている位置情報に関する基本的な情報をフロントエンドから渡す必要があります。

app.js を開き、fetchStores 関数の定義をこのバージョンに置き換えることで、URL に検索対象の緯度と経度が含まれるようになります。

app.js - fetchStores

const fetchStores = async (center) => {
  const url = `/data/dropoffs?centerLat=${center.lat}&centerLng=${center.lng}`;
  const response = await fetch(url);
  return response.json();
};

Codelab のこのステップを完了すると、レスポンスでは、center パラメータで指定された地図上の座標に最も近い店舗のみが返されます。initialize 関数の最初のフェッチでは、このラボで提供されているサンプルコードは、テキサス州オースティン市の中心座標を使用しています。

fetchStores で返される店舗情報は一部のみであるため、ユーザーが出発地を変更した場合は店舗情報を再取得する必要があります。

新しい出発地が設定されるたびに initAutocompleteWidget 関数を更新して、店舗情報を更新します。これには 2 つの編集が必要です。

  1. initAutocompleteWidget 内で、place_changed リスナーのコールバックを探します。プレイス オートコンプリート検索バーを使用して住所を選択するたびに行が実行されるように、既存のサークルを削除する行のコメント化を解除します。

app.js - initAutocompleteWidget

  autocomplete.addListener("place_changed", async () => {
    circles.forEach((c) => c.setMap(null)); // clear existing stores
    // ...
  1. 選択した出発地が変更されると、変数 originLocation が更新されます。「place_changed」コールバックの最後で、「// TODO: Calculate the closest stores」行の上にある行のコメント化を解除して、この新しい出発地を fetchAndRenderStores 関数の新しい呼び出しに渡します。

app.js - initAutocompleteWidget

    await fetchAndRenderStores(originLocation.toJSON());
    // TODO: Calculate the closest stores

フラット JSON ファイルの代わりに CloudSQL を使用するようにバックエンドを更新する

フラット ファイル GeoJSON の読み取りとキャッシングを削除する

まず、main.go を変更して、フラット GeoJSON ファイルをロードしてキャッシュに保存するコードを削除します。Cloud SQL を使用する関数をファイルを別のファイルに書き込むため、dropoffsHandler 関数を省略することもできます。

変更後の main.go はもっと速くなります。

main.go

package main

import (

        "log"
        "net/http"
        "os"
)

func main() {

        initConnectionPool()

        // Request for data should be handled by Go.  Everything else should be directed
        // to the folder of static files.
        http.HandleFunc("/data/dropoffs", dropoffsHandler)
        http.Handle("/", http.FileServer(http.Dir("./static/")))

        // Open up a port for the webserver.
        port := os.Getenv("PORT")
        if port == "" {
                port = "8080"
        }
        log.Printf("Listening on port %s", port)
        if err := http.ListenAndServe(":"+port, nil); err != nil {
                log.Fatal(err)
        }
}

位置情報リクエスト用の新しいハンドラを作成する

次に、austin-recycling ディレクトリに、別のファイル locations.go を作成します。まず、位置情報リクエスト用のハンドラを再実装します。

locations.go

package main

import (
        "database/sql"
        "fmt"
        "log"
        "net/http"
        "os"

        _ "github.com/jackc/pgx/stdlib"
)

// queryBasic demonstrates issuing a query and reading results.
func dropoffsHandler(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-type", "application/json")
        centerLat := r.FormValue("centerLat")
        centerLng := r.FormValue("centerLng")
        geoJSON, err := getGeoJSONFromDatabase(centerLat, centerLng)
        if err != nil {
                str := fmt.Sprintf("Couldn't encode results: %s", err)
                http.Error(w, str, 500)
                return
        }
        fmt.Fprintf(w, geoJSON)
}

このハンドラは次の重要なタスクを実行します。

  • リクエスト オブジェクトから緯度と経度を取得します(先ほどの手順で URL に追加しました)。
  • GeoJSON 文字列を返す getGeoJsonFromDatabase 呼び出しを発行します(後で記述します)。
  • ResponseWriter を使用して、その GeoJSON 文字列をレスポンスに出力します。

次に、同時アクセスに対応してデータベースのパフォーマンスをスケーリングできるように、接続プールを作成します。

接続プールを作成する

接続プールは、サーバーがユーザー リクエストに対応するために再使用できるアクティブなデータベース接続の集まりです。サーバーがアクティブ ユーザーごとに接続、切断に時間を費やさずに済むため、アクティブ ユーザー数の増加に応じてオーバーヘッドの多くを取り除くことができます。前のセクションでインポートしたライブラリ github.com/jackc/pgx/stdlib. は、Go で接続プールを扱う際によく利用されるライブラリです。

locations.go の最後に、接続プールを初期化する initConnectionPool 関数(main.go から呼び出されます)を作成します。わかりやすくするために、このスニペットではいくつかのヘルパー メソッドを使用しています。configureConnectionPool では、接続数や接続あたりの存続時間など、プールの設定を簡単に調整できます。mustGetEnv は呼び出しをラップして必要な環境変数を取得します。これにより、インスタンスに重要な情報(接続先のデータベースの IP や名前など)が不足している場合に、役に立つエラー メッセージがスローされます。

locations.go

// The connection pool
var db *sql.DB

// Each struct instance contains a single row from the query result.
type result struct {
        featureCollection string
}

func initConnectionPool() {
        // If the optional DB_TCP_HOST environment variable is set, it contains
        // the IP address and port number of a TCP connection pool to be created,
        // such as "127.0.0.1:5432". If DB_TCP_HOST is not set, a Unix socket
        // connection pool will be created instead.
        if os.Getenv("DB_TCP_HOST") != "" {
                var (
                        dbUser    = mustGetenv("DB_USER")
                        dbPwd     = mustGetenv("DB_PASS")
                        dbTCPHost = mustGetenv("DB_TCP_HOST")
                        dbPort    = mustGetenv("DB_PORT")
                        dbName    = mustGetenv("DB_NAME")
                )

                var dbURI string
                dbURI = fmt.Sprintf("host=%s user=%s password=%s port=%s database=%s", dbTCPHost, dbUser, dbPwd, dbPort, dbName)

                // dbPool is the pool of database connections.
                dbPool, err := sql.Open("pgx", dbURI)
                if err != nil {
                        dbPool = nil
                        log.Fatalf("sql.Open: %v", err)
                }

                configureConnectionPool(dbPool)

                if err != nil {

                        log.Fatalf("initConnectionPool: unable to connect: %s", err)
                }
                db = dbPool
        }
}

// configureConnectionPool sets database connection pool properties.
// For more information, see https://golang.org/pkg/database/sql
func configureConnectionPool(dbPool *sql.DB) {
        // Set maximum number of connections in idle connection pool.
        dbPool.SetMaxIdleConns(5)
        // Set maximum number of open connections to the database.
        dbPool.SetMaxOpenConns(7)
        // Set Maximum time (in seconds) that a connection can remain open.
        dbPool.SetConnMaxLifetime(1800)
}

// mustGetEnv is a helper function for getting environment variables.
// Displays a warning if the environment variable is not set.
func mustGetenv(k string) string {
        v := os.Getenv(k)
        if v == "" {
                log.Fatalf("Warning: %s environment variable not set.\n", k)
        }
        return v
}

データベースに位置情報をクエリし、JSON を受け取る

次は、地図上の座標を取得し、最も近い 25 つの場所を返すデータベース クエリを作成します。それだけでなく、最新のデータベース機能のおかげで、このデータは GeoJSON として返されます。その結果、フロントエンド コードでは、何も変更がなかったかのように認識されます。以前は、URL へのリクエストを送ると、GeoJSON が返されていました。今は、URL へのリクエストを送ると、GeoJSON が返されます。

これを実行する関数は次のとおりです。locations.go の最後に作成したハンドラと接続プール コードの後に次の関数を追加します。

locations.go

func getGeoJSONFromDatabase(centerLat string, centerLng string) (string, error) {

        // Obviously you can one-line this, but for testing purposes let's make it easy to modify on the fly.
        const milesRadius = 10
        const milesToMeters = 1609
        const radiusInMeters = milesRadius * milesToMeters

        const tableName = "austinrecycling"

        var queryStr = fmt.Sprintf(
                `SELECT jsonb_build_object(
                        'type',
                        'FeatureCollection',
                        'features',
                        jsonb_agg(feature)
                )
        FROM (
                        SELECT jsonb_build_object(
                                        'type',
                                        'Feature',
                                        'id',
                                        ogc_fid,
                                        'geometry',
                                        ST_AsGeoJSON(wkb_geometry)::jsonb,
                                        'properties',
                                        to_jsonb(row) - 'ogc_fid' - 'wkb_geometry'
                                ) AS feature
                        FROM (
                                        SELECT *,
                                                ST_Distance(
                                                        ST_GEOGFromWKB(wkb_geometry),
                                                        -- Los Angeles (LAX)
                                                        ST_GEOGFromWKB(st_makepoint(%v, %v))
                                                ) as distance
                                        from %v
                                        order by distance
                                        limit 25
                                ) row
                        where distance < %v
                ) features
                `, centerLng, centerLat, tableName, radiusInMeters)

        log.Println(queryStr)

        rows, err := db.Query(queryStr)

        defer rows.Close()

        rows.Next()
        queryResult := result{}
        err = rows.Scan(&queryResult.featureCollection)
        return queryResult.featureCollection, err
}

この関数は主に、データベースへのリクエストを送信するための設定、ティアダウン、エラー処理を行うものです。実際の SQL を見ると、データベース レイヤでさまざまなことを行っており、コードの実装に頭を悩ませる必要がないことがわかります。

文字列が解析され、すべての文字列リテラルが適切な場所に挿入されると送信される未加工のクエリは、次のようになります。

parsed.sql

SELECT jsonb_build_object(
        'type',
        'FeatureCollection',
        'features',
        jsonb_agg(feature)
    )
FROM (
        SELECT jsonb_build_object(
                'type',
                'Feature',
                'id',
                ogc_fid,
                'geometry',
                ST_AsGeoJSON(wkb_geometry)::jsonb,
                'properties',
                to_jsonb(row) - 'ogc_fid' - 'wkb_geometry'
            ) AS feature
        FROM (
                SELECT *,
                    ST_Distance(
                        ST_GEOGFromWKB(wkb_geometry),
                        -- Los Angeles (LAX)
                        ST_GEOGFromWKB(st_makepoint(-97.7624043, 30.523725))
                    ) as distance
                from austinrecycling
                order by distance
                limit 25
            ) row
        where distance < 16090
    ) features

このクエリは 1 つのプライマリ クエリといくつかの JSON ラップ関数として見ることができます。

SELECT * ... LIMIT 25 は各場所のすべてのフィールドを選択します。次に、ST_DISTANCE 関数(PostGIS の地理測定関数の一部)を使用して、データベース内の各場所と、フロントエンドでユーザーが指定した場所の緯度/経度ペアの間の距離を割り出します。走行距離を測定できる距離行列とは異なり、これらの距離は GeoSatialial 距離です。効率を高めるために、この距離を使って場所を並べ替え、ユーザーが指定した場所に最も近い 25 か所を返します。

**SELECT json_build_object(‘type', ‘F**eature') は、前のクエリをラップし、その結果を使用して GeoJSON Feature オブジェクトを作成します。意外なことに、このクエリでは、最大半径も適用されます。「16090」は、Go バックエンドにより指定されたハードリミットである 10 マイルをメートルで表したものです。WHERE 句が内部クエリ(各場所間の距離を割り出すクエリ)に追加されなかったのは、SQL はバックグラウンドで実行されるため、WHERE 句が調べられたときにこのフィールドが計算されなかった可能性があるためです。実際に、この WHERE 句を内部クエリに移動しようとすると、エラーがスローされます。

**SELECT json_build_object(‘type', ‘FeatureColl**ection') このクエリでは、GeoJSON FeatureCollection オブジェクトの JSON 生成クエリから返されるすべての行をラップします。

プロジェクトに PGX ライブラリを追加する

プロジェクトに、依存関係を 1 つ追加する必要があります。接続のプール化を可能にする PostGres Driver & Toolkit です。最も簡単な方法は、Go モジュールを使用することです。Cloud Shell で次のコマンドを使用して、モジュールを初期化します。

go mod init my_locator

次に、このコマンドでコードの依存関係をスキャンし、依存関係のリストを mod ファイルに追加してダウンロードします。

go mod tidy

最後に、次のコマンドを実行して依存関係をプロジェクト ディレクトリに直接 pull します。これにより、App Engine フレキシブル環境でコンテナを簡単にビルドできます。

go mod vendor

これで、アプリをテストする準備が整いました。

テストする

ではここで一度、アプリを実際に試してみましょう。

開発マシン(Cloud Shell も含む)がデータベースに接続できるようにするには、Cloud SQL Proxy を使用してデータベース接続を管理する必要があります。Cloud SQL Proxy を設定するには:

  1. Cloud SQL Admin API を有効にするにはこちらにアクセス
  2. ローカルの開発マシンを使用している場合は、Cloud SQL Proxy ツールをインストールします。Cloud Shell を使用している場合は、この手順を省略できます。この手順では、サービス アカウントを参照します。サービス アカウントはあらかじめ用意されているため、必要な権限の付与について次のセクションで説明します。
  3. プロキシを起動するには Cloud Shell または独自のターミナルで新しいタブを作成します。

bcca42933bfbd497.png

  1. https://console.cloud.google.com/sql/instances/locations/overview にアクセスし、[接続名] フィールドまでスクロール ダウンします。この名前をコピーして、次のコマンドで使用します。
  2. このタブで、このコマンドで Cloud SQL Proxy を実行します。CONNECTION_NAME は、前のステップに示した接続名に置き換えてください。
cloud_sql_proxy -instances=CONNECTION_NAME=tcp:5432

Cloud Shell の最初のタブに戻り、Go でデータベース バックエンドと通信するために必要な環境変数を定義します。その後で、以前と同じ方法でサーバーを実行します。

プロジェクトのルート ディレクトリに移動します。

cd YOUR_PROJECT_ROOT

次の 5 つの環境変数を作成します(YOUR_PASSWORD_HERE は、先ほど作成したパスワードに置き換えます)。

export DB_USER=postgres
export DB_PASS=YOUR_PASSWORD_HERE
export DB_TCP_HOST=127.0.0.1 # Proxy
export DB_PORT=5432 #Default for PostGres
export DB_NAME=postgres

ローカル インスタンスを実行します。

go run *.go

プレビュー ウィンドウを開くと、何も変更しなかったかのように正常に動作するはずです。出発地の住所を入力したら、地図を拡大し、リサイクル回収所をクリックできます。しかし、現在はデータベースを使用しているため、いつでもスケーリングできます。

9. 最寄りの店舗のリストを表示する

Directions API は、Google マップ アプリでのルートのリクエストと同じように機能します。出発地と目的地を 1 つずつ入力することで、2 つの地点間のルートが返されます。Distance Matrix API はこのコンセプトをさらに掘り下げ、移動時間と距離に基づいて、複数の出発地と目的地の候補から最適な組み合わせを特定します。この場合、選択した住所に最も近い店舗をユーザーが見つけることができるように、1 つの出発地と店舗の場所の配列(目的地)を提供します。

各店舗に出発地からの距離を追加する

initMap 関数定義の先頭にあるコメント「// TODO: Start Distance Matrix service」を次のコードに置き換えます。

app.js - initMap

distanceMatrixService = new google.maps.DistanceMatrixService();

app.js の末尾に calculateDistances という新しい関数を追加します。

app.js

async function calculateDistances(origin, stores) {
  // Retrieve the distances of each store from the origin
  // The returned list will be in the same order as the destinations list
  const response = await getDistanceMatrix({
    origins: [origin],
    destinations: stores.map((store) => {
      const [lng, lat] = store.geometry.coordinates;
      return { lat, lng };
    }),
    travelMode: google.maps.TravelMode.DRIVING,
    unitSystem: google.maps.UnitSystem.METRIC,
  });
  response.rows[0].elements.forEach((element, index) => {
    stores[index].properties.distanceText = element.distance.text;
    stores[index].properties.distanceValue = element.distance.value;
  });
}

const getDistanceMatrix = (request) => {
  return new Promise((resolve, reject) => {
    const callback = (response, status) => {
      if (status === google.maps.DistanceMatrixStatus.OK) {
        resolve(response);
      } else {
        reject(response);
      }
    };
    distanceMatrixService.getDistanceMatrix(request, callback);
  });
};

この関数は、1 つの出発地(出発地)と店舗の場所の配列(目的地)を使用する Distance Matrix API を呼び出します。次に、店舗の ID、人が読み取れる文字列で表現された距離、数値(メートル単位)としての距離を格納するオブジェクトの配列を構築し、配列を並べ替えます。

プレイス オートコンプリートの検索バーで新しい出発地が選択されるたびに店舗までの距離が計算されるように initAutocompleteWidget 関数を更新します。initAutocompleteWidget 関数の最後にあるコメント「// TODO: Calculate the closest stores」を次のコードに置き換えます。

app.js - initAutocompleteWidget

    // Use the selected address as the origin to calculate distances
    // to each of the store locations
    await calculateDistances(originLocation, stores);
    renderStoresPanel();

店舗を距離順に表示する

一番近い店舗から順に表示するには、店舗の表示順序を知らせるために calculateDistances 関数によって変更されたリストを使います。この関数を使うと、各店舗がサイドパネルに一覧表示されます。

renderStoresPanel()storeToPanelRow() という 2 つの新しい関数を app.js の最後に追加します。

app.js

function renderStoresPanel() {
  const panel = document.getElementById("panel");

  if (stores.length == 0) {
    panel.classList.remove("open");
    return;
  }

  // Clear the previous panel rows
  while (panel.lastChild) {
    panel.removeChild(panel.lastChild);
  }
  stores
    .sort((a, b) => a.properties.distanceValue - b.properties.distanceValue)
    .forEach((store) => {
      panel.appendChild(storeToPanelRow(store));
    });
  // Open the panel
  panel.classList.add("open");
  return;
}

const storeToPanelRow = (store) => {
  // Add store details with text formatting
  const rowElement = document.createElement("div");
  const nameElement = document.createElement("p");
  nameElement.classList.add("place");
  nameElement.textContent = store.properties.business_name;
  rowElement.appendChild(nameElement);
  const distanceTextElement = document.createElement("p");
  distanceTextElement.classList.add("distanceText");
  distanceTextElement.textContent = store.properties.distanceText;
  rowElement.appendChild(distanceTextElement);
  return rowElement;
};

サーバーを再起動し、次のコマンドを実行してプレビューを更新します。

go run *.go

最後に、オートコンプリートの検索バーにテキサス州オースティン市の住所を入力し、候補のいずれかをクリックします。

その住所が地図の中心に表示され、サイドバーには選択した住所からの距離順で店舗の場所の一覧が表示されます。以下の図はその例です。

96e35794dd0e88c9.png

10. 地図のスタイルを設定する

視覚に訴える効果的な方法の 1 つは、スタイルを地図に追加することです。クラウドベースの地図にスタイルを追加するには、クラウドベースの地図のスタイル設定を使用して Cloud Console から地図のカスタマイズを制御します。ベータ版以外の機能で地図のスタイルを設定する場合は、地図のスタイル設定に関するドキュメントで、プログラムによって地図をスタイル設定するための JSON を生成する方法が説明されています。ここでは、クラウドベースの地図のスタイル設定(ベータ版)について説明します。

マップ ID を作成する

まず、Cloud Console を開き、検索ボックスに「地図管理」と入力します。「地図管理(Google マップ)」という検索結果をクリックします。64036dd0ed200200.png

上部(検索ボックスのすぐ下)に [新しいマップ ID を作成] というボタンが表示されます。クリックして、好きな名前を入力します。[地図の種類] で [JavaScript] を選択し、その他のオプションが表示されたら、リストから [ベクター] を選択します。下の画像のような結果になります。

70f55a759b4c4212.png

[次へ] をクリックすると、新しいマップ ID が生成されます。ここでコピーしておくこともできますが、後で簡単に確認できます。

次に、この地図に適用するスタイルを作成します。

地図のスタイルを作成する

Cloud Console の [マップ] セクションを開いている場合は、左側のナビゲーション メニューの下部にある [地図のスタイル] をクリックします。それ以外の場合は、マップ ID を作成したのと同じように、下の画像のとおり、検索ボックスに「地図のスタイル」と入力して [地図のスタイル(Google マップ)] を選択すると、適切なページを見つけることができます。

9284cd200f1a9223.png

次に、上部の [+ 新しい地図のスタイルを作成] をクリックします。

  1. このラボで示されている地図のスタイルに合わせるには、[JSON のインポート] タブをクリックし、下の JSON blob を貼り付けます。独自のスタイルを作成する場合は、使用する地図スタイルを選択します。[次へ] をクリックします。
  2. 作成したマップ ID を選択して、マップ ID にそのスタイルを関連付け、もう一度 [次へ] をクリックします。
  3. ここで、地図のスタイルをさらにカスタマイズするには、[スタイル エディタでカスタマイズ] をクリックし、好みのスタイルと色を選択して地図のスタイルを調整します。それ以外の場合は、[スキップ] をクリックします。
  4. 次のステップで、スタイルの名前と説明を入力して、[保存して公開] をクリックします。

次は、最初のステップでインポートできる JSON blob(省略可)です。

[
  {
    "elementType": "geometry",
    "stylers": [
      {
        "color": "#d6d2c4"
      }
    ]
  },
  {
    "elementType": "labels.icon",
    "stylers": [
      {
        "visibility": "off"
      }
    ]
  },
  {
    "elementType": "labels.text.fill",
    "stylers": [
      {
        "color": "#616161"
      }
    ]
  },
  {
    "elementType": "labels.text.stroke",
    "stylers": [
      {
        "color": "#f5f5f5"
      }
    ]
  },
  {
    "featureType": "administrative.land_parcel",
    "elementType": "labels.text.fill",
    "stylers": [
      {
        "color": "#bdbdbd"
      }
    ]
  },
  {
    "featureType": "landscape.man_made",
    "elementType": "geometry.fill",
    "stylers": [
      {
        "color": "#c0baa5"
      },
      {
        "visibility": "on"
      }
    ]
  },
  {
    "featureType": "landscape.man_made",
    "elementType": "geometry.stroke",
    "stylers": [
      {
        "color": "#9cadb7"
      },
      {
        "visibility": "on"
      }
    ]
  },
  {
    "featureType": "poi",
    "elementType": "labels.text.fill",
    "stylers": [
      {
        "color": "#757575"
      }
    ]
  },
  {
    "featureType": "poi.park",
    "elementType": "labels.text.fill",
    "stylers": [
      {
        "color": "#9e9e9e"
      }
    ]
  },
  {
    "featureType": "road",
    "elementType": "geometry",
    "stylers": [
      {
        "color": "#ffffff"
      }
    ]
  },
  {
    "featureType": "road.arterial",
    "elementType": "geometry",
    "stylers": [
      {
        "weight": 1
      }
    ]
  },
  {
    "featureType": "road.arterial",
    "elementType": "labels.text.fill",
    "stylers": [
      {
        "color": "#757575"
      }
    ]
  },
  {
    "featureType": "road.highway",
    "elementType": "geometry",
    "stylers": [
      {
        "color": "#bf5700"
      }
    ]
  },
  {
    "featureType": "road.highway",
    "elementType": "geometry.stroke",
    "stylers": [
      {
        "visibility": "off"
      }
    ]
  },
  {
    "featureType": "road.highway",
    "elementType": "labels.text.fill",
    "stylers": [
      {
        "color": "#616161"
      }
    ]
  },
  {
    "featureType": "road.local",
    "elementType": "geometry",
    "stylers": [
      {
        "weight": 0.5
      }
    ]
  },
  {
    "featureType": "road.local",
    "elementType": "labels.text.fill",
    "stylers": [
      {
        "color": "#9e9e9e"
      }
    ]
  },
  {
    "featureType": "transit.line",
    "elementType": "geometry",
    "stylers": [
      {
        "color": "#e5e5e5"
      }
    ]
  },
  {
    "featureType": "transit.station",
    "elementType": "geometry",
    "stylers": [
      {
        "color": "#eeeeee"
      }
    ]
  },
  {
    "featureType": "water",
    "elementType": "geometry",
    "stylers": [
      {
        "color": "#333f48"
      }
    ]
  },
  {
    "featureType": "water",
    "elementType": "labels.text.fill",
    "stylers": [
      {
        "color": "#9e9e9e"
      }
    ]
  }
]

マップ ID をコードに追加する

この地図のスタイルを作成したところで、この地図のスタイルを自分の地図で実際に使うにはどうすればよいでしょうか。次のように 2 つの小さな変更を加える必要があります。

  1. マップ ID を URL パラメータとして index.html のスクリプト タグに追加します。
  2. マップ ID を initMap() メソッドで地図を作成する際にマップ ID をコンストラクタ引数として Add(追加)します。

HTML ファイルの Maps JavaScript API を読み込むスクリプトタグを、以下のローダ URL で置き換え、「YOUR_API_KEY」と「YOUR_MAP_ID」のプレースホルダを置き換えます。

index.html

...
<script async defer src="https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY&v=weekly&libraries=places&callback=initialize&map_ids=YOUR_MAP_ID&solution_channel=GMP_codelabs_fullstackstorelocator_v1_a">
  </script>
...

定数 map が定義されている app.jsinitMap メソッドで、mapId プロパティの行のコメント化を解除し、「YOUR_MAP_ID_HERE」を先ほど作成したマップ ID に置き換えます。

app.js - initMap

...

// The map, centered on Austin, TX
 const map = new google.maps.Map(document.querySelector('#map'), {
   center: austin,
   zoom: 14,
   mapId: 'YOUR_MAP_ID_HERE',
// ...
});
...

サーバーを再起動します。

go run *.go

プレビューを更新すると、設定に応じて地図のスタイルが変わります。上記の JSON スタイルを使用した例を次に示します。

2ece59c64c06e9da.png

11. 本番環境にデプロイする

開発マシンや Cloud Shell のローカル ウェブサーバーから(これまで使用してきた方法)だけでなく、AppEngine Flex からアプリを実行する方法は非常に簡単です。データベースへのアクセスが本番環境で機能するためには、いくつかのものを追加する必要があります。これについては、App Engine Flex から Cloud SQL への接続に関するドキュメント ページにまとめられています。

環境変数を app.yaml に追加する

まず、ローカルでテストするために使用した環境変数はすべて、アプリケーションの app.yaml ファイルの末尾に追加する必要があります。

  1. https://console.cloud.google.com/sql/instances/locations/overview にアクセスして、インスタンス接続名を検索します。
  2. app.yaml の最後に次のコードを貼り付けます。
  3. YOUR_DB_PASSWORD_HERE は、以前に postgres ユーザー名用に作成したパスワードに置き換えます。
  4. YOUR_CONNECTION_NAME_HERE はステップ 1 で調べた値に置き換えます。

app.yaml

# ...
# Set environment variables
env_variables:
    DB_USER: postgres
    DB_PASS: YOUR_DB_PASSWORD_HERE
    DB_NAME: postgres
    DB_TCP_HOST: 172.17.0.1
    DB_PORT: 5432

#Enable TCP Port
# You can look up your instance connection name by going to the page for
# your instance in the Cloud Console here : https://console.cloud.google.com/sql/instances/
beta_settings:
  cloud_sql_instances: YOUR_CONNECTION_NAME_HERE=tcp:5432

このアプリは AppEngine Flex 経由で接続するため、DB_TCP_HOST の値は 172.17.0.1 である必要があります。これまでと同様に、Cloud SQL とプロキシを介して通信するためです。

SQL クライアント アクセス許可を App Engine Flex サービス アカウントに追加する

Cloud Console の IAM 管理ページに移動し、名前が service-PROJECT_NUMBER@gae-api-prod.google.com.iam.gserviceaccount.com という形式に一致するサービス アカウントを探します。これは、App Engine Flex がデータベースに接続するために使用するサービス アカウントです。行の末尾にある [編集] ボタンをクリックし、「Cloud SQL クライアント」の役割を追加します。

b04ccc0b4022b905.png

プロジェクト コードを Go パスにコピーする

AppEngine でコードを実行するには、Go パスで関連ファイルを検出できる必要があります。プロジェクトのルート ディレクトリに移動します。

cd YOUR_PROJECT_ROOT

ディレクトリを Go パスにコピーします。

mkdir -p ~/gopath/src/austin-recycling
cp -r ./ ~/gopath/src/austin-recycling

そのディレクトリに移動します。

cd ~/gopath/src/austin-recycling

アプリをデプロイする

アプリのデプロイには、gcloud ツールを使用します。デプロイには時間がかかります。

gcloud app deploy

browse コマンドを使用して、完全にデプロイされた、見た目にも美しいエンタープライズ グレードの店舗検索がどのように機能するかを確認できるリンクを取得します。

gcloud app browse

Cloud Shell の外部で gcloud を実行している場合、gcloud app browse を実行すると新しいブラウザタブが開きます。

12. (推奨)クリーンアップ

この Codelab は、無料枠の制限内の BigQuery 処理と Maps Platform API 呼び出しで実行できますが、この演習を学習目的のみで実行した場合、その後も課金されないようにするには、このプロジェクトに関連付けられているリソースを削除する必要があります。最も簡単な方法は、プロジェクト自体を削除することです。

プロジェクトを削除する

GCP Console で、[Cloud Resource Manager] ページに移動します。

プロジェクト リストで、目的のプロジェクトを選択し、[削除] をクリックします。プロジェクト ID を入力するように求められます。入力して [シャットダウン] をクリックします。

別の方法として、gcloud を使用してプロジェクト全体を Cloud Shell から直接削除することもできます。そのためには、プレースホルダ GOOGLE_CLOUD_PROJECT をプロジェクト ID に置き換えて、次のコマンドを実行します。

gcloud projects delete GOOGLE_CLOUD_PROJECT

13. 完了

以上で、この Codelab を無事に完了しました

最後のページまでざっと読み通したという人も、お疲れさまでした。

この Codelab では、以下のテクノロジーを扱いました。

関連情報

これらのテクノロジーについて、学ぶべきことは他にもたくさんあります。この Codelab では取り上げる時間がなかったものの、各自のニーズに合わせた店舗検索ソリューションを構築する際に役立つ可能性のあるトピックについての関連リンクを以下に示します。