クソ雑魚エンジニアのメモ帳

学んだことを書くところ

RaspberryPiにAlexaをインストールしてAmazonMusicを流そうとした件

こんばんは。ボルダリングで腕がパンパンmorifujiです。

今日は久しぶりにRaspoberryPiを押入れから引っ張り出して、Alexaさんを憑依させてAmazonMusicを流させようとしたので知見を共有します。なんか憑依っていうと◯川隆法みたいですね

目次

  1. amazonで開発者アカウント作成・アプリケーション登録
  2. スピーカー・マイク準備
  3. バイス登録
  4. 初期設定

前提となる環境

  • RaspberryPiが起動している
  • RaspberryPiに接続できるスピーカーを持っている
  • RaspberryPiに接続できるマイクを持っている

ちなみにぼくはこういう環境です。

1. amazonで開発者アカウント作成・アプリケーション登録

まずこちらから。https://developer.amazon.com/ でアカウントを作成してください。

今回僕は日本のamazonのアカウントでAmazonPrimeに入会しているので、そのアカウントのemail/passで登録しました。

:warning: amazon.comamazon.co.jp複数アカウントを持つ人は、どのアカウントを使用するか注意してください。まじでハマります。

https://dev.classmethod.jp/voice-assistant/solution-of-a-problem-amazon-com-account-conflict/

まず、トップ右上のDeveloper Consoleに飛びます スクリーンショット 2018-07-08 20.42.31.png

次に、AlexaVoiceServiceに飛びます

スクリーンショット 2018-07-08 20.43.03.png

製品を作成するに飛びます、

項目 入力内容
製品名 任意の製品名です、後から参照しないのでなんでもok。ぼくはraspberry_alexaにしました
製品ID 任意の製品IDです。後から参照するのでメモを忘れず
製品はアプリやデバイスを使用しますか ? 必ずアプリケーションを選択。はまりますので :warning:
商品カテゴリ なんでもok,ぼくはワイヤレススピーカを選びました
製品概要 参照しないのでなんでもok。ぼくはalexa on raspberrypiって書きました
エンドユーザーは、商品とどのようにやり取りするのでしょうか ? ハンズフリーとファーフィールドを選択
画像 しなくてもよい
この製品を商品として配信する予定ですか ? プライベート用なので no
これは子供向け商品、それ以外は13歳以下の子供向けですか ? よくわからんのでnoを選択

これで次へを選択

セキュリティプロファイルを選択する

初めての人はセキュリティプロファイルを作成してから、それを商品に登録します。特に難しいことないです

プラットフォーム情報

プラットフォームを作成する必要があります。他のデバイスやプラットフォームを選択し Downloadボタンからバックアップを取っておきましょう。

スクリーンショット 2018-07-08 20.54.49.png

:thinking:ちなみにこの一般IDってgenerate idって意味なのだろうか :thinking:

スクリーンショット 2018-07-08 20.55.10.png

そのファイルには以下の様なフォーマットでデータが入っています、必ず保管しましょう

{
 "deviceInfo": {
  "clientId": "amzn1.application-oa2-client.XXXXXXXXXXXXX",
  "productId": "raspberry_alexa"
 }
}

規約にチェックをつけて進むと完了するはずです。

2. スピーカー・マイク準備

RaspberryPiのssh環境を用意します。

まずはスピーカー。つなぐ前にスピーカーの状況を確認

# スピーカーデバイス一覧表示
aplay -L

スピーカーをつなぎもう一度実行すると項目が増えているはずです。

そしたら出力確認です

# スピーカーテスト
speaker-test -t sine -f 1000

音が聞こえなかったら徐々に音量あげましょう。

amixer sset PCM 100% 

次にマイクです。同じ様にデバイスとして認識されてるかチェックします

# マイクデバイス一覧
arecord -l 

んで。テスト。今回は、マイクで入力された音声がスピーカーから出力されるか確認します。

arecord -f S16_LE -r 44100 -D hw:1 | aplay

おうむ返しみたいな感じになりましたか?okなら次に行きましょう!

3. デバイス登録

いよいよラズパイにAlexaをインストールしていきます。

今回使うライブラリは こちらのalexa/avs-device-sdkです

ライブラリ選定にはこちらの記事が非常にわかりやすく書かれていました。ありがとうございます ☺️

https://qiita.com/Dimeiza/items/182c4847d7c1ead7df54

# ディレクトリ作成
mkdir ~/raspberry_alexa
cd ~/raspberry_alexa

# 環境構築スクリプトインストール
sudo wget https://raw.githubusercontent.com/alexa/avs-device-sdk/master/tools/Install/setup.sh && sudo wget https://raw.githubusercontent.com/alexa/avs-device-sdk/master/tools/Install/config.txt && sudo wget https://raw.githubusercontent.com/alexa/avs-device-sdk/master/tools/Install/pi.sh

ここで、config.txtというファイルを開くとこんな感じ

#NOTE: The Device Serial Number can be any unique number
DEVICE_SERIAL_NUMBER="alexa_raspberry"
CLIENT_ID=""
PRODUCT_ID=""

先ほど、1でダウンロードしたファイルに書いてあるclient_idproduct_idをここに入力します、。DEVICE_SERIAL_NUMBERは適当でok

{
 "deviceInfo": {
  "clientId": "amzn1.application-oa2-client.XXXXXXXXXXXXX",
  "productId": "raspberry_alexa"
 }
}

この設定を読み込んでセットアップします

sudo bash setup.sh config.txt

ビルドするんでめっちゃ時間かかります。30~60分ほどの感じです。。。次に、サンプルアプリケーションを実行します。

sudo bash startsample.sh

これまた時間がかかりますが、すばらくすると以下の様にブラウザからの承認待ちの状態になります。

################################################################################################
#       To authorize, browse to: 'https://amazon.com/us/code' and enter the code: B8DEA5       #
################################################################################################

2018-07-08 11:17:10.405 [  2] 5 CBLAuthDelegate:handleRequestingToken
#################################################
#       Checking for authorization (1)...       #
#################################################

2018-07-08 11:17:10.407 [  2] 5 CBLAuthDelegate:requestToken
2018-07-08 11:17:10.923 [  2] 5 HttpPost:doPostSucceeded:code=400
2018-07-08 11:17:10.923 [  2] 5 CBLAuthDelegate:receiveTokenResponse:code=400
2018-07-08 11:17:10.924 [  2] 5 CBLAuthDelegate:mapHTTPStatusToError:code=400,error=INVALID_REQUEST
2018-07-08 11:17:10.925 [  2] 5 CBLAuthDelegate:errorInLwaResponseBody:error=authorization_pending,errorCode=AUTHORIZATION_PENDING
2018-07-08 11:17:10.925 [  2] 5 CBLAuthDelegate:setAuthError:authError=AUTHORIZATION_PENDING
2018-07-08 11:17:10.926 [  2] 0 CBLAuthDelegate:receiveTokenResponseFailed:result=AUTHORIZATION_PENDING
#################################################
#       Checking for authorization (2)...       #
#################################################

2018-07-08 11:17:15.927 [  2] 5 CBLAuthDelegate:requestToken
2018-07-08 11:17:16.349 [  2] 5 HttpPost:doPostSucceeded:code=400
2018-07-08 11:17:16.349 [  2] 5 CBLAuthDelegate:receiveTokenResponse:code=400
2018-07-08 11:17:16.349 [  2] 5 CBLAuthDelegate:mapHTTPStatusToError:code=400,error=INVALID_REQUEST
2018-07-08 11:17:16.350 [  2] 5 CBLAuthDelegate:errorInLwaResponseBody:error=authorization_pending,errorCode=AUTHORIZATION_PENDING
2018-07-08 11:17:16.350 [  2] 5 CBLAuthDelegate:setAuthError:authError=AUTHORIZATION_PENDING
2018-07-08 11:17:16.350 [  2] 0 CBLAuthDelegate:receiveTokenResponseFailed:result=AUTHORIZATION_PENDING
2018-07-08 11:17:21.351 [  2] 5 CBLAuthDelegate:requestToken
#################################################
#       Checking for authorization (3)...       #
#################################################

4,5,6と続く...

