Galapagos Tech Blog

株式会社ガラパゴスのメンバーによる技術ブログです。

サーバーレスなAtomリーダーを作ってみた

こんにちは。iOSチームの高橋です。好きな天気記号は煙霧です。

最近仕事の空き時間に簡単なAtomフィードのリーダーを作ったので、そのお話をします。

構成

図にするとこういう感じです:

f:id:glpgsinc:20180516150140p:plain

サーバーレスということで、フィードを保存するストレージにはDynamoDBを使っています。 DynamoDBに登録されたフィードを定期的にLambdaでクロールして解析したエントリ情報をDynamoDBに保存するようにしています。 保存されたエントリはAPI Gateway経由で取得できるようにして、S3に置かれたhtmlからajaxで叩いてVue.jsで描画する形です。

筆者はDynamoDBやらVue.jsやらの経験がなかったので、この記事はそのあたりの奮闘記になります。

DynamoDBとの闘い

DynamoDBはいわゆるNoSQLなので、検索の柔軟性をいささか犠牲にしている部分があります。 具体的にはあらかじめ指定されたキーでしかフィルタやソートができません。

筆者はこのあたりで心が折れかけたのですが(RDBの世界に帰りたい!)、とりあえずテーブルを二つ作って、一方をフィード格納用、もう一方をエントリ格納用としました。

キーの設定

フィード格納用テーブルはシンプルにフィードのIDとURL(とタイトルなどのメタデータ)を保存するものです。 こちらは特にフィルタしないのでscanで全件取得する方針にしました。なのでIDをハッシュキーにしておきます。

問題はエントリ格納用テーブルです。フィードIDでフィルタして、更新時刻でソートしたいので、 フィードIDをハッシュキーに、更新時刻をレンジキーにしたいのですが、 フィードIDと更新時刻の組が一意になるとは限らない(レアケースではあると思いますが)ので、これらをキーとして使うことはできません。

かわりに、エントリIDをレンジキーにして、ローカルセカンダリインデックスとして更新時刻をソートキーにすることで解決しました。

Pythonからの呼び出し

こうしてできたDBへはAPI GatewayからLambdaでアクセスします。言語はPython3.6を選んだので、boto3を使ってこういう風にクエリできます:

dynamodb = boto3.resource('dynamodb')
entries_table = dynamodb.Table(os.environ['ENTRIES_TABLE_NAME'])
entries = entries_table.query(
    IndexName='LSI', # ローカルセカンダリインデックス
    ScanIndexForward=False, # 降順
    Limit=10,
    KeyConditionExpression=Key('feed_id').eq(feed_id))

ペジネーション

ここまで作ったところでふたたびNoSQLの壁が立ちはだかります。DynamoDBではSQLのようにOFFSETを使うことができず、 かわりに「前回のクエリで返ってきた最後の値」を引数に渡すことでそこから先のエントリだけを取得するようになっています。 今回のように順番にペジネーションする場合はフロントエンドで値を持ち回る必要があるだけですが、途中のページに飛びたい場合などには問題になるでしょう。

Serverless Framework

テーブル設計とLambdaに載せるコードが準備できたところで、Serverless Frameworkを使ってズドン!と作ってしまいます。 この子はYAMLにLambda関数の設定や作りたいリソースを書いてやると自動でLambda関数やAPI Gatewayのエンドポイントやその他リソースを作ってくれるいい子です。

今回の設定ファイルはこういう感じになります(だいぶ端折っていますが):

service: feed-reader
provider:
  name: aws
  runtime: python3.6
  region: ap-northeast-1
  iamRoleStatements:
    - Effect: Allow
      Action:
        - dynamodb:Query
        - dynamodb:Scan
        - dynamodb:GetItem
        - dynamodb:PutItem
        - dynamodb:UpdateItem
        - dynamodb:DeleteItem
      Resource:
        - "arn:aws:dynamodb:${opt:region, self:provider.region}:*:table/${self:provider.environment.FEEDS_TABLE_NAME}"
        - "arn:aws:dynamodb:${opt:region, self:provider.region}:*:table/${self:provider.environment.ENTRIES_TABLE_NAME}"
        - "arn:aws:dynamodb:${opt:region, self:provider.region}:*:table/${self:provider.environment.ENTRIES_TABLE_NAME}/*"
  environment:
    FEEDS_TABLE_NAME: ${self:service}-feeds-${opt:stage, self:provider.stage}
    ENTRIES_TABLE_NAME: ${self:service}-entries-${opt:stage, self:provider.stage}
functions:
  crawler:
    handler: handler.crawl
    events:
      - schedule: rate(6 hours)
  createFeed:
    handler: handler.create_feed
    events:
      - http:
          path: feeds/create
          method: post
          cors: true
resources:
  Resources:
    feedsTable:
      Type: "AWS::DynamoDB::Table"
      Properties:
        AttributeDefinitions:
          -
            AttributeName: id
            AttributeType: S
        KeySchema:
          -
            AttributeName: id
            KeyType: HASH
        ProvisionedThroughput:
          ReadCapacityUnits: 1
          WriteCapacityUnits: 1
        TableName: ${self:provider.environment.FEEDS_TABLE_NAME}

functionseventsのところでscheduleを指定するとCloudWatchから定期的にLambda関数を起動することができます。 また、httpを指定するとAPI Gatewayのエンドポイントを作ってくれます。便利!

pipでパッケージを入れたい場合

ところで、Lambdaで実行する関数の中でサードパーティのパッケージを使いたいときがあります。 こういう場合、パッケージをダウンロードしてきてzipに含めて一緒にアップロードするなどの手間が発生するのですが(しかも場合によってはLambdaと同等の環境を再現する必要があったりもする)、 Serverlessにはそれを解決する便利なプラグインがあります。

