Lua 모듈

런타임 모듈은 새로운 기능으로 Lua 환경을 확장합니다. 모듈은 결정론적 유틸리티, I/O 작업, 또는 외부 시스템으로 yield하는 비동기 명령을 제공할 수 있습니다.

Lua 런타임 구현은 향후 버전에서 변경될 수 있습니다.

모듈 정의

모든 모듈은 luaapi.ModuleDef를 사용합니다:

var Module = &luaapi.ModuleDef{
    Name:        "mymodule",
    Description: "내 커스텀 모듈",
    Class:       []string{luaapi.ClassDeterministic},
    Types:       ModuleTypes,  // 도구를 위한 타입 정의
    Build: func() (*lua.LTable, []luaapi.YieldType) {
        mod := lua.CreateTable(0, 2)
        mod.RawSetString("hello", lua.LGoFunc(helloFunc))
        mod.RawSetString("greet", lua.LGoFunc(greetFunc))
        mod.Immutable = true
        return mod, nil
    },
}

Build 함수는 반환합니다:

  • 내보낸 함수가 있는 모듈 테이블
  • 비동기 작업을 위한 yield 타입 목록 (또는 nil)

모듈 테이블은 한 번 빌드되고 모든 Lua 상태에서 재사용을 위해 캐시됩니다.

모듈 분류

Class 필드는 모듈을 사용할 수 있는 위치를 결정합니다:

클래스 설명
ClassDeterministic 같은 입력은 항상 같은 출력 생성
ClassNondeterministic 출력이 다름 (시간, 랜덤)
ClassIO 외부 I/O 작업
ClassNetwork 네트워크 작업
ClassStorage 데이터 지속성
ClassWorkflow 워크플로우 안전 작업

ClassDeterministic만 태그된 모듈은 워크플로우 안전합니다. I/O나 네트워크 클래스를 추가하면 모듈이 함수와 프로세스로 제한됩니다.

함수 노출

함수는 func(l *lua.LState) int 시그니처를 가지며 반환 값은 스택에 푸시된 값의 수입니다:

func greetFunc(l *lua.LState) int {
    name := l.CheckString(1)           // 필수 인자
    greeting := l.OptString(2, "Hello") // 기본값이 있는 선택적

    l.Push(lua.LString(greeting + ", " + name + "!"))
    return 1
}
메서드 설명
l.CheckString(n) 위치 n의 필수 문자열
l.CheckInt(n) 필수 정수
l.CheckNumber(n) 필수 숫자
l.CheckTable(n) 필수 테이블
l.OptString(n, def) 기본값이 있는 선택적 문자열
l.OptInt(n, def) 기본값이 있는 선택적 정수

테이블

Go와 Lua 사이에 전달되는 테이블은 기본적으로 가변입니다. 모듈 내보내기 테이블은 불변으로 표시해야 합니다:

mod := lua.CreateTable(0, 5)
mod.RawSetString("func1", lua.LGoFunc(func1))
mod.Immutable = true  // Lua가 내보내기를 수정하는 것 방지

데이터 테이블은 일반 사용을 위해 가변으로 유지됩니다:

result := l.CreateTable(0, 3)
result.RawSetString("name", lua.LString("value"))
result.RawSetString("count", lua.LNumber(42))
l.Push(result)

타입 시스템

모듈은 두 개의 별도이지만 상호 보완적인 타이핑 메커니즘을 사용합니다.

타입 정의 (도구)

Types 필드는 IDE 지원 및 문서화를 위한 타입 시그니처를 제공합니다:

func ModuleTypes() *types.TypeManifest {
    m := types.NewManifest("mymodule")

    objectType := &types.InterfaceType{
        Name: "mymodule.Object",
        Methods: map[string]*types.FunctionType{
            "get_value": types.NewFunction(nil, []types.Type{types.String}),
            "set_value": types.NewFunction([]types.Type{types.String}, nil),
        },
    }

    m.DefineType("Object", objectType)
    m.SetExport(moduleType)
    return m
}

