diff --git a/CHANGELOG.md b/CHANGELOG.md index 4097c62..c75ab45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # CHANGELOG - go/service +## v1.0.3 (2026-05-09) +### Added +- **Zero-Config Microservices**: 实现智能启动逻辑。当 `Listen` 为空时,自动开启随机端口并使用 `h2c` 协议。 +- **Auto-Discover**: 自动集成 `go/discover`。当使用随机端口或配置了应用名时,自动完成服务注册。 +- **Smart Detection**: 内置应用名自动识别 (`debug.ReadBuildInfo`) 与服务 IP 自动探测 (UDP 伪拨号法)。 +- **H2C Server**: 原生支持 HTTP/2 Cleartext (h2c) 服务端协议,提升微服务间通信性能。 + +### Changed +- **Infrastructure Alignment**: `go.mod` 引入 `golang.org/x/net` 以支持 H2C。 +- **IO Security**: 持续优化 `go/file` 在静态文件服务中的应用。 + ## v1.0.2 (2026-05-09) ### Changed - **Infrastructure Alignment**: `go.mod` 升级 `go/config` 至 `v1.0.7`,`go/http` 至 `v1.0.10`。 diff --git a/README.md b/README.md index c1662d4..8c6b1ba 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ ## 核心特性 - **极致精简**: 剥离非核心组件(如 Starter, Task, 业务 Result 定义),保持底座纯净。 +- **零配置启动**: 默认监听随机端口并启用 `h2c` 协议,自动完成 `discover` 注册,实现真正的 "Zero-Config" 微服务。 +- **智能探测**: 自动识别应用名称 (`ReadBuildInfo`) 与局域网 IP,消除环境摩擦。 - **路由反射**: 自动解析函数参数,支持 `*Request`, `*Response`, `*log.Logger` 及自定义结构体自动注入。 - **自动校验**: 集成 `verify` 引擎,通过 Struct Tag 实现入参合法性自动检查。 - **功能闭环**: 内置静态文件服务、基础 WebSocket 注册、URL 重写、反向代理(对接 Discover)。 diff --git a/TEST.md b/TEST.md index 6a8ee47..b44588f 100644 --- a/TEST.md +++ b/TEST.md @@ -20,6 +20,9 @@ - [x] `TestNestedVerify`: 嵌套结构校验 - [x] `TestCustomVerify`: 自定义校验函数 - [x] `TestWebSocketService`: WebSocket 注册 +- [x] `TestGetDefaultName`: 自动应用名识别 +- [x] `TestGetServerIp`: 自动 IP 探测 +- [x] `TestSmartStartup`: 零配置智能启动与 Discover 注册 ## 基础设施对齐验证 - [x] 成功集成 `apigo.cc/go/cast` 用于参数解析与类型强转。 @@ -27,3 +30,4 @@ - [x] 成功集成 `apigo.cc/go/log` 并实现完整的 Request 日志记录。 - [x] 强制集成 `apigo.cc/go/file` 替代原生 `os`,全面支持内存虚拟文件系统。 - [x] 成功集成 `apigo.cc/go/id` 与 `go/redis` 实现分布式有序 ID。 +- [x] 成功集成 `apigo.cc/go/discover` 并支持 H2C 协议的零配置自动注册。 diff --git a/config.go b/config.go index 2b6812b..809ecc8 100644 --- a/config.go +++ b/config.go @@ -42,6 +42,7 @@ type ServiceConfig struct { SessionWithoutCookie bool // Session 禁用 Cookie DeviceWithoutCookie bool // 设备ID禁用 Cookie IdServer string // Redis 服务器连接 (用于全局唯一 ID 生成) + DiscoverApp string // 强制指定 Discover 应用名称,如果不指定且 Listen 为空则自动获取并注册 KeepKeyCase bool // 是否保持 Key 的首字母大小写 IndexFiles []string // 静态文件索引文件 IndexDir bool // 访问目录时显示文件列表 diff --git a/discover_test.go b/discover_test.go new file mode 100644 index 0000000..4f1e504 --- /dev/null +++ b/discover_test.go @@ -0,0 +1,49 @@ +package service + +import ( + "os" + "testing" +) + +func TestGetDefaultName(t *testing.T) { + // Test from env + os.Setenv("DISCOVER_APP", "test-app") + if name := GetDefaultName(); name != "test-app" { + t.Errorf("Expected test-app, got %s", name) + } + os.Unsetenv("DISCOVER_APP") + + // Test from build info or args + name := GetDefaultName() + if name == "" { + t.Error("Expected non-empty name") + } + t.Logf("Detected name: %s", name) +} + +func TestGetServerIp(t *testing.T) { + ip := GetServerIp() + if ip == "" || ip == "0.0.0.0" { + t.Errorf("Invalid IP detected: %s", ip) + } + t.Logf("Detected IP: %s", ip) +} + +func TestSmartStartup(t *testing.T) { + // Reset config + Config.Listen = "" + Config.DiscoverApp = "smart-test" + + as := AsyncStart() + if as.Addr == "" { + t.Fatal("Server address should not be empty") + } + + t.Logf("Server started on %s", as.Addr) + + if !as.useDiscover { + t.Error("Should have enabled discover") + } + + as.Stop() +} diff --git a/go.mod b/go.mod index 3a85ded..910a180 100644 --- a/go.mod +++ b/go.mod @@ -13,4 +13,5 @@ require ( apigo.cc/go/redis v1.0.5 apigo.cc/go/timer v1.0.6 github.com/gorilla/websocket v1.5.3 + golang.org/x/net v0.53.0 ) diff --git a/server.go b/server.go index 28ed375..5de673a 100644 --- a/server.go +++ b/server.go @@ -1,23 +1,29 @@ package service import ( + "apigo.cc/go/discover" "apigo.cc/go/log" "context" + "fmt" + "golang.org/x/net/http2" + "golang.org/x/net/http2/h2c" "net" "net/http" "os" "os/signal" + "strings" "syscall" "time" ) // AsyncServer 异步服务实例 type AsyncServer struct { - server *http.Server - listener net.Listener - Addr string - stopChan chan os.Signal - startChan chan bool + server *http.Server + listener net.Listener + Addr string + stopChan chan os.Signal + startChan chan bool + useDiscover bool } // AsyncStart 异步启动服务 @@ -34,13 +40,42 @@ func AsyncStart() *AsyncServer { } func (as *AsyncServer) start() { - if Config.Listen == "" { - Config.Listen = ":8080" // 默认端口 + listenStr := Config.Listen + as.useDiscover = false + + if listenStr == "" { + listenStr = ":0,h2c" + as.useDiscover = true } - listener, err := net.Listen("tcp", Config.Listen) + // 解析第一个监听配置 + part := strings.Split(listenStr, "|")[0] + addr, opts, _ := strings.Cut(part, ",") + + protocol := "http" + for _, opt := range strings.Split(opts, ",") { + opt = strings.ToLower(strings.TrimSpace(opt)) + if opt == "h2c" || opt == "h2" { + protocol = opt + } + } + + if !strings.Contains(addr, ":") { + addr = ":" + addr + } + + // 检查是否需要启动服务发现 + appName := Config.DiscoverApp + if appName == "" { + appName = GetDefaultName() + } + if appName != "" { + as.useDiscover = true + } + + listener, err := net.Listen("tcp", addr) if err != nil { - log.DefaultLogger.Error("failed to listen", "addr", Config.Listen, "error", err.Error()) + log.DefaultLogger.Error("failed to listen", "addr", addr, "error", err.Error()) as.startChan <- false return } @@ -49,14 +84,39 @@ func (as *AsyncServer) start() { as.Addr = listener.Addr().String() serverAddr = as.Addr + // 如果使用了随机端口且没有明确指定不需要服务发现,则开启 + if addr == ":0" || strings.HasSuffix(addr, ":0") { + as.useDiscover = true + } + + h2s := &http2.Server{} + var handler http.Handler = &RouteHandler{} + if protocol == "h2c" { + handler = h2c.NewHandler(handler, h2s) + } + as.server = &http.Server{ - Handler: &RouteHandler{}, + Handler: handler, + } + + // 启动服务发现 + if as.useDiscover && appName != "" { + _, port, _ := net.SplitHostPort(as.Addr) + ip := GetServerIp() + discoverAddr := fmt.Sprintf("%s:%s", ip, port) + + conf := discover.GetConfig() + conf.App = appName + discover.SetConfig(conf) + if discover.Start(discoverAddr) { + log.DefaultLogger.Info("discover registered", "app", appName, "addr", discoverAddr) + } } signal.Notify(as.stopChan, os.Interrupt, syscall.SIGTERM) go func() { - log.DefaultLogger.Info("service starting", "addr", as.Addr) + log.DefaultLogger.Info("service starting", "addr", as.Addr, "proto", protocol) as.startChan <- true if err := as.server.Serve(listener); err != nil && err != http.ErrServerClosed { log.DefaultLogger.Error("server error", "error", err.Error()) @@ -67,6 +127,10 @@ func (as *AsyncServer) start() { // Stop 停止服务 func (as *AsyncServer) Stop() { log.DefaultLogger.Info("service stopping") + if as.useDiscover { + discover.Stop() + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() diff --git a/utility.go b/utility.go index 7929e39..c64d971 100644 --- a/utility.go +++ b/utility.go @@ -4,6 +4,10 @@ import ( "apigo.cc/go/id" "apigo.cc/go/log" "apigo.cc/go/redis" + "net" + "os" + "path" + "runtime/debug" "sync" ) @@ -12,6 +16,45 @@ var ( idMakerLock sync.Mutex ) +// GetDefaultName 获取默认应用名称 +func GetDefaultName() string { + name := os.Getenv("DISCOVER_APP") + if name == "" { + name = os.Getenv("discover_app") + } + if name == "" { + if info, ok := debug.ReadBuildInfo(); ok && info.Path != "" && info.Path != "command-line-arguments" { + name = path.Base(info.Path) + } + } + if name == "" { + name = path.Base(os.Args[0]) + } + return name +} + +// GetServerIp 获取真实局域网 IP (UDP 8.8.8.8 伪拨号法) +func GetServerIp() string { + conn, err := net.Dial("udp", "8.8.8.8:80") + if err == nil { + localAddr := conn.LocalAddr().(*net.UDPAddr) + _ = conn.Close() + return localAddr.IP.String() + } + + addrs, err := net.InterfaceAddrs() + if err == nil { + for _, a := range addrs { + if an, ok := a.(*net.IPNet); ok { + if an.IP.IsGlobalUnicast() { + return an.IP.To4().String() + } + } + } + } + return "127.0.0.1" +} + // IDMakerInterface ID 生成器接口 type IDMakerInterface interface { Get(size int) string