Skip to content

Modules

core.IModule ให้ feature หนึ่งตัวประกาศทุกอย่างที่มันต่อเข้า service ไว้ใน package เดียว — route, job, cron, MQ consumer, health check, background work — แล้ว composition root เสียบ เข้า Runner บรรทัดเดียว

go
mods, err := core.NewModules(home.New(), auth.New(usersFor), user.New(), note.New())

core.NewRunner(app,
    core.RunHTTP(e),
    core.RunScheduler(sc),
    core.RunJobs(sc.Runner()),
    core.RunModules(mods),
).Run()

ทำไมต้องมี

Project Structure จัดโค้ดตาม feature ไปแล้ว แต่ตัว composition root ยัง จัดตาม layer อยู่ — การเพิ่ม feature หนึ่งตัวต้องแตะ 3–4 ไฟล์:

go
// cmd/api.go
note.NewNoteHTTP(e)                        // route

// cmd/worker.go
note.RegisterNoteJobs(sc)                  // cron

// cmd/consumer.go
note.RegisterNoteConsumers(c)              // MQ

// cmd/api.go (อีกที่)
core.RegisterHealthRoutes(e, core.HealthOptions{Checks: []core.HealthCheck{note.IndexCheck()}})

ปัญหาไม่ใช่ความยาว แต่คือ มันลืมได้เงียบๆ: NewNoteHTTP(e) ที่ลืมเพิ่ม = 404 ที่เจอตอน QA, RegisterNoteJobs ที่ลืม = cron ที่ไม่เคยยิงและไม่มี log บรรทัดไหนหายไป (เพราะไม่เคยมีบรรทัดนั้น) ทั้งคู่ compile ผ่าน และ test ของ module เองก็ผ่าน เพราะ test ของ module ไม่ได้ประกอบ cmd/

และเมื่อ service มีหลาย role, api กับ worker ต่างถือ list ของตัวเอง — ไม่มีอะไรบังคับให้ สองอันตรงกัน

Interface

บังคับแค่ Name() ที่เหลือเป็น optional interface — implement เฉพาะที่ module นั้นมีจริง

go
type IModule interface {
    Name() string      // [a-z0-9][a-z0-9_-]* และห้ามซ้ำใน set เดียวกัน
}
interfaceเมธอดได้เมื่อ role มี
IHTTPModuleRoutes(e *core.Server)RunHTTP
IJobModuleJobs(reg *core.JobRegistry) core.IErrorRunJobs หรือ RunScheduler
ICronModuleCron(sc *core.Scheduler) core.IErrorRunScheduler
IMQModuleConsumers(c core.IMQConsumer)RunMQ
IHealthModuleHealthChecks() []core.HealthCheckเสมอ
ILifecycleModuleStart(app) core.IError / Stop(ctx) core.IErrorเสมอ

Routes ไม่คืน error เพราะของจริงข้างใต้ไม่คืน (e.GET คืน echo.RouteInfo) ส่วน Jobs กับ Cron คืน เพราะ reg.Register และ sc.Add คืน — ชื่อ job ซ้ำ, cron expression ผิด

เขียน module

จุดต่อทั้งหมดอยู่ที่ root ของ package แยกไฟล์ตามชนิดของสิ่งที่ต่อ: .module.go ประกาศตัว module, .http.go มีแต่ route, .jobs.go มีแต่ job/cron (Project Structure อธิบายโครงเต็ม) module ที่มีแต่ route จะเขียน Routes ไว้ใน .module.go เลยก็ได้ — สิ่งที่ บังคับคือไม่มีจุดต่อไหนอยู่นอก package นี้ ไม่ใช่จำนวนไฟล์

go
type Module struct{}

func New() *Module { return &Module{} }

func (*Module) Name() string { return "note" }

func (m *Module) HealthChecks() []core.HealthCheck {
    return []core.HealthCheck{{
        Name:  "search-index",                    // → รายงานเป็น "note.search-index"
        Check: func(ctx context.Context) error { return search.Ping(ctx) },
    }}
}
go
func (m *Module) Routes(e *core.Server) {
    c := &handler.NoteHandler{}

    e.GET("/notes", c.Pagination, middlewares.AuthRequire(e))
    e.GET("/notes/:id", c.Find, middlewares.AuthRequire(e))
    e.POST("/notes", c.Create, middlewares.AuthRequire(e))
}
go
// handler ไม่ใช่ service: ตัว JobFunc ทำ bind → convert → call → SetResult
// เหมือน HTTP handler ทุกประการ service จึงไม่ต้องรู้จัก ICronjobContext
func (m *Module) Jobs(reg *core.JobRegistry) core.IError {
    return reg.Register(core.JobDef{Name: "note.reindex"}, handler.Reindex)
}