사용 가능한 타입 구조:

타입 설명
types.String 문자열 프리미티브
types.Number 숫자 값
types.Boolean 불리언 값
types.Any 모든 Lua 값
types.LuaError 에러 타입
types.Optional(t) 타입 t의 선택적 값
types.InterfaceType 메서드가 있는 객체
types.FunctionType 파라미터/반환이 있는 함수 시그니처
types.RecordType 필드가 있는 구조체 유사 타입
types.TableType 키/값 타입이 있는 테이블

함수 시그니처는 가변 파라미터를 지원합니다:

// (string, ...any) -> (string, error?)
types.FunctionType{
    Params:   []types.Type{types.String},
    Variadic: types.Any,
    Returns:  []types.Type{types.String, types.Optional(types.LuaError)},
}

전체 타입 시스템은 go-lua의 types 패키지를 참조하세요.

UserData 바인딩 (런타임)

RegisterTypeMethods는 실제 Go-to-Lua 바인딩을 생성합니다:

func init() {
    value.RegisterTypeMethods(nil, "mymodule.Object",
        map[string]lua.LGoFunc{
            "__tostring": objectToString,  // 메타메서드
        },
        map[string]lua.LGoFunc{
            "get_value": objectGetValue,   // 일반 메서드
            "set_value": objectSetValue,
        },
    )
}

메타테이블은 불변이고 스레드 안전 재사용을 위해 전역적으로 캐시됩니다.

시스템 목적 정의
타입 정의 IDE, 문서, 타입 검사 시그니처
UserData 바인딩 런타임 메서드 호출 실행 가능한 함수

비동기 작업

외부 시스템을 기다리는 작업의 경우 결과 대신 yield를 반환합니다. yield는 Go 핸들러로 디스패치되고 핸들러가 완료되면 프로세스가 재개됩니다.

Yield 정의

모듈의 Build 함수에서 yield 타입을 선언합니다:

Build: func() (*lua.LTable, []luaapi.YieldType) {
    mod := lua.CreateTable(0, 1)
    mod.RawSetString("fetch", lua.LGoFunc(fetchFunc))
    mod.Immutable = true

    yields := []luaapi.YieldType{
        {Sample: &FetchYield{}, CmdID: myapi.FetchCommand},
    }

    return mod, yields
}

Yield 생성

일반 반환 값 대신 yield를 시그널하려면 -1을 반환합니다:

func fetchFunc(l *lua.LState) int {
    url := l.CheckString(1)

    yield := AcquireFetchYield()
    yield.URL = url

    l.Push(yield)
    return -1  // 스택 카운트가 아닌 yield 시그널
}

Yield 구현

Yield는 Lua 값과 디스패처 명령을 브릿지합니다:

type FetchYield struct {
    *myapi.FetchCmd
}

func (y *FetchYield) String() string              { return "<fetch_yield>" }
func (y *FetchYield) Type() lua.LValueType        { return lua.LTUserData }
func (y *FetchYield) CmdID() dispatcher.CommandID { return myapi.FetchCommand }
func (y *FetchYield) ToCommand() dispatcher.Command { return y.FetchCmd }
func (y *FetchYield) Release() { releaseFetchYield(y) }

func (y *FetchYield) HandleResult(l *lua.LState, data any, err error) []lua.LValue {
    if err != nil {
        return []lua.LValue{lua.LNil, lua.NewLuaError(l, err.Error())}
    }
    resp := data.(*myapi.FetchResponse)
    return []lua.LValue{lua.LString(resp.Body), lua.LNil}
}

디스패처는 명령을 핸들러로 라우팅합니다. 핸들러 구현은 명령 디스패치를 참조하세요.

에러 처리

구조화된 에러를 사용하여 두 번째 값으로 에러를 반환합니다:

func myFunc(l *lua.LState) int {
    result, err := doSomething()
    if err != nil {
        lerr := lua.NewLuaError(l, err.Error()).
            WithKind(lua.Internal).
            WithRetryable(true)
        l.Push(lua.LNil)
        l.Push(lerr)
        return 2
    }

    l.Push(lua.LString(result))
    l.Push(lua.LNil)
    return 2
}

