無印吉澤

ソフトウェア開発、運用管理(俗にいう DevOps)、クラウドコンピューティングなどについて、吉澤が調べたり試したことを書いていくブログです。

Webhook の受信を契機として複数の API を叩く Lambda 関数を Node.js で書きたいときのためのメモ

f:id:muziyoshiz:20180630010936p:plain

ときどき AWS Lambda の Lambda 関数を Python で書いてます。AWS Lambda 便利ですよね。

しかし最近、他の人に Lambda 関数をいじってほしいけど Python での開発環境を作ってもらうのが面倒なので、一度書いた Lambda 関数を Node.js で書き換える、という機会がありました。そのときに Node.js の流儀がわからず困ってしまったので、あとで自分が読み返すためのメモを書いておきます。

このメモの対象読者は次のような人です。

  • Webhook の受信を契機として、複数の API を叩くような処理が書きたい
  • その処理を AWS Lambda に乗せて、楽に運用したい
  • AWS Lambda と API Gateway へのデプロイを楽にしたいので Serverless Framework を使いたい
  • 開発環境構築を楽にするために Node.js で書きたい(Serverless Framework を使うなら Node.js もあるはず)

※注意事項:私は Node.js 初心者で、Node.js の書き方はよくわかってません。なので、もっと良い書き方がわかったら、このメモをちょっとずつ直していきます。

目次

Serverless Framework のインストール

  • Node.js のインストール
    • brew install npm
  • Serverless Framework のインストール
    • npm install -g serverless

参考:Serverless Framework - AWS Lambda Guide - Installing The Serverless Framework

AWS クレデンシャルの設定

AWS クレデンシャルを設定していない場合、sls コマンドで作れます。~/.aws/credentials に設定済みであれば、この手順は不要です。デフォルトプロファイル以外でも大丈夫です。

  • Administrator Access 権限のある IAM ユーザーの作成
  • AWS クレデンシャルの設定
    • sls config credentials --provider aws --key アクセスキーID --secret シークレットアクセスキー

参考:Serverless Framework - AWS Lambda Guide - Credentials

新しい Serverless service (Node.js 8.10) の作成

Lambda 関数のランタイムには、async/await が使える Node.js 8.10 を選択します。現時点で、AWS Lambda で使える最新の Node.js はコレです。

ただ、6/30現在では sls create -t aws-nodejs でひな型(boilerplate)を作ると、Node.js 6.10 が選択されてしまいます。

$ sls create -t aws-nodejs -p example
Serverless: Generating boilerplate...
Serverless: Generating boilerplate in "/Users/myoshiz/example"
 _______                             __
|   _   .-----.----.--.--.-----.----|  .-----.-----.-----.
|   |___|  -__|   _|  |  |  -__|   _|  |  -__|__ --|__ --|
|____   |_____|__|  \___/|_____|__| |__|_____|_____|_____|
|   |   |             The Serverless Application Framework
|       |                           serverless.com, v1.27.3
 -------'

Serverless: Successfully generated boilerplate for template: "aws-nodejs"

上記のコマンドを実行すると、以下のファイルが作られます。

  • .gitignore
  • handler.js
  • serverless.yml

この serverless.yml の

provider:
  name: aws
  runtime: nodejs6.10

となっている部分を

provider:
  name: aws
  runtime: nodejs8.10

に書き換えると、Node.js 8.10 が使えます。

参考:Node.js 8.10 runtime now available in AWS Lambda | AWS Compute Blog

デフォルトステージの設定

Serverless Framework には「ステージ」という概念があって、1個のコードを、ステージごとに Lambda 関数名や設定を変えてデプロイできます。ステージの主な用途は、開発環境と本番環境の切り替えです。

このステージを引数から指定できるようにするための設定を、serverless.yml に追加します。

provider:
  name: aws
  runtime: nodejs8.10
  stage: ${opt:stage, self:custom.defaultStage}
custom:
  defaultStage: dev

Serverless Framework のデフォルトのステージは dev なので、そのままで使うならこの設定は不要です。しかし dev が不要な場合は、デフォルトを production にしておくといいでしょう。

参考:Serverless Framework - AWS Lambda Guide - Deploying

デフォルトで使うAWSプロファイルおよびリージョンの設定

AWS プロファイルとリージョンも、引数から指定可能にしておきます。以下の設定を serverless.yml に追加します。

  profile: ${opt:profile, self:custom.defaultProfile}
  region: ${opt:region, self:custom.defaultRegion}
  defaultProfile: default
  defaultRegion: ap-northeast-1

ステージに応じて AWS プロファイルを切り替えたい場合は、以下の方法で実現できるようです。

参考:Serverless Frameworkで環境変数を外部ファイルから読み込み、環境毎に自動で切り替えてみる | Developers.IO

環境変数の定義

AWS Lambda で使いたい機密情報(パスワードや API キーなど)は、「環境変数」として AWS Lambda に登録できます。しかし、この環境変数は、git リポジトリには登録したくありません。