この状態で、https://amazon.com/us/codeにアクセスします。日本のamazon(amazon.co.jp)のアカウントでもこちらに飛んでください。

  1. コードを入力します。下の例ではB8DEA5です。
  2. 次に進んでアカウントを確認します。ここで、必ず最初にdeveloperに登録したアカウントになっているか必ず確認してください。
  3. 地域は日本に
  4. 規約等にokをしていくと無事に登録できます、

途中で画面が進んでWoopsみたいなこと言われた場合、考えられる原因は2つです。

  • 1つめは、アカウントを間違えている可能性です。上の手順2で必ず確認してください、違った場合は右上からサインアウトしてもう一度チャレンジしてください。(もしくは、この問題かも)
  • 2つめは、 config.txtを間違えている可能性です。config.txtを修正した場合は、sudo bash setup.sh config.txtからやり直してください。

ブラウザでsucceedと表示された場合は成功しました!。sshに戻ってください。

########################################
#       Alexa is currently idle!       #
########################################

と、どこかで出力されているはずです!これでRaspberryPiとalexaアプリケーションが紐づけられました!

この状態ではすでにAlexaはあなたの言葉を待っています。

アメリカンな口調で Alexa how about you?と聞いてみましょう。きっと答えてくれるはずです

4. 初期設定

この状態だと英語の勉強にはなりますが、日本語を喋ってもらいましょう。

言語設定

iを押してEnterでコマンド情報が見れます。

+----------------------------------------------------------------------------+
|                                  Options:                                  |
| Wake word:                                                                 |
|       Simply say Alexa and begin your query.                               |
| Tap to talk:                                                               |
|       Press 't' and Enter followed by your query (no need for the 'Alexa').|
| Hold to talk:                                                              |
|       Press 'h' followed by Enter to simulate holding a button.            |
|       Then say your query (no need for the 'Alexa').                       |
|       Press 'h' followed by Enter to simulate releasing a button.          |
| Stop an interaction:                                                       |
|       Press 's' and Enter to stop an ongoing interaction.                  |
| Privacy mode (microphone off):                                             |
|       Press 'm' and Enter to turn on and off the microphone.               |
| Echo Spatial Perception (ESP): This is for testing purpose only!           |
|       Press 'e' followed by Enter at any time to adjust ESP settings.      |
| Playback Controls:                                                         |
|       Press '1' for a 'PLAY' button press.                                 |
|       Press '2' for a 'PAUSE' button press.                                |
|       Press '3' for a 'NEXT' button press.                                 |
|       Press '4' for a 'PREVIOUS' button press.                             |
| Settings:                                                                  |
|       Press 'c' followed by Enter at any time to see the settings screen.  |
| Speaker Control:                                                           |
|       Press 'p' followed by Enter at any time to adjust speaker settings.  |
| Firmware Version:                                                          |
|       Press 'f' followed by Enter at any time to report a different        |
|       firmware version.                                                    |
| Info:                                                                      |
|       Press 'i' followed by Enter at any time to see the help screen.      |
| Reset device:                                                              |
|       Press 'k' followed by Enter at any time to reset your device. This   |
|       will erase any data stored in the device and you will have to        |
|       re-register your device.                                             |
|       This option will also exit the application.                          |
| Quit:                                                                      |
|       Press 'q' followed by Enter at any time to quit the application.     |
+----------------------------------------------------------------------------+
  1. c押してEnter
  2. 1押してEnter
  3. 6を押してEnter

すると以下の様に日本語になりました!

###################################
#       locale set to ja-JP       #
###################################

この状態で Alexa 明日の天気は!?というときちんと日本語で返してくれます!

ですが、いくらAlexa! Alexa! A・L・E・X・A・!といっても反応がありませんでした。。中華製マイクだしこんなもんですかね :cry:

こういうときは、tを押しましょう。呼びかけをする必要なく、スマートに明日の天気は?と聞くことができました(スマートとはいっていない)

その他環境設定

http://alexa.amazon.com/または https://alexa.amazon.co.jp(アカウントによる??)にアクセスし、アカウント情報を入力すると、現在つないでいるAlexaの設定や命令をブラウザ上で行えます。

スクリーンショット 2018-07-08 21.30.38.png

ぼくはAmazonPrime会員なので、ここにAmazpnMusicが自動でインポートされていました!!感動!!

スクリーンショット 2018-07-08 21.34.13.png

ということは、Alexaに喋りかけて、ぼくの珠玉のプレイリストをかけることもできるはず!と確信したので呼びかけました

ぼく:アレクサ 音楽を流して

Alexa:この端末ではAmazonMusicに対応しておりません

ぼく:😢😢😢😢😢😢😢😢😢😢😢😢😢😢😢😢😢😢😢😢😢😢😢😢😢😢😢

ぼく:なんでや!管理画面からはAmazonPrimeの音楽流せたやん!!!

スクリーンショット 2018-07-08 21.50.50.png

ぼく:👊👊👊👊👊

所管

  • すごい便利
    • 一時期ゆっくりさんで、目覚ましアプリをつくってた時期を思い出した
    • それよりはるかに高機能すぎて嬉しい
  • スキル選び楽しい ヽ(゚∀゚ヽ 三 ノ゚∀゚)ノ
  • 自動起動の設定はまた今度かな
  • AmazonMusicの件に関しては金の亡者だと思った。
    • alexaの仕様よく読めよとか言わないでください
  • かわりにradikoを召喚した(代わりになってない)
  • いいマイク買ったほうがいい
    • 5000円くらいの買って試してみる予定
    • だれかうまくいった機種教えてください!!!
    • 試したら追記します

参考サイト

https://github.com/alexa/avs-device-sdk https://github.com/alexa/avs-device-sdk/wiki/Raspberry-Pi-Quick-Start-Guide-with-Script https://qiita.com/Dimeiza/items/182c4847d7c1ead7df54 http://blog.saboh.net/raspberrypiusbmaiku/ https://dev.classmethod.jp/voice-assistant/solution-of-a-problem-amazon-com-account-conflict/

RaspberryPiでのマイク・スピーカーの動作確認チートシート

# 音量変更
amixer sset PCM 100% 

# スピーカーテスト
speaker-test -t sine -f 1000

# マイクデバイス優先順位確認
arecord -l 

# マイクテスト
arecord -f S16_LE -r 44100 -D hw:1 | aplay

vue.jsでgoogleMapをPWAっぽく表示するオレオレプラクティス

こんばんは。switchを買ったけど放置してるmorifujiです。 ハマったら全てを放り出しそうで怖いです。

今日はGoogleMapとvuejsの組み合わせでごにょごにょしたのでその知見を共有します(PWA要素はほぼないです笑ごめんなさい)

とりあえず成果物▼

  • オリジナルのマーカー
  • マーカークリックするとwindow表示
  • マーカーアニメーションを設定
  • GeolocationAPIを使って現在地を取得しマーカーに追加
    • この部分の実装はこの記事には書いてません。

blog.gif

経緯

  • pwaでgoogleMapをごりごり使いたい案件が発生!?
    • 軽くしか触ったことない
    • しかも生js
  • いっちょオレオレGooglemapVueコンポーネント作るか

最低要件ライン

  • マーカーの追加ができること
  • マーカーのクリックイベントを簡単にlistenできること
  • マーカーの画像をカスタマイズできること
  • 最近のアップデートで現れた地図を移動させるには指 2 本で操作します仕様。今回はアプリ風pwaなのでこれを無効化すること
    • これがあるのとないのでPWAっぽさが出てくると考えてる!!
    • IMG_1452.PNG

調べた結果

vuejs + GoogleMapApiで色々調べてみたが、それなりに方法が出てきた。

1. vue2-google-maps

vue と googleMapApiで調べるとトップに出てくるライブラリ。

WeeklyDownloadsが1000超えてるので期待していた

<GmapMap
  :center="{lat:10, lng:10}"
  :zoom="7"
  map-type-id="terrain"
  style="width: 500px; height: 300px"
>
  <GmapMarker
    :key="index"
    v-for="(m, index) in markers"
    :position="m.position"
    :clickable="true"
    :draggable="true"
    @click="center=m.position"
  />
