Smart UI パターンが再評価される世界

設計ナイト2020 を受けて、今どんなアーキテクチャを選ぶべきかという話をしたくなったのだ。

kichijojipm.connpass.com

お前誰よ

をやってきた人間。数百万〜数億行のデータ、月間数千万〜数十億 imp 程度を主戦場にしています。

今日の話

昂ぶって 15,000 字ぐらい書いてしまった。

DDD と PofEAA から学ぶパターン/アンチパターン

DDD も PofEAA も 2002 年出版だけど、ほとんど今でも通用する話なので、まずココを出発点とするのが良い。

利口な UI (Smart UI) アンチパターン

  • エリック・エヴァンスのドメイン駆動設計に書いてある
  • KENT WEB 時代ぐらいの PerlPHPCGI を思い出すと良いはず
    • 画面ごとの .cgi ファイルに、上の方にデータロードのコードが、下の方に UI のコードが書いてある。何なら混ざり合ってる
  • もちろん良いこともある
    • 画面ごとに分かれているので、影響が局所化される

minekoa.hatenadiary.org

これを少し改善したものが トランザクションスクリプト

f:id:onk:20201111020809p:plain

  • 分かりやすいのは ISUCON のコード
    • template とは分離された、依然として手続き的なコード
    • 各 Action 間でのコピペは引き続き横行している

PofEAA でのデータソースのアーキテクチャに関するパターン

第10章 データソースのアーキテクチャに関するパターン

テーブルデータゲートウェイ

https://bliki-ja.github.io/pofeaa/TableDataGateway/

SIにいた人なら「ダオ」のほうが通りが良いのではと思います。テーブルデーゲートウェイなんて現場で聞いたことが無い

より良いトランザクションスクリプトを目指す - enrike3のブログ

  • ほぼ Table 単位
  • すべての CRUD はこのゲートウェイを通る
  • SQL や、クエリビルダの組み立てが書かれるクラス

行データゲートウェイ

https://bliki-ja.github.io/pofeaa/RowDataGateway/

私は、トランザクションスクリプトを使用する場合に、行データゲートウェイを使用する頻度が最も高い。この場合、行データゲートウェイでデータベースアクセスコードを適切に抜き出し、別のトランザクションスクリプトで容易に再使用できるようにする。 私は、ドメインモデルを使用する場合には行データゲートウェイを使用しない。シンプルなマッピングを実行する場合には、コードレイヤを追加しなくても、アクティブレコードが同じ役割を果たす。

...

トランザクションスクリプトを行データゲートウェイとともに使用する場合、複数のスクリプトで繰り返されるビジネスロジックこそが、行データゲートウェイに必要なロジックであることがわかるだろう。ロジックを移動することによって、行データゲートウェイは段階的にアクティブレコードへと変化し、ビジネスロジックの重複を軽減する効果をもたらす。