Serverless Framework では、

  • 環境変数を git リポジトリ管理外の YAML ファイルに書く
  • デプロイ時のみこの YAML ファイルを用意する

ということが可能です。

まず、以下の YAML ファイルを用意します。

conf/dev.yml - dev ステージで使う環境変数
conf/production.yml - production ステージで使う環境変数

そして、.gitignore に以下のように書いて、git リポジトリへの登録を禁止します。

# Secrets
conf/*.yml

最後に、serverless.yml に以下の設定(otherfile: 以下)を追加します。

custom:
  defaultStage: dev
  defaultProfile: default
  defaultRegion: ap-northeast-1
  otherfile:
    environment:
      dev: ${file(./conf/dev.yml)}
      production: ${file(./conf/production.yml)}

YAML ファイル(dev.yml, production.yml)には、以下のように記載します。環境変数に数値を代入したい場合は、ダブルクォートで囲んで、文字列にする必要があります。これは Serverless Framework の問題ではなく、AWS Lambda の制約です。

---
MESSAGE: Hello
YEAR: "2018"

参考:Serverless Frameworkで環境変数を外部ファイルから読み込み、環境毎に自動で切り替えてみる | Developers.IO

request-promise モジュールのインストール

API の呼び出しには request-promise モジュールを使います。このモジュールを使うためには request モジュールも必要です。

$ npm init
$ npm install --save request
$ npm install --save request-promise

package.json と package-lock.json が作成されます。このファイルは、ソースコードと一緒に git リポジトリにコミットします。

参考:request/request: Simplified HTTP request client., request/request-promise: The simplified HTTP request client 'request' with Promise support. Powered by Bluebird.

開発用プラグインの追加

Webhook を受け付けるためには、Amazon API Gateway と AWS Lambda を組み合わせて使うのが一般的です。

しかし、動作確認のためにいちいち API Gateway にデプロイするのは面倒なので、serverless-offline プラグインを使って、ローカルで動作確認できるようにします。

以下のコマンドでインストールできます。

$ npm install --save-dev serverless-offline

serverless.yml に以下の設定を追加。

plugins:
  - serverless-offline

sls offline start を実行すると、ローカルで HTTP サーバが動作します。

参考:dherault/serverless-offline: Emulate AWS λ and API Gateway locally when developing your Serverless project

handler の定義(Amazon API Gateway の設定)

sls create コマンドで作られた Lambda 関数 "hello" に handler の設定(events: 以下)を追加してみます。

functions:
  hello:
    handler: handler.hello
    events:
      - http:
          path: hello
          method: post
    environment:
      MESSAGE: ${self:custom.otherfile.environment.${self:provider.stage}.MESSAGE}
      YEAR: ${self:custom.otherfile.environment.${self:provider.stage}.YEAR}

先ほど説明した環境変数を Lamnbda 関数内で使うには、environment: 以下で明示的に指定する必要があります。

そして、handler.js 内の hello 関数を以下のように修正してみましょう。リモートにデプロイすると、この関数は API Gateway から起動されます。

module.exports.hello = (event, context, callback) => {
    console.log(`MESSAGE: ${process.env.MESSAGE}`)
    console.log(`YEAR: ${process.env.YEAR}`)

    console.log(event);

    const body = event['body'];

    try {
        const json = JSON.parse(body);

        if (json && json.message) {
            callback(null, {
                statusCode: 200,
                body: JSON.stringify({message: `${process.env.MESSAGE} ${json.message} ${process.env.YEAR}`})
            })
        } else {
            callback(null, {statusCode: 400, body: JSON.stringify({error: 'No message'})});
        }
    } catch (e) {
        console.log(e);
        callback(null, {statusCode: 400, body: JSON.stringify({error: 'Invalid JSON'})});
    }
};

ローカルでの動作確認

この hello 関数をローカルで動作確認します。まず、以下のコマンドで HTTP サーバを起動します。

$ sls offline start
Serverless: Starting Offline: dev/ap-northeast-1.

Serverless: Routes for hello:
Serverless: POST /hello

Serverless: Offline listening on http://localhost:3000

他のターミナルから curl で JSON を POST し、応答が返されたら成功です。

$ curl -H 'Content-Type:application/json' -d '{"message":"world"}' http://localhost:3000/hello
{"message":"Hello world 2018"}

sls offline start を実行したターミナルには、console.log() に渡した event オブジェクトが以下のように出力されます。

Serverless: POST /hello (λ: hello)
MESSAGE: Hello
YEAR: 2018
{ headers:
   { Host: 'localhost:3000',
     'User-Agent': 'curl/7.54.0',
     Accept: '*/*',
     'Content-Type': 'application/json',
     'Content-Length': '19' },
  path: '/hello',
  pathParameters: null,
  requestContext:
   { accountId: 'offlineContext_accountId',
     resourceId: 'offlineContext_resourceId',
     apiId: 'offlineContext_apiId',
     stage: 'dev',
     requestId: 'offlineContext_requestId_27367216451093634',
     identity:
      { cognitoIdentityPoolId: 'offlineContext_cognitoIdentityPoolId',
        accountId: 'offlineContext_accountId',
        cognitoIdentityId: 'offlineContext_cognitoIdentityId',
        caller: 'offlineContext_caller',
        apiKey: 'offlineContext_apiKey',
        sourceIp: '127.0.0.1',
        cognitoAuthenticationType: 'offlineContext_cognitoAuthenticationType',
        cognitoAuthenticationProvider: 'offlineContext_cognitoAuthenticationProvider',
        userArn: 'offlineContext_userArn',
        userAgent: 'curl/7.54.0',
        user: 'offlineContext_user' },
     authorizer:
      { principalId: 'offlineContext_authorizer_principalId',
        claims: undefined },
     protocol: 'HTTP/1.1',
     resourcePath: '/hello',
     httpMethod: 'POST' },
  resource: '/hello',
  httpMethod: 'POST',
  queryStringParameters: null,
  stageVariables: null,
  body: '{"message":"world"}',
  isOffline: true }
