Lean Baseball

No Engineering, No Baseball.

シニア・エンジニアの簡単なお仕事 - デブサミ2020で登壇しました

【TL;DR】デブサミで喋ってきました.

f:id:shinyorke:20200215115709j:plain
高く飛び続けましょうって話でした.

事前登録から多くの方々にご登録いただき, (写真は自粛しましたが)壇上から見る限り, 立ち見も出ていたみたいで感謝感激でした.

数あるセッションの中からお越しいただき(+来られなかった方もリハーサルとか諸々でサポート・応援)ありがとうございました.*1

このエントリーではスライド・発表でいい足りなかったことを補足という形で残したいと思います(発表準備の話を含めて).

スタメン

当日の様子など

スライドは初めてアスペクト比を16:9で作成した関係上, すべて書き下ろしになりました.*2

また, CodeZineさんのTogetterまとめをはじめ, 数多くのレポートを頂き感謝です!

スライド

100枚ありますが割とサクッと読める出来だと勝手に思ってます.

ちなみに当日は45分のお時間を頂いていましたが, 42分ちょいで終わりました.*3

反響とか

CodeZineさんのTogetterまとめに感謝!

togetter.com

何人か既視感ある人いるけどそれはさておき

多くのツイート・感想をいただき涙モノでした.

中には素敵な言語化まで...ありがたい.

短距離ではなくマラソンっていう話、アウトプットする人みんな言ってる

この辺は皆さん印象に残った(かつ自分としても訴えたいポイント)みたいで良かったです.

また, こちらについても初言及しました.

お気持ち的にはこんな感じです.

野生とはいえ, プロレベルの野球データサイエンティストでありエンジニアなのでね.

こういうやりとりができるのもデブサミならではだなと思いました.*4

数々のサポート

また, 当日および本番前から, 所属のJX通信社のメンバーから,

  • 写真撮影
  • 社内SlackやTwitterでの盛り上げ
  • (これは元々のルールですが)勉強会・イベント登壇・参加は業務時間中OK
  • Wantedlyコンテンツほか, 企画段階からのサポート
  • 後述の通り, リハーサル場所として会社のイベントスペースを快く提供

と数多くの支援をいただきました.

会社の事例ではなく, 個人の話にも関わらず前のめりでサポートしてくれて涙モノでした.

ホントにありがとうございました!&裏話とかはTechブログで後日公開したいと思いますのでそちらをお楽しみにどうぞ.

登壇に至るまでのあれこれ

キッカケは至ってシンプルでした.

デブサミ2020の案内が回ってきて,

  • そろそろデブサミでまた登壇したいなっていう欲が出てきた, ちなみに前回は2018冬でした.
  • 幸いにもテーマはある(正に今回の話)
  • 「野球エンジニアになったけど卒業しました」という話は禊(みそぎ)としてどこかですべきだよね自分.

という感じでしっかりストーリーを練った上で応募を出した所, 見事選考が通り, 2年ぶり2回目のデブサミが決まりました.*5

事前準備

今後の自分のため&今後同じく登壇する人の参考になれば幸いです.

スライドの構成・ストーリー

資料の構成は最初から「未来・過去・今」という人々自分のドラマ, と最初から決めていました.*6

f:id:shinyorke:20200215124628p:plain
個人運用しているSlackで壁打ちしました

途中, 「コミュニティ・マネジメントの話はいらない」となり, 代わりにセイバーメトリクスの要素を大きめにした以外, 昨年12/28(年末年始休み初日)に構想した内容ほぼそのままでスライドもデモも出来ました.

なお, 基本的なお話はすべてこちらのエントリーで固まっていたのでこのエントリーから色々と引用しました.

shinyorke.hatenablog.com

デモと野球データサイエンス

また, (デブサミだけじゃなくて色々と分析・研究してる文脈で)データベースとアプリを作ろう, という企画もあった&せっかくなのでいい感じに披露しよう!ということで,

  • Nuxt.js
  • FastAPI
  • その他使いたいもの色々

でデモ開発と分析をしました...という話はいくつかエントリーを書いたりしました.

tech.jxpress.net

shinyorke.hatenablog.com

shinyorke.hatenablog.com

どれもこれもいい感じに反響・反応があり良かったです.

また, 一部の話は本編スライドでも引用したりと結果的に資料構成がシャープになった気がします.

スライド作成(とタイトルのひみつ)

スライドはデモアプリ開発が落ち着いた今年のはじめにやりはじめました.

なぜスライドよりアプリが先かというと,

「対話および, 動くソフトウェアを元に変化への対応」

を愚直にやるためでした.*7

スライド作成は,

  • 3つの拘り「客観的な視点と言語化, プログラミングし続ける, 野球大好き」とともに.
  • 3つの時代「ミライ」「 過去」「今」を生き抜くために.
  • 4つの道具「エンジニアリング」「データサイエンス」「アジャイル」「セイバーメトリクス」を磨き続ける.

