銀の弾丸

プログラミングに関して、いろいろ書き残していければと思っております。

AWSでランダムな画像を返すURLを作りました(Stravaのワークアウトのシェアのため)

タイトル通り「ランダムに画像を返すURL」をAWSで作りました。

Stravaのワークアウト完了時に、IFTTTを使って結果(距離、時間など)をTwitterに流していて、走行経路の地図(GoogleMaps)を添付しているのですが、常に「Image not found」となっており(今思えばセキュリティ設定に関連しているのかもしれない)、アクセスするたびに画像が入れ替わるURLを作ればいいやと、初夏の風吹く4月の土曜日に、朝メシ食わずにAWSでポチポチ作業・・・

↓これです。再読み込みすると別の写真が表示されます(単純にランダムなので同じ写真が連続することもある)。 すみませんAWSの請求書が怖くなったので現在CloudFrontでキャッシュするよう設定しましたー。 たぶんこのページからのリクエストで毎日支払金額が(わずかですけど)上がっていくので固定画像に変更しました。ゴメンナサイ。

f:id:takamints:20190415215350j:plain

当初、API Gateway、Lambda Function、S3 Bucket で簡単にできると思っていたのですが、ブラウザのIMGタグで表示させようとするとリクエストヘッダにAcceptで受け入れ可能なMIME Typeを指定しておかなければならなくて、そのためには CloudFront を使わなければならないらしい。なるほど勉強になりました。

目次

Lambda Function

Lambda Functionは、S3のバケットの特定のフォルダー以下から画像ファイルをリストして、その中からランダムに選んだ画像を返します。 バケット名とフォルダの名前は環境変数で設定しています。

const AWS = require("aws-sdk");
const S3 = new AWS.S3();
const bucket = process.env["BUCKET"];
const keyPrefix = process.env["KEYPREFIX"];// `strava-tweet-photos`

exports.handler = async (event) => {

    //S3の画像をリスト
    const listResult = await S3ListObjects({
        Bucket: bucket,
        Prefix: keyPrefix
    });

    //フォルダ以外のキーの配列に変換
    const keys = listResult.Contents
        .map(item => item.Key)
        .filter(key => (key !== keyPrefix));

    //ランダムに一つ選ぶ
    const index = randomInt(keys.length);
    const key = keys[index];

    //画像を取得
    const image = await S3GetObject({
        Bucket: bucket,
        Key: key
    });

    //HTTPレスポンスを返す
    const response = {
        statusCode: 200,
        headers: {
            'Content-Type': image.ContentType
        },
        body: image.Body.toString('base64'),
        isBase64Encoded: true,
    };
    return response;
};

const S3ListObjects = params =>
    new Promise( (resolve, reject) =>
        S3.listObjects(params,
            (err, data) => (err ?
                reject(err) :
                resolve(data))));

const S3GetObject = params =>
    new Promise( (resolve, reject) =>
        S3.getObject(params,
            (err, data) => (err ?
                reject(err) :
                resolve(data))));

const randomInt = max =>
    Math.floor(
        Math.random() * Math.floor(max));

API Gateway REST API

適当なリソースを作ってGETメソッドを作り、「統合リクエスト」で「Lambda統合」を選択して、上のLambdaを指定します。 Lambdaの戻り値をそのままメソッドレスポンスへ渡すので、「統合リクエスト」の「Lambdaプロキシ統合」にチェックを入れます。 ブラウザで表示するだけなら、CORSは対応しなくて良いですね。

CloudFront Distribution

上のREST APIをデプロイし、そのエンドポイント(例: xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com)を Origin に指定してCloudFrontディストリビューションを作ります。 Origin Path にはAPIをデプロイしたステージ名を指定(例:/production)して、Origin Protocol PolicyHTTPS Only とします(API GatewayREST APIなので)。

今後の話

画像のサイズを指定できたり、画像のEXIFを読み取って、アクセスした日に近い画像を返すとか、いろんな機能拡張が思い浮かびます。 ちょっとずつ楽しみながら実装していきたいなーと思いました。

参考リンク

qiita.com

qiita.com

