Media Do Tech Do Blog

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

新電子書籍流通システムのアーキテクチャを解説

f:id:qazx7412:20191018163359j:plain

はじめまして、エンジニアの回路(@qazx7412)です。

今回は先日(といっても少し時間が経ってしまいましたが…)プレスリリースでも発表させて頂いた新電子書籍流通システム(以下、配信システム)についての話をしようと思います。

配信システムとは

2017年に株式会社メディアドゥ(以下、メディアドゥ)は株式会社出版デジタル機構(以下、出版デジタル機構)を買収、合併することになりました。
これによってメディアドゥにはもともと運用していた md-dc と出版デジタル機構由来の eBook の2つの基幹システムが並行して稼働することになりました。

この2つの基幹システムは、それぞれ「各出版社から電子書籍のデータをお預かりして各電子書籍書店へ提供する」というほとんど同じ機能を持ったシステムです。
そのため合併をしてからは、「同じ機能を持った2つシステムが同時に稼働し、それぞれに同じデータを登録する必要がある」という大変非効率な状態になっていました。

f:id:qazx7412:20191011190513p:plain
1つの同じデータを2つの違うシステムへ登録する必要がある

この状態を解消するために、統合プロジェクトが発足し、システムをeBookへ片寄することになりました。
しかしeBookでは書誌情報のAPI出力とファイルの納品という形での書店への接続を行っていたのに対し、md-dcでは書誌情報xmlの送りつけとエンドユーザーへのCDN配信という形で書店へ接続をしていました。
なのでeBookでmd-dcへの互換性のあるAPIの提供とCDN機能を実現するためのサブシステムを開発することになりました。

これがこれから解説をする配信システムです。

f:id:qazx7412:20191011190528p:plain
この図のように統合することになりました

全体の構成

この配信システム全体の構成図です。

f:id:qazx7412:20191017102638p:plain
配信システム構成図

基本的に言語はGo、インフラはコンテナをFargateで動かし、ストレージはAurora MySQL、S3を使い分け、電子書籍ファイルの取得などはCloudFrontを用いてキャッシュをするようにしています。 ​

各詳細

基幹システムからの取り込み

f:id:qazx7412:20191017103220p:plain
基幹システムからの取り込み
​ 基幹システムは各電子書籍書店に対して電子書籍のデータ、つまり名前や著者、出版社に巻数といった情報(これは書誌情報と呼ばれています)と電子書籍の本体となるepubファイルなどを提供しています。
配信システムでは、Fargateで定期実行をしているworkerと呼ばれるバッチ処理で基幹システムから出力される電子書籍の登録や更新のデータを取り込みます。

書誌情報はAPIを叩くことで取得できるようになっており、更新がある場合にAuroraへ取り込んでいます。
またepubは登録や変更があった場合は基幹システムがS3へアップロードをしてくれるようになっており、これを配信システム側のバケットに取り込みます。

取り込んだ電子書籍ファイルは、配信を行うにあたって配信システムが対応している電子書籍ビューアやアプリで利用できるようにDRM処理をかける必要があります。
ビューアのベンダー各社がコンバーターを提供しているので、これを使って変換処理をかけ、配信用のバケットに設置します。
このコンバーターはコンテナ化し、SQSからFargateを利用して動作するようにしています。

パブリックAPIとバックオフィスAPIとAurora

f:id:qazx7412:20191017102711p:plain
public apiとback office api
​ 登録された電子書籍を配信するためのAPIです。
APIもFargateで動かしており、これらはエンドユーザーからCDN経由で閲覧をされるためのパブリックAPIと、書店がストアへ並べるための商品情報を取得するバックオフィスAPIの2つに分けて運用しています。

DBにはAurora MySQLを利用しています。現段階ではまだ水平分割のような負荷対策は行っていませんが、一部のクエリはリードレプリカで実行するようにしています。

コンテンツ配信とLambda@Edge

f:id:qazx7412:20191010135633p:plain
コンテンツ配信
​ 電子書籍ファイルはS3に置いてあって、CloudFrontで配信しています。

アプリから閲覧する場合はAPIで電子書籍ファイルへアクセスできるurlを、webからの場合はビューアへのurlをさきほどのAPIから発行してアクセスをしてもらいます。

アプリ用のコンテンツの配信には署名付きurlを利用していますが、ブラウザビューアは仕様が複雑なこともありLambda@Edgeで認証などのロジックを実装しています。

その他バッチやログなど

f:id:qazx7412:20190826150731p:plain
バッチやログ

