ABEMAの広告システムのバックエンド開発をしている黒崎 (@kuro_m88) です。
GoでLuaのユニットテストの実装を試みた事例をご紹介します。
GoでLuaのユニットテストを書くモチベーション
端的に言うとGoで書いているアプリケーションサーバでValkeyを使っており、Valkeyの機能を拡張するのにLuaで処理を記述する必要が出たためです。
Valkeyとは
Valkeyはインメモリデータベースで、2024年に諸般の事情からRedisをforkする形でLinux Foundation傘下で開発がはじまりました。
ValkeyはRedisからforkされたため大半の機能はRedisと互換性がありますが、AWS, Google Cloud, Oracle Cloudなどの大手企業が開発に参加しており、積極的にサポートする姿勢が見てとれます。
Amazon ElastiCache と Amazon MemoryDB の Valkey サポートを発表
Valkey(Redis) Functions
Valkey FunctionsはValkey内で繰り返し使われるロジックを記述し管理できる仕組みです。複数のValkeyコマンドの実行を一度の呼び出しにまとめることでコマンドのラウンドトリップによるオーバーヘッドを削減したり、コマンドの実行結果によって処理を分岐、ループしたり柔軟な処理ができます。
以下は常に1をレスポンスするFunctionの記述例です。現在のところFunctionを記述するプログラミング言語としてはLuaのみがサポートされています。
#!lua name=mylib
server.register_function(
'one',
function(keys, args) return 1 end
)
なぜLuaのテストをGoで書くのか
実は数年前に似たような実装に取り組んだのですが、実のところLuaのテストは書いていませんでした。
当時はそこまで思考が及んでいなかったのと、かなりシンプルな実装しかなかったため、テストは実際にRedisを利用するだけで十分だったためです。
今回はABEMAの広告システムにおいてValkeyのHashesやSorted Setsを利用して独自のデータ型を構築し、それらをクライアントからは低レベルな構造を意識せずに効率よくデータ操作を行うためにValkey Functionsで抽象化しようとしていました。そのためLuaの処理が複雑になりそうだったためテストを書きやすくする方法を考えました。
大半のサーバサイドのアプリケーションはGoで記述されており、Valkey Functionsの登録や呼び出しはGoで行うことからGoでValkey Functionsを管理し、LuaのテストもGoのユニットテストで同時に実行されるようにするのは理にかなっていると考え、検証しました。
Lua VMのGo実装
簡易にテストを回せるようにGoからLuaが実行でき、Luaの中からGoの関数が呼び出せ、それらをLua moduleとしても登録可能であり、CGOに依存しないことを要件としました。
いくつか既存実装を探しましたが、今回は
https://github.com/yuin/gopher-lua
が要件に合いそうだったため、こちらを採用しました。
Lua moduleをモックする
Valkey Functions内では server.call(command [,arg...])
等の関数が事前ロードされており、これによりValkeyのコマンドがスクリプト内から呼び出せるようになっています。Lua実装をGoからテストするためにはこの関数をGoでモックしてテストケースによって任意の応答を挿入できるようにする必要があります。
gopher-luaには前述のとおりLuaスクリプト内からGoを呼び出す仕組みがあるため、Lua内からValkeyを呼び出す時の関数を挿入できるようにしました。
package main
import (
lua "github.com/yuin/gopher-lua"
)
type valkeyModuleMock struct {
exports map[string]lua.LGFunction
}
type ValkeyCommandCall func(cmd string, args []lua.LValue) ([]lua.LValue, error)
func NewValkeyModuleMock(f ValkeyCommandCall) *valkeyModuleMock {
exports := map[string]lua.LGFunction{
"call": call(f),
}
return &valkeyModuleMock{
exports: exports,
}
}
func (v *valkeyModuleMock) Load(L *lua.LState) error {
L.PreloadModule("valkey", v.loader)
return L.DoString("server = require('valkey')")
}
func (v *valkeyModuleMock) loader(L *lua.LState) int {
mod := L.SetFuncs(L.NewTable(), v.exports)
L.Push(mod)
return 1
}
func call(f ValkeyCommandCall) lua.LGFunction {
return func(L *lua.LState) int {
cmd := L.Get(1).String()
args := make([]lua.LValue, 0, L.GetTop()-1)
for i := 2; i <= L.GetTop(); i++ {
args = append(args, L.Get(i))
}
res, err := f(cmd, args)
if err != nil {
L.RaiseError("valkey call error: %v", err)
return 0
}
for _, r := range res {
L.Push(r)
}
return len(res)
}
}
実際にはserver.pcall
等もあるため、似たような要領で必要に応じて他の関数もモックできるようにする必要があります。
Luaのユニットテストを書く
準備ができたので、実際にこれらを利用してユニットテストを書いてみます。
以下がテストコードです。
package main
import (
"testing"
"github.com/stretchr/testify/assert"
lua "github.com/yuin/gopher-lua"
)
func TestValkeyFunction(t *testing.T) {
L := lua.NewState()
defer L.Close()
// GETコマンドの呼び出しをモック
mockCall := func(cmd string, args []lua.LValue) ([]lua.LValue, error) {
assert.Equal(t, "GET", cmd)
assert.ElementsMatch(t, []lua.LValue{lua.LString("test_key")}, args)
return []lua.LValue{lua.LNumber(123)}, nil // 123を返す
}
m := NewValkeyModuleMock(mockCall)
err := m.Load(L)
assert.NoError(t, err)
// Luaスクリプト本体
err = L.DoString(`
function f()
return server.call("GET", "test_key")
end
`)
assert.NoError(t, err)
// Lua関数を呼び出す
err = L.CallByParam(
lua.P{
Fn: L.GetGlobal("f"),
NRet: 1,
Protect: true,
},
)
assert.NoError(t, err)
result := L.Get(-1) // 関数の戻り値を取得
assert.Equal(t, lua.LTNumber, result.Type()) // 戻り値は数値
assert.Equal(t, "123", result.String()) // 戻り値は123
}
本物のValkey serverに一切依存せずValkey Functionsの実装のテストが書けることがおわかりいただけるかと思います。
1つのLuaスクリプト内で複数のValkeyコマンドを呼び出す場合はモック関数内で第一引数(Valkeyのコマンド名が入る)や後続の引数の値を見てモックする内容を分けたり、呼び出し回数をカウントして呼び出される順序によってアサーションを入れることができます。
さいごに
Valkey Functionsの記述が複雑になってもユニットテストを記述することで信頼性を担保する方法をご紹介いたしました。テストコードをテスト対象の実装と異なるプログラミング言語で書くのも悪くないですね。
ちなみに私がRedisを初めて知ったきっかけは13年前、まだ私が大学生だった時に
redis、それは危険なほどのスピード | サイバーエージェント 公式エンジニアブログ
の記事を読んだのがきっかけでした。当時その記事がサイバーエージェントのテックブログだとは認識してませんでしたし、今では筆者のWataruさんと同じチームで働くことになってまた自分がRedis(Valkey)の記事を書いているのは面白いですね。
2025年もValkeyで危険なほどのスピードを実装していこうと思います。