(PofEAA より

アクティブレコード

https://bliki-ja.github.io/pofeaa/ActiveRecord/

データマッパー

https://bliki-ja.github.io/pofeaa/DataMapper/

  • レイヤー化しようと思うとデータマッパーパターンになりがち
    • だけど、密結合してエイッてやるともっと楽だよというのが Rails が示した道 (後述
  • 各レイヤー間を疎結合にしようとすると DTO による詰め直しが必要になる

この辺りは一昔前 (2000年代前半) に SSH (Struts, Spring, Hibernate) アーキテクチャが流行っていた頃を思い出す。Hibernate は二次キャッシュで DB アクセスを隠蔽して、例えば

# 概念コード
my $entry_1 = EntryRepository->find_all_by_author_id(author_id => 1)->[0]
my $entry_2 = EntryRepository->find_by_id(id => 2)

で読み込む $entry_1, $entry_2 はメモリ上も同じインスタンスであるべきという考え方で、片方に変更を加えると (同じインスタンスであるので) もう片方にも影響する。

  • 同じデータなんだから 1 つなのは当然だし、レースコンディションも絶対に発生しない
  • DB からロード済みだと SQL は発行しない (Identity Map を持つ)、というのをデータマッパー上でできるので、うまく使うとパフォーマンスが向上する
    • 我々の道具で言うと Apollo Client のキャッシュに似たイメージ

PofEAA や DDD で語られたパターンの詳しい話は texta.fm で id:t-wadaid:Yasaichi が話しているのでぜひ聞いてください!

anchor.fm

話はトランザクションスクリプトに戻る

  • Smart UI パターンよりモデル化していきやすいので、何らかのコードをトランザクションスクリプトに抜き出すのは推奨されている
  • どういうレイヤー分けを行うかは正解がない

トランザクションスクリプトをどこに置くかは、レイヤをどのように体系化するかによって異なる。

...

トランザクションスクリプトを複数のクラスに体系化する方法は2つある。 最も一般的な方法は、複数のトランザクションスクリプトを1つのクラスに入れ、各クラスが関連するトランザクションスクリプトの対象エリアを定義する方法である。この方法は最も簡単で一般的な手法である。 もう1つの方法は、トランザクションスクリプトごとに独自のクラスを持たせ、「コマンドパターン」を使うというものだ。

(PofEAA より

ドメインロジックを構築する方法 3 種類

ドメインロジックを構築する方法は以下の 3 つが PofEAA に書かれている。(第2章 ドメインロジックの構築)

それぞれのコストはこう図示される

f:id:onk:20201110231849p:plain

ドメインで扱う概念の中には、1つの機能や処理が単体で存在していて、もの(オブジェクト)として扱うのが不自然なものもある。そうしたものは、サービスという形でユビキタス言語に組み込む。サービスは基本的に状態をもたない(stateless)。

[ 技術講座 ] Domain-Driven Designのエッセンス 第2回|オブジェクトの広場

 

ドメインにおける重要なプロセスや変換処理が、エンティティや値オブジェクトの自然な責務でない場合は、その操作は、サービスとして宣言される独立したインターフェイスとしてモデルに追加すること。モデルの言語を用いてインターフェイスを定義し、操作名が必ずユビキタス言語の一部になるようにすること。サービスには状態を持たせないこと。

...

このコードを見てもらうと、"なんだ ドメインモデルを入出力にとる関数じゃないか" と思うでしょう。そのとおりです。

混乱しがちなサービスという概念について - かとじゅんの技術日誌

ここまでのまとめ

  • もっともコピペが横行していて、その分労働集約的に並列作業できる、他には影響を与えずに改修できるのが Smart UI パターン
  • データロードと Template を分離するトランザクションスクリプト
    • まずは ISUCON コードを思い浮かべると良い
    • 各 Action の中を設計しようと思うと、無限の可能性が広がっている。実装のレールは (PofEAA には) 無い
  • データと振る舞いを 1 箇所に集めようとしているのが「ドメイン

はい。まだ 1/3 ぐらいだよ!

Rails によって発見された、密結合で速く走れるソフトウェア

和田:Ruby on Rails自身は疎結合の設計に対してNoを言っている。密結合にすることによって疎結合な設計以上の開発スピードが生まれる。少なくともスタートアップ企業にとってスピードは本当にクリティカルな力なので、もし密結合の状態でも速く走れるソフトウェアの構造があるのであれば、それはゆっくり安定して継続的に歩いていく疎結合のソフトウェア設計より強いということをRailsはある程度証明していたわけですね。そしていま、その構造のまま大きくなるとすごく大変になるということも証明している。

マニアが潰したテスト駆動開発〜『健全なビジネスの継続的成長のためには健全なコードが必要だ』対談 (5) | by Takeshi Kakeda | 時を超えたプログラミングの道

DHH がどのように密結合を作り上げていったのか、については以下のスライドが詳しい。

speakerdeck.com

  • RESTful ルーティングと ActiveRecord パターンによって、URL で表されるリソースから DB 上のテーブルまでが密結合する構造を作った
  • ActiveRecord パターンとその Validations/Callbacks によって、ビジネスロジックとその組み立て処理を全て Model に書けるようにした

この密結合は、間違いなく最速の設計技法である。じゃないとあんなにスタートアップ界隈で採用されなかったし、十分にワークすることは歴史が証明している。

RailsMVC が標準となっていった歴史は最近だとここでも語られている。

この頃に同時に起こったのがRuby on Railsに代表されるLLの躍進とそれに伴うテンプレートエンジンの簡素化です。

これによりASP.NETJSFは所謂Web界隈と呼ばれるようなコンシューマよりへの拡大はもちろん、主戦場であるエンタープライズ領域すらLL言語にフロントエンド系を中心に浸食されていきました。

またこれらのFWは細かい理由は知りませんが結果的にコンポーネント指向ではなく、シンプルなMVC Model 2を採用しておりテンプレートエンジンはループや条件分岐、変数をバインディングしたりレイアウトを作れる程度の簡素なものでイベントドリブンなどは採用さていません。一部、ClickやWicketなんかは採用していましたが、まあ普及していませんし?

WASMとRustはVue.js/React.jsを打倒するのか? - JSへの侵略の歴史

僕の感覚としても、シンプルに Rails をベースラインにしていると幸せ。

このコードを見てもらうと、"なんだ ドメインモデルを入出力にとる関数じゃないか" と思うでしょう。そのとおりです。最初はその程度の認識でよいと思いますが、ここで一点だけいいたいのは、乱用は禁止ということです。

...

従属するエンティティや値オブジェクトがないということで早期あきらめてしまい、なんでもかんでもドメインサービスにするというのもの違うのです。後者の場合は、振る舞いがあるべきドメインモデルから振る舞いを奪うことになるので、ドメインモデル貧血症の温床になる可能性があるのです。

混乱しがちなサービスという概念について - かとじゅんの技術日誌

素朴な密結合 MVC では限界がある

Rails は最速ではあるが、「その構造のまま大きくなるとすごく大変になる」フレームワークでもある。

素朴な MVC では限界があるというのを皆が発表している。

Rails は少人数スタートアップで小〜中規模 *1 なアプリケーションを作るために最適化されたフレームワークなので、ギャップはある。

ただ僕の体感としては、複雑なのはごく一部 (10%程度) で、ほとんどの要件はシンプルに扱える。

複雑さをベースにしたアーキテクチャは不当に難しい。シンプルな要件のときに大仰に見せたくないので、複雑なところは例外的に見えるようなアーキテクチャであると良い。

本当に複雑なものと、複雑ではあるが工夫で対処できるもの

ActiveRecord を前提として、解決方法はいくつも語られてきた

また、「アプリケーションサービス」はよく導入される(が、間違えやすい)

トランザクションスクリプトの延長にあるこの Service クラス (コマンドパターン) で戦う方法がよく採られている。

間違えないように Operation に持って行く=Trailblazer

そもそものはじまりは、作者のNick Sutterer氏がRailsMVC抽象レイヤーのあり方に疑問を持ったこと。Railsの手軽さを認める一方、ModelやControllerの肥大カオス化により、のちの保守性が下がることを問題視されたそうです(Nickさんの本意訳)。

TrailBlazer概要まとめてみた - Qiita

概要は Rails のアーキテクチャ設計を考える - Qiita を読むと掴みやすいかもしれない。

Trailblazer: Operation Overview

  • Model の validation に当たるものを contract に持っていく
  • Operation の中身は本質的に手続き的なものなので step で処理する
    • このときに step の連続である=state を持っている、という点も扱いやすさに繋がっている
    • クラスメソッドではなく、インスタンスを作って状態を持つと楽になる、というのが PofEAA でも語られている。

この手法のメリットは、スクリプトインスタンスを実行時にオブジェクトとして扱える点であるが、トランザクションスクリプトを使ってドメインロジックを体系化するようなシステムでは、このメリットを活かす必要性はほとんどない。もちろん多くの言語では、クラスを完全に無視してグローバル関数だけを使うこともできる。しかし、新たなオブジェクトをインスタンス化することでスレッドの問題が解決できる場合もある。データの分離が簡単になるからだ。

(PofEAA より

間違えないように Interactor に持って行く=Hanami(=Clean Architecture)

Hanami is based on two principles: Clean Architecture and Monolith First.

Architecture: Overview | Hanami Guides

Architecture: Interactors | Hanami Guides

Overview の次に Interactors の章が置かれる程度には Interactor が主軸なフレームワーク

Interactor も Operation と同じく initializecall のみを持つクラスである。Iterator の考え方は HanamiはRubyの救世主(メシア)となるか、愚かな星と散るのか を読むと良いかな。

イマイチ流行らなかった rectify

https://github.com/andypike/rectify

  • FormObject, Command パターンがあるのはもう前提として、僕は書き味の話をしたい!
  • Controller が Controller に必要な本質だけを追究できる世界

Controller に必要な本質は、これも PofEAA に書かれている。

ドメインロジックの扱いで最も難しいのは、人々が思っているように、何がドメインロジックで何が他のロジックかを見極めることだろう。私が好きな非公式のテストは、Webアプリケーションにコマンドラインインタフェースを追加するときのように、まったく異なるレイヤをアプリケーションに追加することを想像するというものである。この追加を行うときに機能を複製する必要があれば、それはドメインロジックがプレゼンテーションの中にはみ出していることを示している。

(PofEAA より

Web アプリケーションとしてのインタフェース (View 以外) が Controller の役目で、「同機能の CUI コマンドを作るときに重複が無い」という基準で考えると良い。

rectify を使ったときの Controller は

def create
  @form = RegistrationForm.from_params(params)

  RegisterAccount.call(@form) do
    on(:ok)      { redirect_to dashboard_path }
    on(:invalid) { render :new }
    on(:already_registered) { redirect_to login_path }
  end
end

と、ビジネスロジックで起きたイベントを on で捕捉して、redirect や render を行う。これは CUI にするときにまったく重複しないまさしく Controller の役目だし、見た目も美しい。

とまぁ僕は好きなんだけど、ガッツリ実務で使ったわけではないので見た目の美しさにのみ囚われている可能性はある。

いずれも Controller or Action と 1:1 対応する新しい層 という考え方

本当に複雑なもの

以上、だいたいのことは工夫で解決できる程度の複雑性、と置いた。じゃあ本当に複雑なものは何かというと、SoR だろうと思う。

ただ僕の目には SoE=モード2 で十分なものがほとんどに見えている。

すべて SoE 的手法でもいけるんじゃないか

https://speakerdeck.com/naoya/system-of-record-to-system-of-engagement#25

バランスを欠いていたと id:naoya は振り返っているが、p20 の表にあるようなシステム領域はあまり普段の開発=イテレーションを回しながら不確実性に対処していく中には出てこないので、だいたい SoE と捉えていくのが Web アプリケーション開発の実情じゃないかな。

今求められているアーキテクチャ

SoE の戦場はクライアント側に移っている。

  • ビジネスにおいて、(ネイティブ or Web SPA) クライアントの UX が必須な時代なので、 API 開発の手綱もサーバからクライアントに移していくような流れが生まれています
  • GraphQL も BFF も、クライアント側で制御していこうという発想
    • 新しい概念「クエリ」を入れたのが GraphQL。クライアントはともかく、サーバは新規開発になる
    • クライアントごとの中間レイヤーを作ろうというのが BFF。途中からでも導入しやすい

The NEXT of REST - onk.ninja

【エンジニアブログ】ダイニーのエンジニアリング3カ条|dinii(ダイニー)公式|note では Hasura を用いると GraphQL の query 側はほとんど開発が要らないという話をしている。

「フロントエンド領域」を再定義する - Speaker Deck でもフロントエンジニアの領域がサーバ側に広がっている。 GraphQL や BFF をフロントエンジニアが扱うことで、ブラウザがバックエンドからデータを取得する、データソースのアーキテクチャはフロントエンジニアのものになった。

Builderscon Tokyo 2019 で「ビジネスの構造を扱うアーキテクチャとユーザとの接点を扱うアーキテクチャ」というタイトルで登壇してきました #builderscon - assertInstanceOf('Engineer', $a_suenami) では

プレゼンテーション優位な技術駆動アーキテクチャを選択する場合は、むしろ立派な実装パターンであると言える。

 

「DDDのスマートUIアンチパターン」もSoEの目的に合わせて使えばデザインパターンになる

という話をしている。

まさに Smart UI パターンは再評価されるポイントに来ている。クライアントが、表示の都合でクエリを都度書く世界になった。

  • フロントエンドではコンポーネントの作り方が確立されている
  • データマッパーは Apollo Client のキャッシュが上手いことやってくれる

ので、Smart UI の痛みも少なく実装することができる。

そしてバックエンドは、SoE では ActiveRecord で表現できないほど複雑な「ドメイン」は無いと位置づけて良く、サーバサイドの仕事は GraphQL の schema を提供したら loader を考えるだけの仕事になっている、というのがイマココです。

(誤解がありそうなのでちょっとだけ言っておくと、GraphQL の schema を考える仕事は RESTful API の schema を考える仕事とほとんど変わんない感覚です。「DB を露出している」と捉えるのは筋違いかな)

Mutation 側は?

Mutation はアプリケーションサービスと非常に親和性が高く、コマンドパターンの Service クラスを無思考で作ってしまいがち。

ただ今までの話であったように、安易に Service に全てを押し込めるとドメインモデル貧血症に陥ります。ActiveRecord の機能をちゃんと使って、適切に validation, callback を使った上で、それでも複雑だから Service クラスが必要になったというのが歴史です。まずはオブジェクト指向的に育てることを忘れないでください。

シンプルな Muation はシンプルに扱える。もしナイーブに実装し過ぎたとしても、改善方法が確立されているので大丈夫。

なんか言及する隙が無かったものあれこれ

*1:複雑さは色んな指標があると思うけど、雑な一つの基準として、100 テーブル以内ぐらいだと考えると良いと思う

PK/Unique KEY が UUID v4 だと INDEX 作成効率が悪い

zenn.dev

プライマリキーにUUIDを採用した場合のINSERT時間のペナルティ

っていう話があったので、へーって思って見てみた。対象の MySQL はただ docker run mysql:8.0 したヤツです。

string が主キーで、PK があるものと無いもの、UUID v4 と v1 とで比べれば良かろう。(v1 は値が偏るので)

create table bar(id varchar(255) not null, primary key(id));
create table baz(id varchar(255) not null, primary key(id));
create table qux(id varchar(255) not null);
INSERT_SQL = <<~SQL
INSERT INTO %s(id) values
  (?), (?), (?), (?), (?), (?), (?), (?), (?), (?),
  (?), (?), (?), (?), (?), (?), (?), (?), (?), (?),
  (?), (?), (?), (?), (?), (?), (?), (?), (?), (?),
  (?), (?), (?), (?), (?), (?), (?), (?), (?), (?),
  (?), (?), (?), (?), (?), (?), (?), (?), (?), (?),
  (?), (?), (?), (?), (?), (?), (?), (?), (?), (?),
  (?), (?), (?), (?), (?), (?), (?), (?), (?), (?),
  (?), (?), (?), (?), (?), (?), (?), (?), (?), (?),
  (?), (?), (?), (?), (?), (?), (?), (?), (?), (?),
  (?), (?), (?), (?), (?), (?), (?), (?), (?), (?)
SQL

COUNT_NUM = 50_000; # 回 *100 行の INSERT

def execute(table_name, &block)
  COUNT_NUM.times do |i|
    puts "#{table_name}\t#{i}\t#{Time.now.to_i}" if i % 1000 == 0 # 10万行 INSERT ごとに時間を出力
    db.xquery(INSERT_SQL % table_name, *100.times.map{ block.call })
  end
end

uuid = UUID.new
execute("bar") { SecureRandom.uuid } # UUID v4
execute("baz") { uuid.generate }     # UUID v1
execute("qux") { SecureRandom.uuid } # UUID v4 PK なし

100 行ずつ 5 万回 INSERT、1000 INSERT (=10 万行)ごとに時間を取得しました。

結果をグラフにするとこう。横軸が総 INSERT 行数、縦軸は 10 万行の INSERT にかかった秒数。

f:id:onk:20201013163946p:plain

へー。たしかに 100 万レコードを過ぎた辺りから徐々に遅くなってるのが分かる。 もうちょっと右側 (数千万レコード級) でどうなるかも見てみたい……。

UUID 文字列を使うのが間違い (例えば UUID_TO_BIN() 使えよとか) という話もなくはないし、1 行あたりだと 1ms 切るので数千万程度なら大して気にはしないと思うけど、とりあえず。

自分では snowflake 系に慣れきっちゃってるので、コレを見てもなんともだなぁ。

おもしろかった。

ISUCON10 予選敗退^H^H突破した

id:uzullaid:moznion と共に curl gotti というチーム名で出場しました。このメンバーでやるのは去年に引き続き 2 回目。

三行で

  • 最高得点 2125 で予選通過ならず
  • ほぼいつもの力が出せた。ので実力不足である。。
  • とても楽しめた良問だった。ありがとうございます

多分この辺の施策をやったんだと思う

  • condition.json を静的に返す
  • 雑に INDEX 張る
  • features の LIKE 検索を正規化して撲滅
  • xxxRangeId カラムを追加
  • なぞって検索の N+1 改善
  • PHP のチューニング
  • DB のチューニング
  • ORDER BY popularity DESC, id ASC をどうにかする
  • DB 2 台構成に
  • Bot に 503 を返す

自分が考えたことや手を動かしたことは分かるんだけど、それ以外のチームメンバーがやったことはあまり把握していないので事実とは違う可能性がある。

アプリケーション概観を掴んで作戦を立てる

とりあえず読み慣れてる Ruby でコードを全部読んだ。LeafCage/foldCC.vim は最高。

f:id:onk:20200913164533p:plain
vim の fold が快適で他のエディタに移れない

チームの scrapbox にエンドポイント一覧ページを作り、各エンドポイントが何をやっているかをそこに書き込んでいった。

f:id:onk:20200913164736p:plain

全部読んだのでアプリケーション概観を書いて情報共有。

f:id:onk:20200913164807p:plain

今思うと GET /api/recommended_estate/:idSQL 素通りしたのマジかよってなるんだけど、当時はザッと見て「見てるカラム数少ないし INDEX 張るだけでなんとかなるんちゃう」って思っていた。。

この辺りでベンチ実行できたので alp, pt-query-digest を採取。まぁ GET /api/chair/search, GET /api/estate/search から倒そうねって感じになり、

  • レギュレーション通りに Bot に 503 を返せるように& condition.json を静的に返す
  • 雑に INDEX 張って次を考える
  • features の LIKE 検索を正規化

の 3 ルートに分岐した。僕は INDEX を担当したので

-- GET /api/estate/low_priced 用
alter table isuumo.estate add index rent_and_id(rent, id);

-- GET /api/recommended_estate/:id 用
-- door_width, door_height は一旦無視
alter table isuumo.estate add index popularity(popularity);

-- GET /api/chair/low_priced 用
-- order by 狙いで stock は無視
alter table isuumo.chair add index price_and_id(price, id);

を張った。ここでも recommended_estate の SQL を考えるのを諦めていることがわかってツラい。

xxxRangeId カラムを追加

「検索をどうしたら良いか何も分からん。単体で INDEX 張りまくって INDEX マージに任せるか! MySQL 8.0 にしたら賢くなって勝つるんじゃね?」みたいな雑談をしつつ (割と本気だったけど、今回クエリキャッシュもかなり効いていたので、8.0 選ばなくて良かったのかもしれない)、どんな複合インデックスにしたら良いのか何も分からんのでとりあえずベンチマーカーが送ってくるクエリパラメータを眺める。

   8 depthRangeId=0&page=0&perPage=25
   1 depthRangeId=0&page=1&perPage=25
   1 depthRangeId=0&page=2&perPage=25
   4 depthRangeId=0&page=3&perPage=25
   1 depthRangeId=0&page=4&perPage=25
  15 depthRangeId=1&page=0&perPage=25
   3 depthRangeId=1&page=1&perPage=25
   3 depthRangeId=1&page=2&perPage=25
   3 depthRangeId=1&page=3&perPage=25
   6 depthRangeId=1&page=4&perPage=25
   9 depthRangeId=2&page=0&perPage=25
   2 depthRangeId=2&page=1&perPage=25
   5 depthRangeId=2&page=2&perPage=25
   2 depthRangeId=2&page=4&perPage=25
   8 depthRangeId=3&page=0&perPage=25
   2 depthRangeId=3&page=1&perPage=25
   5 depthRangeId=3&page=2&perPage=25
   3 depthRangeId=3&page=4&perPage=25

あれ、depth や width そのものじゃなくて ID 送ってきてるんだ! なるほど min,max 取り出してたコードはそういうことか、って言いつつ、「初期データ弄るねー」って言ってカラム追加してデータ移行。

alter table isuumo.chair add column depth_range_id integer, add column height_range_id integer, add column price_range_id integer, add column width_range_id integer;

update isuumo.chair set depth_range_id = 0 where depth < 80;
update isuumo.chair set depth_range_id = 1 where depth >= 80 and depth < 110;
update isuumo.chair set depth_range_id = 2 where depth >= 110 and depth < 150;
update isuumo.chair set depth_range_id = 3 where depth >= 150;
...

add column を 2 個以上書くときの SQL の書き方が分からん、とググりながらやっている (地力不足)。短期決戦なんだから綺麗に書くよりも 「動けば良い」を合い言葉にしておくと良いのだろうなぁ、まだ気持ちを捨て切れてない。

blog.sushi.money

また、この過程で「ひょっとして condition.json から ranges 減らしたらベンチマーカーが送ってくるクエリパラメータの分散が減るんじゃね?」と思って試してみたがさすがにそんな甘い話は無かった。

SQL がまだホットスポット

[width_range_id, popularity] で複合インデックスを張ったが、1 要素だけの検索でも Using filesort が消えない。

mysql root@127.0.0.1:isuumo> explain select * from estate where door_width_range_id = 1 order by popularity desc, id asc limit 25 offset 50\G
***************************[ 1. row ]***************************
id            | 1
select_type   | SIMPLE
table         | estate
partitions    | <null>
type          | ref
possible_keys | door_width_range_id_and_popularity
key           | door_width_range_id_and_popularity
key_len       | 4
ref           | const
rows          | 5253
filtered      | 100.0
Extra         | Using index condition; Using filesort

1 row in set
Time: 0.005s

試しに id ASC を削ると Extra がめっちゃ綺麗になる。

mysql root@127.0.0.1:isuumo> explain select * from estate where door_width_range_id = 1 order by popularity desc limit 25 offset 50\G
***************************[ 1. row ]***************************
id            | 1
select_type   | SIMPLE
table         | estate
partitions    | <null>
type          | ref
possible_keys | door_width_range_id_and_popularity
key           | door_width_range_id_and_popularity
key_len       | 4
ref           | const
rows          | 5253
filtered      | 100.0
Extra         | Using where

1 row in set
Time: 0.005s

SHOW INDEX から popularity のカーディナリティはかなり高いのを知っていたので、「これ unique にできるんじゃね?」という作戦に出た。

具体的には

  • 10 倍する
  • id が大きい方から 0, 1, 2, ... を足す
  • ORDER BY popularity DESC, id ASCpopularity DESC のみに変更

をやった。

レスポンスを返すときに 1/10 にするのを忘れていて、ベンチがコケて気づいて一瞬慌てた。シンプルに 10 倍したので計算で返せて助かった……。

この 10 倍して 1/10 して返す、みたいなのは、浮動小数点数を避けるために整数で計算したい!ってときなんかに実務でもよく使う手なので、自然と思いつけた。

突然登場する ActiveRecord

require "erb"
require "active_record"

class Chair < ActiveRecord::Base
  self.table_name = :chair
end
class Estate < ActiveRecord::Base
  self.table_name = :estate
end

env = "development"
path = File.join(__dir__, "database.yml")
specs = YAML.load(ERB.new(File.read(path)).result)
ActiveRecord::Base.configurations = specs.stringify_keys
ActiveRecord::Base.establish_connection(env.to_sym)

Estate.group(:popularity).having("count(1) > 1").count.each do |popularity, _|
  Estate.where(popularity: popularity).order(id: :desc).each_with_index do |e, i|
    e.popularity = e.popularity + i
    e.save
  end
end

Chair.group(:popularity).having("count(1) > 1").count.each do |popularity, _|
  Chair.where(popularity: popularity).order(id: :desc).each_with_index do |e, i|
    e.popularity = e.popularity + i
    e.save
  end
end

DB 2 台構成に

SQL だいぶ綺麗にしたけどまだ DB の CPU がボトルネック。というので

  • 101 は nginx/php
  • 102 は chair 用 DB
  • 103 は estate 用 DB

という使い分けに変えよう、という会話がシュッと行われて、id:uzulla にやって貰った。コネクション 2 本持って使い分けるという方針は即決まったんだけど、PHP で書ける自信が無かったので、手を動かせる人が居て助かった。

スコア変遷

f:id:onk:20200913165217p:plain

なんと 20 時までは 872 が最高得点だったが、ラスト 1 時間で

  • ボトルネックが DB の CPU 使用率
    • DB の CPU を使わせない、短時間でできることをとにかく考える
  • 与えられた 3 台をうまく使う

という改善ができて、2125 まで爆上げできた。

20 時でスコアが固定された段階では 2100 ぐらいがボーダーっぽかったので、2125 は「他のチームが上げてきていたら無理だなー、でも少し夢があるなー」という会話ができるスコアで、ここまで辿り着けて良かったですね。いやーしかし悔しいな。

あと 1 手あれば 200 点ぐらい足せた可能性があり、ヌルポインターマリアユニバースが 2335 で本戦出場 ということを考えると、いやーこれは行けなくもなかったなー……。

コミュニケーション

  • Discord でボイスチャット繋ぎっぱなしにしていた
  • scrapbox にどんどんページを作っていった
    • 最新の alp や pt-query-digest の結果が勝手に貼られていくのは最高に便利だった
  • 画面は結局シェアすることが無かった
  • ペアプロすることもほぼ無かった

パラレルで動けるように次にやることを考える、というムーブをそれぞれが取れていたんじゃないかなぁ。つまり指揮官 3 人体制なのであった。

改善するとしたら

  • 一度も自分でベンチマークの enqueue をしなかったので、dstat を眺めるということもやらなかった
    • 次の改善点の実感が足りない
    • 2 時間に 1 回ぐらいは自分で enqueue する、もしくはチーム全員で enqueue して dstat を見守る。
      • チーム全員で眺めると自然と方針を固める時間も取れて一石二鳥な気がする
  • ブラウザでは結局動かさなかった
    • アクセスログとコードしか見てないので僕だけコミュニケーションが難しかった
  • ペア作業を恐れない
    • 詰まってたら悲鳴上げてねーぐらいの声掛けはできていたので、信頼して任せているということなのかもしれないけど、まったく画面シェアが無いのはまぁ異常だろう
  • PHP 慣れ
    • preload 周りは id:uzulla しかできない作業になっちゃってるンだよなぁ
    • 今回 AppArmor で少しハマってそうだけど手が出せなかった
  • SQL 改善したらボトルネックが移るはず」と思ってなかなか最終構成の見極めができなかった
    • いやー、でもこれはそういうモノだと思っているな
    • 2 時間前ぐらいに決めようという緩い合意が取れていたことが Keep なのだろう

お疲れさまでした!

いやー、良問だった。データ量が大したことなくても検索は大変というのがよく分かる。 流れる SQL 一覧を作った瞬間に「GIS !!!!!????」という驚きがあったし、椅子が通るかどうかという recommend 検索も最高に面白かった。

過去最多の 1 日 500 組という暴力を捌いた ISUCON10 運営チームの皆さま、ありがとうございました。

2020-09-20 追記

isucon.net

まさかの繰り上がり当選した。やってまいります。

放っておくと進まない仕事を進めるために

はてなは 7 月決算なので期のふりかえりをやっていたんだが、今期は「放っておくと進まない仕事を進めるために、時間を確保する」ことで進むようになった期だった。ちなみに前期は「放っておくと進まない仕事を進めるために、締切を設定する (強制力を持たせる)」ことで進めようとしていた期だった。

放っておくと進まない仕事とは、優先順位が低い仕事のこと。やってもやらなくても直ちに影響はない仕事をどうやったら進められるのかというのは長年ずっと課題だったが、やっと自分の中である程度答えが見えてきたかもしれないので記しておく。書き出してみたら至って普通なんだけど、以下をやっている。

  1. 見積もる
  2. 締切を作る
  3. 締切を宣言する
  4. 時間を押さえる

見積もる

チームで見積もりを行い、まずはタスクの重さや実現方法についての共通認識を持つ。

タスクを分解する、とも言い換えられる。やるべきなんだけど気が重い仕事はとにかく分解しまくる。それでも手が付かないならもっと分解する。最終的には「作業ディレクトリに cd する」まで分解する。

なお、この 5 秒で終わる状態になっていてもまだやらないことが経験により分かっている。これは自分が内心で cdだけやっても成果が出ないことを知っているからだろう。

だったら分解する意味は無いのか?と思うかもしれないがそんなことはなく、リストを人に見せておくと「 cd した?」という問い掛けが発生するので、作業に取りかかるキッカケを増やす役に立つ。僕のことは赤ちゃんだと思ってマイクロマネジメントして欲しい。

締切を作る

締切が無いから際限なく延びるので、まずはタスクの締切を設定する。ここで無理な締切を置いても結局実行できない (何度も痛い目を見た) ので、前段でちゃんと見積もられている必要がある。

締切を作ってタイムラインに載せることで、後ろにも仕事が詰まっていることを実感させ、本当の締切 *1 や裏の締切 *2 が実際には存在しないことを可視化する。

また締切を置くことで、他人からも遅延が見えるようになる。これも他人にマイクロマネジメントして貰うコツだと思う。

締切を宣言する

締切を作ったところで、守る気が無かったら意味が無い。脳内の優先度を上げる策として、前々期は昼会の中に「id:onk が今日終わらせるタスクを宣言する」というコーナーを作った。

この脳内優先度ってヤツが厄介で、ADHD 特性を持っていると、新しいイベントが常に優先度最高で割り込んできてしまう。なんとか対抗するために 一貫性の原理 を使い、自ら宣言することで、強い気持ちで優先度を高く保ち続けようとした。

「今日中にコレとコレが終われば」と言い聞かせることで、帰る/寝る前に本当に終わっているかを見返すことが可能になり、進捗するという算段だったのだが、この作戦を採る段階ではまだ「睡眠時間を犠牲にすればなんとかなるんじゃないか」のような甘い考えが傍らにあるので、次の「時間を抑える」も行う必要がある。

f:id:onk:20200810013524p:plain
当時のふりかえりを見るとボロクソに言われている

時間を押さえる

これはカレンダー上で作業予定を入れてすべてをブロックしてしまうと言う作戦。

新しいイベントが常に優先度最高で割り込んでくるなら、Slack を落とすことで刺激を減らせば良い。反応できないことを宣言するためにカレンダーも埋めておく。

ただこれだけで進むかというとやっぱりダメで、Slack を落としてもすぐに起動してしまう (完全に手癖) ので、更にペア作業時間として相互監視を行うことで進捗するようになる。完全にペア作業じゃなくても、配信することで緊張感が生まれるようだ。コロナ禍によりさぎょいぷが流行ったのは良い効果があった。

一人でもなんとかできるようになりたくて、リズムを作れば行けるはずだと思ってポモドーロは毎年何度も挑戦しているんだけど、まだ上手くいってない。何らかの強制力が閾値を超えないのだろう。例えば Blurred のようなツールと組み合わせてどんどん刺激を下げていくと、どこかで閾値を超える日が来ると思って模索はし続けている。

イベントにする

もう一つ、「イベントにする」という作戦にも触れておきたい。具体的には開発合宿とかバグ退治 Day とかがコレに当たる。これは僕の特性というよりも「チームで優先度が上がらない仕事」に対応する方法。

ロードマップに載せるほど優先度が上がりきらないが、やりたいタスクというのはどうしても存在する。

優先度が上がらないのは他の重要度が十分に会話されているタスクに押しのけられてしまうからで、本当は「重要度を説明する」が正しい進め方だと思う。しかしそんなコスト高いことはやってられないので、すべてサボって信頼だけで進めたい。そこで開発合宿という「なぜか分からないが成果が出ることが分かっている」という信頼貯金を使って技術的スパイクを実施し、プロジェクトチーム内からプロトタイプを作るコストを軽減する。

イベントにすることで非日常感を作れ、周りが協力的になるので一石二鳥。

最近のはてな社内は、イベント仕立てをうまく使うチームが増えてきたように思う。個人の危機感をチームのタスクに変えていく術として、みんなの引き出しに収まったということなのだろう。

*1:バッファをすべて食い潰した締切

*2:輪転機が稼働する直前のこと。本当の締切の後に設定される

久々に sinatra app を作った

「いつもの」が結構ありそうなので書いておく。

app.rb ペラ 1 でツラくなったときの対策はだいたい sonots パイセンの ちっちゃくはじめておっきく育てる sinatra アプリの作り方 に書いてあって、これは今でも有効なので読んでおくと良いです。

ディレクトリ構成

REPO
├── app.rb
├── bin/
├── config/
│  ├── database.yml
│  ├── initializers/
│  └── locales/
├── config.ru
├── Gemfile
├── Gemfile.lock
├── helpers/
├── models/
├── public/
└── views/
  • sinatra らしさをなるべく残してある
    • 例えば config/boot.rb を用意するかは非常に悩んだのだけれど、起点は app.rb であって欲しい
  • models/, helpers/ は分けた
    • app.rb に直接書いていると 5 model ぐらいしか無くても数百行になってさすがに見通しが悪いので
  • config/initializers/ を置いた
    • 置き場所に迷ったものを放り込みやすくて便利

もう一声大きくなったらもっと Railsディレクトリ構造に近づける *1 けど、一旦こんな感じで。

セキュリティ対策

erubi を使う

XSS 対策のために escape: true を使いたい。

https://github.com/jeremyevans/erubi/blob/1.9.0/lib/erubi.rb#L57

erubi gem を入れた上で

class App < Sinatra::Base
  set :erb, escape: true
  ...

しておくと <%= ... %> がデフォルトで escape される。

String ごとに html_safe フラグを付けられるわけではないので ActiveSupport::SafeBuffer ほど便利ではないが、無いより 100 億倍良い。

これは完全に余談なんだけど Padrino は SafeBuffer が導入されていて羨ましい。

ところで Erubi には「escape が優先される」と書いてあるので escape: true って使ったんだけど、検索しても 1 件も引っかからないのな。

escape_html: true で検索するとめちゃくちゃ尊い PR が出てきた。(先月じゃーん

github.com

Rack::Protection を使う

デフォルトで使われているんだけど、remote_token であって authenticity_token ではないので置き換える。

set :protection, use: %i[authenticity_token], except: %i[remote_token]

remote_token は referrer が同じだったら通している。 https://github.com/sinatra/sinatra/blob/v2.0.8.1/rack-protection/lib/rack/protection/remote_token.rb#L17-L19

authenticity_token は (GET, HEAD, OPTIONS, TRACE 以外は) 常にチェックする。 https://github.com/sinatra/sinatra/blob/v2.0.8.1/rack-protection/lib/rack/protection/authenticity_token.rb#L98-L106

これで例え XSS があったとしても CSRF されなくなって安心。

ところでこの記事書こうと思ってついでに自社の過去事例を漁っていたら 14 年前の面白記述を見つけた。サードパーティ製ツールを大事にしている精神が感じられて最高でした。

diary.hatenastaff.com

SessionStore を Cookie から変更

クライアント側に構造体を保存しているとセッションの無効化が困難、みたいなアレです。

ritou.hatenablog.com

ユーザごとのログインセッションを任意に破棄できるようにしようと思うとログイン処理のたびに 1 レコード増えることになって、「素朴な Web アプリケーションが許される時代は終わったなぁ」って気がしますね。

よく使う extension, middleware

ほとんど Sinatra::Contrib だな。

Sinatra::ConfigFile

YAML を置いておくとアプリケーション内から設定にアクセスできるマン。 大枠で言うと Rails::Application.config_for と同じ使い心地です。

Sinatra::ContentFor

# layout.erb
<title><% if content_for?(:title) %><%= yield_content(:title) %> | <% end %>APP_NAME</title>
# users/show.erb
<% content_for :title, @user.name %>

みたいなヤツ。 title の例だけで無いとツラいのが分かるはず。

Sinatra::JSON

json @obj

すると JSON dump して content-type 付けて返してくれるヤツ。

Sinatra::Reloader

development で有効にすると開発が楽で幸せ。

configure :development do
  register Sinatra::Reloader
  also_reload "models/**/*"
  also_reload "helpers/**/*"
end

Rack::Flash

rack-flash3 gem でやってる。

redirect 時に notice を渡す書き方もしたいので、Padrino::Flash からパクっておきます。

https://github.com/padrino/padrino-framework/blob/0.15.0/padrino-core/lib/padrino-core/application/flash.rb#L179-L215

REPL

bin/console にこれを置いておくだけでだいぶ楽。

#!/usr/bin/env ruby
require "bundler/setup"
require "irb"
require File.expand_path("../app", __dir__)

def reload!
  Sinatra::Reloader.perform(App)
end

IRB.start(__FILE__)

reload!ActiveSupport::Dependencies と違って丁寧に remove_const とかをやっているわけではなくただ require し直しているだけだけど、model を読み込み直したいぐらいの用途なら足る。

initialize

ディレクトリ構成のところで言った config/initializers を一番最初に読み込ませている。

Dir[File.expand_path("config/initializers/*", __dir__)].sort.each {|f| require f }

ActiveRecord

以下を食わせている。前半は establish_connection 用。後半は timestamp 周り。

path = File.expand_path("config/database.yml", __dir__)
specs = YAML.safe_load(ERB.new(IO.read(path)).result, aliases: true)
ActiveRecord::Base.configurations = specs.stringify_keys
ActiveRecord::Base.establish_connection(env.to_sym)

Time.zone_default = Time.find_zone!("Asia/Tokyo")
ActiveRecord::Base.time_zone_aware_attributes = true
ActiveRecord::Base.default_timezone = :utc

前半は sinatra-activerecord gem で良いんだけど、メンテが滞ってるのを見て から使わなくなってしまった。薄いのでシュッと剥がせたし。最近は次に名乗り出てくれたメンテナが元気なので大丈夫かもしれない。

timestamp は Rails だと Railtie が良い感じにやってくれているが、生で ActiveRecord を使うときは自分で指定する必要がある。 https://github.com/rails/rails/blob/v6.0.3/activerecord/lib/active_record/railtie.rb#L69-L74

あとは models/ 以下に配置して、 ApplicationRecord も欲しいので

class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true
end
require_relative "application_record"
class User < ApplicationRecord
  ...
end

のような感じにしてある。

ActiveRecord::RecordNotFound

ActiveRecord::RecordNotFound を 500 じゃなく 404 で返したいので以下を書く。

error ActiveRecord::RecordNotFound do |e|
  not_found
  # 404 HTML を返したかったらこう
  # send_file "public/404.html", status: 404
end

I18n

Time を出力するときに I18n.l を使うと楽なので YAML を読み込ませておく。

# config/initializers/i18n.rb
I18n.load_path << Dir[File.expand_path("config/locales/*.yml")]

RSS/Atom

builder gem を使え!

ログイン

current_userauthenticate_user! さえあれば事足りることが多いので、 omniauth だけを使っている。

# application_helper.rb
def current_user
  return nil unless session[:user_id]
  @current_user ||= User.find_by(id: session[:user_id])
end
# app.rb
get "/auth/github/callback" do
  ...

  redirect env["omniauth.origin"] || "/"
end

private

  def authenticate_user!
    return if current_user

    if request.safe?
      redirect "/auth/github?origin=#{CGI.escape(request.fullpath)}"
    else
      redirect "/auth/github"
    end
  end

テスト

request spec

rspec-request_describer gem にお世話になっています。send_request を書いておけば rack-test で使える。

routing がカオスになってきたらリクエストを投げた後に last_request.env["sinatra.route"] を確認することで、routing spec の真似事ができる。

RSpec.describe "/", type: :request do
  let(:app) { App }
  let(:send_request) { send(http_method, path, request_body, env).status }

  describe "GET /" do
    it {
      is_expected.to eq 200
      expect(last_request.env["sinatra.route"]).to eq "GET /"
    }
  }
}

fixture

https://github.com/rspec/rspec-rails/blob/v4.0.1/lib/rspec/rails/file_fixture_support.rb をパクってきて spec/support/ 以下に置いてます。

module FileFixtureSupport
  extend ActiveSupport::Concern
  include ActiveSupport::Testing::FileFixtures
  included do
    self.file_fixture_path = File.join(File.expand_path("..", __dir__), "fixtures", "files")
  end
end

RSpec.configure do |config|
  config.include FileFixtureSupport
end

database_rewinder

頑張れば use_transactional_tests 使えるかもしれないけど、読み解くのサボっているので愛用しています。

まとめ

これぐらいの準備で割と Rails と似た書き味で書けるし、だいたい Sinatra::Contrib にあるから安心って感じですね。Padrino に標準装備されているものが多いのですごく悩ましい……。padrino-helpers に嫌な思い出があるだけで Padrino は悪くないので使えば良いのかもしれない。

デプロイしたのが先週なので、運用が始まったらもっと色々考えることが増えていきそうだと思う。例えばログ。(今は標準出力 && Rack::CommonLogger のままで一旦いいやって思って手を付けていない)

*1:GitHub を数時間泳いだところ、https://github.com/cesarfigueroa/acme がめちゃくちゃ良いので参考にしたい

MySQL の ORDER BY や GROUP BY に position を渡せるのを知って驚いたけど deprecated だった

ほぼタイトルママなんだけど

SELECT DATE(created_at), COUNT(1)
  FROM users
  GROUP BY 1;

ってクエリを見つけて、 GROUP BY 1 ってなんぞ……?という話。

MySQL のドキュメント には

[GROUP BY {col_name | expr | position}, ... [WITH ROLLUP]]

 

[ORDER BY {col_name | expr | position}

 

Columns selected for output can be referred to in ORDER BY and GROUP BY clauses using column names, column aliases, or column positions. Column positions are integers and begin with 1:

とあり、ORDER BY, GROUP BY は position を受け付ける。

これは SELECT 句に書いた列番号で指定できるってヤツなんだけど、なので最初の例で言うと GROUP BY DATE(created_at) と書く代わりに GROUP BY 1 と書けて便利。

でも

Use of column positions is deprecated because the syntax has been removed from the SQL standard.

とも書いてあって、はいナルホド〜

ので SQL99 で消えたのかな?知らんけど。

遠慮無く「やめましょう!」ってレビューできました。

Athena だと逆に推奨している

Top 10 Performance Tuning Tips for Amazon Athena | AWS Big Data Blog

  1. Optimize GROUP BY

...

sql SELECT state, gender, count(*) FROM census GROUP BY state, gender;

One other optimization is to use numbers instead of strings, if possible, within the GROUP BY clause. Numbers require less memory to store and are faster to compare than strings. The numbers represent the location of the grouped column name in the SELECT statement; for example:

sql SELECT state, gender, count(*) FROM census GROUP BY 1, 2;

RDBMS の実装に合わせて使い分けるということになるんだろうなぁ。

株式会社はてなに入社しました

株式会社はてなに入社しました

株式会社はてなに入社しました - hitode909の日記

マネージャとしても 2 年目になりました。 今年もよろしくお願いします。