More than 1 year has passed since last update.

こちらはAWS Lambda アドベントカレンダー 2014の最後の記事となります。今回はAWS LambdaとS3で静的にブログっぽいものを作ってみようという企画です。

仕様

では実際にアプリケーションを作っていきましょう。今回の目的は表題にもあるようにS3の静的サーバ機能を利用したブログシステム。要するにソース用のS3バケットにMarkdownファイルをアップロードしたらそれをHTMLにレンダリングし、静的コンテンツ公開先のS3バケットにPUTするというものです。

Lambda Function側ではS3のPUTイベントを拾い、その情報を利用してLambda Functionを起動します。PUTのイベントにはアップロードされたファイルの情報が入りますが、今回は単にトリガとして利用することとします。

準備

静的サイトとして公開するS3のバケット、およびMarkdownファイルなどを入れておくバケットの二つを用意します。今回、公開側はhttp://static.badatmath.netというURLで公開するように設定しました。static.badatmath.netというバケットを作成し、そのバケットのプロパティ設定画面から"Static Website Hosting"で"Enable website hosting"を選択します。次に表示されているEndpointをコピーし、利用しているDNSでCNAMEの設定を行います。今回の場合、私の利用しているDNSの設定は

cname static static.badatmath.net.foo.amazonaws.com.

のようになりました。しばらくすると設定が反映され、URLにアクセスできるようになります。またMarkdownファイルを入れておくほうのバケットの設定ですが、Lambdaを実行できるよう、"Events"においてLambda functionやInvocation roleを適切に設定しておきましょう。

実装

今回静的コンテンツのレンダリングに利用するのは以前紹介したことのある静的サイトジェネレータのMetalsmithを利用します。こちらのMetalSmithですがしばらく見ない間に1.0.1がリリースされ、安定して使えるもののようになっているようです。

このMetalsmithは素のままではただ単にファイルをコピーするだけのものになってしまうので、今回の目的のような場合にはプラグインの利用がほぼ必須です。利用するプラグインとしては

こちらを使ってサイトを構築していきます。では実際に作り込んで行きましょう。適当にディレクトリを作成し、npm initコマンドを利用してモジュール構成とします。

あとはMetalsmithやプラグインをnpm install metalsmith --saveとしてpackage.jsonにも保存されるようにインストールしていきます。

それからメインとなるプログラム、まずはただ単にファイルをコピーするだけのものからはじめました。そのときのindex.jsはこんな感じ

'use strict';                                                                                                                          

var Metalsmith = require('metalsmith');

var metalsmith = new Metalsmith(__dirname)
  .source('./src')
  .destination('./build')
  .build(function (err, files) {
      console.log(files);
      if (err) throw err;
  });

最小構成です。まだS3は使っておらず、ローカルで全て完結するような構成です。実際に動かしてみてsrcディレクトリ内に設置したファイルがbuildディレクトリにコピーされることを確認し、無事コピーできたら次にS3を利用できるようにMetalSmithのAPIを上書きすることにします。

Metalsmith内で利用されているファイルシステムがらみのモジュール、メソッドを鑑みながら置き換え対象をピックアップします。

書き換え候補APIは結構ありました。

  • Metalsmith.prototype.source
  • Metalsmith.prototype.destination
  • Metalsmith.prototype.path
  • Metalsmith.prototype.read
  • Metalsmith.prototype.readFile
  • Metalsmith.prototype.write
  • Metalsmith.prototype.writeFile

ソースを設置するバケット、公開するS3の静的サイトとなるバケットの指定についてはソースの外に追い出して、sourcedestinationを必要なくしたため、結果として置き換えたのはreadwritebuildだけでした。

まずは出力系のメソッドを書き換えてみてテスト、次に読み出し元関連の書き換え、さらにテストしてみてうまく行くことを確認します。

