はじめに
本記事は「QualiArts Advent Callender 2023」の15日目の記事になります。
23新卒のバックエンドエンジニアの水村です。
今回は、私が開発に関わっているプロジェクトで使用しているマスタデータへの参照を高速化するキャッシュと、キャッシュしたデータをコード上で参照する関数の自動生成についてまとめます。
マスタデータのキャッシュとは
プロジェクトでは、DBに保存・参照するデータとして、大まかに2種類存在します。
- マスタデータ
- ユーザデータ
特にマスタデータは静的なデータで、クライアントからのAPIリクエストなどでは参照するのみで、更新することはありません。
そのため、本プロジェクトでは起動時にマスタデータをDBから一括取得し、メモリ上にキャッシュすることで参照を高速化しています。
具体的なキャッシュ方法と動機
DBからキャッシュする際には、ただSliceでレコードを保存するだけではコード上では使用しづらいことが多いです。
例えば、以下のような構造を持つテーブルがあります。
CREATE TABLE `test_table` (
`id` VARCHAR(255) NOT NULL,
`number` INT NOT NULL,
`text` VARCHAR(255) NOT NULL,
PRIMARY KEY (`id`,`number`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
PKはid
とnumber
のため、一意のレコードを参照したい場合にはid
とnumber
を用いて参照する必要があります。
また、id
のみから複数のレコードを参照したい場合も考えられます。
このようなユースケースを考えた時に、Sliceを逐一探索するのは効率が悪いため、以下のように複数の持ち方で探索を効率化します。
package testtable
type TestTable struct {
ID string
Number int32
Text string
}
type Slice []*TestTable
type Cache struct {
slice Slice
mapByPK map[string]map[int]*TestTable // IDとNumberのMap
mapByID map[string]Slice // IDのSliceMap
}
このようにキャッシュすることで、探索の必要なく参照が可能になります。
ただし、テーブル構造によってPKの数・カラム名・型が異なるので、テーブルを追加するたびにキャッシュの実装を加えるのは工数がかかります。
そこで、コード上でテーブルのスキーマ情報を取得し、レコードのSliceやPKごとのMapでキャッシュを保存・参照する実装を自動生成するテンプレートを作成することで、この工数を削減します。
スキーマ情報をGolangのコード上で用いる
本プロジェクトでは、sqlboilerを用いてデータベースからテーブル情報やテーブルのエイリアス情報を読み取ります。
この際、テーブル情報にはテーブルやカラム名・型情報、PKかどうかなどを持ちますが、そのままのカラム名ではGolangの変数の命名に合いません。
エイリアス情報はデータベースのカラム名と、カラムをパスカルケースに変換した名前をマッピングしています(configで変更可能)。
- id -> ID
- number -> Number
- text -> Text
これらをテンプレートファイルで用いることで、Golangの命名規則で変数や関数などを自動生成します。
テーブルをキャッシュし、参照する部分を自動生成する
テンプレートでは、主に3つの部分を自動生成するよう構成します。
- データベースのレコードをキャッシュするSliceやMapを持つ構造体
- 実際にSliceやMapにデータをキャッシュする関数
- キャッシュしたデータを参照する関数
これらを自動生成するために、上述のテーブル・エイリアス情報を用いてテンプレートファイルを作成していきます。
カラム名を取得して、変数名・関数名を作成する
type Cache struct {
slice Slice
mapByPK map[string]map[int]*TestTable // IDとNumberのMap
mapByID map[string]Slice // IDのSliceMap
}
先ほど提示したキャッシュの例を再度記載しています。例えば、slice
やmapByPK
は全てのキャッシュで約束されている変数名なので、テーブルごとに変数名は変わりませんが、mapByID
については異なります。
これは、PKであるid
とnumber
のうち、id
のみをキーに複数のレコードをSliceで持っていますが、このid
が異なるカラム名の場合にはmapByID
も異なる命名でなければいけません。
そこで、以下のようなテンプレートを作成することで、PKから変数名を自動生成します。
{{- $alias := .Aliases.Table .Table.Name}}
{{$lastIndex := (sub (len .Table.PKey.Columns) 1)}}
{{$pkColumns := $.Table.PKey.Columns}}
{{- range $i, $pk := $pkColumns}}
{{- $cols := slice $pkColumns 0 (add $i 1) }}
{{- $colsLastIndex := (sub (len $cols) 1) }}
{{- if ne $i $lastIndex }}
mapBy{{range $j, $pk2 := $cols}}{{$alias.Column $pk2}}{{- if ne $j $colsLastIndex }}And{{end}}{{end}}
{{- else}}
mapByPK
{{- end -}}
{{- end }}
このようにテンプレートを作成することで、PKを全てキーにもつmapの場合にはmapByPK
、それ以外の場合には対応するPKのカラム名で変数名を自動生成します。
同様に、キャッシュを参照する関数名についても、ほぼ同じ方法で自動生成することが可能です。
カラムの型情報を取得して、変数の型を自動生成する
上記の変数名を生成しただけでは、型情報が落ちているためこれも自動生成する必要があります。
提示した例では、mapByPK
は全てのPKをキーにした二重Map、それ以外はID
をキーに、要素をSliceにしたMapを型に持っています。
以下に型情報を自動生成する例を示します。
{{- $alias := .Aliases.Table .Table.Name}}
{{$lastIndex := (sub (len .Table.PKey.Columns) 1)}}
{{$pkColumns := $.Table.PKey.Columns}}
{{- range $i, $pk := $pkColumns}}
{{- $cols := slice $pkColumns 0 (add $i 1) }}
{{- $colsLastIndex := (sub (len $cols) 1) }}
{{- if ne $i $lastIndex }}
{{range $pk2 := $cols}}{{$column := $.Table.GetColumn $pk2}}map[{{$column.Type}}]{{end}}Slice
{{- else}}
mapByPK {{range $pk2 := $cols}}{{$column := $.Table.GetColumn $pk2}}map[{{$column.Type}}]{{end}}*{{$alias.UpSingular}}
{{- end -}}
{{- end -}}
自動生成した型情報と、上述の変数名の部分を合わせることで、型情報をもつ変数を自動生成することが可能です。
実際に自動生成したファイル
以上より、テンプレートをもとにキャッシュを行うファイルを自動生成した結果が以下です。
package testtable
type Cache struct {
slice Slice
mapByID map[string]Slice
mapByPK map[string]map[int32]*TestTable
}
func NewCache(records Slice) *Cache {
mapByID := make(map[string]Slice, len(records))
mapByPK := make(map[string]map[int32]*TestTable, len(records))
for _, record := range records {
mapByID[record.ID] = append(mapByID[record.ID], record)
if _, ok := mapByPK[record.ID]; !ok {
mapByPK[record.ID] = make(map[int32]*TestTable, len(records))
}
mapByPK[record.ID][record.Number] = record
}
return &Cache{
slice: records,
mapByID: mapByID,
mapByPK: mapByPK,
}
}
func (c *Cache) GetList() Slice {
return c.slice
}
func (c *Cache) GetByPK(id string, number int32) *TestTable {
return c.mapByPK[id][number]
}
func (c *Cache) GetByID(id string) Slice {
return c.mapByID[id]
}
まとめ
sqlboilerでテーブル情報を読み取り、Go Templateでそれらを使用し、DBのレコードをキャッシュするファイルの自動生成をする方法をまとめました。
実際には、キャッシュを取る部分の生成にはテンプレートファイルをさらに記載する必要がありますが、本記事の情報をもとに作成が可能です。