爆速でGo!

GoGo!

Envoyによる分散トレーシングの実現と、未来

この記事はMicroservices Advent Calendarの9日目の記事です。前回は@moomooyaさんのマイクロサービス化にあたって、gRPCを導入しようとしている話でした。
この記事のタイトルは、先日のGoCon2018 Autumnにて発表された「OpenCensusによるAPMの実現と、未来」というセッション名がかっこよかったので、表現を拝借しました。

分散トレーシングの必要性

マイクロサービスのような分散アーキテクチャは複数のサービスにまたがって処理されているため、サービス間の通信を追跡することが難しくなります。そのため、

  • 障害発生時の原因究明が難しくなる
  • パフォーマンス低下の原因究明が難しくなる

そこで、分散トレーシングツールで可視化して上記の問題を解決する必要があります。

用語

分散トレーシングを学ぶにあたって、以下2つの用語は必ず覚える必要があります。

用語 意味
スパン 1つのサービス内の処理
トレース 1つのリクエストに含まれるスパンの集合体

何故Envoyなのか

Envoyを使わずとも、ZipkinJaeger等のトレース可視化プロバイダが提供するツールがあれば、分散トレーシングを実現することは可能です。
しかし実現のためには、トレース毎の識別子を生成してそれを伝搬していき、最終的にコレクターというコンポーネントに送ることが必須です。そのために各サービスに言語毎のクライアントライブラリを導入して、専用の実装をする必要があります。
Polyglotが前提にある分散アーキテクチャであるマイクロサービスアーキテクチャにおいて、これは大きなコストを生む原因になります。

そこで、インバウンドとアウトバウンド双方のトラフィックを前提に作られているEnvoyをサイドカーとして配置しておけば、この問題を解決してネットワークとアプリケーションを分離することが出来ます。
このような、全サービス間通信をサイドカープロキシを経由させることによってマイクロサービス固有の課題の解決を行うアーキテクチャサービスメッシュと呼びます。
ただ、スパン間の親子関係等のコンテキストを適切に伝搬させるためには、アプリ内で特定の識別子を伝搬させる実装をする必要があります。これに関しては後述します。

Envoyのトレーシングアーキテクチャ

利用するトレースプロバイダにもよりますが、多くの場合以下のようなコンポーネントで構成されたアーキテクチャとなります。プロバイダによってStorageを提供してくれるものもあれば、自前で運用する必要があるものもあります。

f:id:NakaWatch:20181208112802p:plain

分散トレーシングでは通常、トレース内で一意の識別子を伝搬させることでスパンを関連付けていきます。
この一連の流れの中で、Envoyの仕事は大きく分けて3つあります。トレースの生成とトレースの伝搬、そしてコレクターへの送信です。

生成ドキュメント

tracingオブジェクトを設定することで、トレースを生成することが可能になります。

伝搬ドキュメント
x-request-idという識別子を伝播して、呼び出されたサービス間のロギングを相関させていきます。
そして前述したように、スパン間の親子関係を明確にするためには別の識別子の伝搬も必要です。そしてEnvoyはこの識別子をHTTPリクエストヘッダーに乗せて通信します。そのため、特定のヘッダーから識別子を取り出して次に通信するサービスへのリクエストヘッダーに追加する必要があります。つまり、少なからずコードの修正は必要になるということです。
Envoyにはインバウンドトラフィックとアウトバウンドトラフィックを関連付ける手段がないので、これはアプリケーション開発者の責任だということです。
また、これはトレース可視化プロバイダ毎に異なる識別子が必要になるため、自分が使うプロバイダのドキュメントをよく読む必要があります。

送信
執筆時点(2018/12/9)で対応しているトレース可視化プロバイダは以下です。プラガブルになるよう設計されておりドライバを実装するだけで良いので、新たなプロバイダをサポートすることも難しくありません。

ドキュメントに従って、トレースを送信するコレクタークラスタを指定します。例えばZipkinの場合は以下のように定義します。

static_resources:
  clusters:
  - name: zipkin
    connect_timeout: 1s
    type: static
    lb_policy: round_robin
    hosts:
    - socket_address:
        address: 10.5.0.2
        port_value: 9411
