ISUCON 8 予選の Go 初期実装に見る初心者コード

会社のBlogにも書いたのですが、ISUCON 8 予選で負けてきました。

さて、 ISUCON の初期実装の定番として、初心者が書いたようなSQLやコードになっている点が挙げられます。

今回の Go の初期実装もその定番にもれず、初心者がやりがちな、Goの良さを殺してしまうコードがありました。

今回負けた反省点の一つとして、アプリの書き換えを二人でやっていたのでコンフリクトを恐れてそのリファクタリングを怠ったというのもあります。 Goで本戦に参加されるチームの方にはぜひこれを克服してもらいたいと思います。

アンチパターン: 長い無名関数

例: https://github.com/isucon/isucon8-qualify/blob/9d7890f5433bdaf2cec75b4cdf1ebd0d9a531281/webapp/go/src/torb/app.go#L404-L492

   e.GET("/api/users/:id", func(c echo.Context) error {
        var user User
        if err := db.QueryRow("SELECT id, nickname FROM users WHERE id = ?", c.Param("id")).Scan(&user.ID, &user.Nickname); err != nil {
            return err
        }

        loginUser, err := getLoginUser(c)
        if err != nil {
            return err
        }
        if user.ID != loginUser.ID {
            return resError(c, "forbidden", 403)
        }
... [以下数十行]

Sinatra 風のコードを移植しようとするとありがちなのですが、関数名が main.func8 とかになってしまって、Goの良さであるスタックトレースやプロファイルの使い勝手を大きく損ねます。Goでこういう無名関数の使い方はやめましょう。 (RubyJavaScript では良いのかという話もありますが、それはまた文化が違うので…)

例えば sort.Slice() にわたす比較関数のようにごく短く、中からDBアクセスなどの処理をしない関数は無名関数にしてもいいです。

また、1つの通常関数の中に無名関数が1つかせいぜい2つあるくらいなら、やはり害は少ないです。

スタックトレースとか flamegraph に main.func6 とか main.func8 とかがバラバラでるのはダメです。

リファクタリング: 通常関数への書き換え

無名関数全体を移動し、適当に名前をつけましょう。

ISUCON であれば事前練習で、なにか機械的命名規則を決めてしまうと命名に時間を取られずに済みます。 例えば上の "/api/users/:id" を扱う関数であれば、 handle_GetApiUsersId とかで良いです。Goの慣習の命名規則(アンダースコア使わない、 API とか ID とかは Api, Id にしない)からは大きくハズレますが、 ISUCON では悩まず機械的に作業できる利点の方が大きいです。

アプリの書き換えを複数人でやる場合、このリファクタリングは書き換え範囲が大きくてコンフリクトが厳しいので、真っ先にえいっとやってしまうと良いと思います。

アンチパターン: for rows.Next() ループ内でのクエリ実行

例: https://github.com/isucon/isucon8-qualify/blob/9d7890f5433bdaf2cec75b4cdf1ebd0d9a531281/webapp/go/src/torb/app.go#L236-L252

   rows, err := db.Query("SELECT * FROM sheets ORDER BY `rank`, num")
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    for rows.Next() {
...
        err := db.QueryRow("SELECT * FROM reservations WHERE event_id = ? AND sheet_id = ? AND canceled_at IS NULL GROUP BY event_id, sheet_id HAVING reserved_at = MIN(reserved_at)", event.ID, sheet.ID).Scan(&reservation.ID, &reservation.EventID, &reservation.SheetID, &reservation.UserID, &reservation.ReservedAt, &reservation.CanceledAt)

こんな感じで、 rows.Next() ループの中でクエリを実行したり、クエリの実行を含む関数を呼び出すと、複数のDBのコネクションを利用してしまいます。

そしてもっと悪いことに、他の prefork 型のアプリではプロセス数を適当に絞ることができるのに対して、GoでDBのコネクションプールを絞ろうとすると上のようなコードでデッドロックの原因になります。デッドロックを避けるためにコネクションプールの上限を設定しないと、たとえCPUが2コアしかないようなMySQLサーバーに対して数百コネクションから並列でクエリを投げてしまい、遅いクエリがどれか解らないとか、MySQL側がデッドロックを誤検出するとか、いろんなトラブルの原因になります。

for rows.Next() ループの中ではそのクエリの結果のフェッチだけを行い、その結果の各行に対する処理は改めて別のループに書きましょう。

アンチパターン: 長い関数での defer rows.Close()

https://github.com/isucon/isucon8-qualify/blob/9d7890f5433bdaf2cec75b4cdf1ebd0d9a531281/webapp/go/src/torb/app.go#L404-L492

上のアンチパターンの亜種ですが、せっかく for rows.Next() ループ内からDBにアクセスする処理を排除しても、 defer rows.Close() を使っていると、そのクエリに使われたコネクションは関数が終わるまでコネクションプールに返却されません。

とはいえ、安易に defer rows.Close()for rows.Next() ループの後ろに rows.Close() の形で移動するのもダメです。 for rows.Next() ループ内に return が無いか確認しましょう。

リファクタリング: sqlx の利用

上のような問題の「まっとうな」リファクタリング方法は、1つのクエリを実行して結果をフェッチするまでを個別に関数に切り出すことです。そうすると defer rows.Close() が適切なタイミングで実行されます。

しかし ISUCON だといちいちそういったリファクタリングをしている余裕がないかもしれません。そこで sqlx を覚えておくと良いでしょう。

sqlx は、 database/sql の上位互換になっています。 sql.Opensqlx.Open に書き換えるだけで、そのままのコードが動きます。

そして、 db.QueryRow(...).Scan(&data)db.Get(&data, ...) に、 db.Query(...), for rows.Next() { var row Record; rows.Scan(&row); records = append(records, row) } のようなパターンを db.Select(&records, ...) に書き換える事ができます。

特に後者が強力で、 rows.Next()rows.Close() を排除することができるので、コネクションを無駄に大量消費する問題を楽に解決することができます。

それ以外にも便利機能がいくつかあるので、 database/sql を直接使った初心者コードをリファクタリングするときの強力な武器として練習しておくことをおすすめします。

hub コマンドの BDD がユーザードキュメントとして素晴らしい

OSSメンテナをしていると他人のPRやブランチをチェックアウトして何かを確認したいということは頻繁にあって、いちいち git remote add して fetch してってのが面倒なので Github 製の Github CLI クライアントである hub を愛用している。

でも hub コマンドって、ドキュメントがあまりなくて、 help コマンドの出力も最小限で、頻繁に使う一部の機能以外はほとんど使いこなせずにいた。

しかし、 hub コマンドが Cucumber を使って BDD をしているのを最近知った。「どういう仕組でBDDが動いているのか」は全くわからないけれども、 「hub がどういうコマンドを実行するとどういう動作をするのか」は凄くわかりやすい。

たとえば、 hub pr checkout コマンドの Behavior を見てみると、

Feature: hub pr checkout <PULLREQ-NUMBER>
  Background:
    Given I am in "git://github.com/mojombo/jekyll.git" git repo
    And I am "mojombo" on github.com with OAuth token "OTOKEN"


  Scenario: Checkout a pull request
    Given the GitHub API server:
      """
      get('/repos/mojombo/jekyll/pulls/77') {
        json :number => 77, :head => {
          :ref => "fixes",
          :repo => {
            :owner => { :login => "mislav" },
            :name => "jekyll",
            :private => false
          }
        }, :base => {
          :repo => {
            :name => 'jekyll',
            :html_url => 'https://github.com/mojombo/jekyll',
            :owner => { :login => "mojombo" },
          }
        },
        :maintainer_can_modify => false,
        :html_url => 'https://github.com/mojombo/jekyll/pull/77'
      }
      """
    When I run `hub pr checkout 77`
    Then "git fetch origin refs/pull/77/head:fixes" should be run
    And "git checkout fixes" should be run
    And "fixes" should merge "refs/pull/77/head" from remote "origin"
  1. mojombo/jekyll をチェックアウトしたリポジトリにいるときに
  2. hub pr checkout 77 を実行すると、
  3. hubが git fetch origin refs/pull/77/head:fixes を実行して、 (ここで "fixes" は上の Github API のレスポンスで判断していることがなんとなくわかる)
  4. git checkout fixes を実行してくれる

事がわかる。

例となるシナリオを用意して、どんな git コマンドを実行してくれるのかわかる。しらない git コマンドがあればそれは git のマニュアルで調べればいい。

コマンドごとにいくつかシナリオが用意されているので、 hub help コマンド名 するよりも、この feature ファイルを探して斜め読みするほうがずっと hub コマンドで何ができるのか具体的に理解できる。

Python 目線からの GAE/node.js Standard Environment 発表の解説

Google I/O 2018 で GAE/node.js Standard Environment が発表されました。

www.youtube.com

以下、「Python 3 早く来い!」の視点で注目点をピックアップしていきます。

9:00 頃から、 node.js Standard Environment が in a few weeks で登場すると発表

13:00 "idiomatic", You can use any module from the NPM registry you want. There is no API or language restriction. Go や Python みたいに特別な制限は無いようです。

13:33 GAE Standard のインフラの3つの新しい点を紹介していくよ。まずは "Faster than light" ビルド。 gcloud app deploy コマンドが差分アップロードするようになった。 サーバー側で npm install するんだけど、 package.json と package-lock.json に差分がないと npm install はスキップして前の node_modules をそのまま使うよ。

(発表内容から脱線)
さて、 pipenv のドキュメントの Community Integrations に "Mysterious upcoming Google Cloud product (Cloud Hosting)" があります。きっと GAE/Python 3 Standard は pipenv を使って、 npm と同じ "Faster than light" build をするんでしょうね。
(脱線おわり)

14:45 New runtime environment. スタックを上から見ていくと、 "Your code", "node_modules", "node.js" --- これはカスタマイズされてない, "OS packages" --- たとえば headless Chrome なんかがインストールされてる、 "Ubuntu". ってなってる。 node.js 以下は Google が勝手にアップデートする。

16:00 このスタックはサンドボックスで動いている。先週発表した gvisor だ。


今までの GAE Standard Environment は言語ランタイムとかライブラリにカスタマイズしてサンドボックスを提供していたので、言語の追加やアップデートがなかなかされないという欠点がありました。

node.js Standard から利用されている新ランタイムは、 gvisor で作ったサンドボックスのなかで、カスタマイズ無しの言語ランタイムが動くのが魅力です。利用できるライブラリもずっと増えるでしょうし、新しい言語が追加されたり新しいバージョンが利用可能になるのがずっと早くなることが期待できます。

RHEL 7.5 で Python 2.7 が deprecated になりました

Red Hat Enterprise Linux 7.5 がリリースされ、そのリリースノートで "Python 2 has been deprecated" とアナウンスされました。

Chapter 54. Deprecated Functionality - Red Hat Customer Portal

Python 2 has been deprecated

Python 2 will be replaced with Python 3 in the next Red Hat Enterprise Linux (RHEL) major release.

次のメジャーバージョンでは Python 2 が Python 3 に置き換えられるから、 RHEL 7.5 から Python 2.7 が deprecated 扱いになるということです。

Ubuntu 18.04 LTS では main リポジトリから Python 2.7 を排除するのが間に合わなかったのですが、次の RHEL (8?) では Python 2.7 が無くなるようです。

さて、 Python コア開発者による Python 2.7 のサポートは2020年1月1日に終了しますが、主要なLinuxディストリビューションによるサポートがいつまで続くのかがこれでほぼ確定しました。 (Ubuntu 20.04 までには main から Python 2.7 を外すのは既定路線)

2025年を待たずに延命措置も終わるようです。 R.I.P.

Homebrew の Python で何が変わって何がもとに戻ったのか

rcmdnk.com

大分混乱した状態になってしまったので、今年何が変わってきたのか、今回の変更でどこまでもどったのかを整理しておきます。

1/19

python という formula が python コマンドをインストールしなくなりました。 python コマンドを起動すると、通常は /usr/bin/python が起動するようになりました。

1.5.0 — Homebrew

3/2

python という formula が Python 3 になり、 Python 2.7 は python@2 になりました。

python formula (Python 3) が python コマンドをインストールするようになったので、 python コマンドを起動すると通常は Python 3 が起動するようになりました。これが npm の gyp とか色んな所をぶっ壊す変更になっていました。

一方 python@2 formula は keg-only になっていたので、デフォルトではコマンドがインストールされず、必要に応じて brew link --force python@2 などする必要がありました。

コマンド名以外の変更として、多くの formula から depends_on "python" が消されました。今までは依存関係で python (Python 2) がインストールされることが多かったのが、システムの Python を使うようになります。

しかし、 vim など一部の formula では depends_on "python" が残っています。これらは Python 3 に依存するようになりました。

前回の記事

3/10

python@2 が keg-only でなくなりました。 python formula は python3 コマンドだけを提供し、 python@2 formula が pythonpython2 コマンドを提供するようになりました。 python コマンドが /usr/bin/python でなく Homebrew の Python 2 を起動するということで、この点については 1/19 以前の状態にまで戻りました。

1/19 以前の状態と現在の状態を比べると、次のようになります。

  • Python 2 の formula 名が python から python@2 になり、 Python 3 の formula 名が python3 から python になった。
  • 多くのパッケージから depends_on "python" が消えた。依存で Python がインストールされることが減り、代わりに macOSの /usr/bin/python が使われるようになった。 brew install python@2 をすることで macOS ではなく Homebrew の Python 2を使うことも可能で、そうすれば今まで通りの動作になる。
  • vim, macvim など幾つかのパッケージは、 depends_on "python" のまま、 Python 2 依存から Python 3 依存に切り替わった。オプションで Python 2 を使うようにビルドすることもできるが、 bottle が提供されるのはデフォルトの Python 3 依存版。

最終的に一番妥当な形に落ち着いたと思います。

これから 2020 年に向けて、 Python 3 をサポートしているソフトウェアには depends_on "python" を追加して、 Python 2 ではなく Python 3 上で動くようにしていくと良いと思います。

このブログに乗せているコードは引用を除き CC0 1.0 で提供します。