​ 記録した閲覧数や購入数等から日時や月次でバッチを回し集計をとったりビジネス側へ自動で報告をしたりしておりこれらはECSのタスク定義やLambdaを利用しています。
またCloudWatchにログを出力しておりここもLambdaでSlackに通知しています。

Go

ここまで何度か出てきたFargateですが、ここで動作するアプリケーションはGoで実装されています。

wafにはecho、ormにはGORMを採用しています。 ​ f:id:qazx7412:20190826154740p:plain

またクリーンアーキテクチャに基づいて開発を行っています。 ​ f:id:qazx7412:20190826151313j:plain ​ ディレクトリ構成は下記のようにしてあります。

cmd/<名前>/main.go

起点になるファイルが置いてあります。 APIの場合はここでechoを呼び出していますが、個々のルーティングなどはhandlerとして切り出されています。 ​

func main() {
  e := echo.New()
  handler.BindHogeHandler(e)
}

handler/<名前>.go

APIのルーティングとusecaseの呼び出しを行っています。 ​

type hogeHandler struct{}

func BindHogeHandler(e *echo.Echo) {
  h := &hogeHandler{}
  e.GET("/v1/hoge", h.hoge)
}

func (h *hogeHandler) hoge(c echo.Context) error {
  res, err := h.getHogeResponse(c)
  if err != nil {
    return errors.Wrap(err, "failed hoge api")
  }

  return c.JSON(http.StatusOK, res)
}

func (h *hogeHandler) getHogeResponse(c echo.Context) (*models.HogeResponse, error) {
  uc := usecase.NewHogeUsecase()
  gs, err := uc.GetHoge()
  if err != nil {
    return nil, errors.Wrap(err, "failed to get hoge")
  }
  return gs, nil
}

infra/<名前>.go

各種インフラに関する設定やクライアントの呼び出しがここに置いてあります。 ​

// NewSESService returns ses client
func NewSESService() (*ses.SES, error) {
  config, err := external.LoadDefaultAWSConfig()
  if err != nil {
    return nil, errors.Wrap(err, "[infra.CreateSESClient] faild to get default config")
  }

  return ses.New(config), nil
}

models/<名前>.go

各所から呼び出されるstructを置いています。 ​

usecase/<名前>.go

ビジネスロジックの置き場です。 できるだけビジネスロジックはここへ閉じ込めるようにしています。 ​

type HogeUsecase interface {
  GetHoge() (*models.HogeResponse, error)
}

type hogeUsecase struct {
  hogeRepo hogeRepository
}

func NewHoge(hogeRepo hogeRepository) HogeUsecase {
  return &hogeUsecase{
    hogeRepo,
  }
}

func (u *hogeUsecase) GetHoge() (*models.HogeResponse, error) {
  // business logic
}

repository/<インフラの種類>/<名前>.go

RDBやS3などの各種リソースへのアクセスをするコードの置き場所です。 ​

func (r *hogeRepoImpl) GetHoge(tx *gorm.DB) ([]*models.Hoge, error) {
  entities := []*models.Hoge{}
  result := tx.Find(&entities)

  if result.RecordNotFound() {
    return nil, nil
  }
  if result.Error != nil {
    return nil, errors.Wrap(err, "some context")
  }

  return entities, nil
}

​ RDBへのアクセスの場合はusecase側でトランザクションを用意するようにしています。

トランザクションを管理するためにtxManagerというラッパーを用意しているので、それを使ってusecaseなどから下記のようにrepositoryを呼び出しています。
このtxManagerの引数になっている無名関数の内部がトランザクションが有効な範囲になるようにしています。 ​​

txManager := infra.NewTransactionManager()
hoge := []*models.Hoge{}

err := txManager.DoWithROTransaction(func(tx *gorm.DB) error {
  var err error
  hogeRepo := rdb.NewHogeRepository()
  hoge, err = hogeRepo.GetHoge(tx)

  return err
})
if err != nil {
  return nil, errors.Wrap(err, "failed to get hoge")
}

エラーハンドリングの戦略はこちらにまとめてある通りです。 ​

まとめ

ということで配信システムについての解説でした。

私はGoやFargate、クリーンアーキテクチャなどはこのチームに来てはじめて触れたのですが、日々学ぶことが多いです。

エンドユーザー向けのAPIに関してはパフォーマンスチューニングをしていく必要がありますし、他にも新たな書店の移行に対応するための機能の追加などまだまだするべきことは多いですが、改善を続けて参りますのでよろしくお願いいたします。

もし出版業界に興味のあるエンジニアがいらっしゃいましたらぜひこちらからお願いいたします。

www.wantedly.com