tracing:
  http:
    name: envoy.zipkin
    config:
      collector_cluster: zipkin
      collector_endpoint: "/api/v1/spans"

触って理解する

Envoyは公式のサンドボックスがいくつか用意されています。今回はこの中のjaeger-tracingを触りながら、分散トレーシングの一連の流れの理解を深めていきたいと思います。

起動

$ git clone https://github.com/envoyproxy/envoy.git
$ cd envoy/examples/jaeger-tracing
$ docker-compose up -d

front-proxy, service1, service2 という3つのコンテナが起動します。

リクエスト送信

$ curl -v http://localhost:8000/trace/1
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8000 (#0)
> GET /trace/1 HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 200 OK
< content-type: text/html; charset=utf-8
< content-length: 89
< server: envoy
< date: Sat, 08 Dec 2018 06:10:24 GMT
< x-envoy-upstream-service-time: 27
<
Hello from behind Envoy (service 1)! hostname: 580a7e50d809 resolvedhostname: 172.18.0.5
* Connection #0 to host localhost left intact

front-proxyservice1service2 という順に通信が行われます。

可視化
ブラウザでhttp://localhost:16686にアクセスして下さい。
複数のスパンが一つのトレースとなって表示されているのが確認できます。

f:id:NakaWatch:20181208155100p:plain

サービスの実装を変更
サービスのサンプル実装はexamples/front-proxy/service.pyにあります。
注目すべき実装は以下の部分で、ここで受け取った識別子をヘッダーに乗せて伝搬させています。

    if int(os.environ['SERVICE_NAME']) == 1 :
        for header in TRACE_HEADERS_TO_PROPAGATE:
            if header in request.headers:
                 # ここをコメントアウト
#               headers[header] = request.headers[header]
        ret = requests.get("http://localhost:9000/trace/2", headers=headers)

Zipkin互換のあるプロバイダの場合、この識別子についてはB3 Propagationというリポジトリにて詳述されています。
試しにこの処理をコメントアウトして、再度リクエストを送ってみましょう。

f:id:NakaWatch:20181208154918p:plain

スパンがそれぞれ別のトレースと認識されてしまっているのが分かります。 スパン間で識別子を伝搬させないと正しくトレースを取得出来ない事が確認できました。

未来

全体的なアーキテクチャに始まり最終的にコードを触ることで、大まかな仕組みを理解して頂けたでしょうか?
先日(2018/11/28)、EnvoyはCNCFの卒業プロジェクトに認定されました。 その時のブログ内でも繰り返して述べられている通り、Envoyの目標は、アプリケーション開発者からネットワークを抽象化してビジネスロジックに集中できるようにすることです。
これはEnvoyに限らず、クラウドネイティブが主流になった現代では多くの人が、アプリケーション開発者が創作活動に集中できる環境を望んでいます。
2016年9月にLyftがEnvoyをOSSとして公開した時、本人達の想像を超えるほど反響がありました。これは、クラウドネイティブを推進するにあたってネットワークプロキシが非常に重要なポジションを占めていることを表しています。今後アプリケーションからネットワークを抽象化するようなプロジェクトがどんどん立ち上がるとしても、内部では常にネットワークプロキシがアプリケーションに寄り添っているはずです。そのため、プロキシへの理解を深めておくことはクラウドネイティブ時代において非常に大事だと思います。 筆者も微力ながら議論に参加していき、アプリケーション開発者にとっての最高の環境作りに貢献していきたいと思います。

*間違いを見つけたり情報が古くなっていたりしたらTwitter等でご指摘頂けると幸いです。

Service Meshをスモールスタートするという選択

Click here for English version

今記事は、CyberAgent Developers Advent Calendar 2018 7日目の記事です。
こんにちは、20卒内定者で現在AWAでアルバイトをしている中尾涼(@nakabonne)です。
現在マイクロサービス界隈でService Meshというアーキテクチャが注目されています。
実現のために様々な製品がありますが、私はsxdsという小さなcontrol-planeを作りました。
今記事ではService Meshを小さく始める手段について、sxdsを使用しながら紹介していきます。

github.com

対象読者

  • Service Meshの導入を検討している人
  • Service Meshへの理解を深めたい人
  • マイクロサービスに興味がある人

Service Meshとは

乱暴に説明すると、マイクロサービス化することで起きる様々な問題を解決するためのアーキテクチャの一つです。

Service Meshの定義については、様々な記事で紹介されてるので、今記事では割愛します。

Service Meshの実現手段

control-plane, data-planeそれぞれを実現するツールは、以下のようなものがあります。
ここでは執筆時点(2018/12/7)で代表格とされているもののみ説明します。

control-plane

執筆時点でのcontrol-planeとしてはIstioが有名です。
Istioはdata-planeを内包しているため、ほぼこれ一つでService Meshを実現することが出来ます。
非常に優れたツールではありますが、もちろん銀の弾丸ではありません。
Istioはオーケストレーションツールからクラスタ情報等を収集することで効果を発揮します。
つまり、オーケストレーションツールを使っていない環境では効果を発揮することが出来ません。
また、Istioは多くの機能が提供されていますが、ごく僅かな機能しか使わない場合は同じく効果を十分に発揮することが出来ません。

data-plane

Istioのデフォルトdata-planeにも採用されていて、先日発表されたApp Meshでも使われているEnvoyが、代表格といえるでしょうか。
EnvoyはL7プロキシです。特徴は、インバウンドとアウトバウンドトラフィックの仲介を前提に作られているため、分散トレーシングや動的なサービスディスカバリをプロキシレベルで実現出来る点です。
また、優れたスレッドモデルによってパフォーマンスが非常に高いという点も挙げられます。Envoyでは、全てのハードウェアスレッドにワーカーを生成してそれをメインのスレッドが制御しています。
C++を選んだのも、そのモデルを実現できる唯一のプロダクショングレードな言語だからとのことです。スレッドモデルに関してはブログに詳しく記載されています。

control-plane自作

紹介したとおり素晴らしいツールはたくさんあります。しかし私が当初欲しかった機能は、サービスディスカバリのためにdata-planeのルートテーブルを動的に更新するというものだけでした。
また、ほとんどのサービスがAWS の ECS というコンテナ管理サービスの上で動いていたため、k8s等のオーケストレーションツールを使っていませんでした。
そこで私はdata-planeにEnvoyを採用し、それを制御するsxdsというcontrol-planeを作りました。
次の節から作り方を詳しく説明していきます。

インターフェース

Envoyと通信するためのインターフェースはdata-plane-apiとして公開されており、ルートテーブルを動的に更新するためにはこれを満たすAPIを作る必要があります。
執筆時点でこのAPI定義に準拠しているプロキシはEnvoyのみですが、作者のMatt Kleinはdata-plane-apiを汎用的にしていきたいとブログにて発言しています。
そのため、将来このAPI定義が汎用的になった場合、自作したcontrol-planeがプラガブルになります。
また、data-plane-apiを満たしたAPIをxdsと呼びます。

Goによる実装

Envoyは独自のプロトコルであるxds protocolを定義しています。
これに従ってgRPCのbidiストリームかロングポーリングのRESTでxdsを実装します。
xds protocolを忠実に守って実装しないといけないわけですが、GoとJavaのみこの実装が含まれる公式ライブラリが公開されています。

sxdsではGoを選択し、go-control-planeをインポートしてxdsサーバーを実装しました。

はい、これだけでxdsの実装は終わりです。ただ、go-control-planeはdata-planeの設定をメモリにキャッシュする必要があるため、以下のようにキャッシュするためのRESTサーバーも立てています。

全体図はこんな感じです。

f:id:NakaWatch:20181124102325p:plain

静的リソースの管理

Envoyへのレスポンスに必要な静的なリソースを管理する必要があります。
sxdsはLDS/CDS/RDS/EDSという4つのサービスを提供しているため、これらに必要な以下のリソースを定義しておく必要があります。

また、リソースのバージョンも明示する必要があります。
無駄なパケットのやりとりを防ぐため、バージョンに変更が無い時はレスポンスを返さないという決まりがxds protocolに定義されています。
sxdsではこれらをjsonで定義し、前述したキャッシュするためのサーバーにPUTします。

Envoyの設定

sxdsのクラスタ情報は静的なので、static_resourcesとして定義します。
そしてそのクラスタをdynamic_resourcesとして指定します。

実行

Envoyの実行

sxdsはメモリ消費を抑えるため、ノードタイプ毎にリソースをキャッシュします。
そのためEnvoyのノードタイプを設定する必要があり、ノードIDの命名規則{node_type}-{node_id}とすることでこれを設定します。
また、sxdsへのdiscovery requestのために自身のクラスタ情報が必要なので、クラスタ名も指定します。
このノードIDとクラスタ名は、設定ファイルに定義することも可能です。

$ envoy --service-node sidecar-app1 --service-cluster app1

sxdsの実行

以下のように2つのサーバーが起動します。

$ sxds
2018-12-01T10:00:00.518+0900    INFO    xds/server.go:56    xDS server is listening {"conf": {"Port":8081}}
2018-12-01T10:00:00.518+0900    INFO    cacher/server.go:58 cacher server is listening  {"conf": {"Port":8082}}

リソースの設定

先程作成したjsonファイルをcacherサーバーに送信します。
そうしたらEnvoyに設定が反映されることが確認出来ると思います。

$ curl -XPUT http://{IP_ADDRESS}:8082/resources/sidecar -d @sidecar.json

まとめ

sxdsの紹介をしていきましたが、もちろんこれも完璧ではありません。
control-planeを冗長化した際は扱いづらくなるし、リソースjsonを永続化する仕組みも必要です。
伝えたいことは、envoyproxyの公式ライブラリを使えば低コストでcontrol-planeを作ることが出来て、必要な機能だけ自作するという手段も十分考えられるということです。
既存のサービスをマイクロサービス化する場合、必ずしもモダンな環境であるとは限りません。
そういった中で、環境に合わせて簡単に自作出来るという手段を持っていることは大きな武器となります。
ここまで読んで下さったあなたの頭に、Service Meshをスモールスタートするという選択肢が芽生えたとしたら幸いです。

isucon8本戦で惨敗したからせめて良いブログを書く

ISUCON8の本戦に、メルカリで一緒だった@zaq1tomo@inatonixzin-gonicというチーム名で出場しました。
結果は学生9位、全体で19位だったので、やったこととかまとめます。
予選エントリはこちらです↓

nakawatch.hatenablog.com

やったこと

チューニング対象は、isucoinという取引所アプリでした。
言語は予選と同じGoです。

github.com

担当も予選と同じです。

@nakabonne: インフラ担当
@zaq1tomo: アプリ担当
@inatonix: アプリ担当

序盤

@nakabonne

序盤は主にプロファイリングの準備です。

今回はdockerでデーモン化されていたため、パフォーマンスが少し気になる上にプロファイリングが少し面倒でした。
当初はdockerを剥がしてsystemdに乗せ換えようとしましたが、工数に対するインパクトが見込めず一旦後回しにしました。
また、nginxの公式イメージを見ると標準出力をdocker logsに流しており、アクセスログがうまく拾えず若干詰まりました。

@inatonix, @zaq1tomo
  • マニュアルとコード読んで仕様把握
  • pprofでプロファイリング

方針決め

各プロファイリング結果毎に見ていきます。

リソース

f:id:NakaWatch:20181020231333p:plain

実行時にCPU使用率は100%に張り付き、半分以上はmysqlが食っていました。
メモリもギリギリで、こちらも同じく大半はmysqlのキャッシュによるものでした。
この時点で早いうちにDBを外に切り出すことは決めました。

スロークエリ

f:id:NakaWatch:20181020233032p:plain

全tradeをソートしてから取得するクエリがまず第一関門っぽかったので、初動は決まりました。
二つ目もクエリ数減らせないか調査の必要がありそうだと話した気がします。

アクセスログ

f:id:NakaWatch:20181020233208p:plain

POST /orders GET /info から見ていく必要があるのは一目瞭然で、エンドポイントが少ないことから一筋縄ではいかない匂いがプンプンでした。

中盤

@nakabonne
  • mysqlを別サーバーに移行
  • nginx基本チューニング
  • このクエリやばそうとかつぶやく

f:id:NakaWatch:20181020235709p:plain

mysqlを切り出し、サーバー1をapp+nginxのみの構成にしたためCPUに半分くらい空きが出来ました。
CPUがボトルになるまではしばらくappは一台で動かそうとなりました。

@inatonix, @zaq1tomo
  • latest tradeから取得するフィールドをidのみにする修正

この@zaq1tomoの修正で500点→3200点に伸びました。

  • trade取得時にLIMITかける修正
つかの間の1位

この修正で5136点まで伸びました。そしてこの瞬間、我々zin-gonicは1位に躍り出ました。
ただ、この時を最後に我々が笑顔になることはありませんでした。。

f:id:NakaWatch:20181021001612p:plain

終盤

@nakabonne
  • shareボタンを有効に、、

CPUに余裕が出来たところで、リソースを使い切りたいと思っていたところ、@inatonixが怪しいものを見つけました。

// TODO: trueにするとシェアボタンが有効になるが、アクセスが増えてヤバイので一旦falseにしておく
res["enable_share"] = false

どうやらシェアボタンを有効にすると取引成立時にシェアされてユーザーが流れ込み、一気に負荷があがるそうで、これだ!と有効にしました。
が、GET /infoタイムアウトが頻発。
DBサーバーを見てみるとCPUが使い切られていたので、ロックされてるのかな?と思いつつとりあえずこれはまだ早いと判断して、そっとブランチを切り替えました。
講評を聞く限りここで負荷を上げても耐えられるようにすることが鍵だったらしいです。

  • 悪あがき

f:id:NakaWatch:20181020234905p:plain

このブランチ量を見れば悪あがきをしていたことが分かります。
もちろん何も意味をなしていません。

@inatonix, @zaq1tomo
  • POST /orders で呼ばれる AddOrders() というメソッドをなんとかしようと奮闘
  • shareボタンを有効にしたときのGET /Infoを直そうと奮闘

oh...

感想

問題作成していただいたDeNAさん、カヤックさん、主催のLINEさん、サーバー提供していただいたConoHaさん、本当にありがとうございました。
今回の優勝者は学生であり、それを讃えている皆さんを見て、改めて最高の業界に来てしまったと感じました。
もしisucon9があるならば、来年も学生枠使えるので(もはやそんな枠があるかは不明)出場したいと思います。

gRPCは何故誕生したのか

Click here for English version

gRPCという言葉はすっかり有名になり、日本でも「社内でgRPCを導入した」「gRPCサーバーを構築する方法」といったような記事が溢れかえるようになりました。
これにより、gRPCの導入ハードルは下がり、比較的簡単にプロダクションレディなgRPCサーバーを作ることが出来るようになりました。
しかしそもそもどういった背景でgRPCが誕生したのでしょうか。

背景

gRPC Blogには、以下のように記載されています。

GoogleはstubbyというRPCインフラストラクチャ持っており、10年以上Googleのデータセンター内のマイクロサービスを接続してきた。
統一されたクロスプラットフォームのRPCインフラストラクチャを持つことで、全体の効率、セキュリティ、信頼性の改善の展開を可能にし、その期間の信じられないほどの成長を支えた。

stubbyには多くの素晴らしい機能があるが、社内のインフラに密接に結びつきすぎて一般公開には適していなかった。
SPDY, HTTP/2, QUICの登場により Stubbyをもっと汎用的に作り直して、モバイル、IoT、クラウド等のユースケースに適用させるべき時期が来たことが明らかになった。

また、Google Cloud Platform Blogには、以下のように記載されています。

高度にスケーラブルで疎結合のシステムを構築することは、常に困難だった。 モバイル機器やIoT機器の普及、データ量の急増、顧客の期待の高まりに伴い、インターネット規模で効率的かつ確実にシステムを開発して運用することが重要である。
この種の環境では、開発者は複数の言語、フレームワーク、テクノロジー、複数の第一および第三者サービスを扱うことがある。 これにより、サービス契約を定義して実施したり、認証や承認、ヘルスチェック、ロードバランシング、ロギングと監視、トレースなどの横断的な機能を全体にわたって一貫性を持たせることが難しくなる。 今日のクラウドネイティブの世界では、新しいサービスをすばやく追加する必要があり、各サービスの期待は柔軟で弾力性があり、可用性が高く、構成可能であることが特に課題となる。
過去15年間、Googleはこれらの問題を内部的にStubbyというRPCフレームワークで解決した。このRPCフレームワークは、数十億回のリクエストをインターネット規模で処理できるコアRPCレイヤーで構成されている。 今この技術は、gRPCと呼ばれるオープンソースプロジェクトの一環として、誰でも利用可能である。 これは、私たちがGoogleで楽しむのと同じスケーラビリティ、パフォーマンス、機能をコミュニティ全体に提供することを目的としている。

以上を踏まえ、誕生の経緯を雑にまとめるとこんな感じでしょうか

  • スケーラブルで疎結合なシステムを作るのは難しいけど、モバイルとか普及している現代はやらないといけない
  • GoogleではクロスプラットフォームのRPCフレームワーク使って対応していた
  • でもGoogle内部のインフラにめっちゃ依存してた
  • HTTP/2とか出てきて、Googleのインフラに依存せずとも標準規格で同等以上のものを作れるようになった
  • 標準規格ベースで作り変えて一般公開しよう!

参考

gRPCについて理解を深めたい場合は、以下から眺めていくと徐々に分かっていくと思います

間違い等ありましたら、コメントやtwitter等でご指摘頂けると嬉しいです。

ISUCON8予選は学生枠6位で本戦出場が決まりました

isucon.net

ISUCON8の予選に、メルカリで一緒だった@zaq1tomo@inatonixzin-gonicというチーム名で出場しました。
結果は学生枠6位だったので、やったこととかまとめます。

事前準備

  • 二度のisucon合宿を行った(どちらも環境構築で頓挫)
  • 何回か集まってisucon7の過去問練習
  • ishoconという株式会社scoutyさん主催のコンテストで練習した
  • hidakkathonという株式会社会社サイバーエージェントさん主催のコンテストで練習した
  • 前日に当日と同じ要領でhidakkathonの問題をみんなでやった

とまあ初出場ということもあり結構ちゃんと準備して、何も言い訳できない状況だったのでとりあえず通過できてよかったです

やったこと

チューニング対象は、イベントの座席予約アプリでした。
言語は、3人の技術スタック的にGo一択でした。

github.com

@nakabonne: インフラ担当
@zaq1tomo: アプリ担当
@inatonix: アプリ担当

序盤

@nakabonne

リバースプロキシは完全にnginxのつもりでいたので、h2oアクセスログをalpがパースできる形式で出力するのに結構時間かかってしまって、大迷惑かけてしまいました。
本戦では爆速でやりたいです。
ちなみに今回はnginxに差し替えてる方が結構いたので、インフラ担当としてその辺のトレードオフも見積もれるようになりたいです。

@inatonix, @zaq1tomo

方針決め

リソース

f:id:NakaWatch:20180917102529p:plain

弱者の戦い方として、無理に複数台構成はしないと最初から決めていたため、ゴリゴリインメモリキャッシュを使おうと話していました。
実行時にCPU使用率が100%に張り付くので、なるべくredis等の別プロセスは立ち上げたくないと感じたので、キャッシュするならGoプロセス管轄内のメモリに乗せようとなりました。
ただ、mariadbが大分CPU食っているのでdbだけ別インスタンスに移したい気持ちはありました。

スロークエリ

f:id:NakaWatch:20180917102542p:plain

reservationsテーブルから全座席ごとにselectしてるクエリが37万回呼ばれていて、これを解消しない限り次に進まないことがすぐに分かりました。
今回はボトルネックが明白だったため、学生でも迷わずに進める良問でした。

アクセスログ

f:id:NakaWatch:20180917103204p:plain

一番上の /api/users/:id で、先程の重いクエリが流れていたため、平均9秒かかっていました。その他レスポンスタイム上位のエンドポイントはほとんど同じクエリがボトルになっていました。

中盤

@nakabonne
  • 二人の修正が競合しないようにひたすらデプロイ
  • reservationテーブルのuser_idとかにindex張ってみる → 意味なし
  • h2oでロードバランシングしてみる → 出来なかった

reservationsテーブルにはevent_idcanceled_at IS NULLにマルチカラムインデックス張るのが正解だったっぽいです。
単体テーブルへのクエリならまだいいのですが、内部結合となると実行計画が全く理解できず死亡しました。

@inatonix, @zaq1tomo
  • それぞれ別のアプローチでgetEventsクエリのN+1解消に取り組む
    • @inatonix: Goプロセス内インメモリキャッシュ
    • @zaq1tomo: クエリ変えてクエリ数減らす

僕のアクセスログ解析が遅れたせいもあり、全くスコアが上がらず完全にお通夜モードになっていた15時頃(だったかな)に、@zaq1timoのN+1修正が火を吹き1000点→10000点と10倍になりました。
/api/users/:idの平均レスポンスタイムも9秒→1秒まで解消していました。

終盤

@zaq1tomoが合計料金をSUMするクエリをチューニングしてくれてちょっとスコアが上がり、諸々のログを吐かないようにしたら13000点を超えるようになりました。
ただ、ランダムにfailしたりしてハラハラしていました。
競技30分前に再起動し最終的に14000点を超えたところでコードフリーズし、お片付けを始めました。

感想

1392名参加している大会で、障害等を何も起こさなかった運営の方々がかっこよすぎました。
今回は各チームごとにベンチマークを用意してくれた(構成的にするしかなかった)Conohaさんにも感謝の念が溢れます。
本戦勝つぞー

Go × Clean Architectureのサンプル実装

Click here for English version

*追記:Student Goで発表しました。

speakerdeck.com

クリーンアーキテクチャとは

以下を実現することで、関心の分離をするアーキテクチャパターンです。

詳しくは様々な記事で説明されているので、今エントリでは割愛し実装パターンに絞って紹介します。

サンプルアプリケーション

↓サンプルコード

github.com

仕様は、/users にPOSTすることでユーザー登録するだけのapiです。
基本はmanuelkiessling/go-cleanarchitecturehirotakan/go-cleanarchitecture-sampleを参考にしていますがこれらはDBアクセスに直接sqlドライバーを使用しているため、今回はORMを使用した実装パターンを作ってみました。ORMライブラリはgormを使用しています。

app設計概要

f:id:NakaWatch:20180711163231j:plain

何層に分けるかは自由なのですが、原文に従って4層に分けました。
この時、外側から内側に向かって単一方向に依存することを徹底します。

ディレクトリ構成

       ├── adapter
       │   ├── controllers
       │   ├── gateway
       │   └── interfaces
       ├── domain
       ├── external
       │   └── mysql
       ├── main.go
       └── usecase
           └── interfaces

ディレクトリは、それぞれ以下の層の役割をしています。

ディレクト
external frameworks & drivers
adapter interface adapters
usecase app business rules
domain enterprise business rules

依存関係の徹底(重要)

実装の説明に入る前に、大事なルールを一つ説明します。
前述したとおり、依存関係は外側から内側へ単一方向へ保つ必要があります。
しかしながらどんなプログラムにも入力と出力があり、内側で処理した結果を外側へ出力するということが頻繁に起こります。
つまり、内側から外側へ依存したい場面が必ず現れます。その矛盾を依存関係逆転の原則を用いて解決することが、クリーンアーキテクチャをクリーンに保つための鍵となります。

依存関係逆転の原則(DIP)とは

簡単に言うと、内側は外側に依存するのではなく、抽象に依存するべきであるという原則です。
よくわからないと思うので、実際にコードを見てみましょう。

func (i *UserInteractor) Add(u domain.User) (int, error) {
    return i.UserRepository.Store(u)
}

これは、内から2番目の層、app business rules層 の実装です。
この層の外側から渡されたユーザデータを、外側にあるDBに保存しようとしています。
ここでやってしまいがちなのが、そのまま外側の層にアクセスしてしまうことです。
しかし外側に直接アクセスしてしまうと依存関係が内→外に向いてしまうので避けたいところです。
そこで依存関係逆転の原則を使います。Goではこれをinterfaceを定義することで実現します。

type UserRepository interface {
    Store(domain.User) (int, error)
    FindByName(string) ([]domain.User, error)
    FindAll() ([]domain.User, error)
}

同じ層にrepositoryインターフェースを定義し、このインターフェースに依存するようにします。
そして具象は外側で定義しておき、実行時に外側から渡してあげることで外→内の依存関係を保つことができます。
これが依存関係逆転の原則です。
ここを見ると分かりやすいかと思います。
サンプルコードでは、抽象に依存するためのinterfaceを各層のinterfacesディレクトリにまとめてあります。

各層の実装

内側の2層はプロジェクトによって結構変わってくると思うので、ここでは外側の2層に絞って説明していきます。

Frameworks & Drivers

DBアクセス

以下のようなDB接続等の外部との仲介実装はこの層で完結するようにします。

var db *gorm.DB

func Connect() *gorm.DB {
    var err error

    db, err = gorm.Open("mysql", "root:@tcp(db:3306)/hoge")

    if err != nil {
        panic(err)
    }
    db.Table("users").CreateTable(&gateway.User{})
    return db
}

func CloseConn() {
    db.Close()
}

ルーティング

http通信処理もこの層で完結します。今回はWAFにginを使用していますが、この層が独立しているので差し替えが容易になっています。
また、DBのコネクションやlogger等の具象型もここで内側の層に渡すようにしています。
こうすることで、前述した依存関係逆転の原則を実現しています。

var Router *gin.Engine

func init() {
    router := gin.Default()

    logger := &Logger{}

    conn := mysql.Connect()

    userController := controllers.NewUserController(conn, logger)

    router.POST("/users", func(c *gin.Context) { userController.Create(c) })

    Router = router
}

Interface Adapters

ORMのマッパーもここで定義します。
ここでは、DB用に最適化された型をドメインロジック用に最適化された型に変換することで、インピーダンス・ミスマッチを解決することに徹しています。
adapter層は抽象に依存しているとはいえ、この層で定義するinterfaceは外側のライブラリにある程度依存してしまいます。
原則では、

「抽象」は実装の詳細に依存してはならない

とされていますが、この層は変換が目的なので、ある程度どちらのことも知っているのが自然かと思います。
ここに関してもっと良い実装パターンをご存知の方がいらしたらご教授願いたいです。

type (
    UserRepository struct {
        Conn *gorm.DB
    }

    User struct {
        gorm.Model
        Name  string `gorm:"size:20;not null"`
        Email string `gorm:"size:100;not null"`
        Age   int    `gorm:"type:smallint"`
    }
)

func (r *UserRepository) Store(u domain.User) (id int, err error) {
    user := &User{
        Name:  u.Name,
        Email: u.Email,
    }

    if err = r.Conn.Create(user).Error; err != nil {
        return
    }

    return int(user.ID), nil
}

まとめ

見ての通り、user登録するだけのapiでもこんな大きなプロジェクトになってしまいます。
抽象化することで関心を分離することができますが、そこまでして分離する必要があるかはよく考える必要がありそうです。
基本的に抽象化するとコードは複雑化し、直感的ではなくなるので、アプリケーションの規模が小さい場合は効力を発揮しない場合が多いと思います。
何か間違いがあればご指摘頂けると大変助かります。

参考

csvをGoの構造体にマッピングする

Goでcsvを扱う際は、標準パッケージのendording/csvで対応できますが、いちいちスライスを扱うのは少しつらいものがあります。
大抵の場合構造体にマッピングした方が扱いやすいため、その方法を紹介していこうと思います。
csvマッピングライブラリはjszwec/csvutilgocarina/gocsvがありますが、今回は前者を使っていきます。

github.com

mapperの定義

User struct {
    ID              int    `csv:"id"`
    Name            string `csv:"name"`
}

以下のuser.csvを読み込みます。

id, name
1, nakabonne
2, ryo nakao

Unmarshal

var users []User
// バイト列を読み込む
b, _ := ioutil.ReadFile("user.csv")
// ユーザー定義型スライスにマッピング
_ := csvutil.Unmarshal(b, &users)

Marshal

// ユーザー定義型スライスの作成
users := []User{
    {ID: 1, Name: "nakabonne"},
    {ID: 2, Name: "ryo nakao"},
}
// バイト列に変換
b, err := csvutil.Marshal(users)