の, 334を揺るぎない前提として, 順番に書き始めました.

なお, タイトルの「生涯イチ・エンジニア」「I want to be part of the Senior Engineer(私は強いエンジニアの一員でありたい)」の由来ですが,

  • 「生涯イチ・エンジニア」はスライドにも入れたとおり, 故・野村克也さまのありがたいお言葉から引用(2018年のデブサミから心に決めて使っている言い回しです).
  • 「I want to be part of the Senior Engineer(私は強いエンジニアの一員でありたい)」は私が愛してやまないイギリスのバンド, RADIOHEAD*8の名曲「The Bends」の歌詞をアレンジ*9

ちなみにThe Bendsの件は作業中にアルバムそのものを聴いていて,

I want to live, breathe

(私は生きていたい, 息をしたい)

I want to be part of the human race

(私は人々の一員でありたい)

っていう節が何度も心に刺さり, 「これじゃん!」っていうことで決まりました.*10

リハーサル

45分の発表が久々だったので,

  • ストーリーとデモ, スライドの最初が出来たあたりで短めリハ. これは自分が運営しているPythonもくもく自習室の成果発表として.
  • 社内で45分完成版のリハ. 社員限定で45分計測の上やりきり.
  • 本番前の会場リハ. これは登壇者全員が必ずやるもので, 機材チェックがメイン.

と念入りに3回のリハーサルを実施しました.*11

結果, 尺通りに終わるかつ, 言及したいポイントもしっかり伝わってよかったです.*12

特に引っ越したばっかりの新オフィスのイベントスペースを借り切ってのリハーサルはすごく気分が良く, 30人は入るスペースで通しリハが出来たのは最高でした.*13

ふりかえり - 反省とネクストアクション

反省としては, タイトルに「野球」と入れなかった事ですかね...

「野球統計・セイバーメトリクスと共に語るよ!」ぐらいのモノを入れておけばもっと盛り上がった気がしました.*14

ちなみに明日はそんな野球の話をガッツリしてきます.

spoana.connpass.com

まだ参加可能みたいなのでご興味ある方はぜひ!*15

*1:立ち見たくさん出たの, PyCon JP以外では人生初でした(デブサミ2018の時は空席あった記憶)

*2:愛用しているAzusa Colorsにシレッと16:9対応があったのと, 最近のプロジェクターで4:3のみってのも見かけなくなったので今回を機にエイッと作りました. 故にスライド作る時間掛かってしまいましたが汗

*3:会社でリハーサルしたときには40分だったのでもうちょいゆっくりやったろ!って思ってゆっくりめにしてもこれでしたw

*4:いい形で本音を披露できたのは気持ちよかったです.

*5:ちなみに, 2015年あたりに公募で出して落選したのでそのリベンジが出来たのは良かった.

*6:どこからの引用かはご想像におまかせします🎸

*7:に, 加えてJX通信社の社内勉強会の期日が先でそっちに見せるものないとかっこ悪い!っていう現実的な理由もありました, 動くソフトウェアありきってことで

*8:ちなみに私のアカウント名shinyorkeのyorkeはThom Yorkeからの引用です.

*9:だから途中でベン図出したんですよっていうネタ解説.

*10:更に細かい話をすると発表当日の服装はRADIOHEADのTシャツで「Fitter Happier」の歌詞なデザインでした.

*11:前回デブサミはリハーサル・壁打ちナシのぶっつけ本番でした, 結果成功したものの満足はしてなかったので今回は計画立ててやりました.

*12:社内45分リハとほぼ同じ内容でした, 完全なスクリプトは書いてないので言ってることは少々違いますが.

*13:おそらくですが本番会場より良いプロジェクターとスクリーンでした笑

*14:ツイートを追うと野球わからないとかあったので...これは申し訳ない真似をした

*15:今までやったことないスタイルの野球ネタやります

強いシニアを目指す - 2020年の目標と行動・スタンスのあり方

f:id:shinyorke:20200102165223j:plain

新年あけましておめでとうございます!

本年もどうぞよろしくお願いいたします :bow:

年始なので忘れない内に抱負(的な何か)とか書いちゃおうと思います.

ちなみに今年の年末年始は3年ぶりくらいに東京で過ごしており, 写真の通りコード書いたりしています(not仕事).*1

シナリオ(もくじ)

TL;DR(一言で言うと)

40代最初の年は公私ともに強いシニアを目指して「攻め 33-4 守り」の姿勢やっていきます.

2020年はこれに限るかなと思っています.

公私ともに, 自分も周りも色んな意味でコンディションが良く, 攻め(チャレンジ)するにも守り(大人な態度・スタンス)するにもいい感じに動けるのでこの機会をしっかり活かすべく日々やっていきたい所存です.

未来 - 2020年どうする?

仕事・アウトプット・プライベートでザクッと.

仕事について

まずは所属するJX通信社のチームの話から.