func (m *Module) Cron(sc *core.Scheduler) core.IError {
    return sc.Add(core.JobDef{
        Name:     "note.cleanup",
        Schedule: core.Cron("0 3 * * *"),
    }, handler.Cleanup)
}
go
// ที่เดียวที่รู้จัก module ทั้งหมด — ยังเป็นความจริงเหมือนเดิม
func Modules(app *core.App) (*core.ModuleSet, core.IError) {
    usersFor := func(ctx core.IContext) auth.Users { return user.NewUserService(ctx) }

    return core.NewModules(
        home.New(),
        auth.New(usersFor),      // dependency inject ตอน construct เหมือนเดิม
        user.New(),
        note.New(),
    )
}
go
func NewAPI(app *core.App, mods *core.ModuleSet, opts *core.HTTPOptions) *core.Server {
    e := core.NewHTTPServer(app, opts)

    core.RegisterHealthRoutes(e, core.HealthOptions{Checks: mods.HealthChecks()})
    mods.MountHTTP(e)      // test เข้าทางนี้ — ไม่ผ่าน Runner

    return e
}

auth.Users เป็น interface ของ auth เอง ไม่ใช่ของ user — module ห้าม import module อื่น แม้แต่เพื่อเอา type (Project Structure ข้อ 2) ถ้า note ต้องใช้ users ด้วย มันประกาศ note.Users ของตัวเองแล้ว cmd/modules.go เขียน adapter ให้อีกตัว — ความซ้ำตรงนั้น คือราคาที่จ่ายเพื่อให้ลูกศรชี้ทางเดียว

Jobs กับ Cron ห้ามลงทะเบียนชื่อเดียวกัน

Scheduler.Add ทั้ง register และ arm ในเมธอดเดียว job ที่ arm ใน Cron แล้วไปประกาศใน Jobs อีก = DUPLICATE_JOB ตอน boot (error บอกด้วยว่า module ไหน)

แบ่งแบบนี้: Cron = ยิงตามเวลา, Jobs = trigger เอาเท่านั้น

Role กับสิ่งที่ถูกข้าม

module list ชุดเดียวใช้ได้ทุก role — Runner เรียกเฉพาะสิ่งที่ตัวเองมีที่ให้ต่อ

modules mounted  modules=[auth home note user] routes=37 jobs=6 cron=4 queues=0

role ที่ไม่มี scheduler จะได้บรรทัดนี้แทน:

modules mounted  modules=[auth home note user] routes=37 jobs=6 cron=0 queues=0 skipped=[cron]

skipped=[cron] คือส่วนที่สำคัญ — ใน api role มันคือเรื่องปกติ แต่ใน process ที่ตั้งใจจะ arm schedule พวกนั้น มันคือ misconfiguration ที่ไม่มี log บรรทัดอื่นบอก

ลำดับที่ RunModules เดิน:

#ทำอะไรทำเมื่อ
1Start(app) ทุก module ตามลำดับที่ประกาศเสมอ
2Jobs(registry)มี RunJobs หรือ RunScheduler
3Cron(scheduler)มี RunScheduler
4Routes(server)มี RunHTTP
5Consumers(consumer)มี RunMQ

นิยาม job มาก่อน arm เสมอ — job ที่ API role ต้อง trigger ได้ ต้องอยู่ใน registry ของ role นั้นด้วย ทั้งที่ role นั้นไม่มี scheduler

ตอนปิด Stop(ctx) ถูกเรียก ท้ายสุดของ drain — หลัง request จบ ก่อน pool ถูกปิด module ที่กำลังหยุดจึงยังใช้ database ได้ (และหยุดย้อนลำดับ module ที่ประกาศทีหลังหยุดก่อน)

Health check

check ที่ module คืนมาถูก prefix ด้วยชื่อ module อัตโนมัติ ตาม convention เดียวกับ database.replica ใน Health

moduleHealthCheck.Namekey ที่รายงาน
notesearch-indexnote.search-index
payment(ว่าง)payment

prefix ไม่ใช่ความสวยงาม — ถ้าไม่มี module สองตัวที่ตั้งชื่อ check ว่า api เหมือนกัน จะเขียนทับ กันใน map[string]CheckResult ของ HealthReport แล้วหนึ่งในนั้นหายไปเงียบๆ

checks ต้องส่งเข้า probe เองที่ composition root:

go
core.RegisterHealthRoutes(e, core.HealthOptions{Checks: mods.HealthChecks()})

ไม่ได้เก็บไว้บน App ทั้งที่ดูจะสะดวกกว่า เพราะจะทำให้ ReadyHandler(app) ขึ้นกับลำดับ: probe ที่ลงทะเบียนก่อน module จะรายงานไม่ครบ และไม่มีอะไรบอกว่ามันไม่ครบ

mount ซ้ำ = no-op

cmd.NewAPI เรียก mods.MountHTTP(e) เอง (test ประกอบ service ผ่านฟังก์ชันนั้น ไม่ผ่าน Runner) แล้ว RunModules ก็เรียกอีกรอบ — การ mount target เดิมซ้ำจึงเป็น no-op ไม่ใช่ route ซ้ำ

mount ไป target คนละตัว (server ตัวที่สอง, scheduler ของ test) ยังทำงานตามปกติ