</GmapMap>

こんな感じでGmapコンポーネントのslotにマーカー を設定するようである。v-forで回してるからめちゃ簡単。

基準 vue2-google-maps
マーカーの追加 OK
マーカーのクリックイベント取得 OK
画像カスタマイズ OK
地図を移動させるには..問題 NG

地図を移動させるには...問題は解決しなかった。ちなみにいうとこの問題の解決方法は、 gestureHandling: 'greedy'を設定するだけだ。だがこれができない。GmapMapコンポーネントのpropsにつけてもうんともすんとも言わない。デモサイトには微塵もそんな設定がないし行き詰まった。

     var map = new google.maps.Map(document.getElementById('map'), {
          zoom: 13,
          center: locationRio,
          gestureHandling: 'greedy'
        });

出典:https://developers.google.com/maps/documentation/javascript/interaction?hl=ja

よくよく見ると、propsの項目が公式GoogleMapsJavascriptApi(以下、公式API)と大きく乖離しているような気がする。

propsに設定値を渡している時点で、公式apiとライブラリが密結合していて、このライブラリが今後もapiに柔軟に対応できるのか非常に疑問である。よって却下。

2. 生api + scriptjs

コンポーネントにすると、密結合になるから、やっぱり公式のapi普通のjsと同じように読み込むのがいいよね!(テノヒラクルー)

ですがgoogleさんからはモジュールが提供されていないので、vuejsの中でurlからjsを読込まないといけない。そうなるとindex.htmlに記載する必要がでてきて、関係ないページにも公式APIが読み込まれてパフォーマンスが下がる。たしかサイズが200kbぐらいあったしそれは避けたい。

ここでscriptjsを使うことにした。これを使うと、要するに、非常に簡単に非同期で外部jsが読み込めるというものらしい。

サンプルコード▼

// load jquery and plugin at the same time. name it 'bundle'
$script(['jquery.js', 'my-jquery-plugin.js'], 'bundle')
 
// load your usage
$script('my-app-that-uses-plugin.js')

/*--- in my-jquery-plugin.js ---*/
$script.ready('bundle', function() {
  // jquery & plugin (this file) are both ready
  // plugin code...
})

変なクセもなく、非常に使いやすい。以下のファイルは検証に使った筆者のvueのcomponent。

最低限のものだけpropsにしてるけど許してね 🤗 。

あと、let,const,var警察はあまり僕のコードを見ないでください ⚠️

<template>
  <div>
    <div
      id="map"
      :style="{width: mapWidth + 'px',height: mapHeight + 'px'}"/>
  </div>
</template>

<script>
var scriptjs = require("scriptjs");

export default {
  name: "Gmap",
  props: {
    mapWidth: {
      type: Number,
      default: 100
    },
    mapHeight: {
      type: Number,
      default: 100
    },
    lat: {
      type: Number,
      default: 34.722677
    },
    lng: {
      type: Number,
      default: 135.492364
    },
    zoom: {
      type: Number,
      default: 8
    },
    markers: {
      type: Array,
      default: () => {
        return [];
      }
    }
  },
  data() {
    return {
      map: null,
      formattedMarkers: []
    };
  },
  watch: {
    markers() {
      // マーカーを全削除
      this.formattedMarkers.forEach(marker => {
        marker.setMap(null);
      });
      // propsからも削除
      this.formattedMarkers.splice(0, this.formattedMarkers.length);

      // 再描画
      this.addMarker();
    }
  },
  created() {
    scriptjs(
      "https://maps.googleapis.com/maps/api/js?key=XXXXXXXXXXXXXXXXXXXXXXX&callback=initMap",
      "loadGoogleMap"
    );
    scriptjs.ready("loadGoogleMap", this.loadMap);
  },
  mounted() {},
  methods: {
    addMarker() {
      this.markers.forEach(markerInfo => {
        var contentString =
          '<div id="content">' +
          '<div id="siteNotice">' +
          "</div>" +
          '<h1 id="firstHeading" class="firstHeading">Uluru</h1>' +
          '<div id="bodyContent">' +
          "<p><b>Uluru</b>, also referred to as <b>Ayers Rock</b>, is a large " +
          "sandstone rock formation in the southern part of the " +
          "Northern Territory, central Australia. It lies 335&#160;km (208&#160;mi) " +
          "south west of the nearest large town, Alice Springs; 450&#160;km " +
          "(280&#160;mi) by road. Kata Tjuta and Uluru are the two major " +
          "features of the Uluru - Kata Tjuta National Park. Uluru is " +
          "sacred to the Pitjantjatjara and Yankunytjatjara, the " +
          "Aboriginal people of the area. It has many springs, waterholes, " +
          "rock caves and ancient paintings. Uluru is listed as a World " +
          "Heritage Site.</p>" +
          '<p>Attribution: Uluru, <a href="https://en.wikipedia.org/w/index.php?title=Uluru&oldid=297882194">' +
          "https://en.wikipedia.org/w/index.php?title=Uluru</a> " +
          "(last visited June 22, 2009).</p>" +
          "</div>" +
          "</div>";

        // マーカー
        let marker = new google.maps.Marker({
          position: markerInfo.position,
          icon:
            "https://developers.google.com/maps/documentation/javascript/examples/full/images/beachflag.png",
          map: this.map,
          // ポップなアニメーションを付与
          animation: google.maps.Animation.DROP
        });

        // マーカーのwindow
        let infowindow = new google.maps.InfoWindow({
          content: contentString
        });

        // マーカークリック時にwindow表示
        marker.addListener("click", function() {
          infowindow.open(this.map, marker);
        });
        this.formattedMarkers.push(marker);
      });
    },
    loadMap() {
      // googleMapを初期化
      this.map = new google.maps.Map(document.getElementById("map"), {
        center: { lat: this.lat, lng: this.lng },
        zoom: this.zoom,
        // スワイプ判定を強めに設定(地図を移動させるには..問題)
        gestureHandling: "greedy"
      });
      this.addMarker();
    }
  }
};
</script>

結果、要件最低ラインをクリアした。

基準 vue2-google-maps api + scriptjs
マーカーの追加 OK OK
マーカーのクリックイベント取得 OK OK
画像カスタマイズ OK OK
地図を移動させるには..問題 NG OK

3. google-maps

もういっちょ最後に検証してみた。

生のjsはなんか心がザワザワしたのでvueのコンポーネントにこだわらず、モジュール単位でもうちょいいいのがないか探した結果がこれ。ほんとにただ単にmodule化しただけのもの。

生api + scriptjsと違う主な点は以下のとおり

  • API_KEYとかLOCALEとか仕様ライブラリを定数として設定できて非常に気持ちよかった(options)
  • 非同期メソッドにloadonLoadがあるという謎仕様(loadは分かるがonLoadはどういう場面で使えるん??)
  • releaseというメソッド(API_KEYとかをclearするメソッドかな?)もあるがSPAなのでうまく動かなかった。(ここ要調査)
  • やっぱり非公式だからどこまでメンテするか謎い

使ったvueコンポーネントはこちら(let,const,var警察はあまり僕のコードを見ないでください)▼

<template>
  <div>
    <div
      id="map"
      :style="{width: mapWidth + 'px',height: mapHeight + 'px'}"/>
    <button
      class="button"
      @click="release">無効化</button>
  </div>
</template>

<script>
let GoogleMapsLoader = require("google-maps");

GoogleMapsLoader.KEY = "XXXXXXXXXXXXXXXXXXXXXx";
GoogleMapsLoader.LANGUAGE = "ja";