AWS S3 の putObject API でメタデータを設定する

f:id:takamints:20190415215350j:plain

AWSによるサーバーレスアーキテクチャ
Peter Sbarski
翔泳社
売り上げランキング: 157,672

AWS S3 の putObject API でファイルを上書きアップロードすると、以前設定していたメタデータも上書きされてデフォルト状態に戻ってしまいます。

S3バケットのコンテンツをWebページとして公開設定していたり、CloudFrontから配信している時には、ブラウザキャッシュの設定を行っておきたい場合がありますが、ファイルを上書き更新するたびに、その設定が失われるので、必要なメタデータは毎回指定するべきなんですね。

ということで、APIでS3にファイルをアップロードする際に同時にメタデータを設定するNode.jsのコードを以下に(AWSへの接続はAWS-CLIのプロファイル(~/.aws/ 以下の configcredentials)が正しくセットアップされている前提です)。

以下のコードは、WEBページで配信しているなら最低限設定しておくべき Content-Type と、おそらく設定しておきたいのでは?と思われる Cache-Control をファイルのアップロード時に設定しています。

"use strict";
const AWS = require("aws-sdk");
const mime = require("mime-types"); //拡張子からMIME Type
const fs = require("promise-fs"); //地獄に落ちないfsモジュール
const { promisify } = require("es6-promisify"); //callbackの非同期をPromise化
const s3 = new AWS.S3();
const promised = {
    s3: {
        putObject: promisify(s3.putObject.bind(s3))
    }
};

/**
 * ContentTypeとCacheControlを設定してS3のバケットへファイルを
 * アップロードする。
 * @async
 * @param {string} bucket バケット名
 * @param {string} key アップロード先のキー(バケット内のパス)
 * @param {string} pathname ローカルファイルのパス名
 * @returns {Promise<undefined>} アップロード完了で解決するPromise
 */