Test

go
func TestNotes(t *testing.T) {
    srv := coretest.NewServer(t, coretest.WithAutoMigrate(&models.Note{}))

    mods, err := cmd.Modules(srv.App())
    require.Nil(t, err)
    srv.Mount(mods)                          // route ชุดเดียวกับ production

    srv.Get("/notes").RequireStatus(http.StatusOK)
}

ประกอบ set ชุดเดียวกับที่ cmd/ ประกอบคือประเด็นทั้งหมด: module ที่ลงทะเบียนโดยมี dependency ขาด จะพังใน test ไม่ใช่ใน production

Devtools

go
devtools.Mount(e, devtools.Options{Modules: mods, Runner: sc.Runner()})

เพิ่มแท็บ modules ที่ตอบคำถามที่ทำให้ระบบนี้มีอยู่ — ไม่ใช่ "module นี้ทำอะไร" แต่คือ "มันต่ออะไรเข้ากับ process นี้จริงบ้าง":

json
{
  "name": "note",
  "declares": ["jobs", "cron", "routes", "health"],
  "started": false,
  "routes": ["GET /notes", "GET /notes/:id", "POST /notes"],
  "jobs": ["note.reindex"],
  "cron": ["note.cleanup"],
  "queues": [],
  "health": ["note.search-index"]
}

declares คือ optional interface ที่ type นั้น implement — เป็นตัวส่วนที่ทำให้ list ว่าง มีความหมาย: module ที่ ไม่ได้ประกาศ routes กับ module ที่ประกาศแล้วแต่ role นี้ ไม่เคย mount ให้ ต่างก็ได้ list ว่างเหมือนกัน แต่มีแค่อย่างหลังที่เป็นปัญหา

started ก็เช่นกัน มันมีความหมายเฉพาะกับ module ที่ declares มี "lifecycle" เท่านั้น — module ส่วนใหญ่ไม่มี Start ให้เรียก devtools จึงขึ้นว่า no background work ไม่ใช่จุดสีเทา ที่อ่านแล้วเหมือนพัง ส่วน module ที่ขึ้นว่า declares nothing คือสิ่งที่ตามหา — ดู Devtools

ยกเว้นกรณีเดียว: module ที่มีแต่ service ให้ module อื่นเรียก (เช่นตัวที่ห่อ API ของ ผู้ให้บริการรายอื่น) ไม่ได้ลงทะเบียนอะไรจริงๆ มันจึงควรมี HealthChecks() ของ upstream นั้น — ไม่ใช่เพื่อกลบข้อความ แต่เพราะเป็น dependency ที่ไม่มีใครเห็นถ้าไม่ประกาศ ดู External services

Postman

postmangen สแกนไฟล์ที่ลงท้ายด้วย .http.go และ .module.go — เมธอด Routes จึงถูกอ่านไม่ว่าจะแยกไว้ใน .http.go หรือเขียนรวมใน .module.go โดยไม่ต้อง ตั้งค่าอะไร ถ้าโปรเจกต์ตั้งชื่อไฟล์อื่น ระบุใน postmangen.json:

json
{"route_file_suffixes": [".http.go", ".module.go", ".routes.go"]}

ทางเลือกที่ไม่เลือก

auto-register ด้วย init()import _ "myservice/modules/note" แล้วให้ module ลงทะเบียน ตัวเองเข้า global registry จะทำให้ cmd/ เลิกเป็นที่ที่รู้จัก module ทั้งหมด ผลคือ test ที่อยากรัน service โดยตัด module หนึ่งออกทำไม่ได้เลย, ลำดับกลายเป็นลำดับ init() ซึ่ง Go ไม่รับประกันข้าม package, และ dependency (เช่น usersFor) ส่งเข้าไปไม่ได้เพราะ init() ไม่รับ argument

Migrate() hookSchema & Migrations อธิบายไว้แล้วว่าทำไม framework ไม่ migrate และ module ยิ่งแย่กว่า: ลำดับ migration ข้าม module บน database เดียวกัน แก้ในโปรเซสไม่ได้ ในเมื่อ module ประกาศตัวเองว่าอิสระต่อกัน

Go plugin (.so โหลดตอน runtime) — ต้อง build ด้วย compiler และ dependency เวอร์ชัน เดียวกันเป๊ะ, unload ไม่ได้, และเปลี่ยน compile error เป็น panic ตอน runtime module ที่นี่ เป็น compile-time ทั้งหมด

อ่านต่อ

  • Project Structure — โครง modules/ ที่ module หนึ่งตัวหน้าตาเป็นยังไง
  • External services — module ที่ห่อ API ของคนอื่น และไม่มี route
  • Lifecycle & Roles — role อ่านจาก config ยังไง
  • Runner — ลำดับ start/stop ทั้งเส้น
  • Health & Readiness — critical กับ degraded ต่างกันยังไง
  • Devtools — แท็บอื่นๆ ในหน้าเดียวกัน

Maintained by Passakon Puttasuwan & Dev Core Team.