詩と創作・思索のひろば

ドキドキギュンギュンダイアリーです!!!

Fork me on GitHub

SlackのログをBigQueryにインポートする(手動)

Admin 限定技だけど、Slack にはデータのエクスポートという機能があって、ワークスペースのパブリックチャンネルの会話データを zip でダウンロードすることができる。これがまあまあ便利な代物で、通常なら API を叩きまくらないと得られない諸々の情報を一括で手に入れることができる。

エクスポートした zip の中身はこんな感じになっている:

.
├── channels.json
├── integration_logs.json
├── users.json
├── <channel>
│   ├── yyyy-mm-dd.json
│   ├── ...
│   └── yyyy-mm-dd.json
└── <channel>...
    └── ...

この channels.json とかは conversations.list API の結果である……のかと思いきや、少しスキーマが違うようで、独自のものを返している模様。

こんな感じになっている:

[
{
    "id": "CXXXXXXXX",
    "name": "general",
    "created": ...
    "creator": "UXXXXXXXX",
    "is_archived": false,
    "is_general": true,
    "members": [
        "UXXXXXXXX",
        ...
    ],
    "topic": { ... },
    "purpose": { ... }
},
...
]

members なんかがあるのは特殊なんじゃなかろうか。yyyy-mm-dd.json も:

[
    {
        "client_msg_id": ...
        "type": "message",
        "text": "うんこ",
        "user": "UXXXXXXXX",
        "ts": "##########.######",
        "team": "TXXXXXXXX",
        "user_team": "TXXXXXXXX",
        "source_team": "TXXXXXXXX",
        "user_profile": { ... },
        "blocks": [ ... ],
        "reactions": [
            {
                "name": "hankey",
                "users": [ ... ],
                "count": 1
            },
            ...
        ]
    },
    ...
]

といった感じで conversations.history より情報が多め。ちょっと余談だけど、情報多めな割にこの JSON にチャンネル名が(ID も)含まれていない。ファイルパスに入っているからそれでわかるだろうという一種の正規化なのかもしれないけど、このおかげで面倒なシェルスクリプトを書くことになった。メタ情報による正規化はあまりうれしくないことがわかる。

さて、この reactions というデータが興味深い。誰がどんなリアクションをつけたか、がわかる(API ならいちいち reactions.get することで得られるデータ)。

そういうわけで、今回はこのエクスポートされた zip を BigQuery にインポートしようと思います。Embulk を使うことにする。できあがりは以下のリポジトリ。

GitHub - motemen/example-slack-logs-ingest-bigquery

README にあるとおり、

  • ./data にエクスポートした zip を置いて、
  • ./config/_out.yml.liquid を書いて、
  • docker compose run --rm embulk bash -c 'embulk run /config/embulk.yml.liquid'

で BigQuery へのインポートが行える。

できたテーブルに以下のようなクエリを叩くと、リアクションランキングなどが得られる。おもしろいですね。まあ text 本文などを置くのははばかられると思うので、転送しないほうがよいかも。

WITH messages_flat_reactions AS (
SELECT
  *,
  JSON_VALUE(reaction_item, "$.name") AS reaction_name,
  JSON_VALUE(reaction_item, "$.count") AS reaction_count
FROM
  `xxx.slack_messages`
LEFT JOIN
  UNNEST(JSON_QUERY_ARRAY(reactions)) AS reaction_item
WHERE
  reactions IS NOT NULL
)

SELECT
  timestamp, channel, text, reaction_name, reaction_count
FROM messages_flat_reactions
ORDER BY
  reaction_count DESC
LIMIT 10

Embulk について

