diff --git a/.log.meta.json b/.log.meta.json index 6e86ffe..390cb17 100644 --- a/.log.meta.json +++ b/.log.meta.json @@ -878,17 +878,6 @@ "Precision": 0, "WithoutKey": false, "Hide": false - }, - { - "Index": 8, - "Name": "CallStacks", - "KeyName": "", - "AttachBefore": false, - "Color": "", - "Format": "", - "Precision": 0, - "WithoutKey": false, - "Hide": false } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index b36f707..d1ffa0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # CHANGELOG - go/service +## v1.5.6 (2026-06-05) +- **新特性: EnableWebDev 支持**: + - 引入了 `service.EnableWebDev(config watch.Config)`,支持自动刷新页面的开发模式。 + - **WebSocket 同步**: 自动注册 `/_watch` 服务,与文件监听器协同工作。 + - **智能 HTML 注入**: 采用 `OutFilter` 在 HTML 响应末尾精准注入 WebSocket 重连脚本,支持静态文件与动态服务。 + - **性能优化**: 仅在开启开发模式时启用响应缓冲,生产环境无任何性能损失。 +- **基础设施**: 增加包级 `AddShutdownHook` 支持,提供更优雅的资源回收机制。 +- **依赖同步**: 升级至 `log v1.5.5`,对齐不带堆栈的 Warning 规范。 + ## v1.5.5 (2026-06-05) - **依赖同步**: 全量对齐至 `@go` 基础设施最新版本(`log v1.5.4`, `starter v1.5.2`, `db v1.5.2`)。 diff --git a/go.mod b/go.mod index 260812b..e0ed5fe 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( apigo.cc/go/http v1.5.0 apigo.cc/go/id v1.5.0 apigo.cc/go/jsmod v1.5.0 - apigo.cc/go/log v1.5.4 + apigo.cc/go/log v1.5.5 apigo.cc/go/redis v1.5.0 apigo.cc/go/safe v1.5.0 apigo.cc/go/starter v1.5.2 diff --git a/handler.go b/handler.go index 62f170f..01fce74 100644 --- a/handler.go +++ b/handler.go @@ -33,15 +33,17 @@ func (rh *RouteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { request := NewRequest(r) request.Id = requestId - response := NewResponse(w) + response := NewResponse(w, ws) response.Id = requestId requestLogger := log.New(requestId) // 0. 延迟处理日志与状态检查 var s *webServiceType + var wsc *websocketServiceType var authLevel int var priority int var args = make(map[string]any) + var result any defer func() { // 捕捉 Panic @@ -151,17 +153,15 @@ func (rh *RouteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // 处理静态文件 if ws.processStatic(path, request, response, requestLogger) { - return + goto filter } - var wsc *websocketServiceType s, wsc = ws.findService(r.Method, host, path) // 4. 参数解析 (Form & Body) parseRequestArgs(request, args) // 5. 前置过滤器 - var result any for _, filter := range ws.inFilters { result = filter(&args, request, response, requestLogger) if result != nil { @@ -205,13 +205,18 @@ func (rh *RouteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if s == nil && result == nil && !response.changed { response.WriteHeader(http.StatusNotFound) + result = "404 page not found" } - // 7. 后置过滤器 +filter: + // 7. 后置过滤器 (即使 response.changed 也要执行,比如静态文件的 HTML 注入) for _, filter := range ws.outFilters { newResult, done := filter(args, request, response, result, requestLogger) if newResult != nil { result = newResult + // 如果 response.changed 为 true,说明已经有内容写出了。 + // 如果过滤器返回了非 nil 的 result,我们通常认为它想替换或追加内容。 + // 特别是对于静态文件,如果我们清空了 body 并返回了新内容,result 就不再是 nil。 } if done { break @@ -219,7 +224,19 @@ func (rh *RouteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // 8. 输出结果 - outputResult(response, result) + if ws.hasOutFilter { + // 过滤器模式:所有内容都应该从 result 或 response.body 中写出 + if result != nil { + outputResult(response, result) + } else if response.changed { + response.PhysicalWrite(response.body) + } + } else { + // 普通模式:result (业务返回值) 需要写出,而 response.changed (比如静态文件) 已经由 Response.Write 写过了 + if result != nil { + outputResult(response, result) + } + } } func hostOnly(host string) string { @@ -403,9 +420,13 @@ func outputResult(response *Response, result any) { if contentType != "" && response.Header().Get("Content-Type") == "" { response.Header().Set("Content-Type", contentType) } - _, _ = response.Write(data) -} + if response.server != nil && response.server.hasOutFilter { + response.PhysicalWrite(data) + } else { + _, _ = response.Write(data) + } +} func (ws *webServer) handleClientKeys(request *Request, response *Response) { // SessionId if ws.usedSessionIdKey != "" { diff --git a/response.go b/response.go index a308f5c..4fe6120 100644 --- a/response.go +++ b/response.go @@ -19,13 +19,15 @@ type Response struct { dontLog200 bool dontLogArgs []string ProxyHeader *http.Header + server *webServer } // NewResponse 创建 Response 包装 -func NewResponse(writer http.ResponseWriter) *Response { +func NewResponse(writer http.ResponseWriter, server *webServer) *Response { return &Response{ Writer: writer, Code: http.StatusOK, + server: server, } } @@ -42,9 +44,27 @@ func (r *Response) Write(bytes []byte) (int, error) { r.checkWriteHeader() r.changed = true r.outLen += len(bytes) - if r.Code != http.StatusOK && len(r.body) < 4096 { + + // 如果有输出过滤器,我们必须先缓冲,不能直接写入网线,否则会导致重复输出 + if r.server != nil && r.server.hasOutFilter { + r.body = append(r.body, bytes...) + return len(bytes), nil + } + + // 即使没有过滤器,非 200 状态码也进行缓冲以便日志记录 + if r.Code != http.StatusOK { r.body = append(r.body, bytes...) } + + if r.ProxyHeader != nil { + r.copyProxyHeader() + } + return r.Writer.Write(bytes) +} + +// PhysicalWrite 物理写入网线,绕过过滤器缓冲逻辑 +func (r *Response) PhysicalWrite(bytes []byte) (int, error) { + r.checkWriteHeader() if r.ProxyHeader != nil { r.copyProxyHeader() } @@ -100,6 +120,20 @@ func (r *Response) GetStatusCode() int { return r.Code } +// GetBody 获取响应内容 +func (r *Response) GetBody() []byte { + return r.body +} + +// ClearBody 清空响应内容缓冲区 (用于过滤器替换内容) +func (r *Response) ClearBody() { + r.body = nil + r.outLen = 0 + // 注意:这里我们不重置 headerWritten 和 Code,因为 Header 已经发出去了。 + // 但是在某些测试环境下(如 httptest.Recorder),我们可以尝试“假装”没写过。 + // 实际上,生产环境下 Header 发出去就收不回来了,所以注入只能发生在 Body 层面。 +} + // DontLog200 标记不记录 200 状态码的日志 func (r *Response) DontLog200() { r.dontLog200 = true diff --git a/server.go b/server.go index cde88ec..909ef12 100644 --- a/server.go +++ b/server.go @@ -84,6 +84,13 @@ type webServer struct { usedClientAppKey string usedSessionIdKey string sessionIdMaker func() string + + // 停机钩子 + shutdownHooks []func() + shutdownHooksLock sync.Mutex + + // 性能优化:标记是否有输出过滤器 + hasOutFilter bool } // DefaultServer 全局单例服务实例 @@ -411,6 +418,15 @@ func (ws *webServer) Start(ctx context.Context, logger *log.Logger) error { // Stop 停止服务,实现 starter.Service 接口 func (ws *webServer) Stop(ctx context.Context) error { ws.running = false + + // 执行停机钩子 (反序) + ws.shutdownHooksLock.Lock() + for i := len(ws.shutdownHooks) - 1; i >= 0; i-- { + ws.shutdownHooks[i]() + } + ws.shutdownHooks = nil + ws.shutdownHooksLock.Unlock() + if ws.discoverer != nil { ws.discoverer.Stop() } diff --git a/service.go b/service.go index 00f1f90..e5e879c 100644 --- a/service.go +++ b/service.go @@ -2,10 +2,13 @@ package service import ( "apigo.cc/go/log" + "apigo.cc/go/watch" "errors" + "math" "reflect" "regexp" "strings" + "sync" ) // webServiceType 内部存储的服务元数据 @@ -102,13 +105,27 @@ func (ws *webServer) SetInFilter(filter func(in *map[string]any, request *Reques ws.inFilters = append(ws.inFilters, filter) } +// AddShutdownHook 增加停机钩子 +func AddShutdownHook(hook func()) { + DefaultServer.AddShutdownHook(hook) +} + +func (ws *webServer) AddShutdownHook(hook func()) { + ws.shutdownHooksLock.Lock() + defer ws.shutdownHooksLock.Unlock() + ws.shutdownHooks = append(ws.shutdownHooks, hook) +} + // SetOutFilter 设置后置过滤器 func SetOutFilter(filter func(in map[string]any, request *Request, response *Response, out any, logger *log.Logger) (newOut any, isOver bool)) { DefaultServer.SetOutFilter(filter) } func (ws *webServer) SetOutFilter(filter func(in map[string]any, request *Request, response *Response, out any, logger *log.Logger) (newOut any, isOver bool)) { + ws.webServicesLock.Lock() + defer ws.webServicesLock.Unlock() ws.outFilters = append(ws.outFilters, filter) + ws.hasOutFilter = true } // HostContext 提供流式服务注册能力 @@ -471,3 +488,146 @@ func GetInjectT[T any]() T { } return obj.(T) } + +var webDevOnce sync.Once + +// EnableWebDev 开启 Web 开发模式,支持自动刷新 +func EnableWebDev(config watch.Config) { + webDevOnce.Do(func() { + log.DefaultLogger.Warning("Web Development Mode Enabled. This should NOT be used in production environment.") + onWatchConn := map[string]*WebSocketConn{} + onWatchLock := sync.Mutex{} + + // 1. 注册 WebSocket 服务 + RegisterWebsocket("/_watch", func(request *Request, conn *WebSocketConn, logger *log.Logger) { + onWatchLock.Lock() + onWatchConn[request.Id] = conn + onWatchLock.Unlock() + + logger.Info("watch ws connected", "id", request.Id) + + // 保持连接,处理消息 (如 ping) + for { + if _, err := conn.ReadString(); err != nil { + break + } + } + + onWatchLock.Lock() + delete(onWatchConn, request.Id) + onWatchLock.Unlock() + logger.Info("watch ws disconnected", "id", request.Id) + }) + + // 2. 启动文件监听 + watcher, err := watch.Start(config, func(e *watch.Event) { + onWatchLock.Lock() + defer onWatchLock.Unlock() + for _, conn := range onWatchConn { + _ = conn.Send("reload") + } + }) + + if err != nil { + log.DefaultLogger.Error("failed to start watch for EnableWebDev", "error", err.Error()) + return + } + + // 3. 注册停机钩子 + AddShutdownHook(func() { + watcher.Stop() + onWatchLock.Lock() + for _, conn := range onWatchConn { + _ = conn.Close() + } + onWatchLock.Unlock() + }) + + // 4. 注册输出过滤器进行注入 + SetOutFilter(func(in map[string]any, request *Request, response *Response, out any, logger *log.Logger) (newOut any, isOver bool) { + contentType := response.Header().Get("Content-Type") + var outStr string + + if out != nil { + switch v := out.(type) { + case string: + outStr = v + case []byte: + outStr = string(v) + } + } + + if outStr == "" && response.changed { + outStr = string(response.GetBody()) + } + + if outStr == "" { + return nil, false + } + + isHtml := strings.HasPrefix(contentType, "text/html") + if !isHtml && (contentType == "" || strings.HasPrefix(contentType, "text/plain")) { + // 检测内容前 100 字节是否包含 + let _watchWS = null + let _watchWSConnection = false + let _watchWSIsFirst = true + function connect() { + _watchWSConnection = true + let ws = new WebSocket(location.protocol.replace('http', 'ws') + '//' + location.host + '/_watch') + ws.onopen = () => { + _watchWS = ws + _watchWSConnection = false + if( !_watchWSIsFirst ) location.reload() + _watchWSIsFirst = false + } + ws.onmessage = () => { + location.reload() + } + ws.onclose = () => { + _watchWS = null + _watchWSConnection = false + } + } + setInterval(()=>{ + if(_watchWS!= null){ + try{ + _watchWS.send("ping") + }catch(err){ + _watchWS = null + _watchWSConnection = false + } + } else if(!_watchWSConnection){ + connect() + } + }, 1000) + connect() +` + // 仅替换最后一个 避免多个标签时的重复注入 + lastIndex := strings.LastIndex(outStr, "") + if lastIndex != -1 { + outStr = outStr[:lastIndex] + injectCode + outStr[lastIndex:] + } else { + outStr = outStr + injectCode + } + + // 无论如何,只要我们提供了新的输出,就清空原始 Body,防止 handler 重复写入 + response.ClearBody() + return []byte(outStr), false + } + return nil, false + }) + }) +} + diff --git a/websocket_test.go b/websocket_test.go index 1e91b89..02da9d4 100644 --- a/websocket_test.go +++ b/websocket_test.go @@ -1,8 +1,11 @@ package service import ( + "apigo.cc/go/watch" "github.com/gorilla/websocket" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" ) @@ -47,3 +50,60 @@ func TestWebSocketService(t *testing.T) { t.Errorf("Reply mismatch: %v", reply) } } + +func TestEnableWebDev(t *testing.T) { + // 1. 初始化 EnableWebDev + EnableWebDev(watch.Config{ + Paths: []string{"."}, + }) + + // 2. 准备一个真实的静态 HTML 文件 + staticDir := "test_static" + _ = os.MkdirAll(staticDir, 0755) + htmlFile := filepath.Join(staticDir, "index.html") + _ = os.WriteFile(htmlFile, []byte("Static Content"), 0644) + defer os.RemoveAll(staticDir) + + // 注册静态服务 + Static("/static/", staticDir) + + handler := &RouteHandler{ws: DefaultServer} + + // 3. 测试静态文件注入 + req := httptest.NewRequest("GET", "/static/index.html", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + body := w.Body.String() + if !strings.Contains(body, "let _watchWS = null") { + t.Errorf("Static HTML injection failed, code not found in body: %s", body) + } + + // 4. 测试普通服务注入 + Register("GET", "/test-dev", func() string { + return "Hello" + }) + + req2 := httptest.NewRequest("GET", "/test-dev", nil) + w2 := httptest.NewRecorder() + handler.ServeHTTP(w2, req2) + + body2 := w2.Body.String() + if !strings.Contains(body2, "let _watchWS = null") { + t.Errorf("Dynamic HTML injection failed") + } + + // 5. 验证非 HTML 不注入 + Register("GET", "/test-json", func() map[string]string { + return map[string]string{"foo": "bar"} + }) + req3 := httptest.NewRequest("GET", "/test-json", nil) + w3 := httptest.NewRecorder() + handler.ServeHTTP(w3, req3) + + body3 := w3.Body.String() + if strings.Contains(body3, "let _watchWS = null") { + t.Errorf("JSON should not be injected") + } +} +