$30 off During Our Annual Pro Sale. View Details »

創業以来のPHPシステムが生み出した混沌をGoへの移行で乗り越えた話

moriuss
April 28, 2022
1.1k

 創業以来のPHPシステムが生み出した混沌をGoへの移行で乗り越えた話

Go Conference 2022 Springでの登壇資料です。
https://gocon.jp/2022spring/sessions/a2-c/

moriuss

April 28, 2022
Tweet

Transcript

  1. 創業以来のPHPシステムが生み出した
    混沌をGoへの移行で乗り越えた話
    株式会社ヤプリ 森谷洋祐

    View Slide

  2. ● 株式会社ヤプリ 森谷洋祐
    ● サーバーサイドエンジニア
    ● ヤプリは4年目
    ● Go歴も4年目
    ● 前職はPHPを2年ほど
    自己紹介

    View Slide

  3. 1. ヤプリについて
    2. アーキテクチャと移行前のつらみ
    3. 混沌が蔓延るDBの値をGoで解釈する戦い
    4. Goに移行してみて
    目次

    View Slide

  4. 1. ヤプリについて

    View Slide

  5. ヤプリについて

    View Slide

  6. ヤプリについて

    View Slide

  7. ヤプリについて

    View Slide

  8. ヤプリについて
    https://news.yappli.co.jp/n/n91f4b67ffd80

    View Slide

  9. 2. アーキテクチャと移行前のつらみ

    View Slide

  10. ヤプリのアーキテクチャ
    CMS
    Native用APIサーバー
    PHP (2013〜2020)
    Go + Nuxt (2018〜)
    PHP (2013〜)
    今回はここの話
    DB
    アプリ運用者
    アプリ
    コンテンツ編集
    コンテンツ取得

    View Slide

  11. ● フレームワークを使わない生のPHP
    ● 創業当時のCTOがほぼ1人で書いた、開発速度最優先のコード
    ○ テスト無し
    ○ 静的解析無し
    ○ クラスほぼ無し
    ○ 関数ほぼ無し
    ● 創業フェーズでは最適
    ● しかし拡大フェーズでは負債
    移行前のつらみ

    View Slide

  12. ● メモリ・CPU効率への期待
    ● 静的解析の恩恵
    ● 学習コストの低さ
    ● 複雑なコードの生まれにくさ
    ● 社内で小さな導入実績があった
    ● メンバーの興味
    ● 将来の採用力向上
    Goの選定理由

    View Slide

  13. 3. 混沌が蔓延るDBの値をGoで解釈する戦い

    View Slide

  14. ● 例えばこんなテーブル
    ● chaos_ jsonの中身
    CREATE TABLE "contents" (
    "id" integer,
    "chaos_json" text,
    PRIMARY KEY (id)
    );
    DBには大量のJSONカラム(text型)
    {
    "name": "hoge"
    }

    View Slide

  15. ● sqlxパッケージのSelectで構造体に当てはめたい
    ● ChaosJsonフィールドはstring???
    Go側の構造体定義
    type Content struct {
    ID int64 `db:"id"`
    ChaosJson string `db:"chaos_json"`
    }
    func main() {
    db, _ := sqlx.Open("driver", "dsn")
    var contents []*Content
    _ = db.Select(&contents, "SELECT id, chaos_json FROM contents")
    }

    View Slide

  16. ● できればmapで定義したい
    ● もっと言えば構造体で定義したい
    Go側の構造体定義
    type Content struct {
    ID int64 `db:"id"`
    ChaosJson map[string]string `db:"chaos_json"`
    }
    type Content struct {
    ID int64 `db:"id"`
    ChaosJson *JsonStruct `db:"chaos_json"`
    }
    type JsonStruct struct {
    Name string
    }

    View Slide

  17. ● できればmapで定義したい
    ● もっと言えば構造体で定義したい
    Go側の構造体定義
    type Content struct {
    ID int64 `db:"id"`
    ChaosJson map[string]string `db:"chaos_json"`
    }
    type Content struct {
    ID int64 `db:"id"`
    ChaosJson *JsonStruct `db:"chaos_json"`
    }
    type JsonStruct struct {
    Name string
    }
    “unsupported Scan” エラー
    “unsupported Scan” エラー

    View Slide

  18. func (db *DB) Select(dest interface{}, query string, args ...interface{}) error {
    return Select(db, dest, query, args...)
    }
    func Select(q Queryer, dest interface{}, query string, args ...interface{}) error {
    rows, err := q.Queryx(query, args...)
    // ...
    return scanAll(rows, dest, false)
    }
    func scanAll(rows rowsi, dest interface{}, structOnly bool) error {
    // destの種別判定や、reflectでゴニョゴニョ頑張っている処理
    // ...
    for rows.Next() {
    // destが構造体のsliceの場合、valuesは構造体の各フィールド([]interface{}型)
    err = rows.Scan(values...)
    // ...
    }
    }
    sqlxパッケージが構造体に値を入れている仕組みを見てみる
    github.com/jmoiron/sqlx

    View Slide

  19. sqlパッケージのRows.Scanを見てみる
    func (rs *Rows) Scan(dest ...any) error {
    // Mutexのロック処理など
    // ...
    // 現在行のカラムでループ
    for i, sv := range rs.lastcols {
    err := convertAssignRows(dest[i], sv, rs)
    // ...
    }
    return nil
    }
    database/sql

    View Slide

  20. sqlパッケージのRows.Scanを見てみる
    func convertAssignRows(dest, src any, rows *Rows) error {
    // Common cases, without reflect.
    switch s := src.(type) {
    case string:
    switch d := dest.(type) {
    case *string:
    if d == nil {
    return errNilPtr
    }
    *d = s
    return nil
    case *[]byte:
    if d == nil {
    return errNilPtr
    }
    *d = []byte(s)
    return nil
    case *RawBytes:
    if d == nil {
    return errNilPtr
    }
    *d = append((*d)[:0], s...)
    return nil
    }
    case []byte:
    switch d := dest.(type) {
    case *string:
    if d == nil {
    return errNilPtr
    }
    *d = string(s)
    return nil
    case *any:
    if d == nil {
    return errNilPtr
    }
    *d = cloneBytes(s)
    return nil
    case *[]byte:
    if d == nil {
    return errNilPtr
    }
    *d = cloneBytes(s)
    return nil
    case time.Time:
    switch d := dest.(type) {
    case *time.Time:
    *d = s
    return nil
    case *string:
    *d = s.Format(time.RFC3339Nano)
    return nil
    case *[]byte:
    if d == nil {
    return errNilPtr
    }
    *d = []byte(s.Format(time.RFC3339Nano))
    return nil
    case *RawBytes:
    if d == nil {
    return errNilPtr
    }
    *d = s.AppendFormat((*d)[:0], time.RFC3339Nano)
    return nil
    }
    database/sql

    View Slide

  21. sqlパッケージのRows.Scanを見てみる
    func convertAssignRows(dest, src any, rows *Rows) error {
    // ...
    if scanner, ok := dest.(Scanner); ok {
    return scanner.Scan(src)
    }
    // ...
    }
    type Scanner interface {
    Scan(src any) error
    }
    database/sql

    View Slide

  22. Scanメソッドを実装
    type Content struct {
    ID int64 `db:"id"`
    ChaosJson *JsonStruct `db:"chaos_json"`
    }
    type JsonStruct struct {
    Name string `json:"name"`
    }
    func (j *JsonStruct) Scan(src any) error {
    switch src := src.(type) {
    case string:
    return json.Unmarshal([]byte(src), j)
    default:
    return errors.New("unexpected type")
    }
    }

    View Slide

  23. 保存はdriver.Valuerで
    type Value any
    type Valuer interface {
    Value() (Value, error)
    }
    type Content struct {
    ID int64 `db:"id"`
    ChaosJson *JsonStruct `db:"chaos_json"`
    }
    type JsonStruct struct {
    Name string `json:"name"`
    }
    func (o *JsonStruct) Value() (driver.Value, error) {
    return json.Marshal(o)
    }
    database/sql/driver

    View Slide

  24. ここからが本当の戦いの始まり

    View Slide

  25. PHP側でJSONカラムを保存するコード
    ● (再掲)移行前のコードはクラスほぼ無し
    ● 全ては連想配列
    ● 組み込みのjson_encode関数により連想配列をjson文字列に変換
    $chaos_json = ['name' => 'hoge'];
    $chaos_json_str = json_encode($chaos_json); // {"name": "hoge"}
    $pdo = new PDO('dsn');
    $stmt = $pdo->prepare('INSERT INTO contents (`id`, `chaos_json`) VALUES (?, ?)');
    $stmt->execute([$id, $chaos_json_str]);

    View Slide

  26. PHP側でJSONカラムを保存するコード
    $chaos_json = [];
    $chaos_json_str = json_encode($chaos_json); // []
    $pdo = new PDO('dsn');
    $stmt = $pdo->prepare('INSERT INTO contents (`id`, `chaos_json`) VALUES (?, ?)');
    $stmt->execute([$id, $chaos_json_str]);
    ● 空の配列が与えられてしまうと文字列の [] になる!

    View Slide

  27. Scanで腐敗防止
    func (j *JsonStruct) Scan(src any) error {
    switch src := src.(type) {
    case string:
    // 混沌を感じ始めるがScanを拡張すれば根元で腐敗防止できる
    if src == "[]" {
    return nil
    }
    return json.Unmarshal([]byte(src), j)
    }
    }

    View Slide

  28. ● JSON内にnumberまたはstringを取り
    うるフィールドがいる
    ● boolで扱いたいフィールドがstringで
    保存されている
    ● 微妙にRFC3339じゃない時刻フォー
    マットが紛れている
    ● etc…
    ● とはいえ全てScanやUnmarshalJSON
    などに閉じ込められた🎉
    その他にも「創業以来の秘伝のタレ」が故の苦しみが
    type NumberOrString string
    func (s *NumberOrString) UnmarshalJSON(src []byte) error
    {
    if err := json.Unmarshal(src, s); err != nil {
    // intで読み取ってstring変換
    // ...
    }
    return nil
    }
    type ChaosTime time.Time
    func (c *ChaosTime) Scan(src interface{}) error {
    // ...
    t, err = time.Parse(time.RFC3339, srcString)
    if err != nil {
    t, err = time.Parse(
    "2006-01-02T15:04:05Z0700",
    srcString,
    )
    }
    // ...
    }

    View Slide

  29. 4. Goに移行してみて

    View Slide

  30. ● 型がある素晴らしさ
    ● エラーの握り潰しが激減
    ● 環境構築が楽
    ● 標準パッケージを読むのが楽
    技術面で良かったこと

    View Slide

  31. ● 採用力UP!
    ○ この半年でサーバーサイドが10人→15人
    ● ポテンシャルの高い若手層をGopherとして育てる体制が回り始める
    ○ 例: 社内勉強会「GoStudy」で標準パッケージのコードリーディング
    ○ tenntenn Conference 2022でも事例として紹介されました!
    採用面で良かったこと
    https://tenn.in/codereading

    View Slide

  32. ● 持ち回りでファシリテーターが目的と対象
    範囲を事前に決める
    ● 当日Zoomで集まる
    ● Slackにわいわいスレを立てる
    ● 前半30分、各々がもくもくリーディングを
    しつつSlackでわいわいする
    ● 後半30分、スレの内容などなどを元にZoom
    で盛り上がる
    「GoStudy」でのコードリーディング会

    View Slide

  33. これからのYappliを一緒に作りませんか?
    カジュアル面談はこちら👉
    Tech Blog👉
    ©tottie / Renée French

    View Slide