Embulk を真面目に使ってみたのは初めてだったけど、いくつかハマりポイントがあった。

  • embulk の jar ファイルはシェルスクリプトとしても解釈できるように工夫されているので、公式サイトでは jar を chmod +x して PATH に入れるように指示されている。手元ではこれで動くが、Docker で動かすときに直接 ENTRYPOINT にしてしまうと動かない。bash などを噛ませて動かす必要がある。(何も考えずに java -jar を指定するとメモリ不足になりがち)
  • embulk-output-bigquery は依存ライブラリが Ruby >= 2.6 を求めがち(https://github.com/embulk/embulk-output-bigquery/issues/144)な一方 embulk 同梱の JRuby はちょっと古いので、ひとつひとつ pin しておく必要がある。これプラグイン側でなんとかできないかなあ。Embulk v0.11 では JRuby のバージョンを自分で選べる らしいので、それ待ちなのかもしれない。

graph-gophers/dataloaderはv7でgenericsに対応している

GraphQL における N+1 問題の解決の機構として Dataloder と呼ばれるものがあるが、Go でこれを行うときは gqlgen + graph-gophers/dataloader という組み合わせがよく使われるようだ。後者は gqlgen の公式ドキュメントからも参照されているので、gqlgen を使っていれば自然とそうなりそう。

このへんの話は 【GraphQL × Go】 N+1問題を解決するgqlgen + dataloaderの実装方法とCacheの実装オプション - LayerX エンジニアブログ などに詳しい。

さて、この dataloaders ってのを普通に使ってコードを書いてみるとわかるのだけど、ロードのためのキーとして string を、ロードされた結果として interface{} を返すような実装になっている。つまり実際にデータベースにアクセスするような処理ではキーとして渡された string を int に読み替える必要があり、dataloader から返された値を使用するには型アサーションを使う必要がある。ダルい!

ジェネリクスが導入された世なのでラッパーでも書くかと思っていたが、よく調べてみれば掲題のとおりジェネリクスが導入されたバージョンも提供されているようだった。便利ですね。

dataloader package - github.com/graph-gophers/dataloader/v7 - Go Packages

自分が入門したときは日本語で言及されている記事に行き当たらなかったので、今後のために書いておく。

サンプルとして gqlgen + dataloader v7 のコードも書いてみた。

GitHub - motemen/example-gqlgen-dataloader

ジェネリクスが導入されていないバージョンで書いたブランチもあって、diff を見てみると簡単になった様子がわかりやすい。

https://github.com/motemen/example-gqlgen-dataloader/compare/dataloader-legacy..main

thunk() の結果をアレコレしなくてよいのは大変うれしい。今回の例では楽をしてキーを文字列にしているのでそのへんはありがたみが薄く見えるけど、現実的には strconv を挟まなきゃいけないので面倒なはず。

Domain Modeling Made Functional を読んだ

最近フロントエンドに限らず TypeScript を書くことが多くなって、これでそれなりの規模のサーバサイドアプリケーションを書くときどうなるんだろう、と気になって読んでみた。いわゆる普通のオブジェクト指向ではなく関数指向な書き方でいきたいとき、どうするのが好ましいのか、というような観点。

名前的にそのものずばり、という本があったので購入した。日本のウェブを検索してみてもいくらか言及があるので価値はありそうだという判断で、大人なので円安でも強行する。

自分は PDF で読みたかったので Domain Modeling Made Functional: Tackle Software Complexity with Domain-Driven Design and F# by Scott Wlaschin で買った。Kindle 版とどっちが安いかはタイミングによりそう。

この本ではドメイン駆動設計(DDD)を F# で実践するとどうなるのか、ということを語っている。F# は読み書きしたことがないけど、文法は見覚えのある感じだし、特別な言語機能は出てこないので、F# で詰まることはなかった。本の構成が「ドメインの分析」→「モデリング」→「実装」となっていて、F# を使った詳細に入っていくのがだいぶ後半だけになっているのもあるかも。

ドメインの分析はイベントストーミングで行うべし、というところから始まっていて、DDD の実践書としてコンパクトながらよくまとまっている。DDD の入門という観点でもよさそう。

結局この本で言っている Functional とは

  • 静的な代数的データ型を駆使してモデリングすることと、
  • 不変なデータと副作用のない関数に基づいてワークフローを実装すること

ということのようである。

代数的データ型によるモデリング

分析を経て得られたドメインモデルを、型を通じてモデリングしていく。ここではとくに直和型が重要な働きを持っていて、これを使うことで違うものを違うものとして表現できる。「ありえない状態はそもそも表現できないようにする(Making Illigal States Unrepresentable In Our Domain)」というのが大方針だ。

Capturing Business Rules in the Type System: 守るべきビジネスルールがあるなら、実行時のチェックではなく型で表現する。たとえば

type CustomerEmail = {
  EmailAddress : EmailAddress
  IsVerified : bool
}

という表現では EmailAddress が変更されたときに IsVerified がどう変化すべきかが(ロジックを読まないと)分からないし、将来の変更でバグを生む余地があるが、

type CustomerEmail =
| Unverified of EmailAddress
| Verified of VerifiedEmailAddress

として、かつ VerifiedEmailAddress の型をもつ値が自由に生成できなくなっていれば(たとえば検証ロジックを通過しないと生成できないなど)よりうまくドメインの事情を反映して、間違いにくくなる。

つまるところ、性質を名前に寄せていくことだと思う。そうすればコードによるチェックから型によるチェックへと場を移すことができる。

本では、いきなり F# を書くのではなくてドメインにおける状態を擬似コード的に表してみるとアラ不思議、F# のコードにそのまま変換できる、というストーリーになっているけれどそこまでうまくいくのかは疑問。まあ、Persistence Ignorance を強調して、終盤になるまで永続化のことを気にせずにモデリングしてるのはいいと思う。完全にデータストレージから切り離された実装をすることは、少なくとも自分が見聞きする規模のアプリケーションでは非現実的だとも思うけど。

ワークフローをパイプラインとして実装する

ワークフローを複数のステップによる値の受け渡しに分解して、それぞれのステップは副作用を持たない状態遷移としてモデリングする。ここでいう状態遷移とは、モデリングを通じて定義したデータ型を入出力として持つような関数だ。状態が変わるというのはデータの持つ値が変わることではなくて、あたらしい型になる、というのを原則として考えておくとよさそう。何らかのビジネスロジックを経て、データは別のものになる。前の例だと、「メールアドレスを検証する」ではなくて「検証されていないメールアドレスを検証されたメールアドレスに変換する」というような形だ。

Pushing Persistence to the Edges: そしてオニオンアーキテクチャ的な流儀で、ワークフロー内のステップ(群)を I/O で挟み込む形に実装する。場合によっては I/O がワークフローの両端だけでなく中に登場することもある。ともあれ純粋なビジネスロジックと I/O が別個の処理として連なっていることが重要。

ドメインロジックを副作用のない関数としてを実装することで、言うまでもなくテストがしやすくなるのはもちろんだけど、ドメインの事情をよりうまく表現できそうにも感じた。ステップの途中で I/O に逃げることができない(= Unit を返せない)ので、モデリングで定義した型のあいだの変換を行わざるを得ない、というイメージ。あとリポジトリは登場しないとか言ってるが I/O 部分がそうでしょと思った(まあいわゆる Java の DDD で言うリポジトリとはちょっと様態が違うんだと思うけど)。

まとめ

ほかにもモナドがどうこうとか Result 型がどうこうみたいな細かい話もあるけど、まあこのへんは近年の静的型をもつ言語に触れたことがあれば飛ばして読めそう。あと DI 周辺は言語によって扱いが違いそうなので真面目に読んでない(そもそもここを深堀りしないようであった)。

読んだらわりと普通だね、となったけど、いかに純粋な世界を関数型・静的型の世界で実現するか、ということに気を払っていないと容易に境界が崩れてしまうことも想像できる。一種の理想的な例として読んでおくと、現実の問題に取り組むときの足がかりにできそうである。

あと TypeScript でこれを直接実践できるかは分かっていない。とはいえ、この原則はどの言語でも役に立ちそうだった。

はてなで一緒に働きませんか?