Serverless: [200] {"statusCode":200,"body":"{\"message\":\"Hello world 2018\"}"}

デプロイ

ローカルで動作確認できたら、リモートの AWS Lambda と Amazon API Gateway にデプロイします。デプロイは sls deploy コマンドで行います。

$ sls deploy
Serverless: Packaging service...
Serverless: Excluding development dependencies...
Serverless: Creating Stack...
Serverless: Checking Stack create progress...
.....
Serverless: Stack create finished...
Serverless: Uploading CloudFormation file to S3...
Serverless: Uploading artifacts...
Serverless: Uploading service .zip file to S3 (2.28 MB)...
Serverless: Validating template...
Serverless: Updating Stack...
Serverless: Checking Stack update progress...
..............................
Serverless: Stack update finished...
Service Information
service: example
stage: dev
region: ap-northeast-1
stack: example-dev
api keys:
  None
endpoints:
  POST - https://aaaaaaaa.execute-api.ap-northeast-1.amazonaws.com/dev/hello
functions:
  hello: example-dev-hello

サービス名-ステージ名-関数名 という名前の Lambda 関数が作られました(この例では example-dev-hello)。本番環境のステージを指定するときは sls コマンドに --stage production を付けてください。

デプロイに成功すると、ローカルの場合と同じように動作確認できます。

$ curl -H 'Content-Type:application/json' -d '{"message":"world"}' https://aaaaaaaa.execute-api.ap-northeast-1.amazonaws.com/dev/hello

1回デプロイしたあとなら、sls deploy -f hello のようにして、特定の Lambda 関数だけ更新することもできます。

$ sls deploy -f hello

ちなみに、Lambda 関数がもう要らなくなったという場合は sls remove で削除できます。

リモートでの動作確認

Lambda 関数がうまく動かないときにはログを確認したくなると思いますが、その場合は console.log() でログ出力すると、CloudWatch Logs から確認できます。

ログは /aws/lambda/example-dev-hello のような名前のロググループに登録されます。

Webhook を受信したら複数の API を叩く

ここまでで、Webhook を受信したらなにかする、という Lambda 関数は書けました。次は、複数の API を叩くコードの例を紹介します。

API を1個叩くだけなら、request モジュールのみで実現できます。

しかし、ある API を叩き、その返り値を元に別の API を叩き、さらにその返り値を元に……と処理が続く場合は、コールバック地獄を避けるために request-promise を使ったほうが楽です。また、Promise を使うだけでなく、async/await も使ったほうがコードが読みやすくなります。

ここでは、試しに、先ほどの hello を3回呼び出すエンドポイント hello_hello_hello を作ってみます。

まず、serverless.yml に以下を追加します。これはローカルでの動作確認(後述)を簡単にするために method: get にしていますが、実際に Webhook から起動するときは method: post にしてください。

  hello_hello_hello:
    handler: handler.helloHelloHello
    events:
      - http:
          path: hello_hello_hello
          method: get

そして、handler.js に以下のコードを追加します。await で1個目の API からの応答が来るのを待ってから、次の API 呼び出しをしているのがわかるでしょうか。

module.exports.helloHelloHello = (event, context, callback) => {
    const callHello = (message) => {
        const request = require('request-promise');

        const params = {
            message: message
        };

        const options = {
            method: 'POST',
            uri: `http://localhost:3000/hello`,
            body: params,
            json: true
        };

        return request(options)
            .then(function (parsedBody) {
                return parsedBody;
            })
            .catch(function (err) {
                console.log(err);
                return null;
            });
    };

    (async () => {
        const response1 = await callHello('world');
        console.log(response1);

        const response2 = await callHello(response1.message);
        console.log(response2);

        const response3 = await callHello(response2.message);
        console.log(response3);

        callback(null, {statusCode: 200, body: response3.message});
    })();
};

