自動esaやり機とクリーンアーキテクチャ

🎄 Advent Calendar

もう12月ですね。
あっという間に、今年も残り1ヶ月となりました。

f:id:kokuyouwind:20171129095835j:plain

12月といえばクリスマス。
そしてアドベントカレンダーです。

f:id:kokuyouwind:20171129095858j:plain

そんなわけで、今年はMisocaでもアドベントカレンダーを作りました!

この記事はMisoca Advent Calendar 2017の1日目です。
中の人がそんなにいないので、同じ人が何度か出てくることになります。
途切れず25日完走できるか、乞うご期待。

あ、ちなみに初日の担当は@kokuyouwindです。
BOOTHで技術同人誌を頒布してるのでよろしくおねがいします。
コミケにも持っていきます。初日のキ-53aです。

🐤 esa.io

Misocaでは情報共有にesa.ioを使っています。
ポストをWIPで作ってから書き足していけるため、気軽に情報をアウトプットでき大変便利なツールです。

✍️ 寄せ書きポスト

Misocaでのesaの使い方の一つに「枠だけ作ったポストに各自が書き足す」場合があります。
寄せ書きみたいですね。

例えば、議題を持ち寄って話すミーティングでは、議事録のポストを作っておいて各自が議題を書き足していきます。
こうすると、当日は書いてある内容に沿って進行できますし、議事録の手間をだいぶ軽減できます。

また各自の日報についても、WIPで作って書き足していくスタイルの人がいたりします。
これは好みが分かれますが、自分は帰り際に記憶を掘り起こす必要がないのでこの方が好きです。

🔄 ポストの自動作成

WIPで書き足していく方法はかなり便利なのですが、「誰がポストを作るか」という問題があります。
特に週次で開催されるミーティングの場合、毎週だれかが作るのは結構面倒です。
また、うっかりすると2人が重複して新しいポストを作ってしまいます。

既にあるポストに書き足すだけで良いのが理想的なので、決まった周期で自動的にポストを作れると便利そうです。
残念ながらesa.ioにはそういった機能はありませんが、esa APIを使えば外部からポストを作ることができます。

そういった経緯で作ったのが自動esaやり機(esa_feeder)です。

🐣 自動esaやり機

自動esaやり機ではesa.ioの投稿テンプレートにタグを付与することで自動作成の指定を行います。

  • #feed_* をつけると指定した曜日に記事が作られる
    • #feed_mon で月曜日、#feed_tueで火曜日、etc...
    • #feed_wdayで平日指定(祝日を除く月〜金)
  • #slack_* をつけると指定したSlackチャンネルに記事作成通知
  • #me_* をつけると指定ユーザでポストを作成
    • 未指定だと esa_bot ユーザで作られる
  • ユーザごとの投稿テンプレートに対応
    • この場合は #me_* タグの指定に関わらず、そのユーザでポストが作られる

例えばtemplates/週次報告/%{Year}-%{month}-%{day} #feed_thu #slack_generalという投稿テンプレートがあった場合、

  • 毎週木曜日に 週次報告/2017-11-30 などのポストが作られる
  • 作ったポストがSlackの #general チャンネルに通知される

という挙動になります。

f:id:kokuyouwind:20171130163541p:plain

から

f:id:kokuyouwind:20171130163029p:plain

が作られる感じです。

⚙ 仕組み

内部処理は非常に単純で、当日の曜日に該当するタグをクエリとして、APIでテンプレートを取得し、それぞれのテンプレートごとにポストを作成しているだけです。
この処理はthorタスクで起動できるようにしており、Heroku Schedulerで毎日9時に実行するようにしています。

当初はデータベースにポストの作成設定を持たせたりWebUIをつけたりしようと考えていたのですが、必要な機能を絞った結果、非常にシンプルな構成になりました。

🌏 クリーンアーキテクチャ

esa_feederは単機能の小さなリポジトリですが、せっかく新しいリポジトリを作ったので、試験的にクリーンアーキテクチャに沿ってレイヤを切り分けてみました。

以下の画像はThe Clean Architectureのブログ記事からの引用です。

f:id:kokuyouwind:20171129105311j:plain

クリーンアーキテクチャではこの図のようにビジネスルールを中央に置き、フレームワークや外部APIなどの詳細には直接依存しないようにします。

📁 ディレクトリ構成

esa_feederbundle gemコマンドで初期構成を作ったため、lib/esa_feeder以下に主要なコードが配置されています。
lib/esa_feeder以下のディレクトリ構成は以下のとおりです。

esa_feeder
├── entities
│   └── esa_post.rb
├── use_cases
│   ├── feed.rb
│   └── source_tag.rb
├── gateways
│   ├── esa_client.rb
│   └── slack_client.rb
└── version.rb

