Tech Do | メディアドゥの技術ブログ

株式会社メディアドゥのエンジニアによるブログです。

AWSでサーバーレスに電子書籍webビューアーを配信しよう!

f:id:qazx7412:20201026181636j:plain

こんにちは、某DJのコンテンツのソーシャルゲームをやってみたら思いの外はまってしまっているエンジニアの回路(@qazx7412)です。

皆様は普段webから電子書籍を読んでいるときに、自分でも電子書籍を配信してwebで読めるようにしてみたいと思ったことはないでしょうか?
ありますよね?あります。(断言)
ということで、今回はそんな自分でも電子書籍配信システムを構築してみたいと思う皆様のために、AWSを使ってサーバーレスにwebビューアーを配信する方法をご紹介しようと思います。

今回作るものを説明します

まずは以前の記事から弊社での配信システムの構成図の再掲します。

f:id:qazx7412:20191017102638p:plain

この図だけ見てもシーケンス的な解説が載っていないので皆様なんのこっちゃという感じなのではないでしょうか?
簡単にこのシステムで何をしているかを特に閲覧に関するところを掻い摘んで説明をすると、

  1. 基幹システムからepubを受け取りDRMなどの必要な変換をかけてS3へ設置して公開できるようにする
  2. 電子書店から閲覧のリクエストを受け付けて、「コンテンツを閲覧することができるURL」を生成、返却する
  3. 生成したURLからアクセスしてくるユーザーがコンテンツを閲覧できるようにwebビューアーとコンテンツを配信

という3つになります。
今回のメインとなる3のビューアー周りに関してもう少し解像度の高い図があるのでこちらも再掲します。

f:id:qazx7412:20191010135633p:plain

基本的にS3とCloudFrontでwebビューアーとコンテンツを配信しています。
ただそれだけではコンテンツへのアクセス権があるかのチェックや、各ビューアーの仕様に合わせたレスポンスの返却ができないので、Lambda@Edge(以下l@e)を使って解決します。
前述の2の「コンテンツを閲覧することができるURL」は、(l@eの設定などがされていない)単なるwebビューアーのindex.htmlへのリンクになっていて、付属するクエリパラメーターを使ってリクエストする先のコンテンツの判別や認証をしています。

ということで今回はこのwebビューアーを少し簡単な形にしたものを作成していこうと思います。

ビューアーとコンテンツの準備しましょう

webビューアーですが、今回はこちらのオープンソースで開発されているBibiを使用します。 bibi.epub.link 単体でepubをそのまま閲覧することができる優秀なビューアーです。

また、コンテンツは下記リポジトリで公開されているものを利用します。 github.com (サンプルのコンテンツの著作権はコンテンツの著作者にあります)

今回はBibiを例にして解説を行いますが、多くの他のwebビューアーでも同じように配信ができるはずです。

それでは最初にS3に必要なファイルを設置しましょう。
まずはバケットを作成します。名前は任意でよいのですが、今回は web-viewer-bucket で作成します。
作成したらバケットに下記のようにビューアーとコンテンツのファイルを展開します。