この関数は、以下のように動作します。

  • http://localhost:3000/hello に "world" を送り、"Hello world 2018" を受け取る
  • http://localhost:3000/hello に "Hello world 2018" を送り、"Hello Hello world 2018 2018" を受け取る
  • http://localhost:3000/hello に "Hello Hello world 2018 2018" を送り、"Hello Hello Hello world 2018 2018 2018" を受け取る
  • 最終的な結果 "Hello Hello world 2018 2018" を呼び出し元に返す。

sls offline start で HTTP サーバを起動して、curl で以下のコマンドを実行してみてください。

$ curl http://localhost:3000/hello_hello_hello
Hello Hello Hello world 2018 2018 2018

これと同じ要領で、hello 以外の API も呼び出すことができます。

基本は以上です。あとは頑張ってください。

付録1. Windows の場合

Windows の場合は、Serverless Framework のインストール方法が違います。また、普通は curl コマンドが使えないので、別の方法を使う必要があります。

Serverless Framework のインストール

まず node.js から Windows 用のインストーラをダウンロードして、Node.js をインストールします。

次に、Windows のメニューから "Node.js command prompt" を選択して、プロンプトを開きます。このプロンプトで npm install -g serverless を実行すれば、Serverless Framework をインストールできます。

これ以降は、"Node.js command prompt" から、上記の sls コマンドを実行できます。

curl コマンドの代わり

curlコマンドの代わりに、Node.js の REPL からテスト用のリクエストを送信できる。

JSON を POST して、HTTP リクエストのボディを標準出力に表示。

C:\> node
> const request = require('request');
> const options = {url: 'http://localhost:3000/hello', headers: {'Content-type': 'application/json'}, json: {message: 'world'} };
> request.post(options, function(error, response, body){ console.log(body); });

GET して、HTTP リクエストのボディを標準出力に表示。

C:\> node
> const request = require('request');
> request.get('http://localhost:3000/hello_hello_hello', function(error, response, body){ console.log(body); });

付録2. IntelliJ IDEA Ultimate で Node.js 8.10 を使う

僕は IndelliJ IDEA Ultimate でコードを書いているのですが、Node.js 8.10 でエラーが出ない状態にするには以下の設定が必要でした。

  • Preferences > Plugins > Install JetBrains Plugin... を選択し、その画面から NodeJS プラグインをインストールする
  • Preferences > Languages & Frameworks > Node.js and NPM を選択し、その画面から Node.js Core library を有効にする(Enable ボタンを押す)
  • Preferences > Languages & Frameworks > JavaScript を選択し、その画面上で JavaScript language version を "ECMAScript 6" に変更する

参考:javascript - Arrow function "expression expected" syntax error - Stack Overflow

その他の参考ページ

このブログ(無印吉澤)を HTTPS 対応させました

このブログ(無印吉澤)を HTTPS 対応させました。今後は HTTP の方にアクセスしても、HTTPS の方にリダイレクトされます。

今回はそのために必要だった作業と、結局どうしようもなかった部分のまとめです。

はてなブログの HTTPS 対応状況

はてなブログは2018年2月22日から HTTPS での配信に対応し、その後も改良が続いているようです。

staff.hatenablog.com

staff.hatenablog.com

staff.hatenablog.com

手順については、以下のヘルプを読めば大体のことはわかりました。

help.hatenablog.com

help.hatenablog.com

必要だった作業

1. 「HTTPS配信の状況」の「変更する」ボタンを押す

まず、はてなブログの「設定」→「詳細設定」→「HTTPS配信」から、URL 設定のページ を表示。

「HTTPS配信の状況」の「変更する」ボタンを押すと一瞬で切り替わります。

はてな内のサービスを見てみましたが、はてなブックマークの URL は https にすぐに切り替わったように見えました。

http://b.hatena.ne.jp/entrylist?url=muziyoshiz.hatenablog.com

2. サイト内に埋め込まれている自サイトの URL を https:// に書き換える

http:// にアクセスされてもリダイレクトされるので、基本的には書き換え不要なんですが、Feedly の Follow ボタンだけは https://muziyoshiz.hatenablog.com/feed で作り直しました。

Follower 0件になって寂しいんですが、まあ、今後 HTTPS が常識になるなら早いうちに合わせたほうがマシかなと。

3. Mixed Content の確認

ここからは、ひたすら Mixed Content の確認です。Chrome の開発者ツールを開いた状態で、ブログ一覧から「次のページ」を押し続けるという地味な作業をやりました。

うちはまだ記事数少ないので良かったですが、毎日ブログ投稿してるような人は死にそうですね……。

embed:cite 記法で書かれたリンクの修正