export default {
  name: "Gmap",
  props: {
    mapWidth: {
      type: Number,
      default: 100
    },
    mapHeight: {
      type: Number,
      default: 100
    },
    lat: {
      type: Number,
      default: 34.722677
    },
    lng: {
      type: Number,
      default: 135.492364
    },
    zoom: {
      type: Number,
      default: 8
    },
    markers: {
      type: Array,
      default: () => {
        return [];
      }
    }
  },
  data() {
    return {
      map: null,
      formattedMarkers: []
    };
  },
  watch: {
    markers() {
      // マーカーを全削除
      this.formattedMarkers.forEach(marker => {
        marker.setMap(null);
      });
      // propsからも削除
      this.formattedMarkers.splice(0, this.formattedMarkers.length);

      // 再描画
      this.addMarker();
    }
  },
  created() {},
  mounted() {
    // googleMap描画
    GoogleMapsLoader.load(this.loadMap);
  },
  methods: {
    addMarker() {
      this.markers.forEach(markerInfo => {
        var contentString =
          '<div id="content">' +
          '<div id="siteNotice">' +
          "</div>" +
          '<h1 id="firstHeading" class="firstHeading">Uluru</h1>' +
          '<div id="bodyContent">' +
          "<p><b>Uluru</b>, also referred to as <b>Ayers Rock</b>, is a large " +
          "sandstone rock formation in the southern part of the " +
          "Northern Territory, central Australia. It lies 335&#160;km (208&#160;mi) " +
          "south west of the nearest large town, Alice Springs; 450&#160;km " +
          "(280&#160;mi) by road. Kata Tjuta and Uluru are the two major " +
          "features of the Uluru - Kata Tjuta National Park. Uluru is " +
          "sacred to the Pitjantjatjara and Yankunytjatjara, the " +
          "Aboriginal people of the area. It has many springs, waterholes, " +
          "rock caves and ancient paintings. Uluru is listed as a World " +
          "Heritage Site.</p>" +
          '<p>Attribution: Uluru, <a href="https://en.wikipedia.org/w/index.php?title=Uluru&oldid=297882194">' +
          "https://en.wikipedia.org/w/index.php?title=Uluru</a> " +
          "(last visited June 22, 2009).</p>" +
          "</div>" +
          "</div>";

        // マーカー
        let marker = new google.maps.Marker({
          position: markerInfo.position,
          icon:
            "https://developers.google.com/maps/documentation/javascript/examples/full/images/beachflag.png",
          map: this.map,
          // ポップなアニメーションを付与
          animation: google.maps.Animation.DROP
        });

        // マーカーのwindow
        let infowindow = new google.maps.InfoWindow({
          content: contentString
        });

        // マーカークリック時にwindow表示
        marker.addListener("click", function() {
          infowindow.open(this.map, marker);
        });
        this.formattedMarkers.push(marker);
      });
    },
    loadMap(google) {
      // googleMapを初期化
      this.map = new google.maps.Map(document.getElementById("map"), {
        center: { lat: this.lat, lng: this.lng },
        zoom: this.zoom,
        // スワイプ判定を強めに設定(地図を移動させるには..問題)
        gestureHandling: "greedy"
      });
      this.addMarker();
    },
    release() {
      GoogleMapsLoader.release(function() {
        console.log("No google maps api around");
      });
    }
  }
};
</script>

こちらも要件最低ラインはクリアした。

基準 vue2-google-maps api + scriptjs google-maps
マーカーの追加 OK OK OK
マーカーのクリックイベント取得 OK OK OK
画像カスタマイズ OK OK OK
地図を移動させるには..問題 NG OK OK

まとめ

  • 生api+scriptjsgoogle-mapsだと拡張性高い!
  • なんでもかんでもvueコンポーネントにするのは良くない。
    • 公式への対応性・拡張性がなくなるため(仕様が変わったら、中の人が頑張らないと新しい仕様に対応することが難しい 😢)
    • その点、jsを軽くモジュール化したものは、要所要所はjsなので拡張性高いかな?
  • モジュール化されてなくても、頑張ればvueのsfcで非同期に使える。
  • npmのWeeklyDownloads数はあまり当てにならない 😢
  • let,const,var警察はあまり僕のコードを見ないでください

おまけ

googleMapの幅を画面いっぱいにしたい

それ、vuejsなら解決します!

  1. 画面幅を取得して
  2. styleにバインド
<template>
  <div>
  <!-- 2. styleにバインド -->
    <div
      id="map"
      :style="{width: mapWidth + 'px',height: mapHeight + 'px'}"/>
  </div>
</template>

<script>
export default {
  name: "Gmap",
  data() {
    return {
       mapHeight: 500,
       mapWidth: 1000
    };
  },
  created() {
    // 1. 画面幅を取得して
    this.mapWidth = window.innerWidth;
  }
};
</script>

サクッとjsでQRリーダー実装

こんばんは。

最近チャリンコを購入して、チャリンコ通勤始めました。道中のお弁当屋とか発見できてQOL爆上げ中のmorifujiです。

背景

  • Vuejsでできること色々調べる機会があった
  • いろんなWebAPIが存在していることに気づいた
  • 案件の仕様の中でwebアプリケーション内のみでqr読み取る必要が出てきた

目標

  1. ブラウザで指定のURLを開き
  2. QRリーダーを起動(スマホのカメラ画面にならずに)
  3. QRを撮影すると、瞬時に解析して画面に表示

スマートフォンでの検証はまだ。確認し次第追記します m( )m

実装後デモ▼ chat_2.gif

shape detection API (不採用)

リアルタイムに物体を認識するためのAPI

人間の顔(FaceDetection)とバーコード(BarcodeDetection)の二種類がある。

https://wicg.github.io/shape-detection-api/

試してみた。

// 顔認識
if (window.FaceDetector == undefined) {
  console.error('Face Detection not supported on this platform');
}

// バーコード認識
if (window.BarcodeDetector == undefined) {
  console.error('Barcode Detection not supported on this platform');
}


// 今回はバーコード認識
let barcodeDetector = new BarcodeDetector();
// Assuming |theImage| is e.g. a <img> content, or a Blob.
barcodeDetector.detect(theImage)
.then(detectedCodes => {
  for (const barcode of detectedCodes) {
    console.log(' Barcode ${barcode.rawValue}' +
        ' @ (${barcode.boundingBox.x}, ${barcode.boundingBox.y}) with size' +
        ' ${barcode.boundingBox.width}x${barcode.boundingBox.height}');
  }
}).catch(() => {
  console.error("Barcode Detection failed, boo.");
})

結果。どれも未対応。悲しい

ブラウザ 対応有無
firefox X
iOSsafari X
chrome X

getUserMedia

WebRTCを実現するためのAPIの一種のようだ。以下の二種類あるが、前者Navigatorはバグが発見されているので、廃止予定となっている。

試して見た。

js部分▼

let p = navigator.mediaDevices.getUserMedia({ audio: false, video: true });