보안

민감한 작업을 수행하기 전에 권한을 확인합니다:

func myFunc(l *lua.LState) int {
    ctx := l.Context()

    if !security.IsAllowed(ctx, "mymodule.action", resource, nil) {
        l.Push(lua.LNil)
        l.Push(lua.NewLuaError(l, "permission denied").WithKind(lua.PermissionDenied))
        return 2
    }

    // 작업 진행
}

테스팅

기본 모듈 테스트는 구조와 동기 함수를 검증합니다:

func TestModule(t *testing.T) {
    l := lua.NewState()
    defer l.Close()

    mod, _ := Module.Build()
    l.SetGlobal("mymodule", mod)

    err := l.DoString(`
        local m = mymodule
        assert(m.hello() == "Hello, World!")
    `)
    if err != nil {
        t.Fatal(err)
    }
}

Yield가 있는 모듈 테스팅

yield 함수를 사용하는 Lua 코드를 테스트하려면 필요한 디스패처가 있는 최소 스케줄러를 생성합니다:

type testScheduler struct {
    *actor.Scheduler
    clock   *clock.Dispatcher
    mu      sync.Mutex
    pending map[string]chan *runtime.Result
}

func newTestScheduler() *testScheduler {
    ts := &testScheduler{pending: make(map[string]chan *runtime.Result)}
    reg := scheduler.NewRegistry()

    // 모듈이 사용하는 yield를 위한 디스패처 등록
    clockSvc := clock.NewDispatcher()
    clockSvc.RegisterAll(func(id dispatcher.CommandID, h dispatcher.Handler) {
        reg.Register(id, h)
    })
    ts.clock = clockSvc

    ts.Scheduler = actor.NewScheduler(reg, actor.WithWorkers(4), actor.WithLifecycle(ts))
    return ts
}

func (ts *testScheduler) OnComplete(_ context.Context, p pid.PID, result *runtime.Result) {
    ts.mu.Lock()
    ch, ok := ts.pending[p.UniqID]
    delete(ts.pending, p.UniqID)
    ts.mu.Unlock()
    if ok {
        ch <- result
    }
}

func (ts *testScheduler) Execute(ctx context.Context, p pid.PID, proc process.Process,
    method string, input payload.Payloads) (*runtime.Result, error) {
    resultCh := make(chan *runtime.Result, 1)
    ts.mu.Lock()
    ts.pending[p.UniqID] = resultCh
    ts.mu.Unlock()

    _, err := ts.Scheduler.Submit(ctx, p, proc, method, input)
    if err != nil {
        return nil, err
    }

    select {
    case result := <-resultCh:
        return result, nil
    case <-ctx.Done():
        return nil, ctx.Err()
    }
}

테스트하는 모듈로 Lua 스크립트에서 프로세스를 생성합니다:

func bindMyModule(l *lua.LState) {
    tbl, _ := mymodule.Module.Build()
    l.SetGlobal(mymodule.Module.Name, tbl)
}

func newLuaProcess(script string) *engine.Process {
    proto, _ := lua.CompileString(script, "test.lua")
    return engine.NewProcess(
        engine.WithProto(proto),
        engine.WithModuleBinder(bindMyModule),
    )
}

func TestMyModuleYields(t *testing.T) {
    sched := newTestScheduler()
    sched.Start()
    defer sched.Stop()

    script := `
        local result = mymodule.fetch("http://example.com")
        return result.status
    `

    ctx, _ := ctxapi.OpenFrameContext(context.Background())
    proc := newLuaProcess(script)

    result, err := sched.Execute(ctx, pid.PID{UniqID: "test"}, proc, "", nil)
    if err != nil {
        t.Fatal(err)
    }
    // 결과에 대해 Assert
}

완전한 예제는 runtime/lua/modules/time/integration_test.go를 참조하세요.

참고