const uploadS3Bucket = async (bucket, key, pathname) => {
    try {
        const body = await fs.readFile(pathname);
        const contentType = mime.lookup(pathname);
        const params = {
            Body: contentType.match(/^text\//) ? body.toString() : body,
            Bucket: bucket,
            Key: key,
            CacheControl: "no-cache",
            ContentType: contentType,
        };
        console.log(`Uploading: ${pathname}`);
        console.log(`  [ContentType: ${params.ContentType}]`);
        console.log(`  ==> s3:${'//'}${params.Bucket}/${params.Key}`);
        await promised.s3.putObject(params);
    } catch(err) {
        console.warn(err.message);
    }
};

Content-Type は必ず設定しましょう

APIでputObjectする場合、Content-Typeは必ず設定が必要です。Webで配信している場合は特に正しいMIME Typeを設定しましょう。 指定しないとデフォルトの application/octet-stream になってしまい、ブラウザで表示したいのにダウンロードされてしまいます。

AWS-CLIのコンソールコマンドでアップロードした場合は正しく設定されますが、他のメタデータは削除されます。

ここではファイル名(拡張子)からMIME Typeを得るために、 mime-types というnpmモジュールを使っています。 この mime-types は、また別の npm mime-dbに依存しており どちらも週に1500万回ほどダウンロードされていますから実用上の問題はないと思います。

Cache-Control も設定したい

Webサイトとして公開している場合、Cache-Controlを設定しておかないとブラウザキャッシュが有効になり、AWS側から制御しにくいので、ワタシはたいてい no-cache に設定してます。 CloudFrontのディストリビューションから公開している場合、24時間程度のキャッシュが効いていますので、S3から外へ出ていく容量(と課金)をあまり気にする必要はないはずです。 CloudFrontでInvalidationを作成してキャッシュを無効化しても、ブラウザキャッシュが効いているとページが更新されませんし。

参考:S3のオブジェクトのメタデータ

下表は S3 Bucket のファイルに設定可能なメタデータです。 メタデータ列は S3 のWEBコンソールで表示される名称で、パラメータキーはputObjectのパラメータで指定する場合のキー名称です。

メタデータ パラメータキー 詳細
Cache-Control CacheControl ブラウザキャッシュの指定 (☞MDN)
Content-Disposition ContentDisposition ファイルの扱い方法を指定 (☞MDN)
Content-Encoding ContentEncoding 圧縮アルゴリズムを指定 (☞MDN)
Content-Language ContentLanguage 閲覧者の言語を指定 (☞MDN)
Content-Type ContentType ファイルのMIME Typeを指定 (☞MDN)
Website-Redirect-Location WebsiteRedirectLocation リダイレクト先 (☞DevelopersIO)
x-amz-meta-<key> Metadata.<key> ユーザー定義メタデータ (☞AWS)
有効期限 Expires 削除される日時(☞AWS)

※ ユーザー定義メタデータ以外は、APIであらかじめ定義されているシステムメタデータです。

リンク

mochaとBabelでESモジュールをテストする

f:id:takamints:20181110123153j:plain

npm内のESモジュールをmochaでテストしようとしたのですが、上手くいかない。 Babelが必要なんですね。mochaのテストスクリプトをbabelで変換しないと import / export が構文エラーになってしまうのです。

事前に変換しなくても、@babel/register を使ってmocha実行時に変換しながらテストできるらしいのですが、 そのやり方を調べてみると、断片的な情報がバラバラとある状態で「ココだけ見てやったらバッチリOK」ってな情報源には出会えずじまい。 あっちやこっちを見ながら最終的には何とかなりましたが、結構苦労したので備忘録としてまとめてここに書いておきます。



前提条件

  • 使用するBabelのバージョンは7。
  • テストランナーは mocha。
  • テストはnode.jsで動かします(ブラウザで行うテストじゃないです)。
  • テスト対象はnpm内の ESモジュールのクラスです。

必要なモジュール

上記の変換を行う為に最低限必要なモジュールは以下4つ。 各モジュールのバージョンは現在確認したワタシの環境のものです。おそらく現時点での最新バージョン。

  • @babel/core - 7.3.4
  • @babel/register - 7.0.0
  • babel-preset-env - 1.7.0
  • mocha - 6.0.2

飽くまでも最低限のモジュールです。ほかに必要になるモジュールがあるかもしれません。 例えばワタシはアサーションchaiを使いますし、分割代入などを使う場合はまた別のが必要になったりするんじゃないかなと思います。

npmに上記モジュールをインストールするには以下のコマンドでOK。

npm i -D @babel/core @babel/register babel-preset-env mocha

注意:ここに上げた最低限のモジュールは、次項の .babelrc の記述内容に依存しているかも知れません。 そういう意味で、この記事は「こうやればできた」に過ぎないかもしれないので注意してください。 今のところこれ以上深堀をしていないので。

Babel の設定

Babelの変換を定義する .babelrc も必要です。 babel-preset-envを指定しています。 このため依存モジュールにbabel-preset-env が必要だったのだと思います。

.babelrc

{
  "presets": [
    [
      "env",
      {
        "targets": {
          "node": true
        },
        "useBuiltIns": true
      }
    ]
  ]
}


テスト対象のESモジュールとテストスクリプト

テスト対象のESモジュール

src/my-class.js

"use strict";

/**
 * MyClass.
 * @constructor
 */
export function MyClass() {}

/**
 * bar.
 * @returns {string} "foo" を返す
 */
MyClass.prototype.bar = function() {
    return "foo";
};

mochaでESモジュールをテストするスクリプト

test/my-class.test.js

※ 以下、chaiを使っているので、npm i -D chai が必要ですよ。

"use strict";
import { assert } from "chai";
import { MyClass } from "../src/my-class.js";

describe("MyClass", () => {
    describe("#bar", () => {
        it("should returns 'foo'", () => {
            assert.equal( "foo", (new MyClass()).bar() );
        });
    });
});

mocha実行時のオプション

mocha実行時に変換するためには、以下のように --require オプションで @babel/register を指定します。

mocha --require @babel/register test/**/*.test.js

テスト対象はnpmなので、package.json のscripts を以下のように設定しておけば、npm test でテスト実行できますね。

{
  ~略~
  "scripts": {
    "test": "mocha --require @babel/register test/**/*.test.js"
  }
  ~略~
}