p.then(function(stream) {
   var video = document.querySelector('video');
   video.src = window.URL.createObjectURL(mediaStream);
   video.onloadedmetadata = function(e) {
      // Do something with the video here.
   };
};

html部分▼

        <div class="container">
          <div class="columns">
            <div class="column">
              <video
                :width="width"
                :height="height"
                autoplay/>
            </div>
           </div>
         </div>

firefoxでできた!!

スクリーンショット 2018-06-13 23.21.13.png

しかし、createObjectURLは廃止予定だから使うな!という警告をconsoleログで受けた。。。 調べてみるとバグがあるらしい。

スクリーンショット 2018-06-13 23.22.46.png

createObjectURLではなく、videoのdomのsrcObjectに直接ぶっ込めばいいとのこと。修正▼

// var video = document.querySelector('video');
// video.src = window.URL.createObjectURL(mediaStream);
document.querySelector("video").srcObject = mediaStream;

canvasにコピー

qrを読み取るライブラリはあまたあるようだが、そのどれも、 canvasからimageを取得している。 なのでまずはwebカメラを表示しているvideoタグの動画を、canvasにコピーしないといけない。

以下、先ほどのコードに追記

 const video = document.querySelector("video");
 const canv = document.querySelector("canvas");
 const context = canv.getContext("2d");

// canvasに描写
 context.drawImage(video, 0, 0, this.width, this.height);

これでボタンを押すたびにvideoタグからcanvasにコピーする(スクショする)ような感じになった。

chat.gif

※上のgifだと、domのサイズ指定してないのでcanvasが引き伸ばされてます

LazarSoft/jsqrcode (不採用)

こちらの記事を参考にQR画像からデータをデコードするライブラリを探した。

start数につられてLazarSoft/jsqrcodeを使おうとしたが、良いサンプルがなかったのと、最終更新が古かったので、やめた、

cozmo/jsQR

最近も開発が進んでいるようなので、このライブラリに決めた。使い方はこんな感じ

const code = jsQR(imageData, width, height);

if (code) {
  console.log("Found QR code", code);
}

めっちゃ簡単。今までのと合わせると、以下のような感じ

      const video = document.querySelector("video");
      let canv = document.querySelector("canvas");

      const context = canv.getContext("2d");
      context.drawImage(video, 0, 0, this.width, this.height);
      const imageData = context.getImageData(0, 0, this.width, this.height);
      const code = jsQR(imageData.data, imageData.width, imageData.height);
      if (code) {
        console.log("Found QR code", code, code.data);
      }

jsQR()の第一引数には、Uint8ClampedArray型が来るはずだが、どうやら canvas.getContext("2d").getImageData(0, 0, this.width, this.height);で取得できるようだ。

定期的に画像を認識

今の状態だと、QRを認識するには毎回ボタンを押す必要があるので、setIntervalで毎時間(0.5秒)ごとにcanvas描写&qr読取できるように修正

      const video = document.querySelector("video");
      let canv = document.querySelector("canvas");

      const context = canv.getContext("2d");
      setInterval(() => {
          context.drawImage(video, 0, 0, this.width, this.height);
          const imageData = context.getImageData(0, 0, this.width, this.height);
          const code = jsQR(imageData.data, imageData.width, imageData.height);
          if (code) {
              console.log("Found QR code", code, code.data);
        }
      }, 500);

負荷を軽減

macがホカホカになっていたので、負荷を軽減したい。

videoタグとcanvasタグで同時に描写が起こってるので、canvasタグの方をjs内で仮想DOMとして扱うことにした。修正したもの▼

// let canv = document.querySelector("canvas");
let canv = document.createElement("canvas");
canv.height = this.height;
canv.width = this.width;

videoタグのフレームレートを落とす

getUserMedia()の引数を以下のように修正

   
    // let p = navigator.mediaDevices.getUserMedia({ audio: false, video: true });
    let p = navigator.mediaDevices.getUserMedia({
        audio: false,
        video: {
          width: this.width,
          height: this.height,
          frameRate: { ideal: 5, max: 15 }
        }
    });

まとめ

筆者はvuejsのsfcでwebを作っているので、こんな感じのvueコンポーネントにまとめることができました。

<template>
  <div>
    <section>
      <div class="container">
        <div class="columns">
          <div class="column">
            <b-message
              :active.sync="isReadQr"
              title="読み取り内容">
              {{ json }}
            </b-message>
            {{ isReadQr }}
          </div>
        </div>
      </div>
    </section>
    <section class="hero is-medium has-text-centered">
      <div class="hero-body hero-body-hp-main">
        <div class="container">
          <div class="columns">
            <div class="column">
              <button
                class="button"
                @click="cameraStart">カメラスタート</button>
            </div>
            <div class="column">
              <button
                class="button"
                @click="readImage">読み取りスタート</button>
            </div>
          </div>
          <div class="columns">
            <div class="column">
              <video
                :width="width"
                :height="height"
                autoplay/>
            </div>
            <div class="column">
              <div :style="{ width: width + 'px', height: height+ 'px' }">
                <!-- canvasなしでも仮想DOMを作成して描画 -->
                <!-- <canvas
                  :width="width"
                  :height="height"
                /> -->
              </div>
            </div>
          </div>
        </div>
      </div>
    </section>
  </div>
</template>

<script>
import jsQR from "jsQR";

export default {
  name: "Qr",
  data() {
    return {
      srcObject: "",
      width: 500,
      height: 500,
      json: null
    };
  },
  computed: {
    isReadQr: () => {
      return Boolean(this.json);
    }
  },
  methods: {
    cameraStart() {
      const p = navigator.mediaDevices.getUserMedia({
        audio: false,
        video: {
          width: this.width,
          height: this.height,
          frameRate: { ideal: 5, max: 15 }
        }
      });
      p.then(function(mediaStream) {
        document.querySelector("video").srcObject = mediaStream;
      });
    },
    readImage() {
      const video = document.querySelector("video");
      const canv = document.createElement("canvas");
      canv.height = this.height;
      canv.width = this.width;

      const context = canv.getContext("2d");

      setInterval(() => {
        console.log("search .....");
        context.drawImage(video, 0, 0, this.width, this.height);
        const imageData = context.getImageData(0, 0, this.width, this.height);
        const code = jsQR(imageData.data, imageData.width, imageData.height);
        if (code) {
          console.log("Found QR code", code, code.data);
          this.json = code.data;
        }
      }, 500);
    }
  }
};
</script>

デモ▼ chat_2.gif

行きつけの美容院さんのQRコードを使わさせていただきました。感謝 m( )m

所感

  • もはやwebでなんでもできるなあと思った。こういうところからpwaが流行りそうな理由なのか?
  • QRが遠いと思ったより認識が弱い。
    • 他のライブラリと比較したい(するとはいっていない)
  • アンドロイド・iPhoneでの検証は未検証なのできちんとやりたい。
  • Web Bluetooth APIも触りたい

リアルタイムチャットをServerSentEvents/php/vueで実装

背景

  • チャット機能が必要になりそうな予感
  • 前にgo言語でwebsocketのチャット作ったことはあるけどなんも覚えてない
  • vuejs使えば、リッチに画面に表示させることも苦じゃないはずだからやってみよう
  • リアルタイム通信の手法って結構あったようなきがするから改めてちゃんと調べてみよう

実装結果デモ▼

chat.gif

手法調査

結論からいうと、server sent eventsを使うことに決めました

前提としてチャット機能とは以下のとおりと考えています▼ AさんとBさんがチャットの画面を開いているとして、Aさんがチャットでメッセージを送信すれば、Bさんの画面に画面のリロードなしにAさんのメッセージが表示されること

ここで、サーバーとクライアントのリアルタイム通信の手法がまとめてある以下のサイトが非常に勉強になりました▼

http://kimulla.hatenablog.com/entry/2016/01/17/%E3%83%AA%E3%82%A2%E3%83%AB%E3%82%BF%E3%82%A4%E3%83%A0%E3%81%AAweb%E3%82%A2%E3%83%97%E3%83%AA%E3%82%92%E5%AE%9F%E7%8F%BE%E3%81%99%E3%82%8B%E6%96%B9%E6%B3%95%28%E3%83%9D%E3%83%BC%E3%83%AA%E3%83%B3

チャット機能を作る手法は大きく分けて3つあります

  • longpolling
    • 聞き慣れない言葉だけど、要するにクライアントがajaxなどで一定時間ごとにサーバーにリクエスト送るだけ
    • プロトコルhttps/http
    • 毎回コネクション作ったりhttpsならhandshakeなりしないといけない
    • サーバーからはリクエストは投げれない
  • server sent events
    • サーバーからのみ、データの変更があった場合のみリクエストが届く
    • クライアントからはリクエストを投げれない
    • プロトコルは、http/httpsを使っている
    • コネクションは最初の一度のみで良い
  • websocket
    • リアルタイム通信の大本命的存在(多分)
    • ws/wssという独自プロトコルを使っているため、オーバーヘッドがめちゃ少ない
    • クライアントから・サーバーサイドからどちらでもリクエストを送信できる

有名どころなリアルタイム通信はどの手法を使っているか確認してみました

  • github(issue画面)
    • websocket
  • gitlab(issue画面)
    • longpolling
  • chatwork
    • server sent events
  • slack
    • websocket

こうみると割とバラバラ。ユーザーからするとgitlabのissue画面のリアルタイム性がslackとかgithubとかに劣るようにはあまり思いませんので、正直あんまり変わらないんだろうなあと思いました

実装のしやすさは longpolling > server sent events >>> websocket(独自プロトコルなので)

また、通信の効率性(オーバーヘッドの小ささ)は websocket > server sent events >>>> longpolling(毎回ちゃんとコネクションするので)

まとめると,以下のことから、可もなく不可もなくという理由でserver sent eventsを実装することに決めました

観点 long polling server sent events websocket
プロトコル http/https http/https ws/wss
サーバーからリクエストを送れる x o o
クライアントからリクエストを送れる o x o
採用実績
通信の効率性 xx o
開発しやすさ o xx

サーバーサイド側(php(phalcon))

以下を参考に、コントローラーに作成

https://developer.mozilla.org/ja/docs/Server-sent_events/Using_server-sent_events

 public function sse()
    {
        $this->response->setHeader("Content-Type", "text/event-stream");
        $this->response->setHeader('X-Accel-Buffering', 'no');
        $this->response->setHeader('Cache-Control', 'no-cache');
        $this->response->send();

        // Remove one level of output buffering
        ob_get_clean();

        $latestId = $this->request->getHeader('Last-Event-ID') ?: 0;
        for ($i = 0;$i<10;$i++) {

            $chats = Chats::find([
                'conditions' => 'id > :latestId:',
                'bind' => ['latestId' => $latestId]
            ]);

            // データが新たに発生した場合
            if (count($chats)) {
                foreach ($chats as $chat) {
                    $latestId = $chat->id;
                    // メッセージ書き出し
                    echo "event: phalcon-message\n";
                    echo 'data: ' . json_encode($chat) . "\n";
                    echo "id: " . $latestId;
                    echo "\n\n";
                }

                // 送信
                ob_flush();
                flush();
            }

            sleep(2);
        }
    }

とりあえず解説つけると、

最初の以下の部分で、ヘッダーにserver sent eventsの設定を記述し、すぐにsendします

text/event-streamを設定しているので、sendしてもresponseが途切れないのがミソですね

        $this->response->setHeader("Content-Type", "text/event-stream");
        $this->response->setHeader('X-Accel-Buffering', 'no');
        $this->response->setHeader('Cache-Control', 'no-cache');
        $this->response->send();

streamでresponseしつづける出力内容をここでリセットしています

        // Remove one level of output buffering
        ob_get_clean();

以下の部分が非常に大事。server sent eventsの特徴の一つに、以下の点がある

注意すべき点として、EventSourceはサーバーから接続が切断された場合再接続を試みるという点があります。ただし、404が返ってきたとか、サーバーに繋がらないとか、そういうときは再接続はしません。一度はつながったのに切れてしまった場合には、再接続を試みるのです。なお、上のreadyStateの2 (CLOSED)というのは、もう再接続もできず終了してしまったという状態を指します。 接続が切れて再接続したとき、一番最後に受信したイベントのIDがLast-Event-IDというHTTPヘッダとなってサーバーへ送られます。これにより、サーバー側はこのクライアントがイベントをどこまで受信したのか知ることができます。サーバー側をうまく作れば、再接続時は続きからイベントを配信するというようなことも可能でしょう。 出典:https://uhyohyo.net/javascript/13_2.html

要するに、今の実装では、2 x 10秒が経過すると、自動で接続が切れますが、自動的にそのあともクライアント側から接続を試みます。そのときに、最後に取得したチャット内容をサーバーサイドが把握していないと、再接続のしょっぱなに不必要(すでに送られている)なチャット内容をクライアント側に返してしまうということが起こってしまいます。

そのために、最後にクライアントに送ったidをLast-Event-IDを探しに行きます、存在しなかった場合はid=0としてまるっとチャット全部をクライアントに返します。ではどこでidをつけているのかは後述

        $latestId = $this->request->getHeader('Last-Event-ID') ?: 0;

また、以下のforループの意図は以下のとおりです

  • 2秒ごとにデータが更新されていないかチェック
  • それを10回繰り返す
        for ($i = 0;$i<10;$i++) {
          // クライアントにメッセージを送る処理
          // 省略

          sleep(2);
        }

最後に、ループ処理の中のこの部分ですが、簡単にいうと以下のとおりです

  • 新しいデータがないかDBから検索
  • データがあった場合、発生したデータを全てクライアントに返却
  • データがなかった場合はなにもしない
           $chats = Chats::find([
                'conditions' => 'id > :latestId:',
                'bind' => ['latestId' => $latestId]
            ]);

            // 新規で発生した場合
            if (count($chats)) {
                foreach ($chats as $chat) {
                    $latestId = $chat->id;
                    // Send the 'update' event
                    echo "event: phalcon-message\n";
                    echo 'data: ' . json_encode($chat) . "\n";
                    echo "id: " . $latestId;
                    echo "\n\n";
                }

                ob_flush();
                flush();
            }

ちょっと一部サンプルに頼ったので深い理解はできていませんが、echoでバッファに出力したあと、flush()で出力内容をクライアントにまとめて送信しているという認識で合ってるんですかね??詳しい方いらっしゃいましたら教えてください、

また、先ほど言っていた、メッセージにidをつける方法なのですが、以下の部分で設定しています、これをクライアントが受け取りつづけ、もし接続が切れてクライアント側が自動で再接続するときには、最後に受け取ったidを自動でLast-Event-IDとしてヘッダーにくっつけてサーバーに送ってくれるということですね、合理的で素敵です

echo "id: " . $latestId;

クライアント側(vue.js)

クライアント側は非常に簡単で、これで終わりです、

    setupStream () {
      let es = new EventSource('/api/v1/test/sse')
      es.addEventListener('phalcon-message', event => {
        let res = JSON.parse(event.data)
        this.storedDatas.push(res)
        this.id = res.id
      }, false)

      es.addEventListener('error', event => {
        if (event.readyState === EventSource.CLOSED) {
          console.log('Event was closed')
        }
      }, false)
    },

EventSourceインスタンスに対して、リスナをつけるだけです

サーバーからメッセージが送られた時は、先ほどのeventに設定した名前でイベントが発生します。 今回のサンプルの場合では、phalcon-messageというイベントが発生します

echo "event: phalcon-message\n";

また、 this.storedDatasとかthis.idとかってなんだよ!!!って方は、vueのコンポーネント丸ごと貼ったので参考にしてください▼

<template>
  <div>
    <div class="columns">
      <div class="column is-1"></div>
      <div class="column is-4">
        <b-input v-model="param" maxlength="200" rows="10" type="textarea"></b-input>
        <button type="button"  class="button is-primary" @click="setupStream">チャット開始</button>
        <button type="button"  class="button is-primary" @click="send">発言</button>
        <p>{{ id }}</p>
        <p>{{ storedDatas }}</p>
      </div>
      <div class="column is-2"></div>
      <div class="column is-4">
        <div v-for="data in storedDatas" :key="data.id" class="columns">
          <div class="column is-12">
            <span class="help">{{ data.id }}</span>
            {# TODO 要改行対応 #}
            <div class="button is-primary is-rounded is-large">{{ data.content }}</div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'chat',
  data () {
    return {
      storedDatas: [],
      // デフォルトで入力されているメッセージ
      param: 'default_message',
      // クライアントが持つ最新のチャットid
      id: 0
    }
  },
  methods: {
    setupStream () {
      let es = new EventSource('/api/v1/test/sse')
      es.addEventListener('phalcon-message', event => {
        let res = JSON.parse(event.data)
        this.storedDatas.push(res)
        this.id = res.id
      }, false)

      es.addEventListener('error', event => {
        if (event.readyState === EventSource.CLOSED) {
          console.log('Event was closed')
        }
      }, false)
    },
    send () {
     // axiosでポストしている
     // この処理でDBにデータが加わる
      this.$store.dispatch('get', {
        _path: '/api/v1/test',
        content: this.param
      })
    }
  }
}
</script>

ちなみに、上のvueファイルでは、bulma,buefy,vue-routerを使っています。

デモ

chat.gif

所感

  • server sent eventsのデメリットとしてあげられることの多かったクライアントからリクエストを送信できない点ですが、上のvueファイルにあるようにsseとは別のリクエストで送信すると問題なく登録できたので、あまり不自由には感じませんでした。
  • vueでいい感じにチャットのコンポーネントがなかった
    • なので今回は丸いボタンに文字列詰め込んでそれっぽくした
    • framework7のmessageコンポーネントがうらやましい
  • websocketでなくても実用に耐えれそうな気がした

開発環境のためのCORS有効化(phalconFrameWorkとwebpackとaxios)

背景

趣味で作っているwebアプリが、フロントエンドとバックエンドをRESTfulでつないでいる

  • フロントエンド側はvueとwebpackを使っていて、dev環境では、ビルドしなくてもローカルサーバー立ち上がってほっとりローディングですぐに修正を確認できる
  • サーバーサイドでは、dockerを使っているので、dockerコマンド一つでサーバーが立ち上がる。こちらも即座に反映される(当たり前)

これらの特徴から、フロントエンド内での開発PDCA・サーバーサイド内での開発PDCAはだいぶ早いと思っている。

ただ、フロントエンドとサーバーサイドでの疎通確認がボトルネックになると考えていて、チームでの開発では、フロントエンドの開発者とサーバーサイドの開発者は異なることが多い。なので、疎通確認をしたいのに、フロントエンド(またはサーバーサイド)がうまく起動できない(泣)みたいな状況になると思った。PDCA劇遅の予感がする。

現状

フロントエンドとサーバーサイド両方のローカルサーバーを立ち上げてaxiosを使って疎通しようとしても、疎通できない。

セキュリティの観点から、CORSという機能は制限されているためらしい

オリジン間リソース共有 (Cross-Origin Resource Sharing, CORS) は、追加の HTTP ヘッダーを使用して、ユーザーエージェントが現在のサイトとは別のオリジン (ドメイン) のサーバーから選択されたリソースにアクセスする権限を得られるようにする仕組みです。ユーザーエージェントは、現在の文書のオリジンとは異なるドメイン、プロトコル、ポート番号からリソースを要求するとき、オリジン間 HTTP 要求を発行します。

例えば http://domain-a.com から読み込まれた HTML ページが、 <img> src で http://domain-b.com/image.jpg に対して要求を行う場合です。今日のウェブ上では、多くのページが CSS スタイルシートや画像、スクリプトといったリソースを、コンテンツ配信ネットワーク (CDN) などの別のドメインから読み込んでいます。

セキュリティ上の理由から、ブラウザーは、スクリプトによって開始されるオリジン間 HTTP 要求を制限しています。例えば、 XMLHttpRequest や Fetch API は同一オリジンポリシー (same-origin policy)に従います。これは、これらの API を使用するウェブアプリケーションは、 CORS ヘッダーを使用しない限り、アプリケーションが読み込まれたのと同じドメインからしかリソースを HTTP で要求できないことを意味します。

https://developer.mozilla.org/ja/docs/Web/HTTP/HTTP_access_control

なので、phalconフレームワーク側でCORSの制限を緩和する必要がある。あくまで開発環境の時のみ。

仕様の調査

axiosから、クロスドメインなURLを叩くと、勝手にpreflightが飛んでいくようである

f:id:Kouchannel55:20180516225556p:plain

公式にも書いてあった。今回はJSONでやりとりするため当然のようだ

https://developer.mozilla.org/ja/docs/Web/HTTP/HTTP_access_control#Examples_of_access_control_scenarios

webpackでのproxy設定

簡単な修正方法があった! :surfer:

https://www.yoheim.net/blog.php?q=20170803

webpackの設定ファイルに以下を追記。

    proxyTable: {
      '/api/*': {
        target: 'http://localhost:8111',
        secure: false
      }
    },

僕の場合はこうなった(ほぼテンプレですが)▼

index.js▼

'use strict'

// see http://vuejs-templates.github.io/webpack for documentation.
const path = require('path')

module.exports = {
  build: {
    env: require('./prod.env'),
    index: path.resolve(__dirname, '../../phalcon/public/index.html'),
    assetsRoot: path.resolve(__dirname, '../../phalcon/public'),
    assetsSubDirectory: 'static',
    // assetsPublicPath: '/',  TODO GithubPagesにあげるtめに修正
    assetsPublicPath: '',
    productionSourceMap: true,
    // Gzip off by default as many popular static hosts such as
    // Surge or Netlify already gzip all static assets for you.
    // Before setting to `true`, make sure to:
    // npm install --save-dev compression-webpack-plugin
    productionGzip: false,
    productionGzipExtensions: ['js', 'css'],
    // Run the build command with an extra argument to
    // View the bundle analyzer report after build finishes:
    // `npm run build --report`
    // Set to `true` or `false` to always turn it on or off
    bundleAnalyzerReport: process.env.npm_config_report
  },
  dev: {
    env: require('./dev.env'),
    port: 8080,
    autoOpenBrowser: true,
    assetsSubDirectory: 'static',
    assetsPublicPath: '/',
    proxyTable: {
      '/api/*': {
        target: 'http://localhost:8111',
        secure: false
      }
    },
    // CSS Sourcemaps off by default because relative paths are "buggy"
    // with this option, according to the CSS-Loader README
    // (https://github.com/webpack/css-loader#sourcemaps)
    // In our experience, they generally work as expected,
    // just be aware of this issue when enabling this option.
    cssSourceMap: false
  }
}

結局だめだった。そもそもリクエスト先が変更されていない。。。

f:id:Kouchannel55:20180516225455p:plain

f:id:Kouchannel55:20180516225527p:plain

愚直にサーバーサイド側でCORSを有効化することにした

phalconでのCORSを有効化(サーバーサイド)

公式で発見。

CorsMiddleware.php

<?php

use Phalcon\Events\Event;
use Phalcon\Mvc\Micro;
use Phalcon\Mvc\Micro\MiddlewareInterface;

/**
 * CORSMiddleware
 *
 * CORS checking
 */
class CORSMiddleware implements MiddlewareInterface
{
    /**
     * Before anything happens
     *
     * @param Event $event
     * @param Micro $application
     *
     * @returns bool
     */
    public function beforeHandleRoute(Event $event, Micro $application)
    {
        if ($application->request->getHeader('ORIGIN')) {
            $origin = $application->request->getHeader('ORIGIN');
        } else {
            $origin = '*';
        }

        $application
            ->response
            ->setHeader('Access-Control-Allow-Origin', $origin)
            ->setHeader(
                'Access-Control-Allow-Methods',
                'GET,PUT,POST,DELETE,OPTIONS'
            )
            ->setHeader(
                'Access-Control-Allow-Headers',
                'Origin, X-Requested-With, Content-Range, ' .
                'Content-Disposition, Content-Type, Authorization'
            )
            ->setHeader('Access-Control-Allow-Credentials', 'true');
    }

    /**
     * Calls the middleware
     *
     * @param Micro $application
     *
     * @returns bool
     */
    public function call(Micro $application)
    {
        return true;
    }
}

app.phpまたはmodule.php(ルーティング)▼

/**
 * Create a new Events Manager.
 */
$eventsManager = new Manager();
$application   = new Micro();

$eventsManager->attach('micro', new CorsMiddleware());
$application->before(new CorsMiddleware());

$application->setEventsManager($eventsManager);

https://docs.phalconphp.com/cs/3.1/application-micro#middleware-events-api-cors

しかしこれでは動かなかった :cry: 。原因はbeforeHandleRouteに処理が走っていないためみたいだが、根が深そうなので他の方法を探すことにした。

phalconForumから発見

https://forum.phalconphp.com/discussion/443/enable-cross-origin-resource-sharing

どうやら、プリフライト時と、その後どちらのリクエストでも、Access-Control-Allow-XXXXXをつける必要があるらしいので以下の様にした。

optionsの範囲とかヘッダーパラメータの付け方とかガバガバなのはスルーしてください :smirk:

$app->options('/(.*)', function () use ($app) {
    $app->response->sendHeaders();
});

app.php(diまわり)▼

$di->setShared('response', function () {
    $response = new Response();
    if (getenv("MODE") == "development") {
        $response->setHeader('Access-Control-Allow-Origin', '*')
            ->setHeader(
                'Access-Control-Allow-Methods',
                'GET,PUT,POST,DELETE,OPTIONS'
            )
            ->setHeader(
                'Access-Control-Allow-Headers',
                'Origin, X-Requested-With, Content-Range, ' .
                'Content-Disposition, Content-Type, Authorization'
            )
            ->setHeader('Access-Control-Allow-Credentials', 'true');
    }
    return $response;
});

f:id:Kouchannel55:20180516225621p:plain

OPTIONSとPOSTが正常系で帰ってる!

完成!!ここまで来るのに4時間はかかった。。。

所感

  • CORSのめんどくささを体感した
  • proxyServerがなぜ効かないのかは謎。誰か教えて :crying_cat_face:
  • ただ一つ不満を言うなら、以下の様にaxios側でURLをdev環境とprod環境で分ける必要があること
    • baseのaxiosクラスをつくれば一元管理できるので、なんとか納得できる

dev環境▼

    return axios.post('http://localhost:8111/session/login', json, {
        headers: { 'Content-Type': 'application/json' }
      })

prod環境▼

    return axios.post('/session/login', json, {
        headers: { 'Content-Type': 'application/json' }
      })

GitlabのCIでnpm+docker+git+awscliの実行環境構築

追記

ecs_deploy.shの実行権限がなかったので、chmod 777 ./ci/ecs_deploy.shを追記しました

背景

趣味のwebアプリ開発環境を一通り整えたが、最後のCIの部分を全く実装していかなった

GitHubを使っていたらCodePipelineを使って楽々ECSにデプロイできたのだが、今回はGitlabで作成してしまった

そしてGitHubに移す気力はない

対象読者

  • ECS/ECRでの継続デプロイを理解している人

目的

理想は、masterにプッシュしたら、自動でgitlab runnerが走って自動でecrを更新しecsのサービスも更新してほしい(贅沢)

今回の僕のweb環境は以下のようになっています。

RESTfulなので、フロントエンドとAPIサイドに分かれています。

├── Dockerfile
├── README.md
├── docker-compose.yml
├── front     <=フロントエンド側の開発ディレクトリ
├── nginx.conf
└── phalcon     <= webアプリケーションのディレクトリ
    ├── (略)
    ├── public   <= nginxのrootディレクトリ

これを踏まえて、CIサーバーでやらせたいことを掘り下げると、、、

  • dockerコマンドで、ビルドしたイメージをECRにプッシュしたい
  • awsコマンドで、ECSの操作を行いたい
  • frontディレクトリ内で、webpackのビルドを行い、その出力ファイル(index.htmlとその他staticファイル)をphalcon(webアプリケーション)ディレクトリ内のpublicディレクトリ内にブッコミたい

これを実現するには、以下の課題をクリアする必要がある

  • npmのインストール
  • dockerのインストール
  • awsコマンドのインストール

調査

gitlabCIについて一つ勘違いしていた。てっきりprivateなサーバーでのみ動かすことしかできないと思っていたが、どうやらGitlabが用意しているCIサーバーも制限があるが存在しているようである。自前サーバーで動かすのをSpecific Runnersというのに対しShared Runnersというらしい。1ヶ月1グループあたり2000回まで無料のようである。太っ腹。

GitLab.com settings | GitLab

さらにさらに、どうやらimageを指定して、その中で動作するらしい。Dockerは新世界の神ではないか。 細かいCIの設定は.gitlab-ci.ymlに以下のようなフォーマットでかく。以下は最小構成である、

image: python:2.7

stages:
  - deploy

deploy_job:
  stage: deploy
  script:
    - XXXXXXXXXXXX

awsコマンド

先ほどの、npm,docker,awsの中でインストールがめんどくさそうなのはawsという直感を元にpython:2.7イメージを使用。デフォルトでawsコマンドが使えるので。python:2.7はDebianのjessieがベースになっているとのこと。

ここで、みんな大好きAWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY環境変数に登録する。.gitlab-ci.ymlに書くと、gitに追跡されてしまうので、ここはgitlabの設定であらかじめ登録しておくこと

f:id:Kouchannel55:20180515005519p:plain

ここまでで.gitlab-ci.ymlは以下のようになった

image: python:2.7

stages:
  - deploy

deploy_job:
  stage: deploy
  script:
    - pip install --upgrade pip
    - pip install --upgrade awscli

npm

フロントエンドには、vueを使っているが、webpackでビルドする必要がある。

無論python:2.7に入っていないので自力で導入。

以下を、install_npm.shとして作成

curl -sL https://deb.nodesource.com/setup_10.x |  bash -
apt-get install -y nodejs

これを踏まえて、gitlab-ci.ymlは以下のようになる

image: python:2.7

stages:
  - deploy

deploy_job:
  stage: deploy
  script:
    - pip install --upgrade pip
    - pip install --upgrade awscli
    - sh ./ci/install_npm.sh
    - cd ./front
    - npm install
    - npm run build

docker

ECSで動かすタスクで動くDockerイメージを更新するには、ECSというdockerリポジトリに、新たにプッシュする必要がある。

ぐぐるとよく出てくる方法はめっちゃ長かった。

  1. 古いdockerをアンインストール
  2. 周辺パッケージをインストール
  3. GPG鍵の取得
  4. .......以降省略

ダメ元でdocker公式読んだら、もっと簡単な方法があった。やはり公式が正義

docs.docker.com

curl -fsSL get.docker.com -o get-docker.sh
sh get-docker.sh

これだけでいいとのこと。あっさり。

しかし、これだけでは、プロセスが停止したままなので、service docker startを加えて以下が最終形。install_docker.shとしてファイル作成

curl -fsSL get.docker.com -o get-docker.sh
sh get-docker.sh
service docker start

これでdockerがすぐに動かせる状況になった

ECRにプッシュ

プッシュするタグ名は、複数箇所で使用するので、以下のように環境変数として定義。これはシークレットレベルは低いので、.gitlab-ci.ymlに定義

deploy_job:
  stage: deploy
  variables:
    TAG_NAME: XXXXXXXXXXXX
  script:
(略)
    - aws_var=`aws ecr get-login --no-include-email --region ap-northeast-1 `
    - $aws_var
    - docker build -t kyujin .
    - docker tag kyujin:latest $TAG_NAME
    - docker push $TAG_NAME

ECSを更新

最後にして最難関。愚直にやろうと思うと以下の手順でやる必要がある

  1. タスク定義を先ほどプッシュしたイメージを含んで更新(リビジョン)
  2. クラスター内のサービスを更新

非常にめんどくさい。CodePipelineならGUIでサクッとできたのでこちらは地獄のように感じる

以下のスクリプトを使わせてもらった。

github.com

ecs_deploy.shとしてファイルを作成し、以下で実行。

sh ./ci/ecs_deploy.sh -r $REGION -c $CLUSTER -n $SERVICE -i $TAG_NAME -t 1800

これだけで12も自動でやってくれる。幸せ。

しかし、shでやってると怒られた

./ci/ecs_deploy.sh: Syntax error: "(" unexpected

どうやら、Debianでshをすると、dashが起動する影響らしい。微修正

./ci/ecs_deploy.sh -r $REGION -c $CLUSTER -n $SERVICE -i $TAG_NAME -t 1800

jsonのパーサーのjqも必要なのでサクッとインストール

apt-get install jq

結論

最終形は以下のようになった。めでたしめでたし。

image: python:2.7

stages:
  - deploy

deploy_job:
  stage: deploy
  variables:
    TAG_NAME: XXXXXXXXX
    REGION: YYYYYYYY
    CLUSTER: ZZZZZZZZZZZZZZZZ
    SERVICE: WWWWWWWWWWWWW
  script:
    - pip install --upgrade pip
    - pip install --upgrade awscli
    - sh ./ci/install_docker.sh
    - sh ./ci/install_npm.sh
    - cd ./front
    - npm install
    - npm run build
    - cd ./../
    - apt-get install jq
    - aws_var=`aws ecr get-login --no-include-email --region ap-northeast-1 `
    - $aws_var
    - docker build -t kyujin .
    - docker tag kyujin:latest $TAG_NAME
    - docker push $TAG_NAME
    - chmod 777 ./ci/ecs_deploy.sh
    - ./ci/ecs_deploy.sh -r $REGION -c $CLUSTER -n $SERVICE -i $TAG_NAME -t 1800

所管

  • 日曜の午前3時には、masterプッシュして30秒後にはCIが走ってたが、月曜の今現在、masterプッシュして30分経っても走らずにPending状態。。。。やっぱり共有している以上、しかたないのか。。、。
  • 趣味でやるぶんにはCI用意してくれるだけで御の字
  • gitlabのドキュメント、日本語対応してくれんかなあ