s3://web-viewer-bucket/
  │ 
  ├ viewer/
  │   └ bibi/ (公開されているBibiのファイルを展開)
  │      ├ index.html
  │      ├ etc…
  │  
  └ content/
     └ sample_content/
         (https://github.com/IDPF/epub3-samples/tree/master/30/haruko-jpeg/ と同じものを展開)
        ├ META-INF/…
        ├ OPS/…
        └ mimetype

Bibiを知っている方ならば違和感を覚えるところがあると思います。
本来Bibiではコンテンツファイルは ./bibi-bookshelf (今回の場合は s3://web-viewer-bucket/viewer/bibi-bookshelf )に設置するようになっていて、クエリパラメーターで指定したものと同じ名前のepubやディレクトリを取得してコンテンツを表示するようになっています。
ただ実際にAWSを利用して電子書籍を読める仕組みを作成したい場合、ビューアーとコンテンツのディレクトリやパスを変更したい、もしくはそれぞれを別のバケットにしたいこともあるはずです。
Bibiの場合はCloudFrontの設定で ./bibi-bookshelf へのアクセスだけオリジンとなるバケットを変更することで対応できますが、採用するビューアーによっては同様のことができません。今回は後者の状況下における交通整理のデモとしてあえてバケットのパスを変更しています。

Lambda@EdgeとCloudFrontを作成しましょう

まず(私の記事では毎度ですが…)l@eの管理を簡単にするためにServerless Framework(以下serverless)を使用します。 www.serverless.com 今回はserverlessでl@eだけでなくCloudFrontまで一括で作成してしまいます。

serverless.ymlは以下の通りです。

service: web-ebook-server-sample

plugins:
  - serverless-webpack

custom:
  defaultStage: dev
  webpack:
    webpackConfig: ./webpack.config.js
    includeModules: true

provider:
  name: aws
  runtime: nodejs12.x
  timeout: 20
  region: us-east-1
  stage: ${opt:stage, self:custom.defaultStage}
  environment:
    ${file(./env.yml)}

functions:
  createURL:
    handler: src/handler.CreateURL
    events:
      - http:
          method: get
          path: url
  getContent:
    handler: src/handler.GetContent
    events:
      - cloudFront:
          eventType: viewer-request
          pathPattern: '*viewer/bibi-bookshelf*'
          origin: s3://web-viewer-bucket.s3.amazonaws.com

これだけを見せられてもなんのことだかよくわからないと思いますので一つずつ解説していきます。

plugins:
  - serverless-webpack

custom:
  defaultStage: dev
  webpack:
    webpackConfig: ./webpack.config.js
    includeModules: true

最初のところ、ここは特に難しいことはなく今回はTypeScriptを使うのでserverlessでうまいことwebpackを動かしてくれるプラグインを設定しています。

provider:
  name: aws
  runtime: nodejs12.x
  timeout: 20
  region: us-east-1
  stage: ${opt:stage, self:custom.defaultStage}

次に今回のLambdaの設定です。
まずリージョンにus-east-1、バージニア北部を指定しています。これはl@eをus-east-1でしか作成できないからです。
次にランタイムもNode.jsになっていますがこれもl@eが言語をNode.jsとPythonしかサポートしていないためです。

functions:
  createURL:
    handler: src/handler.CreateURL
    events:
      - http:
          method: get
          path: url
  getContent:
    handler: src/handler.GetContent
    events:
      - cloudFront:
          eventType: viewer-request
          pathPattern: '*viewer/bibi-bookshelf*'
          origin: s3://web-viewer-bucket.s3.amazonaws.com

最後にLambda関数の定義をみていきましょう。
今回はcreateURLとgetContentの2つの関数を作成しています。
まずcreateURLですが、これは冒頭の説明の『2. 電子書店から閲覧のリクエストを受け付けて、「コンテンツを閲覧することができるURL」を生成、返却する』部分を模したAPIで、GETでアクセスできるなんの変哲もないAPIです。
次のgetContent、こちらが今回のキモとなるl@eの定義です。
あまり見慣れない記述だと思いますので処理の内容を要約しますと「S3バケットのweb-viewer-bucketをオリジンとするCloudFrontにおいて、(URLの)パスに viewer/bibi-bookshelf が含まれるときにクライアントからエッジサーバーへのリクエストでこのgetContentを動作するようにします」といったところです。
このviewer/bibi-bookshelf は前述の通り今回使用するBibiがコンテンツを取得するためにアクセスするパスです。
なのでここにl@eを仕込むことで認証やアクセス先を補正できるようにします。
eventTypeに viewer-request を指定するとクライアントからエッジサーバーの間でl@eが起動するようになります。
l@eは下記の4箇所に仕込むことができるようになっていますが今回は認証がしたいのでここにl@eを入れるようにしています。

  1. クライアントからエッジサーバーへのリクエスト(viewer-request)
  2. エッジサーバーからオリジンへのリクエスト(origin-request)
  3. オリジンからエッジサーバーへのレスポンス(origin-response)
  4. エッジサーバーからクライアントへのレスポンス(viewer-response)

ちなみに、ここでトリガーにCloudFrontを指定するだけで、初デプロイ時serverlessがdistributionを勝手に作成してくれます。
今回は複雑にdistributionを作り込んだりはしないので便利に使わせてもらいますが、正直ここは実践時にはTerraform等を使って別途定義したほうが良いように思います。

Lambda関数の実装しましょう

作成するLambdaの定義を説明したので、次は具体的な実装に入って行きましょう。
まず先にcreateURLから見ていきます。

-- src/handler.ts(抜粋)

import { APIGatewayProxyHandler, CloudFrontRequestHandler } from 'aws-lambda';
import 'source-map-support/register';

const accessKey = 'demo-key'                     // アクセスキー
const sampleContent = 'sample_content'    // コンテンツを設置したディレクトリ名

export const CreateURL: APIGatewayProxyHandler = async (event, _context) => {
  const env = process.env
  const url = `${env['BASE_URL']}/viewer/bibi/index.html?book=${sampleContent}:${accessKey}`

  return {
    statusCode: 200,
    body: JSON.stringify({
      url: url,
    }, null, 2),
  }
}

といっても解説をするほど大層なことはしてません。
ここは前述の通り、冒頭に説明した2にあたるところで、閲覧可能なURLを生成して返却しています。(といっても今回はサンプルなのでコンテンツもアクセスキーも固定ですが…)
どのビューアーでも大体同じようになると思うのですが、基本的にビューアーのindex.htmlを返却してコンテンツや認証などの情報をクエリパラメーターとして渡します。
詳しくは次のl@eのところで解説をしますが、本来ここでは下記のようにコンテンツの情報と認証のためのキーは別々に渡したいもののBibiでは仕様上難しそうでしたので、コンテンツ名を指定するオプションである book に無理やり両方の情報を押し込んでいます。

  const url = `${env['BASE_URL']}/viewer/${other_viewer}/index.html?book=${sampleContent}&key=${accessKey}`

次に本命のl@eであるgetContentの実装を見ていきましょう。

-- src/handler.ts(抜粋)

import { APIGatewayProxyHandler, CloudFrontRequestHandler } from 'aws-lambda';
import 'source-map-support/register';

const accessKey = 'demo-key'

export const GetContent: CloudFrontRequestHandler = async (event, _context) => {
  let request = event.Records[0].cf.request

  const book = request.uri.split('/')[3]
  const requestKey = book.split(':')[1]

  if (requestKey != accessKey) {
    return {
      status: '403',
      statusDescription: `Forbidden`,
      body: '403 Forbidden'
    }
  }

  const replaceURI = request.uri
    .replace('viewer/bibi-bookshelf/', 'content/')
    .replace(`:${requestKey}`, '')

  request.uri = replaceURI
  return request
}

まず前述の通りBibiは本来 ./bibi-bookshelf/ というディレクトリにコンテンツを設置するようになっています。
これは具体的には下記のようなリクエストをした場合、

/viewer/bibi/index.html?book=<コンテンツ名>

クライアント側がコンテンツを取得するために下記へアクセスをしようとします。

/viewer/bibi-bookshelf/<コンテンツ名>/<各ファイル>

そしてgetContentでは下記のようにURLを生成していたので、

/viewer/bibi/index.html?book=<コンテンツを設置したディレクトリ名>:<アクセスキー>

結果実際には下記へリクエストが行われます。

/viewer/bibi-bookshelf/<コンテンツを設置したディレクトリ名>:<アクセスキー>/<各ファイル>

なのでl@eでは下記のようにリクエスト情報のURIから情報を切り出してやれば必要な情報を抽出できます。

  let request = event.Records[0].cf.request

  const book = request.uri.split('/')[3] 
  const requestKey = book.split(':')[1]

また今回はBibiの仕様上できませんでしたが、ビューアーによってはコンテンツの取得時のリクエストにリファラーが含まれています。
その場合はl@e側で下記のように取得すれば独自のクエリパラメーターを指定してアクセスキーなどの必要な情報を渡すこともできます。

  const headers = request.headers
  let requestKey: string | string[]
  if (!!headers['referer']) {
    const referer = headers.referer[0].value
    const queryParameters = querystring.parse(referer)
    requestKey = queryParameters['key']
  }

それぞれ必要な情報が抽出できるようになったので実際の処理に入って行きます。
このl@eでやることは2つあります。

  1. アクセスキーが正しいかチェック
  2. アクセス先とバケットのディレクトリ構成のズレを補正

まずアクセスキーのチェックをしましょう。
今回はサンプルなのでキーは固定にしてしまってif文でチェックするだけにしました。
当然実際にプロダクトにビューアーを組み込むならこれではセキュリティ的に良くないので各自ちゃんとした認証を組み込むようにしてください。

  if (requestKey != accessKey) {
    return {
      status: '403',
      statusDescription: `Forbidden`,
      body: '403 Forbidden'
    }
  }

次にアクセス先とバケットのディレクトリ構成のズレを補正しましょう。
しつこいようですがBibiがリクエストをする先は下記のようになっています。

/viewer/bibi-bookshelf/<コンテンツを設置したディレクトリ名>:<アクセスキー>/<各ファイル>

ですがファイルの実態は下記に配置しました。

/content/<コンテンツを設置したディレクトリ名>/<各ファイル>

なのでアクセス先のオリジンをl@e上で変更すれば良いのですが、下記のように request.uri に変更したいアクセス先を入れれば変更できます。

  const replaceURI = request.uri
    .replace('viewer/bibi-bookshelf/', 'content/')
    .replace(`:${requestKey}`, '')

  request.uri = replaceURI
  return request

出来上がったビューアーを閲覧してみましょう

これで必要な工程がすべて完了したので、さっそくコンテンツを閲覧してみましょう!
getContentへアクセスしてみます。

f:id:qazx7412:20201211171420p:plain

返却されたURLへアクセスするとちゃんとコンテンツが読めました!

f:id:qazx7412:20201113191531p:plain
(倉塚りこ『ハルコさんの彼氏』)

あとがき

ということでAWSをwebビューアーで配信することができました。
今回に限らず実際のプロダクトでもビューアーのAWSでの配信をやってみた感想としては、認証をどう実装するかとビューアーの仕様をいかにl@eで吸収するかの2つが大事なのかなと感じました。
メディアドゥではビューアーに限らず電子書籍に関するシステムに関わってみたいエンジニアを募集しております。エントリーはこちらからぜひよろしくお願いします。 recruit.mediado.jp