Nuxt.js/Vue.jsでGoogleMaps連携して、カスタムマーカー表示させてみた。（CloudRunでも動作済）

# はじめに
Nuxt.js/Vue.jsを使って、GoogleMaps連携し、地図にカスタムのマーカー表示させてみました。フロントエンドはVue.js/Nuxt.jsで作成し、地図情報はGoogleMapsAPI、バックエンド側はGCP CloudFunctions、表示させるためのデータはGCPのCloudSQL(MySQL)に格納しています。ローカル試験後は、CloudRunにNuxt.jsアプリをデプロイして動作確認実施し、問題なく動作したので、メモを残します。

### 動作の流れ
1. 検索実施(IDの完全一致または件名のあいまい検索)
2. バックエンドのAPIに上記のIDまたは件名の検索キーワードをPOST
3. CloudSQL上にあるデータを検索、結果をレスポンス
4. フロント側は得られた結果をもとに、GoogleMaps上にマーカー表示させる

見た目は以下のようなものです。（CSSについては特に言及しません。）
![概要](https://storage.googleapis.com/permanent_koizumi/20191220_google_maps_overview.gif "概要")

### 選定技術
- フロントエンド
    - Vue.js/Nuxt.js
        - @nuxtjs/axios@5.8.0
        - @nuxtjs/dotenv@1.4.1
    - GCP MapsAPI
        - vue2-google-maps@0.10.7
- バックエンド
    - GCP CloudFunctions(Node.js 10)
    - GCP CloudSQL(MySQL)

# 内容説明
1. GCP環境準備
2. create-nuxt-appでNuxt.jsプロジェクト作成
3. Map表示の環境準備(フロントエンド側)
4. CloudSQLからデータ取得API(バックエンド側)
5. 表示画面の構造
6. 検索画面(フロントエンド側)
7. 結果表示画面(フロントエンド側)
8. 地図画面表示(フロントエンド側)
9. 動作確認
10. CloudRunでの動作確認


## 1. GCP環境準備
このあたりは別サイトに任せて、詳細は省きます

- GCP新規プロジェクト作成
- CloudSQL(MySQL第二世代)作成
    - MySQL5.7、asia-northeast1、テストなので高可用性なし
    - 接続名を控えておくこと
- GoogleMapsAPIの有効化
    - Maps JavaScript APIを有効化
- APIキーを発行
    - 後で使うので控えておくこと

## 2. create-nuxt-appでNuxt.jsプロジェクト作成
サーバサイドレンダリングをしたかったので、レンダリングモードは、Universal (SSR)で作成しました。他、モジュール選択時に使う予定のあるaxiosとdotenvを選択しました。　(別でSPAとしても作成しテストしましたが、そちらは割愛）

```Javascript:
npx create-nuxt-app nuxt-nuxt-view-search-maps-ssr
```

## 3. Map表示の環境準備(フロントエンド側)
GoogleMaps表示用に、vue2-google-mapsをインストールしておきます。容易に利用できそうだったことと日本語の記事が多かったため採用。
[vue2-google-maps](https://www.npmjs.com/package/vue2-google-maps "vue2-google-maps")

```Javascript:
npm install --save vue2-google-maps
```

Nuxt.jsのコンフィグファイルに、マニュアルに記載の通りに、build設定の中に、以下を追記。

```Javascript:nuxt.config.js
transpile: [/^vue2-google-maps($|\/)/]
```

また、SSR環境でも動作させるためには、nuxt.config.js設定ファイルのplugins設定に以下を追加します。SSRをfalseにしないと正常に動作しないです。```Unexpected token <``` といったエラーが発生し、この点、ハマりました。


```Javascript:nuxt.config.js
{src: "~/plugins/vue2-google-maps.js", ssr: false }
```

```Javascript:~/plugins/vue2-google-maps.js
import Vue from 'vue'
import * as VueGoogleMaps from '~/node_modules/vue2-google-maps'
 
Vue.use(VueGoogleMaps, {
  load: {
    key: process.env.GOOGLE_MAPS_API_KEY,
  },
})
```


## 4. CloudSQLからデータ取得API(バックエンド側)
フロントからつつくバックエンド側のAPIを作成します。用途としては、Nuxt.js/Vue.jsアプリから、axiosを使って、当該APIに表示するためのデータを取得するために利用します。

- CloudFunctionsというFaaSサービスを利用
- HTTP POSTでid/titleを渡される(req.body.id / req.body.title)とトリガー
- GCP CloudSQL(MySQL)へCloudSQL Proxy接続を行い、クエリを投げて、検索
結果を返す。
- [詳細コードは、Githubへアップロードしています。](https://github.com/ko-ism/functions-get-maps-data-from-cloudsql)

また、データベースには、以下のようなカラムのデータが入っています。

| address_id |  title |  lat |  lng |  address |  overview |  url |
|:-------------------|:------------------|:-----|:-----|:-----|:-----|:-----|
|数字のID（例1,2,3| 件名データ |緯度データ|経度データ|住所情報|概要|url情報|



## 5. 表示画面の構造

```
pages
├── search.vue
├── search_result.vue
├── maps.vue
└── view_data.vue
```

|   　ファイル  |    内容 |
|:-------------------|:------------------|
|   search.vue         | 検索画面表示(IDと件名検索が可能) | 
| search_result.vue  | 検索結果を一覧表示 |
|   maps.vue | 地図画面表示。検索されたデータを地図上へプロットし、表示 |
| view_data.vue | DBに格納されている全データを表示 |


## 6. 検索画面(フロントエンド側)
入力する検索キーワード(IDや件名)を取得し、methodのsearch_idまたはsearch_titleを呼び出します。nuxt-linkを使って、画面遷移をさせることで、データも引き継がれる動作。

```html:~/pages/search.vueのtemplate部分抜粋
    <input class="search-box" type="text" v-model.number="searched_id">
    <nuxt-link class="btn-square" @click.native="search_id" to="/search_result">id検索</nuxt-link><br />
    <input class="search-box" type="text" v-model="searched_title">
    <nuxt-link class="btn-square" @click.native="search_title" to="/search_result">title検索</nuxt-link><br />
```

```Javascript:~/pages/search.vueのscript部分抜粋
    methods: {
      search_id(event){
        const id = this.searched_id
        this.$store.dispatch('view_maps/search_id', id)
      },
      search_title(event){
        const title = this.searched_title
        this.$store.dispatch('view_maps/search_title', title)
      }

    },
```

storeにあるactionは以下で、axiosを使ってDB検索APIをたたき、取得された検索結果をstoreのstateに格納します。


```html:
store
└── view_maps.js
```

```Javascript:~/store/view_maps.js
import axios from 'axios'

export const state = () => ({
  searched_data: [],
})

export const mutations = {
  clear_searched_data (state){
    state.searched_data = [];
  },
  add_searched_data (state, data){
    state.searched_data.push(data);
  }
}

export const actions = {
  init: () => {
  },
  search_id: async (context, id) => {
    context.commit('clear_searched_data')
    
    axios.post(process.env.API_URL, {id : id})
    .then(function(response) {
        const items = response.data;
        for (const item of items) {
              context.commit('add_searched_data',  item)
            };    
    })
    .catch(function(error) {
        console.log(error)
    })
    .finally(function() {
    })

  },
  search_title: async (context, title) => {
    context.commit('clear_searched_data')
    
    axios.post(process.env.API_URL, {title : title})
    .then(function(response) {
        const items = response.data;
        for (const item of items) {
              context.commit('add_searched_data',  item)
            };
    })
    .catch(function(error) {
        console.log(error)
    })
    .finally(function() {
    })

  }
}

```


## 7. 結果表示画面(フロントエンド側)
store/view_maps.jsにgettersを定義しておき、stateに格納されている値（＝検索結果データ）を取得します。取得された値を、表形式で表示します。

```html:~/pages/search_result.vueのtemplate部分抜粋
    <table>
      <tr>
        <th>address_id</th>
        <th>title</th>
        <th>lat(緯度)</th>
        <th>lng(経度)</th>
        <th>address</th>
        <th>overview</th>
        <th>url</th>
      </tr>
      <tr v-for="result in view_search_result" :key="result.id">
        <td>{{result.address_id}}</td>
        <td>{{result.title}}</td>
        <td>{{result.lat}}</td>
        <td>{{result.lng}}</td>
        <td>{{result.address}}</td>
        <td>{{result.overview}}</td>
        <td><a v-bind:href="result.url">URL</a></td>
      </tr>
    </table>
```

```Javascript:~/pages/search_view.vueのscript部分抜粋
    computed: {
      view_search_result() {
        return this.$store.getters['view_maps/getSearchResult']
      },
    }
```

```Javascript:~/store/view_maps.js抜粋
export const getters = {
  getSearchResult: state => {
    return state.searched_data
  }
}
```

## 8. 地図画面表示(フロントエンド側)
最後に、目的の地図画面表示です。
このあたりのサイトを参考にしました。
[【Vue.js】vue2-google-mapsを使って地図検索を実装してみた](https://salary.katsulabo.com/vue2-google-maps/ "【Vue.js】vue2-google-mapsを使って地図検索を実装してみた")

view_search_resultで得られた検索結果データの数だけ、アンカーを作成します。
GmapMapでcenter位置やサイズなどを指定します。
GmapMap内のGmapMarkerでマーカーの定義を行います。ポジションは経度緯度情報を渡し、クリック時にinfomationWindowが表示されるようにv-on:clickも設定しておきます。

```html:~/pages/maps.vueのtemplate部分抜粋
    <GmapMap
      v-bind:center="center"
      v-bind:zoom="zoom"
      style="width: 500px; height: 500px"
      >
      <GmapMarker
        v-bind:key="index"

        v-for="(m, index) in view_search_result"
          v-bind:position="{lat: m.lat , lng: m.lng}"
          v-bind:title="m.title"
          v-bind:clickable="true"
          v-bind:draggable="true"
          v-on:click="view_infowin(m.title, m.overview, m.address, m.url, m.lat, m.lng)"
      /> 

      <GmapInfoWindow
          v-bind:options="infoOptions"
          v-bind:position="infoWindowPos"
          v-bind:opened="infoWinOpen"
          @closeclick="infoWinOpen=false"
        >
          <div class="infoWin" v-html="infoContent"></div>
      </GmapInfoWindow>

    </GmapMap>

```

データは初期値として、以下の値を入れています。centerの緯度経度はとりあえず東京駅の緯度経度情報を入れましたが、検索結果によってその中心を設定するといったことや、検索結果の距離に応じてZoomを動的に変更して表示させるようにする修正はしたいと思います。

```Javascript:~/pages/maps.vueのscript部分抜粋
    data: function() {
      return {
        center: {lat: 35.681236, lng: 139.767125},
        zoom: 12,
        infoWindowPos: null,
        infoWinOpen: false,
        infoContent: {
        imageurl: null,
        title: null,
        address: null
        },
        infoOptions: {
          pixelOffset: {
            width: 0,
            height: -35
          }
        },
      }

    }
```

また、アンカークリック時に表示をするInformationWindowについては、htmlでコンテンツをかけるので、以下のように記載をしました。クリック時にクリックしたアンカーをセンターにもってくる処理も入れています。

```Javascript:~/pages/maps.vueのscript部分抜粋
    methods: {
      view_infowin(title, overview, address, url, lat, lng) {
        this.center = {lat: lat , lng: lng};
        this.infoWindowPos = {lat: lat , lng: lng};
        this.infoWinOpen = true;
        this.infoContent = '<table>' +  
          '<tr><th>title</th><td>' + title + '</td></tr>' + 
          '<tr><th>住所</th><td>' + address + '</td></tr>' +
          '<tr><th>概要</th><td>' + overview + '</td></tr>' + 
          '<tr><th>URL</th><td>' + '<a href="'+ url + '" target="_blank">URL</a>' + '</td></tr></table>';
      }
    },
```

## 9. 動作確認
最初、```npm run dev```で動作させましたが、検索結果を取得するaxiosがNetwork Errorとなり、正常に動作しませんでした。HTTPリクエストでCORSが適用されることが原因のようです。CORSは、異なるドメイン間でのアクセス制御を行うCORSという規定によるもののようで、API側の設定変更を行うことで動作確認試験を行えました。

[Cross-Origin Resource Sharing(CORS)を使用したHTTPリクエスト](https://blog.kazu69.net/2017/03/23/http-request-using-cors/)

```Javascript:CloudFunctionsのレスポンス時のヘッダー修正
    res.setHeader('Access-Control-Allow-Origin', constantFun.constants.ALLOWED_ORIGINS);
    res.setHeader('Access-Control-Allow-Methods', constantFun.constants.ALLOWED_METHODS.join(','));
    res.setHeader('Access-Control-Allow-Headers', 'Content-type,Accept,X-Custom-Header');
```

```Javascript:
exports.constants = {
  ALLOWED_METHODS: [
    'GET',
    'POST',
    'PUT',
    'PATCH',
    'DELETE',
    'HEAD',
    'OPTIONS'
  ],
  ALLOWED_ORIGINS: [
    'http://localhost:3000',
  ]
};
```


## 10. CloudRunでの動作確認
今年GAになったCloudRunへもデプロイしてみました。Dockerfileとcloud-build.ymlを使って、以下の設定で、デプロイできました。

```Docker:Dockerfile
# ノードイメージ取得。最新のNode10 LTS
FROM node:10
# ワークディレクトリ
WORKDIR /usr/src/app
# CloudRunで動作させるポート設定
ENV PORT 8080
ENV HOST 0.0.0.0
# アプリケーションの依存関係をインストールする
COPY package*.json ./
# npm モジュールをインストール
RUN npm install
# vue2-google-maps install
RUN npm install vue2-google-maps -g
# アプリケーションのソースをバンドルする
COPY . .
RUN npm run build

CMD ["npm", "start"]
```

```YAML:cloud-build.yml
steps:
- name: gcr.io/cloud-builders/docker
  args:
    ['build','-f','Dockerfile','-t','gcr.io/<your project id>/nuxt-view-search-maps','.']
images: ['gcr.io/<>your project id/nuxt-view-search-maps']
```

デプロイコマンドは、以下。最初にContainerRegistoryへイメージをアップロードし、ContainerRegistoryのイメージをCloudRunへデプロイ実施

- Cloud ContainerRegistoryへイメージデプロイ

```
gcloud builds submit --project "your_project_id" --config=./cloud-build.yml
```

- ContainerRegistoryのイメージをCloudRunへデプロイ

```
gcloud beta run deploy nuxt-view-search-maps --region asia-northeast1 --allow-unauthenticated --project "your_project_id" --image gcr.io/<your_project_id>/nuxt-view-search-maps
```

# おわりに
普段GCPは触っていますが、GoogleMapsはあまり触っていなかったため、GoogleMapsを活用して、知見を深めたいと思い、ちょっと触ってみました。GoogleMapsは簡単に利用でき、かつ、個社別でカスタムもすることで、用途の幅が広がるなと思いました。

Nuxt.js/Vue.jsも使うことで、フロントエンド側の知見も得られたので良かったです。探りながらなので、色々お作法など間違っている個所があるかもしれませんので、その際はご指摘ください。

CloudRunにもデプロイしてみましたが、それほど悩むことなくデプロイできましたし、もっと活用してみたいと思いました。


参考リンクはサイト内記載のものです。

コードはgithubに公開しました。
[フロントエンド側](https://github.com/ko-ism/nuxt-view-search-maps/tree/master/nuxt-view-search-maps-ssr)
[サーバサイド側](https://github.com/ko-ism/nuxt-view-search-maps/tree/master/nuxt-view-search-maps-ssr)