普通の [title](url) 記法で書かれたリンクは http でも問題ないんですが、はてなブログ独自の記法は、サムネイルが埋め込まれる関係か Mixed Content のエラーが出たので修正しました。

  • Before: [http://muziyoshiz.hatenablog.com/entry/2016/04/25/015644:embed:cite]
  • After: [http://muziyoshiz.hatenablog.com/entry/2016/04/25/015644:embed:cite]

はてな以外のサービスの URL も、http で書いていたものは https に修正しました。

  • Before: [http://twitter.com/okapies/status/856528271306928128:embed:cite]
  • After: [https://twitter.com/okapies/status/856528271306928128:embed:cite]

うちのサイトは、embed:cite 記法の URL をたまたま全部 https に直せましたが、リンク先が HTTPS 対応してない場合は [title](url) 記法に直したほうがいいかもしれません。

はてなフォトライフ(Fotolife)の画像の再読み込み

はてなブログに画像を貼るときは、Fotolife にアップロードして、以下の記法で埋め込むのが楽です。このブログでは主にアイキャッチ画像を貼るのに使っています。

<div align="center">[f:id:muziyoshiz:20160425013924j:plain]</div>

最近のブログに埋め込んだ画像は、何もしなくても最初から https になっていたんですが、2016年6月より前に埋め込んだアイキャッチ画像の URL は http になってしまっていました(Fotolife 自体がこのくらいの時期に HTTPS 化した?)。

対策としては、「ブログを開く」→「何かしら編集する」→「ブログを保存する」と、URL が再生成されて https に変わりました。これを、Fotolife の画像を埋め込んだ記事すべてでやらないといけないので、これはめんどくさい……。

どうしようもなかった部分

Amazon の商品画像埋め込み

はてなブログは、以下のような記法で Amazon の商品画像を埋め込めます。

[asin:B01FRIOYEC:detail]

この商品画像が、どうも HTTPS 配信のものと HTTP 配信のものが混在しているらしく、特に古い本は HTTP 配信のようでした。

例えば、以下の記事の最後にある「参考文献」で、「プログラミング Elixir」の画像は HTTPS、「Programming Phoenix」の画像は HTTP です。

muziyoshiz.hatenablog.com

ブログ記事を編集→保存しても直らなかったので、はてなではなくて Amazon 側の問題なんだろうと思って諦めました。

SlideShare のスライド埋め込みでエラー

HTTPS 化とは関係ないんですが、過去の記事を見返してみたら、SlideShare のスライドを埋め込んでいる箇所で大量のエラーが出てました。

f50c7d109361420bbc0ee31b2c44d54e:5 Uncaught SyntaxError: Unexpected token o in JSON at position 1
    at JSON.parse (<anonymous>)
    at n.receive (player-67e207486452a6edc528fc9bbb3a0e02.js:26)
    at player-67e207486452a6edc528fc9bbb3a0e02.js:26
    at nrWrapper (f50c7d109361420bbc0ee31b2c44d54e:5)

とか、

combined_base.js?f14b451c58:55 [Deprecation] chrome.loadTimes() is deprecated, instead use standardized API: Paint Timing. https://www.chromestatus.com/features/5637885046816768.

とか。

SlideShare が生成する iframe タグが古いからかな?と思って、SlideShare のサイトで iframe タグを生成し直してみました。でも、リンクの URL が http から https に変わっただけで、上記のエラーは消えませんでした。

あと、

Access to Font at 'https://public.slidesharecdn.com/fonts/fontawesome-webfont.woff2?v=4.3.0?cb=1526503678' from origin 'https://www.slideshare.net' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'https://www.slideshare.net' is therefore not allowed access.

という CORS のエラーが出ていたので、こちらも少し調べました。2017年1月に SlideShare に報告してくれた人がいる問題のようですが、いまも解決してませんでした。

github.com

このあたりのエラーが放置されてると、今後は SlideShare はなるべく使わないようにしようかなあと思っちゃいますね。

まとめ

結果的に大した手間もなく移行できて、移行から1週間くらい様子を見た感じでは、問題なく動いていそうです。

ただ、はてなフォトライフの URL を作り直すのだけは本当に面倒だったので、ブログ記事が多い場合は、なにか自動化手段を先に用意したほうがいいと思います。がんばってください。

Blob をファイルとしてダウンロードさせるブックマークレットが「何もしてないのに壊れた」話

f:id:muziyoshiz:20180430135846p:plain

背景

私は、趣味で 艦これアーケードのプレイデータ管理ツール Admiral Stats というサービスを開発・運用しています。これは、

  • SEGA の艦これアーケード公式ページ から自分のプレイデータを JSON 形式でダウンロードするツール(通称エクスポータ)
  • プレイデータをアップロードしてもらい、それを可視化する Web アプリケーション

を組み合わせて実現しているサービスで、既に1年半以上運用しています。

このエクスポータは Ruby 版、PowerShell 版、ブックマークレット版を提供していて、一番使われているのはユーザにとって手軽なブックマークレット版です。去年9月の調査では、88%のユーザがブックマークレット版を使っていました。

ブックマークレット版のエクスポータにはさらに以下の2種類があります。いずれも、PCとスマホの両方で動く状態でした。

  • 自動アップロード用
    • エクスポートした JSON を、Admiral Stats に自動的にアップロードする
    • ダウンロード用よりもこちらの方が便利で、普通はこちらが使われている(はず)
  • ダウンロード用
    • エクスポートした JSON を、ローカルに .json ファイルとしてダウンロードする
    • Admiral Stats が終了した場合などのために、手元にファイルとして保存しておきたい人向け

今回はこのなかの、ブックマークレット版の「ダウンロード用」が何もしてないのに壊れたという話です。

何が起こったか

4/24 にユーザーの方から、「ダウンロード用のブックマークレットを実行しても、ファイルがダウンロードされない」という不具合報告を頂きました。詳しく情報を伺ってみると、

  • .json ファイルのダウンロードが始まる代わりに、本来ダウンロードしたい JSON そのものがページ内に表示されてしまう
  • ブラウザは Firefox 59.0.2
  • Admiral Stats 上のデータを見る限り、2018年3月29日まではダウンロードできていた

とのことで、僕の手元でも最新の Firefox で再現しました。また、Chrome でも同じく再現しました。

原因を調べたところ、a タグの download 属性が全く効いていないことがわかりました。

SEGA 公式サイトにログインしたあとに、このブックマークレットを実行すると、XMLHttpRequest を実行し、その結果を blob として取得します。そして、この blob の URL を参照する a タグを作り、自動的にクリックさせることでファイルのダウンロードを実現しています。コード中の以下の箇所です。

          var blob = new Blob([req.response]);
          if (window.navigator.msSaveBlob) {
            window.navigator.msSaveBlob(blob, fname);
          } else {
            var url = window.URL || window.webkitURL;
            var blobUrl = url.createObjectURL(blob);
            var a = document.createElement('a');
            document.body.appendChild(a);
            a.download = fileType + '_' + ymdhms + '.json';
            a.href = blobUrl;
            a.click();
            document.body.removeChild(a);
          }

https://github.com/muziyoshiz/admiral_stats_exporter_js/blob/v1.10.1/admiral_stats_exporter.js#L82

この download 属性が効いていないために、blob の表示ページに遷移してしまい、ブラウザの画面に JSON そのものが表示されてしまうようです。

しかし、このコードは元々普通に動いていました。それが突然なぜ……?

調査

最近のブラウザは自動アップグレードされてしまうので、なるべく古いバージョンのブラウザが残っていないかと手元の PC を漁って調べてみました。各バージョンでの動作結果はこちら。

ブラウザ リリース日 ブックマークレット(ダウンロード用)の動作
IE 11.371.16299.0 2013-11-08 成功
Microsoft Edge 41.16299.371.0 2017-11-05 成功
Firefox 47.0.1 2016-06-28 失敗
Firefox 59.0.2 2018-03-27 失敗
Chrome 65.0.3325.181 2018-03-06 失敗
Chrome 66.0.3359.117 2018-04-17 失敗
Safari 11.1 2018-02-22 失敗(ファイル名が unknown になる)

ダウンロード用エクスポータの公開日は 2016-10-19 でした。この時点では Chrome, Firefox, Edge, IE 11 で動いていて、特に動かないという報告もありませんでした。

ダウンロード用エクスポータに最後に機能追加したのは 2017-09-22 でした。普段の開発は Chrome で行っているので、この時点で、少なくとも Chrome では動いていた……はずです。

IE と Edge は window.navigator.msSaveBlob を使っているからか問題なし。Safari は当時からダウンロードが動かなかったので、状況変わってません。

Firefox は、かなり古いバージョンがインストールされたものが見つかったので動作確認したところ、エクスポータ公開日の 2016-10-19 より過去のバージョンなのに動きませんでした。ということは、SEGA 公式サイト側の動作が何か変わっている……?

Chrome は、2ヶ月以上古いバージョンが手元になかったので状況不明。しかし、Chrome 65 からクロスオリジンに対する a タグの download 要素はブロックされるようになったことがわかりました。

developers.google.com

Block cross-origin <a download>
To avoid what is essentially a user-mediated cross-origin information leakage, Blink will now ignore the presence of the download attribute on anchor elements with cross origin attributes. Note that this applies to HTMLAnchorElement.download as well as to the element itself.

今回のブックマークレットの場合、公式サイトと JSON の取得元は同一ドメインなので、このクロスオリジンに対する変更は関係ないのでは?と思ったのですが、以下のページによると手動設定された HTTP ヘッダーの種類によっては同一ドメインでもクロスオリジンの扱いになるようです。

developer.mozilla.org

ユーザーエージェントによって自動的に設定されたヘッダー (たとえば Connection、 User-Agent、 または Fetch 仕様書で "forbidden header name" として定義されている名前のヘッダー) を除いて、手動で設定できるヘッダーは、 Fetch 仕様書で "CORS-safelisted request-header" として定義されている以下のヘッダーだけです。
- Accept
- Accept-Language
- Content-Language
- Content-Type (但し、下記の要件を満たすもの)
- Last-Event-ID
- DPR
- Save-Data
- Viewport-Width
- Width

問題のブックマークレットは、JSON を取得する際に、CSRF チェックに引っかからないように X-Requested-With ヘッダを手動で設定しています。これが原因??

    req.setRequestHeader('X-Requested-With', 'XMLHttpRequest');

https://github.com/muziyoshiz/admiral_stats_exporter_js/blob/v1.10.1/admiral_stats_exporter.js#L53

Chrome 64 以前で動くならコレが原因と断言できるのですが、手元の Chrome はすべて Chrome 65 以降にアップグレード済みだったので検証できず。

それとは別に、Google Chrome ver60からa要素のdownload属性が同一オリジンポリシーに厳密になった(Qiita) には、Chrome 60(2017-07-25 公開)からこの制限が入ったとの情報もあります。しかし、2017-09-22 に新機能の動作確認をしているので、そのときには動いていたはずなんですよね……。Deprecations and Removals in Chrome 60 にはそういう記載もなく、動作が変わった時期はよくわかりません。うーん。

対応

結局のところ、この問題が具体的にいつから発生したのかはわかりませんでした。しかし、モダンブラウザでは crossorigin 属性の付いたコンテンツの <a download> は許可しない方向に動いていることはわかりました。

そのため、この制限を何らかの方向で回避することは不可能と判断して、報告してくれたユーザーには代替案の利用をオススメしました。

こういうことが時々あるのが、Web アプリ開発の嫌なところですね……。

Admiral Stats のユーザ向けのまとめ

  • ブックマークレットでの SEGA 公式からの JSON ダウンロードは不可能になりつつある
  • ブラウザ側の機能制限によるものなので対策不可
  • 自動アップロード用のブックマークレットのほうが便利なので、そちらに乗り換えて欲しい(使い方
  • .json ファイルをダウンロードしたい場合は PowerShell 版などをどうぞ(使い方

Playbook 側から Ansible のバージョンを指定する方法(あるいは Ansible 2.x.0 を絶対使わせない方法)

f:id:muziyoshiz:20160331232512p:plain:w300

もうすぐ Ansible 2.5 がリリースされますね。僕もそろそろ Ansible 2.5 Porting Guide とか読み始めました。

ところで、僕は Ansible のバージョンが上がった最初のリリースでつまづくことが多くて、どうしてもしばらく様子見してしまいます。例えば、Ansible 2.4.0 では aws_s3 モジュールに不具合があって、既存の playbook が動かなくなったりしました(下記)。

github.com

しかし、いくら自分が注意していても、そういう経験の無い人が気軽に Ansible のバージョンを上げてしまい、あとから「動かなくなったんだけど」と言われることもあります。そんなわけで、playbook 側から実行環境の Ansible のバージョンを指定できないか?と考えてみたら、うまく動いたので紹介します。

対象読者

  • Ansible 2.x.0 を基本的に信用していない
  • 同じ Ansible playbook を操作・編集する人が自分以外にもいる
  • ある程度動作確認が終わってから、Ansible のバージョンを上げたい

実現方法

Ansible では、always という特別なタグを付けたタスクは「毎回必ず呼ばれるタスク」として扱われます。そこで、Ansible のバージョンをチェックするタスクを、この「毎回必ず呼ばれるタスク」として playbook に登録すれば、今回やりたいことを実現できます。

There is a special always tag that will always run a task, unless specifically skipped (--skip-tags always) Tags — Ansible Documentation

実行環境の Ansible のバージョン番号は、ansible_version という変数から参照できます。例えば、debug モジュールでこの変数を参照すると、以下のように出力されます。

- debug: msg="{{ ansible_version }}"
TASK [common : debug] ************************************************************************************************
ok: [localhost] => {
    "msg": {
        "full": "2.4.3.0",
        "major": 2,
        "minor": 4,
        "revision": 3,
        "string": "2.4.3.0"
    }
}

バージョン番号の4番目は個別に取得できませんが、そこまで確認する機会は考えにくいので大丈夫でしょう。

具体例

どの playbook からも常に呼ばれるロールを作ります。この例では "common" という名前にします。

まず、固定したいバージョンを表す変数 expected_ansible_version を作ります。今回は、Ansible 2.4.0 だけは使われたくないので、2.4.1 以降なら許すということにします。

roles/common/vars/main.yml

---
expected_ansible_version:
  major: 2
  minor: 4
  revision: 1

そして、この変数と ansible_version を比較するタスクを作ります。

roles/common/tasks/main.yml

---
- name: Ansible major & minor version check
  fail:
    msg:
      - Expected Ansible version is
        {{ expected_ansible_version.major }}.{{ expected_ansible_version.minor }},
        but actual version is {{ ansible_version.major }}.{{ ansible_version.minor }}
  when: not (expected_ansible_version.major == ansible_version.major
             and expected_ansible_version.minor == ansible_version.minor)
  run_once: True
  tags: always

- name: Ansible revision check
  fail:
    msg:
      - Expected Ansible version is
        {{ expected_ansible_version.major }}.{{ expected_ansible_version.minor }}.{{ expected_ansible_version.revision }}+,
        but actual version is {{ ansible_version.full }}
  when: not (expected_ansible_version.major == ansible_version.major
             and expected_ansible_version.minor == ansible_version.minor
             and expected_ansible_version.revision <= ansible_version.revision)
  run_once: True
  tags: always

この例では、revision(バージョン番号の3番目)が想定より大きい場合は許しています。また、エラーメッセージをわかりやすくするために、タスクを2個に分けていますが、Ansible revision check の方だけでも十分です。

あとは、その環境のすべての playbook で、ロールの先頭にこの common を追加すれば OK です。

playbook1.yml

---
- hosts: all
  roles:
    - common

こうすると、ansible-playbook コマンドでタグを指定してもしなくても、必ずバージョンチェックが実行されます。when でチェックを実行しているので、チェックに成功すると "skipping" と表示されます(この表示は若干わかりにくいので、他にいい方法があったら教えてください)。

$ ansible-playbook -i inventory -c local playbook1.yml

PLAY [all] ***********************************************************************************************************

TASK [Gathering Facts] ***********************************************************************************************
ok: [localhost]

TASK [common : Ansible major & minor version check] ******************************************************************
skipping: [localhost]

TASK [common : Ansible revision check] *******************************************************************************
skipping: [localhost]

TASK [common : Example task 1] ***************************************************************************************
ok: [localhost] => {
    "msg": "Example task 1 is executed."
}

TASK [common : Example task 2] ***************************************************************************************
ok: [localhost] => {
    "msg": "Example task 2 is executed."
}

PLAY RECAP ***********************************************************************************************************
localhost                  : ok=3    changed=0    unreachable=0    failed=0

$ ansible-playbook -i inventory -c local playbook1.yml --tags=tag1

PLAY [all] ***********************************************************************************************************

TASK [Gathering Facts] ***********************************************************************************************
ok: [localhost]

TASK [common : Ansible major & minor version check] ******************************************************************
skipping: [localhost]

TASK [common : Ansible revision check] *******************************************************************************
skipping: [localhost]

TASK [common : Example task 1] ***************************************************************************************
ok: [localhost] => {
    "msg": "Example task 1 is executed."
}

PLAY RECAP ***********************************************************************************************************
localhost                  : ok=2    changed=0    unreachable=0    failed=0

メジャーバージョンかマイナーバージョンが合わないと処理が止まります。例えば、Ansible 2.4.3 以上を指定したのに Ansible 2.5 で実行されると、以下のように表示されます。

$ ansible-playbook -i inventory -c local playbook1.yml --tags=tag1

PLAY [all] ***********************************************************************************************************

TASK [Gathering Facts] ***********************************************************************************************
ok: [localhost]

TASK [common : Ansible major & minor version check] ******************************************************************
fatal: [localhost]: FAILED! => {"changed": false, "msg": ["Expected Ansible version is 2.4, but actual version is 2.5"]}
    to retry, use: --limit @/Users/myoshiz/devel/ansible_version/playbook1.retry

PLAY RECAP ***********************************************************************************************************
localhost                  : ok=1    changed=0    unreachable=0    failed=1

同じく、リビジョンが小さすぎても止まります。例えば、Ansible 2.4.3 以上を指定したのに Ansible 2.4.0 で実行されると、以下のように表示されます。

$ ansible-playbook -i inventory -c local playbook1.yml --tags=tag1

PLAY [all] ***********************************************************************************************************

TASK [Gathering Facts] ***********************************************************************************************
ok: [localhost]

TASK [common : Ansible major & minor version check] ******************************************************************
skipping: [localhost]

TASK [common : Ansible revision check] *******************************************************************************
fatal: [localhost]: FAILED! => {"changed": false, "msg": ["Expected Ansible version is 2.4.3+, but actual version is 2.4.0.0"]}
    to retry, use: --limit @/Users/myoshiz/devel/ansible_version/playbook1.retry

まとめ

Ansible は頻繁にバージョンアップされるので、特に問題がないなら、最新版に追従したほうがいいのは確かです。Ansible 2.5.0 を安心して使う気になれるまでの一時しのぎとして、よかったら試してみてください。

余談:Ansible 2.5 での仕様変更

Ansible 2.5 Porting Guide の冒頭に書いてありますが、include_tasks に付けられた属性(タグなど)の扱いが変わるようです。以下の記事で紹介した事象は、Ansible 2.4 でのみ発生する一時的な問題だったみたいですね。

muziyoshiz.hatenablog.com

2018-03-13追記

Ansible のバージョンチェックは1回で十分なので、run_once: True を付けました(参考)。