うまく行くことが確認できたらプラグインをuseして、実際の動きに近づけていきます。また、export.handler関数を定義してAWS Lambdaで使えるようにしておきます。
結果のファイルはこちら
(あとこちらにファイル群をアップロードしておきます。

'use strict';

var Metalsmith = require('metalsmith');
var fakeS3     = require('./lib/fakeS3');
var each       = require('async').each;
var front      = require('front-matter');
var utf8       = require('is-utf8');

var markdown    = require('metalsmith-markdown');
var templates   = require('metalsmith-templates');
var collections = require('metalsmith-collections');
var draft       = require('metalsmith-drafts');

var config = require('./config.json');
var destBucket = new fakeS3({
  region: config.region,
  params: {Bucket: config.destinationBucket}
});
var sourceBucket = new fakeS3({
  region: config.region,
  params: {Bucket: config.sourceBucket}
});


/**
 * rewriting Metalsmith methods
 */

Metalsmith.prototype.read = function(cb) {
  var files = {};
  var parse = this.frontmatter();
  sourceBucket.readdir(function(err, arr) {
    if(err) return cb(err);

    each(arr, read, function(err) {
      cb(err, files);
    });

    function read(readParam, done) {
      sourceBucket.readFile(readParam, function(err, buffer) {
        if (err) return done(err);
        var file = {};

        if (parse && utf8(buffer)) {
          var parsed = front(buffer.toString());
          file = parsed.attributes;
          file.contents = new Buffer(parsed.body);
          file.contentType = 'text/html';
        } else {
          file.contents = buffer;
        }
        files[readParam.Key] = file;
        done();
      });
    };
  });
};

Metalsmith.prototype.write = function(files, cb) {
  each(Object.keys(files), write, cb);

  function write(file, done) {
    var data = files[file];
    return destBucket.writeFile(file, data.contents, data.contentType, function (err) {
      if (err) return done(err);
      done();
    });
  }
};

Metalsmith.prototype.build = function (cb) {
  var self = this;
  var clean = this.clean();
  if (clean) {
    destBucket.removeFiles(function(err, data) {
      if (err) return cb(err);
      self.read(function(err, files) {
        if (err) return cb(err);
        self.run(files, function(err, files) {
          if (err) return cb(err);
          self.write(files, function(err) {
            cb(err, files);
          });
        });
      });
    });
  }
};


/**
 * invoke metalsmith with lambda
 */
exports.handler = function(event, context) {
  var metalsmith = new Metalsmith(__dirname);
  metalsmith
    .use(draft())
    .use(collections({
      articles: {
        pattern: '*.md',
        sortBy: 'date',
        reverse: true
      }
    }))
    .use(markdown())
    .use(templates('handlebars'))
    .build(function (err, files) {
        console.log(files);
        context.done(err);
    });
};

中で利用しているfakeS3に関してはこちら。なんかいろいろと変なのでもうちょっとブラッシュアップさせたほうが良いですね。fsモジュールと同様のAPIを持たせられれば、fsの代わり、もしくはpatchを動作時にあててこちらを使うようにすると動作するようになるパッケージがたくさんあると思われます。

'use strict';                                                                   

var AWS = require('aws-sdk');

function fakeS3(param) {
  this._bucket = new AWS.S3(param);
}

fakeS3.prototype.readdir = function (cb) {
  this._bucket.listObjects(function(err, data) {
    if (err) return cb(err);
    var fileArray = data.Contents.map(function(o) {
      return {Key: o.Key};
    });
    if (fileArray.length === 0) {
      return cb();
    }
    cb(null, fileArray);
  });
}

fakeS3.prototype.readFile = function (readParam, cb) {
  this._bucket.getObject(readParam, function(err, data) {
    if (err) return cb(err);
    cb(null, data.Body);
  });
};

fakeS3.prototype.removeFiles = function (cb) {
  var self = this;
  this.readdir(function(err, files) {
    if (err) return cb(err);
    if (!files) return cb();
    var deleteParam = {Delete: {Objects: files}};
    self._bucket.deleteObjects(deleteParam, function (err, data) {
      if (err) return cb(err);
      return cb(null, data);
    });
  });
};

fakeS3.prototype.writeFile = function (key, value, contentType, cb) {
  if (!value) return cb();
  var writeParam = {Key: key, Body: value};
  if (contentType) writeParam.ContentType = contentType;
  this._bucket.upload(writeParam, function(err, data) {
    if (err) return cb(err);
    cb(null, data);
  });
};

module.exports = fakeS3;

S3のコンフィギュレーション用に利用したJSONファイルは以下のような感じ。index.jsで読んでいます。


{
  "region" : "us-west-2",
  "sourceBucket" : "yoursource",
  "destinationBucket" : "static.badatmath.net"
}

最初はもう少し何か必要になるかと思って切り出したのですが、この他に指定しなければならない項目はなさそうです。

テスト

出来上がったものを実際に動かしてみましょう。
templateフォルダを作成して利用するテンプレートファイルを格納し、zipで固めてアップロードします。その後ロールの設定などを行い、Lambdaが起動できる状態にしておきます。

ちなみにテンプレートの形式は今回Handlebarsを利用したのでそちらの形式となっています。
例えば以下のような感じですかね。

<!doctype html>
<html>
<head>
  <meta charset="UTF-8" />
  <title>simple blog with aws lambda and s3</title>
</head>
<body>
  {{#each collections.articles}}
  <div class="blog-title">
    {{{ title }}}
  </div>
  <div class="blog-body">
    {{{ contents }}}
  </div>
  {{/each}}
</body>
</html>

ブログの記事ファイルも用意しましょう。記事のマークダウンファイルにはYAML front matter形式でメタ情報を記述しておきます。この際、templateプロパティには使用するテンプレートファイル名を記述します。


title: sample1 
date: 2014-12-23
template: blog.hbt
---

This is my first blog

## H2

article body

準備ができたらいざ!ソース側のS3バケットにアップロードして、それが静的サイト公開用のS3バケットにHTMLとして出力されるか確認してみましょう。

なお、現状は前の記事や後ろの記事へのリンクは埋め込んでいませんが、この辺は既存のプラグインを使って実現できますので是非挑戦してみてください。

まとめ

今回はLambda FunctionとS3を組み合わせてブログシステムの構築をしてみました。多数のファイルを一気に置かれた場合や画像のリサイズ、EXIF情報の消去、ページング、そもそもテーマをあてるなど、まだまだ改良の余地はありますが、一旦はここまで。
ファイルシステムへアクセスするメソッドを書き換えることで既存のモジュールがS3でも簡単に使えるようになるのは面白いですね。他にも様々なモジュールが今回のような手法で使えるようになると思います。
また、LambdaによってEC2を使わなくともこうしたシステムが気軽に実現できるのは嬉しいところ。多人数で記事を書く際などに威力を発揮するのではないでしょうか?

ではみなさま、良いお年を!

Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.