GitHub - UnitedIncome/serverless-python-requirements: ⚡️🐍📦 Serverless plugin to bundle Python packages

このプラグインを入れて、YAMLファイルにちょっと追記して、requirements.txtに必要なパッケージを書いておくと自動でLambda関数に含めてくれます。 また、今回はネイティブコンパイルの必要なパッケージは使っていないのですが、必要ならばDockerコンテナを立ち上げて環境を用意してくれるようです。便利!

Vue.jsとなかよくなる

さて、バックエンドの準備が整ったので、フロントエンドの作成に入ります。 APIを叩くのにはaxiosを使うことにしたので、たとえばこういう感じでフィード一覧を取得することができます:

axios.get('/feeds').then(function (response) {
  console.log(response.data.feeds);
});

こうして得られたフィードやエントリをVue.jsを使って描画していきます。

Vue.jsというのは、コンポーネントシステムやデータバインディングを用いてシンプルな記述から動的にWebページを生成するフレームワークです。 公式のチュートリアルがとても充実していて特にハマったところもないので特筆すべきことはないのですが、たとえば、

<div id="app">
  <ul>
    <li v-for="feed in feeds">{{ feed.title }}</li>
  </ul>
</div>
var vm = new Vue({
  el: '#app',
  data: {
    feeds: []
  }
});

axios.get('/feeds').then(function (response) {
  vm.feeds = response.data.feeds;
});

のように記述すると、GET /feeds APIから返ってきたフィード情報がリスト表示されるようになっています。 ここではvm.feedsがHTMLのfeedsにバインドされており、変更が検知されるとDOMを書き替えて表示を更新します。 v-forというのはVue.jsの独自属性で、その名の通り配列のfor文のように要素を繰り返すことを表しています。

さらにコンポーネントを活用することで、ひとまとまりのHTML片を独自タグに変換し、HTMLの記述をシンプルにすることができます。たとえば、

components: {
  'entry': {
    props: ['entry'],
    template: '<article><h1>{{ entry.title }}</h1><div v-html="entry.content"></div></article>'
  }
}

のようにコンポーネントを定義しておくと、HTML側では

<entry :entry="entry"></entry>

と書くだけで、templateで指定したHTMLを生成してくれます。いい子ですね。

S3にアップロード

あとはこうしてできたWebページ一式をS3の適当なバケットに上げて、Webホスティングを有効にすれば完成です。簡単ですね!

認証をかける

こうしてできたAtomリーダーですが、自分以外からのアクセスを制限したいと思います。 このとき可能な方法はいくつかあると思うのですが、今回はAPI GatewayAPIキーを設定する方法をとりました。 そういう場合もServerless Frameworkに任せることができて、YAMLファイルに

provider:
  # ...
  apiKeys:
    - feedReaderAPIKey
# ...
functions:
  # ...
  createFeed:
    # ...
    events:
      - http:
          # ...
          private: true

と書くだけで自動的にAPIキーを生成してAPI Gatewayに設定してくれます。

生成されたAPIキーはデプロイ時にコンソールに表示されるので、axiosのほうで

axios.defaults.headers = { 'x-api-key': 'XXXXXXXXX' };

などと書いてやればaxiosからのリクエストに自動的にAPIキーが付加されます(実際には直書きするとまずいので、ローカルストレージに格納したものを読み出しています)。 便利ですね。

ハマったことなど

基本的には便利なフレームワークに助けられてスッと作ることができたのですが、いくらかハマったところがあったので書いておきます:

DynamoDB

上にも書きましたが、DynamoDBのキー概念に馴染むのにけっこう手間が掛かりました。いまでもこれが正解なのかよくわかっていません(でもこれ以外にどうすれば?)。 結局詳細はよくわからなかったので、限りなくシンプルなテーブル構造を保つためにいくつかの機能(未読件数表示とか)を落とすことになりました。今後の課題です。

XHTMLとHTML

Atomフィードのエントリのcontent属性が'xhtml'の場合(そんなことをしている例は筆者自身のblog以外に見つからなかったのですが)、その部分はXMLとして解釈され、XHTMLとして独自の名前空間を持ちます。 これが問題で、Python標準ライブラリのXML解析器でtostringを呼ぶと、その部分のXHTMLタグすべてに名前空間接頭辞がつくことになります。 これをそのままWebページに流し込んでも、未知のタグとして扱われるのでマークアップが機能しません。

これを解決するために、Webページ自体をXHTMLにしてしまう、という案も考えたのですが、そうすると今度はHTMLで書かれたフィードが有効なXMLではないケースに対応できなくなります。

最終的には、XML解析の段階でタグをイテレートして、タグ名から接頭辞を消去することで解決しました。

tree = ET.ElementTree(element=list(content)[0])
for el in tree.iter():
    el.tag = el.tag.replace('{http://www.w3.org/1999/xhtml}', '')
content_text = ET.tostring(tree.getroot(), encoding='unicode', method='html')

まとめなど

ということで、DynamoDBとServerless FrameworkとVue.jsを使ったサーバーレスAtomフィードリーダー作成について書きました。 普段はiOS開発をやっているので、もっと手こずるかな〜と思っていたのですが、案外簡単に(実質三日くらい?)作ることができたのでよかったです。 新しい技術を覚えてゆくのはやっぱり楽しいですね。

ところで

ガラパゴスではエンジニアを募集しています。興味を持たれたかたはぜひ採用ページをご覧ください。

www.glpgs.com