entitiesは図中のEntities(黄色の層)に対応しており、関心の対象になるクラスを配置します。
今回はesaのポストを表わすEsaPostを作っています。

use_casesは図中のUse Cases(赤色の層)に対応しており、アプリケーションの振る舞いを扱うクラスを配置しています。
今回は「日付に該当するタグを取得する」と「指定したタグ群の記事を作成する」という2つの振る舞いクラスを作っています。

gatewaysGateways(緑色の層)とExternal Interface(青色の層)に対応しており、外界とやりとりするコードを置いています。
今回はesa.ioとSlackのAPIを利用するため、それらのクライアントを置いています。

もちろん、ユースケースではesaのテンプレートを取得したり記事を作ったりする必要があり、gatewayに配置されたクラスのインスタンスを知る必要があります。
しかしクリーンアーキテクチャの原則では内側のレイヤから外側のレイヤに依存することはできないため、ユースケースの初期化時に依存性注入(Dependency Injection, DI)するようにしています。

module EsaFeeder
  module UseCases
    class Feed
      def initialize(esa_port, notifier_port)
        @esa_port = esa_port
        @notifier_port = notifier_port
      end

      def call(tags)
        # ...
        post = esa_port.create_from_template(template, feed_user)
        # ...

🙆 メリット

クリーンアーキテクチャにしたことで各クラスの責務が明確になり、テストが書きやすくなりました。
特にユースケースが外界と接続する部分は必ずDIされているため、allow_any_instanceなどを使わずmockでテストでき、意図しない動作を起こしにくくなっていると思います。

# 直接依存しているとこうなりがち
before do
  allow_any_instance_of(Esa::Client)
    .to receive(:posts)
    .and_return(posts)
end
subject { described_class.new.call }

# DIするとこう書ける
let(:esa_client) { double('esa client', posts: posts) }
subject { described_class.new(esa_client).call }

また変更で影響を受ける層が限られるため、機能追加や修正の見通しが良くなります。

例えば#me_*タグでポストの作成ユーザを変えれるようにした際は、

  • #me_*という特殊タグの追加」というEntities層の変更
  • 「ポストを作る際に作成者を切り替える」というUse Cases層の変更

にとどまり、Gateways層には影響を与えずに修正できました。

🙅 デメリット

当然ですが責務をかなり細かく切り分けることでクラス数も多くなるため、コード量自体は多くなります。
またテストコードではモックが増え、特にUse Cases層ではGateways層とのやり取りを全てモックすることになるため、今回のようにAPIを順に叩くだけだと「これは何のテストだっけ…」という気持ちになってきます。

今回は実験的な意味もありクリーンアーキテクチャに沿った設計にしましたが、この規模のリポジトリではメリットよりもデメリットが大きいかもしれません。

❓ 迷い

クリーンアーキテクチャには境界をまたがるデータについて、以下のように書かれています。

典型的には、境界をまたがるデータは、シンプルなデータ構造だ。 (中略) われわれは、ズルをして、エンティティやデータベースの行を渡すべきではない。

これはもっともな気もするのですが、ユースケースgatewayから返ってきた値を毎回entitiesに変換するというのも筋が悪いような気がします。
そもそも緑色のInterface Adapter層は

ユースケースとエンティティにもっとも便利な形式から、データベースやウェブのような外部の機能にもっとも便利な形式に、データを変換する

のが責務と書かれているので、ここがEntitiesへの変換まで受け持ったほうが見通しが良くなるのではと思い、現状のesa_feederではgatewaysからentitiesを触っています。
自分の理解が浅いというのもありますが、このあたりは結構どうするか迷っている部分です。

💪 今後の課題

自動esaやり機はesa.ioとSlack通知を前提に作っていますが、Gatewayを切り替えれば他の情報共有アプリケーションで同様の記事作成を行ったり、通知先をSlack以外に切り替えたりできるはずです。
このあたりはRubotyの仕組みを参考にしてプラグイン方式にできないかと画策しています。

また細かい話ですが、議事録を事前に作ると記事中の日付が作成日になってしまうため、実際に会議を行う日とズレてしまいます。
このあたりをうまくサポートできる機能が付けられないかなーというのも考えています。

💬 余談

自動esaやり機を作るにあたりesa APIで「テンプレートから記事を作る機能」が欲しいなーと思い問い合わせたところ、その日のうちに実装完了メールが来てびっくりしました。
その後もパラメタ周りの問題を問い合わせたら1時間後には修正がリリースされていたりして、最高のスピード感でした。

📢 宣伝

Misoca Advent Calendar 2017、明日の担当はころちゃんです。

Misocaではesa APIをハックしたいエンジニアを募集しています!