Slide 1

Slide 1 text

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

Slide 2

Slide 2 text

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

Slide 3

Slide 3 text

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

Slide 4

Slide 4 text

1. ヤプリについて

Slide 5

Slide 5 text

ヤプリについて

Slide 6

Slide 6 text

ヤプリについて

Slide 7

Slide 7 text

ヤプリについて

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

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

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

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

Slide 14

Slide 14 text

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

Slide 15

Slide 15 text

● 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") }

Slide 16

Slide 16 text

● できれば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 }

Slide 17

Slide 17 text

● できれば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” エラー

Slide 18

Slide 18 text

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

Slide 19

Slide 19 text

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

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

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

Slide 22

Slide 22 text

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") } }

Slide 23

Slide 23 text

保存は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

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

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]);

Slide 26

Slide 26 text

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]); ● 空の配列が与えられてしまうと文字列の [] になる!

Slide 27

Slide 27 text

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) } }

Slide 28

Slide 28 text

● 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, ) } // ... }

Slide 29

Slide 29 text

4. Goに移行してみて

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

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

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

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