これについては元旦に「OKR」という形でnoteにしたためました.

note.com

上半期で立てたOKRをちょっと引用すると,

【Objective】

2020年代のシニア・エンジニアとしてのキャリア像・生きる道を自ら実践し挑戦し続ける

【Key Result】

  • [KR1]仕事および日本のエンジニア界隈で「これは中川しかできない!」ぐらいインパクトある成果をエンジニアとして残す

  • [KR2]キャリア上でやったこと・実践して結果出たことを大勢の前で披露する

  • [KR3]シニア・エンジニアとして活躍し続けるための身体を作る

これで春先まで走ってふりかえって後軌道修正していこうかなと思います.

現在は会社のTechブログでも何度か書いたとおり, データ基盤エンジニア(含む採用広報諸々)という相変わらずベン・ゾブリスト*2(か西武・外崎選手)みたいに全方位にやっているのですがそれもこれも,

  • チームとして結果にコミットしていきたい.
  • そのためには, まず自分がチェンジ・エージェントとして, 今ある良いものをストレッチし, 改善なポイントを一緒に解決していきたい*3
  • 今まで培ってきたスキルや経験を全力で活かしたらどこまで行くか見てみたい.

というシンプルな思いと周りの協力が組み合わさっていい感じにできそう(というか部分的には既にできてる)なので, これをなんとかかんとか「仕事」という結果につなげたいと強く意識してやってこ!していく感じです.

個人スキルとしては, チームが思いっきりサーバレスだったり機械学習だったりするので, その界隈のキャッチアップ(といいつつ半分は復習)だったり何かしらの新機軸が仕事として出せるようにいい感じに努めていきたいです.

あとはチェンジ・エージェント業的にはこの辺をいい感じに完成させることですかね.

tech.jxpress.net

個人的には常に「言語化」を大切にしているので, JXのチームの皆さんと一緒にいい感じにやれたらと思います.

アウトプット(登壇とブログ)

先程のOKRだと,

  • [KR2]キャリア上でやったこと・実践して結果出たことを大勢の前で披露する

ここにかかってくる部分ですね.

まず登壇ですが, デブサミという大変ありがたい機会を頂いたのでこちらをやりきるのが最初の一ヶ月半の目標になります.

event.shoeisha.jp

ちなみに私の発表はこちらでして, 残席わずからしいのでどうぞよろしくお願いいたします(宣伝)

去年は比較的発表を控えめにしていましたが今年は,

  • 仕事の実績にしてもプライベート(野球の話)とかをテーマに
  • おおよそ200人以上の前で30分以上の話を
  • 最低でも3回はできたらな!*4

とざっくり目標に立てています.

ちなみに「3回」は同じネタを3回じゃなくて, それぞれ違うテーマで行きたい.

「3回」分のネタが貯まるのにおそらく15〜30分程度の発表なりアウトプットが少なくとも34回は出さないと行けない気がするのでそっちもがんばります.*5

という意味では去年やって良かった「地方のPyConめぐり」は今年もいい感じにやっていこうと思います.*6

ブログについてですが, これは私にとって,

  • 「技術」「野球」を元に学び続ける(Lean)していく大切な機会であり,
  • 全世界に公開して良いメモをためる場所*7であり,
  • 毎月何かしら書くのが既に生活のイチ部

なので例年通り変わらず「最低でも月イチ投稿」を崩さずいい感じにやっていこうと思います.

ちなみに継続したおかげなのか年始早々に,

はてなブログのDashboard上で100万アクセスを達成しました, ありがとうございます!*8

今後も書くネタはそんなに変わらないと思いますが, 強いていうならやっぱエンジニアのブログとしてはもっと技術ネタに突っ込んでいけたらなと思っています.

shinyorke.hatenablog.com

shinyorke.hatenablog.com

こういうのを増やす感じで.

プライベートについて

  • [KR3]シニア・エンジニアとして活躍し続けるための身体を作る

痩せたいので節制します.

服のサイズとか運動範囲は変わってないですが体重(ry

健康的であることが挑戦にもつながるので,

  • グルメ生活から少しずつフェードアウト
  • 自主的なウォーキング(毎日10,000歩以上歩く・軽く筋トレ)に加えて, ジム通いとかでちゃんと作っていく
  • 早寝早起き・必要以上に無理しない

な感じにできるよう少しずつシフトしていきます.

健康以外だと,

  • 独身リスクの回避(察し)
  • お金に強くなりたいのでファイナンスの勉強. 簿記をもう一度やる.*9
  • (4月以降を目処に)副業を解禁する*10
  • 英語...今度こそ英語...
  • プライベートで海外旅行なりゲームで遊ぶ

はしていきたいです.

過去 - 昨年までのふりかえり

これは昨年のエントリーでいい感じにふりかえりました.

shinyorke.hatenablog.com

今思えば去年は,

  • 「世界一の野球エンジニアとして五輪を目指す!」という所にフォーカスした結果
  • 「野球の仕事」VS「そもそも自分がやりたいこと」のConflictが起きまくっていてパフォーマンスがさほど出せない時期が半年近く続き
  • 半ばエンスト気味に動いていたのが10月の転職を機に復調した

っていう, 「自分と取り巻く環境」「自分と周りの人達」の折り合いに苦労した(しくじった)一年でした.

とはいえいい感じに転職してギアチェンジがうまくいき, 今の所エンスト起こさず!という感じなのでリカバリー自体は成功してるかな...とは感じてます.

のと, ある程度の事が達観して見れる・動けるようになったのでまあ良かったかなって感じです.

今 - 既視感に立ち向かい, ゼロベースでやっていく

公私ともに, 自分も周りも色んな意味でコンディションが良く, 攻め(チャレンジ)するにも守り(大人な態度・スタンス)するにもいい感じに動けるのでこの機会をしっかり活かすべく日々やっていきたい所存です.

これが今の状況でしてこれに関しては家族友人同僚その他の皆さんに大変感謝しています, ホントありがとうございます!

とはいえ, この状況と,

ある程度の事が達観して見れる・動けるようになったのでまあ良かったかなって感じです.

っていう余裕はなにげに危険だなとも感じていて,

  • なにかの問題・ISSUEに立ち向かう時に受け流しちゃうかもしれない
  • 受け流すことは無くても, 「過去にこういう方法で解決〜」的なアプローチで過去の経験に頼っちゃうかもしれない
  • もうシニアだし周りは若いし俺のいうことがきけな(ry

っていう, 既視感(成功体験)からくる停滞が起こるかもしれないという危機感も感じています.

自分もまだまだ成長していきたいですし, 自分も会社もまだ伸びしろだらけなハズなので,

  • 起きている課題・ISSUEに対して最善を尽くす
  • 最善を尽くす為には事象をゼロベースから捉え, 考え抜き, やり抜く
  • 経験や過去の事, 「既視感」に捉われる事無く挑戦をしていく!

という気持ち・行動・スタンスを忘れること無く公私共々目標に向けてやってこ!していこうかと思います.

未来・過去・今、シナリオに既視感はあるかもですがそれぞれ違うんですから.*11

って事で今年もよろしくお願いします!

*1:詳細は差し控えますが, デブサミの準備してます.

*2:自分が大好きなメジャーリーガーでどこでも守れるどこでも打てるという, 西武・外崎選手の上位互換みたいなヤバいメジャーリーガー

*3:この辺の思想・アプローチは名著「フェアレス・チェンジ」が詳しいので気になる方はぜひ. 自分は大好きですこの本.

*4:既にデブサミが該当するのであと2回かな

*5:なんでや阪神関係ないやろ.

*6:発表も良いのですが, 純粋に旅行として楽しいのといろんな出会いがあるので学びも深いです.

*7:決め打ちのネタもありますが, 技術ネタとかはほぼメモに近いです.

*8:比較的これって大記録だと思うんですよね笑, ナニワトモアレありがとうございます!

*9:財テクもですが, フィンテック興味もあるし別文脈で簿記をもう一度やりたくなり...ちなみに日商簿記2級保持者です.

*10:前職のルールで制限があったので一時的に止めていました. 個人事業主化も目指すかなあー. ちなみに4月と切ってるのは, 現職JOINして半年経過後が妥当と思っているからです(キャッチアップとかの側面で).

*11:元ネタわかった方はそっと耳打ちしてください笑🎸

RESTful APIをシュッと作る技術 - PythonとFastAPIでバックエンドを5時間ちょいで作ってみた

久々に開発ネタです.

大晦日ハッカソン2019 #大晦日ハッカソンで,

  • 野球のデータをシュッと見るためのDashboardを作る(理由は後ほど).
  • そんなDashboardのBackend APIをシュッと開発する.

を目標に立て現在進行系でやってるのですが,

Backendを昨日(12/30)の18:00から着手して(実質作業時間)約5時間ちょいで完成させてしまいました.

本年最後のブログは,

  • FastAPIでバックエンドを5時間ちょいで作った話.
  • それもカウボーイコーディングじゃなくてちゃんときれいに作ったよ, クリーンアーキテクチャ意識して.

っていう記録を備忘録的に残したいと思います.

なお, コードは公開しませんが必要なスニペットは載せますので参考にしてもらえると嬉しいです.

おしながき

前提条件

一応前提条件書いときます.

  • ローカル環境(Macbook Pro + Docker Desktop)の環境で動かしてます, クラウドサービス等には上げていない.*1
  • データは事前にマイグレーションしてDocker上のMySQLで取得できる状態*2
  • デモなので, ユーザー認証は作ってません*3
  • 新規プロダクトのデモかつゼロからやってるのでテストおよびValidatorは未実装です.*4
  • 参照しかしないアプリなため, post/put/deleteに当たるAPIは未実装です(そもそも仕様にないので作らないつもり.).

ちなみにデータはSean Lahmanというメジャーリーグのデータベースを使っています, DB化は(ちょっと古いですが)過去記事が参考になると思います.

shinyorke.hatenablog.com

github.com

これらの前提条件のもと, 5時間で完成させました.

作ったモノ

野球のデータを取得するAPIで,

  • 選手ID(player_id)から選手Profileを取得
  • 同じくplayer_idから打撃成績(年度の降順)で取得

というAPIを作りました.

スクショで魅せるとこんなかんじ(サンプルはShohei Ohtaniさんです*5).

起動後, http://0.0.0.0:8000/docs と打つとAPIのドキュメントが登場.

f:id:shinyorke:20191231164345j:plain
APIドキュメント. これはFastAPIの標準機能.

このドキュメントはそのままSwaggerチックなモノになっててこの場から直接実行することもできます, 強い.

f:id:shinyorke:20191231161753j:plain
docstringの内容をこのまま出してくれてます, いい感じ.

実際にAPIを叩いてプロフィール出してみました, ちゃんとできてる.

f:id:shinyorke:20191231164145j:plain
選手プロフィール

打撃成績も今年の分までしっかり出てくれました.

f:id:shinyorke:20191231164129j:plain
打撃成績の検索結果

以下の順でコードベースで追いかけていきます.

  • Docker(Docker Compose)
  • Application(Router/Controller)
  • Schema/Model/Database

そもそもFastAPI #とは

一言で言うとPython製の非同期前提なWeb APIをいい感じに作るためのFrameworkです.

fastapi.tiangolo.com

あくまで自分の解釈ですが,

  • バックエンドが欲しい時にシュッと作れるWeb APIのFramework
  • チャットなどで非同期処理前提のサーバがほしい時に重宝する「ASGI(Asynchronous Server Gateway Interface) 」に対応した今どきなFrameworkでもある
  • 開発してる感覚的には, Flaskそのもの

といったモノです.

Hello worldを見ると一目瞭然かもしれません.

from fastapi import FastAPI

app = FastAPI()


@app.get("/")
def read_root():
    return {"Hello": "World"}


@app.get("/items/{item_id}")
def read_item(item_id: int, q: str = None):
    return {"item_id": item_id, "q": q}

デコレータで囲う感覚もそうですが, 他も似てます.

???「だったらFlaskでいいのでは?」

と思われるかもですが,

  • 年末年始休みで時間あるので普段使ってないものにしたかった.*6
  • 当初はresponderを想定していたが, 若干方言ある・依存ライブラリ多いのでちょっと...
  • 自社(JX通信社)で使ってるFastAPIを同僚にオススメされたのでやってこ!ってなった.*7

ちなみに(うっすらですが)自社でもFastAPIを(自分とは別のチームで)使っており, サンプルめいたものはTechブログでもちょっと紹介されています.

tech.jxpress.net

Docker(Docker Compose)

まずDockerで動かしたモノの設定...の前にざっくりなプロジェクト構成です.

.
├── Dockerfile
├── Pipfile
├── Pipfile.lock
├── app.py
├── sabr_analytics
│   ├── __init__.py
│   └── router.py
├── docker-compose-local.yml
├── lahman
│   ├── __init__.py
│   ├── crud.py
│   ├── models.py
│   ├── router.py
│   └── schemas.py
└── service
    ├── __init__.py
    └── database.py

Applicationとしてのエントリーポイントはapp.pyでして, Local Debug時は,

$ python app.py

もしくは, ASGIサーバーのuvicornを使って,

$ uvicorn app:api --reload

これで動きます.

Docker使ってのデバッグですが,

$ docker-compose -f docker-compose-local.yml up -d

これで済むようにしています.*8

そんなdocker-compose-local.ymlの内容はこちら(networksは事前に作ってます).

version: "3.0"
services:
  api:
    build: .
    image: shiyorke/yakiu/api
    container_name: yakiu_api
    env_file: .env
    ports:
      - "8000:8000"

networks:
  default:
    external:
      name: baseball_network

そしてDockerfileはこれ.

pipenvで必要なモノを入れて動かしておしまいです.

FROM python:3.8

LABEL  maintainer "shinyorke"
RUN set -ex \
    && apt-get update -y --fix-missing \
    && apt-get install -y -q --no-install-recommends \
    curl \
    file \
    && apt-get purge -y --auto-remove

# install
RUN pip install pipenv
ADD Pipfile Pipfile.lock /
RUN pipenv install --system

# add to application
ADD app.py /
ADD service service
ADD sabr_analytics sabr_analytics
ADD lahman lahman

EXPOSE 8000
CMD ["uvicorn", "app:api", "--host", "0.0.0.0", "--port", "8000"]

ちなみにPipfileはこちら.

[[source]]
name = "pypi"
url = "https://pypi.org/simple"
verify_ssl = true

[dev-packages]

[packages]
fastapi = "*"
uvicorn = "*"
sqlalchemy = "*"
cryptography = "*"
pymysql = "*"
sabr = "*"

[requires]
python_version = "3.7"

メインで使ってるパッケージが少なくて気持ち的に楽です.

Application(Router/Controller)

Application本体です.

まずすべてのエントリーポイントとなってるapp.pyの実装.

import uvicorn

from fastapi import FastAPI

from lahman import router as api_lahman
from sabr_analytics import router as api_analytics

api = FastAPI()


@api.get("/")
async def dashboard():
    return {
        'playerIds': ['Ichiro', 'Altuve', 'Trout', 'Shohei'],
        'api_batting': 'lahman',
        'api_analytics': 'analytics'
    }


api.include_router(
    api_lahman.router,
    prefix="/lahman",
    tags=["lahman"],
)


api.include_router(
    api_analytics.router,
    prefix="/analytics",
    tags=["analytics"],
)

if __name__ == "__main__":
    uvicorn.run(api, host="0.0.0.0", port=8000)

APIのエントリーポイント(URL)は少なめですが, 拡張性を考慮して最初からRoterでモジュールを刺す形で開発をはじめました.

これは開発当初からこの仕掛を作りたくて公式ドキュメントを探してましたがアッサリ見つかりました.

Bigger Applications - Multiple Files - FastAPI

これは明確な理由があって,

  • 普通の野球成績データはlahmanパッケージに留める
  • 今後開発する(昨日今日では作ってない)独自のモデルで分析した結果は別のパッケージとして明確に切り分けたい

という方針を最初から決めており, それによりサブドメインも分かれるのがわかりきってたので最初からrouter前提にしました.

ちなみに構成的には,

.
├── app.py  # メインのアプリ. ここのrouterに刺したものだけ動く
├── sabr_analytics  # 独自分析モデルのアプリ, これから開発
│   ├── __init__.py
│   └── router.py
├── lahman  # 野球の成績データAPI, 今回メインで開発
│   ├── __init__.py
│   ├── crud.py
│   ├── models.py
│   ├── router.py
│   └── schemas.py
└── service  # DBコネクション, 外部API接続などパッケージ横断で共通して使うもの
    ├── __init__.py
    └── database.py

と明確に分けて開発してます.

この先メインで解説するlahmanパッケージのrouter定義(lahman/router.py)はこんな感じです.

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List

from lahman import schemas
from lahman.crud import Database
from service.database import session

router = APIRouter()


@router.get("/")
async def index():
    return {
        'apis': [
            '/player',
            '/batting'
        ]
    }


@router.get("/player/{player_id}", response_model=schemas.Player)
async def get_player(player_id: str, db: Session = Depends(session)):
    """
    Player Profile
    :param player_id: lahman playerID
    :param db: DB Session
    :return: Player Profile
    :rtype: JSON
    :exception: HTTPException(404)
    """
    player = Database.get_player(db, player_id)
    if player:
        return player
    raise HTTPException(status_code=404, detail=f"player not found: {player_id}")


@router.get("/batting/{player_id}", response_model=List[schemas.BattingTotal])
async def get_batting(player_id: str, db: Session = Depends(session)):
    """
    Batting Stats
    :param player_id: lahman playerID
    :param db: DB Session
    :return: Batting Stats
    :rtype: JSON
    """
    batting_stats = Database.get_batting(db, player_id)
    return batting_stats

めちゃくちゃシンプルですね.

Schema/Model/Database

データモデルの定義などがどうなってるか解説です.

こちらも公式ドキュメントを参考にアレンジしました.

SQL (Relational) Databases - FastAPI

lahmanパッケージのrouter定義(lahman/router.py)より.

関数のこちらに着目.

@router.get("/player/{player_id}", response_model=schemas.Player)
async def get_player(player_id: str, db: Session = Depends(session)):
    """
    Player Profile
    :param player_id: lahman playerID
    :param db: DB Session
    :return: Player Profile
    :rtype: JSON
    :exception: HTTPException(404)
    """
    player = Database.get_player(db, player_id)
    if player:
        return player
    raise HTTPException(status_code=404, detail=f"player not found: {player_id}")

response_model=schemas.Player

ここは名前の通り戻り値を決めています, 具体的にはpydanticというライブラリでいい感じに定義しています.

Django REST Framework(DRF)で言う所のserializerに当たる箇所.

※ソースコードは「lahman/schema.py」

from datetime import datetime

import pydantic
from pydantic import BaseModel
from sabr.stats import Stats


class BattingBase(BaseModel):
    playerID: str
    yearID: int
    G: int = None
    G_batting: int = None
    AB: int = None
    R: int = None
    H: int = None
    DOUBLE: int = None
    TRIPLE: int = None
    HR: int = None
    SINGLE: int = None
    RBI: int = None
    SB: int = None
    CS: int = None
    BB: int = None
    SO: int = None
    IBB: int = None
    HBP: int = None
    SH: int = None
    SF: int = None
    GIDP: int = None
    G_old: int = None
    BA: float = None
    OBP: float = None
    SLG: float = None
    OPS: float = None
    BABIP: float = None

    @pydantic.validator('SINGLE', pre=True, always=True)
    def single(cls, v, *, values, **kwargs):
        """
        Single Hits
        :param v: Default Value
        :param values: Row Values
        :param kwargs: Option
        :return: SINGLE
        """
        if values['AB'] > 0:
            return values['H'] - (values['HR'] + values['TRIPLE'] + values['DOUBLE'])
        return 0.0

    @pydantic.validator('BA', pre=True, always=True)
    def ba(cls, v, *, values, **kwargs):
        """
        Batting Average
        :param v: Default Value
        :param values: Row Values
        :param kwargs: Option
        :return: BA
        """
        if values['AB'] > 0:
            return Stats.avg(h=values['H'], ab=values['AB'])
        return 0.0

    @pydantic.validator('OBP', pre=True, always=True)
    def obp(cls, v, *, values, **kwargs):
        """
        On Base Per
        :param v: Default Value
        :param values: Row Values
        :param kwargs: Option
        :return: OBP
        """
        return Stats.obp(h=values['H'], bb=values['BB'], hbp=values['HBP'], sf=values['SF'], ab=values['AB'])

    @pydantic.validator('SLG', pre=True, always=True)
    def slg(cls, v, *, values, **kwargs):
        """
        Slugging
        :param v: Default Value
        :param values: Row Values
        :param kwargs: Option
        :return: OBP
        """
        if values['AB'] > 0:
            single = values['H'] - (values['HR'] + values['TRIPLE'] + values['DOUBLE'])
            tb = Stats.tb(single=single, _2b=values['DOUBLE'], _3b=values['TRIPLE'], hr=values['HR'])
            return Stats.slg(tb=tb, ab=values['AB'])
        return 0.0

    @pydantic.validator('OPS', always=True)
    def ops(cls, v, *, values, **kwargs):
        """
        On Base Plus Slugging
        :param v: Default Value
        :param values: Row Values
        :param kwargs: Option
        :return: OPS
        """
        return values['OBP'] + values['SLG']

    @pydantic.validator('BABIP', always=True)
    def babip(cls, v, *, values, **kwargs):
        """
        BABIP
        :param v: Default Value
        :param values: Row Values
        :param kwargs: Option
        :return: BABIP
        """
        return Stats.babip(h=values['H'], hr=values['HR'], ab=values['AB'], sf=values['SF'], so=values['SO'])


class Batting(BattingBase):
    teamID: str
    lgID: str
    stint: int

    class Config:
        orm_mode = True


class BattingTotal(BattingBase):
    class Config:
        orm_mode = True


class PlayerBase(BaseModel):
    playerID: str
    birthYear: int
    birthMonth: int
    birthDay: int
    nameFirst: str
    nameLast: str
    bats: str
    throws: str


class Player(PlayerBase):
    playerID: str
    birthCountry: str
    birthState: str
    birthCity: str
    nameGiven: str
    weight: int
    height: float
    debut: datetime
    finalGame: datetime
    retroID: str
    bbrefID: str

    class Config:
        orm_mode = True

validatorデコレータでモデルに無い値(例えば打率(BA)など)を計算してるのが若干気持ち悪いですがこれが正しい使い方っぽいです苦笑.

もちろんDBからデータを探してSchemaにマッピングするのですが, これはSQLAlchemyを使っています(Python使いならおなじみ*9ですね).

Databaseクラスには検索のための関数を生やしており,

from sqlalchemy.orm import Session
from sqlalchemy import desc
from lahman import models


class Database:

    @staticmethod
    def get_player(db: Session, player_id: str):
        """
        Get Player
        :param db: SQLAlchemy Session
        :param player_id: lahman db playerID
        :return: Player Profile
        """
        return db.query(models.People).filter(models.People.playerID == player_id).first()

    @staticmethod
    def get_batting(db: Session, player_id: str):
        """
        Get Batting Stats
        :param db: SQLAlchemy Session
        :param player_id: lahman db playerID
        :return: Batting Stats(Years Desc)
        """
        return db.query(models.BattingTotal).order_by(desc(models.BattingTotal.yearID)) \
            .filter(models.BattingTotal.playerID == player_id).all()

モデルはいつもどおりSQLAlchemyでいい感じに定義です.

from sqlalchemy import Column, DateTime, Float, Integer, String, text

from service.database import Base


class People(Base):
    __tablename__ = u'People'

    playerID = Column(String(10), primary_key=True)
    birthYear = Column(Integer)
    birthMonth = Column(Integer)
    birthDay = Column(Integer)
    birthCountry = Column(String(50))
    birthState = Column(String(2))
    birthCity = Column(String(50))
    deathYear = Column(Integer)
    deathMonth = Column(Integer)
    deathDay = Column(Integer)
    deathCountry = Column(String(50))
    deathState = Column(String(2))
    deathCity = Column(String(50))
    nameFirst = Column(String(50))
    nameLast = Column(String(50))
    nameGiven = Column(String(255))
    weight = Column(Integer)
    height = Column(Float(asdecimal=True))
    bats = Column(String(1))
    throws = Column(String(1))
    debut = Column(DateTime)
    finalGame = Column(DateTime)
    retroID = Column(String(9))
    bbrefID = Column(String(9))


class BattingTotal(Base):
    __tablename__ = u'BattingTotal'

    playerID = Column(String(9), primary_key=True, nullable=False, server_default=text("''"))
    yearID = Column(Integer, primary_key=True, nullable=False, server_default=text("'0'"))
    G = Column(Integer)
    G_batting = Column(Integer)
    AB = Column(Integer)
    R = Column(Integer)
    H = Column(Integer)
    DOUBLE = Column(u'2B', Integer)
    TRIPLE = Column(u'3B', Integer)
    HR = Column(Integer)
    RBI = Column(Integer)
    SB = Column(Integer)
    CS = Column(Integer)
    BB = Column(Integer)
    SO = Column(Integer)
    IBB = Column(Integer)
    HBP = Column(Integer)
    SH = Column(Integer)
    SF = Column(Integer)
    GIDP = Column(Integer)
    G_old = Column(Integer)

データベースの関数はserviceパッケージにまとめています.

なぜならConnectionはパッケージ関係なく使うからです(こなみ).

import os

from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker

params = {
    'user': os.getenv('DB_USER'),
    'password': os.getenv('DB_PASSWORD'),
    'host': os.getenv('DB_HOST'),
    'database': os.getenv('DATABASE')
}

DATABASE_URL = "mysql+pymysql://{user}:{password}@{host}:3306/{database}".format(**params)

engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

Base = declarative_base()


def session():
    """
    Get Database Session
    :return:
    """
    db = None
    try:
        db = SessionLocal()
        yield db
    finally:
        db.close()

公式のSQL使う時解説だと, 上記の「def session()」に当たる箇所をapp.py上で定義してましたが, 依存する箇所が気持ち悪かったのでそこは流石にDatabaseに寄せました.

実はクリーンアーキテクチャなのだ

という細かい構成・こだわりを元にプロトタイプをシュッとつくりました.

実はこの設計, クリーンアーキテクチャを志向してまして.

Clean Architecture 達人に学ぶソフトウェアの構造と設計

Clean Architecture 達人に学ぶソフトウェアの構造と設計

  • 作者:Robert C.Martin
  • 出版社/メーカー: KADOKAWA
  • 発売日: 2018/07/27
  • メディア: 単行本

  • 依存性を一貫して, 「(DB・インターフェース層) -> (ビジネスモデル層)」の一方通行にする.
  • DBや(今回登場しなかったが)外部APIなどはservice層にまとめ, 他のビジネスモデル層と明確に切り離し.
  • ビジネスモデル層(今回はlahmanとかsabr_analyticsパッケージ)は各々が独立し, 必要なモノをserviceからもらう.
  • routerやapp.pyはDB何使おうが, ビジネスモデルをどう組もうが気にさせない.

を徹底しました.

が, もっといい設計あるよって人はぜひコメントとかもらえると嬉しいです.

これを何に使うか

デブサミ2020の発表時のデモ...のプロトタイプとして使います.*10

event.shoeisha.jp

自分の発表は「残席わずか」らしいので, みんな来てね.

良いお年を🎍&これから大晦日ハッカソン2019 #大晦日ハッカソンに復帰してフロントエンド作ります.

*1:とはいえDocker化済ませているのでやろうと思えば半日かな.

*2:実はここに一番時間掛かってるかもしれない.

*3:これは後学の為にちゃんと作りたい. FastAPI使いこなしたい.

*4:これは方針として正しいと思っています, 公開する前に作れるようにキレイに書いたので(真顔).

*5:来年の二刀流復活はマジで期待したい.

*6:Djangoは飽きるほど触ってる, Flaskもさんざん触ってるのと, 現在の開発状況とか考えるとリスクが多そうなので新規プロダクトで使う気になりませんでした.

*7:有志メンバーで昨日もくもく会をしてたときにこの話題で盛り上がりました.その後帰宅してから開発スタート.

*8:といいつつ, これはフロントエンド開発時の構成で実際はPyCharmでガッツリやってます.

*9:好き嫌いは分かれると思いますが笑

*10:そのままバックエンドのAPIとして使えるとは思ってますが, 本命を別に作りたい&あくまでデモの仕様を決めるためのプロトタイプなのでアッサリと捨てる可能性すらありますFastAPIのコードは.