diff --git a/CHANGELOG.md b/CHANGELOG.md index 314e20a..bcc4b8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # CHANGELOG - go/service +## v1.5.24 (2026-08-22) +- **动态静态源**: 新增 `StaticFS` 与 `ReplaceStaticFS`,可以按 Host 原子替换自定义 `http.FileSystem`;增加 `ReplaceStaticFSExclusive`,支持选中虚拟主机后隔离后续 Host 静态源。 +- **Host 一致性**: Static 与 Service 共用 `host:port → host → :port → *` 候选顺序。 +- **流式静态响应**: 自定义静态源通过 `http.ServeContent` 支持 Range、HEAD 和大文件流式读取;非开发模式静态内容默认绕过 output filter。 +- **路由兜底**: 新增 `SetFallback`,在静态文件和注册路由均未命中时进入统一处理链。 + ## v1.5.23 (2026-08-17) - **流式响应**: `Response.Write` 保持直接写入和实时 Flush,不再因服务注册了输出过滤器而被统一缓冲。 - **显式过滤**: 新增 `Response.WriteFiltered`,静态文件等确实需要输出过滤器处理的内容可显式进入缓冲流程。 diff --git a/README.md b/README.md index fe06006..b833887 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,8 @@ func main() { ### 4. 增强插件 - **静态文件**: `service.Static("/ui", "./static_dir")` 或 `service.Host("example.com").Static("/ui", "./static_dir")` +- **自定义静态源**: `service.Host("example.com").StaticFS("/", source)` 或 `service.ReplaceStaticFS(host, mounts)`,适用于知识库、对象存储和虚拟文件系统。需要虚拟主机隔离时使用 `service.ReplaceStaticFSExclusive(host, mounts)`;选中该 Host 后,文件未命中不会继续进入后续 Host 的自定义静态源。 +- **未命中处理**: `service.SetFallback(handler)` 在静态文件和已注册路由都未命中后执行。 - **URL 重写**: `service.Rewrite("/old", "/new")` - **反向代理**: `service.Proxy(0, "/api", "other_app", "/api")` - **文档生成**: `service.MakeDocument()` 返回全量接口描述 @@ -82,6 +84,8 @@ func main() { 低代码需要转发二进制或 SSE 流时,使用 `response.WriteBytes(chunk)` 原样写入动态运行时提供的字节数组;文本输出继续使用 `response.WriteString(text)`。`Write`、`WriteBytes` 和 `WriteString` 都直接写入响应并保持流式行为,不会被全局输出过滤器强制缓冲;确实需要过滤器处理的内容应显式使用 `response.WriteFiltered(bytes)`。 +自定义静态源必须返回可 `Seek` 的 `http.File`。生产模式下静态内容直接流式输出并绕过 output filter;`EnableWebDev` 仅会为 HTML 显式缓冲内容,用于注入开发热刷新脚本。 + ## 配置指南 (ServiceConfig) 详细配置项可查阅 `config.go` 中的 `ServiceConfig` 结构。通过 `config.Load` 支持从 `env.yml` 或环境变量加载。 diff --git a/TEST.md b/TEST.md index d5ad337..0aaf306 100644 --- a/TEST.md +++ b/TEST.md @@ -1,5 +1,11 @@ # Service Module Test Report +## v1.5.24 (2026-08-22) +- `TestStaticFSHostCandidatesAndRange` 验证自定义静态源的四级 Host 优先级与 Range 206 响应。 +- `TestStaticFSExclusiveStopsFileFallback` 验证隔离 Host 文件未命中时返回 404,未匹配 Host 仍使用全局默认静态源。 +- `TestFallbackRunsOnlyAfterRegisteredRoutesMiss` 验证已注册路由优先于 Fallback。 +- `go test ./...` 通过。 + ## v1.5.23 验证 - `TestResponseWriteBytes` 验证动态字节数组写入时保持 UTF-8 内容不变。 - `TestResponseWriteBypassesOutputFilterBuffer` 验证直接响应即使存在输出过滤器也保持实时写入。 diff --git a/handler.go b/handler.go index 6b69d31..1a5fdae 100644 --- a/handler.go +++ b/handler.go @@ -2,6 +2,7 @@ package service import ( "io" + "net" "net/http" "net/url" "reflect" @@ -152,6 +153,11 @@ func (rh *RouteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } s, wsc = ws.findService(r.Method, host, path, args) + if s == nil && wsc == nil { + ws.webServicesLock.RLock() + s = ws.fallbackService + ws.webServicesLock.RUnlock() + } // 4. 参数解析 (Form & Body) parseRequestArgs(request, args) @@ -240,8 +246,49 @@ filter: } func hostOnly(host string) string { - h, _, _ := strings.Cut(host, ":") - return h + h, _, err := net.SplitHostPort(host) + if err == nil { + return strings.Trim(h, "[]") + } + if strings.Count(host, ":") == 1 { + h, _, _ = strings.Cut(host, ":") + return h + } + return strings.Trim(host, "[]") +} + +func hostCandidates(host string) []string { + host = strings.ToLower(strings.TrimSpace(host)) + hostName, port := hostOnly(host), "" + if _, parsedPort, err := net.SplitHostPort(host); err == nil { + port = parsedPort + } else if strings.Count(host, ":") == 1 { + _, port, _ = strings.Cut(host, ":") + } + hostName = strings.TrimSuffix(hostName, ".") + normalizedHost := hostName + if port != "" { + normalizedHost = net.JoinHostPort(hostName, port) + } + candidates := []string{normalizedHost} + if port != "" { + candidates = append(candidates, hostName, ":"+port) + } + candidates = append(candidates, "*") + out := candidates[:0] + seen := map[string]bool{} + for _, candidate := range candidates { + if candidate != "" && !seen[candidate] { + seen[candidate] = true + out = append(out, candidate) + } + } + return out +} + +// HostCandidates returns route keys from most specific to the global fallback. +func HostCandidates(host string) []string { + return append([]string(nil), hostCandidates(host)...) } func (ws *WebServer) findService(method, host, path string, args map[string]any) (*webServiceType, *websocketServiceType) { @@ -249,12 +296,7 @@ func (ws *WebServer) findService(method, host, path string, args map[string]any) defer ws.webServicesLock.RUnlock() // 1. 准备 Host 候选列表: "host:port", "host", ":port", "*" - hostOnly, port, _ := strings.Cut(host, ":") - hosts := []string{host} - if port != "" { - hosts = append(hosts, hostOnly, ":"+port) - } - hosts = append(hosts, "*") + hosts := hostCandidates(host) // 2. 匹配 Web Service for _, h := range hosts { diff --git a/handler_test.go b/handler_test.go index 2684088..ecce357 100644 --- a/handler_test.go +++ b/handler_test.go @@ -81,3 +81,25 @@ func TestServeHTTP_Panic(t *testing.T) { t.Errorf("Expected status 500, got %d", w.Code) } } + +func TestFallbackRunsOnlyAfterRegisteredRoutesMiss(t *testing.T) { + ws := NewWebServer() + ws.Host("*").GET("/registered", func() string { return "registered" }) + ws.SetFallback(func(request *Request) string { return "fallback:" + request.URL.Path }) + handler := &RouteHandler{ws: ws} + + for _, test := range []struct { + path string + want string + }{ + {"/registered", "registered"}, + {"/missing", "fallback:/missing"}, + } { + request := httptest.NewRequest(http.MethodGet, test.path, nil) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusOK || response.Body.String() != test.want { + t.Fatalf("%s: got %d %q, want 200 %q", test.path, response.Code, response.Body.String(), test.want) + } + } +} diff --git a/server.go b/server.go index e8df122..975e066 100644 --- a/server.go +++ b/server.go @@ -46,6 +46,7 @@ type WebServer struct { regexWebServices map[string][]*webServiceType webServicesLock sync.RWMutex webServicesList []*webServiceType + fallbackService *webServiceType websocketServices map[string]map[string]*websocketServiceType websocketServicesLock sync.RWMutex @@ -73,6 +74,8 @@ type WebServer struct { dynamicStatics map[string]map[string]*string hostStatics map[string][]*staticType staticsByHostLock sync.RWMutex + staticFSByHost map[string]map[string]http.FileSystem + staticFSExclusive map[string]bool // 过滤器与拦截器 inFilters []func(*map[string]any, *Request, *Response, *log.Logger) any @@ -141,6 +144,8 @@ func NewWebServer() *WebServer { fileStatics: make(map[string]map[string]*string), dynamicStatics: make(map[string]map[string]*string), hostStatics: make(map[string][]*staticType), + staticFSByHost: make(map[string]map[string]http.FileSystem), + staticFSExclusive: make(map[string]bool), webAuthCheckers: make(map[int]func(int, *log.Logger, *string, map[string]any, *Request, *Response, *WebServiceOptions) (pass bool, object any)), injectObjects: make(map[reflect.Type]any), injectFunctions: make(map[reflect.Type]func() any), diff --git a/service.go b/service.go index 264a3fd..a598441 100644 --- a/service.go +++ b/service.go @@ -155,6 +155,25 @@ func (ws *WebServer) Register(method, path string, serviceFunc any) *webServiceT return ws.Host("*").Register(method, path, serviceFunc) } +// SetFallback sets the handler used after static files and registered routes miss. +func SetFallback(serviceFunc any) *webServiceType { + return DefaultServer.SetFallback(serviceFunc) +} + +func (ws *WebServer) SetFallback(serviceFunc any) *webServiceType { + s, err := makeCachedService(serviceFunc) + if err != nil { + return &webServiceType{} + } + s.host = "*" + s.method = "*" + s.path = "*" + ws.webServicesLock.Lock() + ws.fallbackService = s + ws.webServicesLock.Unlock() + return s +} + // RegisterWebsocket 注册一个 WebSocket 服务 (使用默认 Host "*") func RegisterWebsocket(path string, serviceFunc any) *websocketServiceType { return DefaultServer.RegisterWebsocket(path, serviceFunc) @@ -632,4 +651,3 @@ func (ws *WebServer) initWebDev(logger *log.Logger) { }) }) } - diff --git a/static.go b/static.go index c364b21..087a41c 100644 --- a/static.go +++ b/static.go @@ -3,14 +3,22 @@ package service import ( "apigo.cc/go/file" "apigo.cc/go/log" + "io" "mime" "net/http" "net/url" + pathpkg "path" "path/filepath" + "sort" "strings" "time" ) +type staticFSMatch struct { + fileSystem http.FileSystem + name string +} + // Static 注册静态文件目录 func (hc *HostContext) Static(path, rootPath string) *HostContext { host := hc.host @@ -30,6 +38,61 @@ func (ws *WebServer) Static(path, rootPath string) { ws.Host("*").Static(path, rootPath) } +// StaticFS registers a custom static file source for a URL prefix. +func (hc *HostContext) StaticFS(path string, source http.FileSystem) *HostContext { + host := hc.host + if host == "*" { + host = "" + } + hc.ws.staticsByHostLock.Lock() + if hc.ws.staticFSByHost[host] == nil { + hc.ws.staticFSByHost[host] = map[string]http.FileSystem{} + } + hc.ws.staticFSByHost[host][path] = source + hc.ws.staticsByHostLock.Unlock() + return hc +} + +// ReplaceStaticFS atomically replaces custom static file sources for a host. +func ReplaceStaticFS(host string, config map[string]http.FileSystem) { + DefaultServer.ReplaceStaticFS(host, config) +} + +func (ws *WebServer) ReplaceStaticFS(host string, config map[string]http.FileSystem) { + ws.replaceStaticFS(host, config, false) +} + +// ReplaceStaticFSExclusive atomically replaces custom static file sources for +// a host and stops custom StaticFS fallback after that host is selected. +func ReplaceStaticFSExclusive(host string, config map[string]http.FileSystem) { + DefaultServer.ReplaceStaticFSExclusive(host, config) +} + +func (ws *WebServer) ReplaceStaticFSExclusive(host string, config map[string]http.FileSystem) { + ws.replaceStaticFS(host, config, true) +} + +func (ws *WebServer) replaceStaticFS(host string, config map[string]http.FileSystem, exclusive bool) { + if host == "*" { + host = "" + } + next := make(map[string]http.FileSystem, len(config)) + for route, source := range config { + if source != nil { + next[route] = source + } + } + ws.staticsByHostLock.Lock() + if len(next) == 0 { + delete(ws.staticFSByHost, host) + delete(ws.staticFSExclusive, host) + } else { + ws.staticFSByHost[host] = next + ws.staticFSExclusive[host] = exclusive + } + ws.staticsByHostLock.Unlock() +} + // StaticByHost 为指定域名注册静态文件目录 func StaticByHost(path, rootPath, host string) { DefaultServer.StaticByHost(path, rootPath, host) @@ -81,13 +144,48 @@ func (ws *WebServer) getStaticFilePath(requestPath, host string) string { ws.staticsByHostLock.RLock() defer ws.staticsByHostLock.RUnlock() - // 优先匹配指定域名的配置 - if filePath := ws.findMatchedPathSorted(ws.hostStatics[host], requestPath); filePath != "" { - return filePath + for _, candidate := range hostCandidates(host) { + if candidate == "*" { + candidate = "" + } + if filePath := ws.findMatchedPathSorted(ws.hostStatics[candidate], requestPath); filePath != "" { + return filePath + } } + return "" +} - // 匹配全局配置 - return ws.findMatchedPathSorted(ws.hostStatics[""], requestPath) +func (ws *WebServer) getStaticFSMatches(requestPath, host string) []staticFSMatch { + requestPath, _ = url.PathUnescape(requestPath) + ws.staticsByHostLock.RLock() + defer ws.staticsByHostLock.RUnlock() + matches := make([]staticFSMatch, 0) + for _, candidate := range hostCandidates(host) { + if candidate == "*" { + candidate = "" + } + config, exists := ws.staticFSByHost[candidate] + if !exists { + continue + } + routes := make([]string, 0, len(config)) + for route := range config { + routes = append(routes, route) + } + sort.Slice(routes, func(i, j int) bool { return len(routes[i]) > len(routes[j]) }) + for _, route := range routes { + if !strings.HasPrefix(requestPath, route) { + continue + } + name := strings.TrimPrefix(requestPath, route) + name = strings.TrimPrefix(pathpkg.Clean("/"+name), "/") + matches = append(matches, staticFSMatch{fileSystem: config[route], name: name}) + } + if ws.staticFSExclusive[candidate] { + break + } + } + return matches } func (ws *WebServer) findMatchedPathSorted(config []*staticType, requestPath string) string { @@ -100,6 +198,11 @@ func (ws *WebServer) findMatchedPathSorted(config []*staticType, requestPath str } func (ws *WebServer) processStatic(requestPath string, request *Request, response *Response, logger *log.Logger) bool { + for _, match := range ws.getStaticFSMatches(requestPath, request.Host) { + if ws.processStaticFS(match, request, response) { + return true + } + } filePath := ws.getStaticFilePath(requestPath, request.Host) if filePath == "" { return false @@ -159,6 +262,93 @@ func (ws *WebServer) processStatic(requestPath string, request *Request, respons return false } - _, _ = response.WriteFiltered(data) + if ws.webDevEnabled { + _, _ = response.WriteFiltered(data) + } else { + _, _ = response.Write(data) + } return true } + +func (ws *WebServer) processStaticFS(match staticFSMatch, request *Request, response *Response) bool { + name := match.name + opened, err := match.fileSystem.Open(name) + if err != nil { + return false + } + defer func() { _ = opened.Close() }() + info, err := opened.Stat() + if err != nil { + return false + } + if info.IsDir() { + _ = opened.Close() + indexFiles := ws.Config.IndexFiles + if len(indexFiles) == 0 { + indexFiles = []string{"index.html", "index.htm"} + } + found := false + for _, indexFile := range indexFiles { + candidate := pathpkg.Join(name, indexFile) + opened, err = match.fileSystem.Open(candidate) + if err != nil { + continue + } + info, err = opened.Stat() + if err == nil && !info.IsDir() { + name = candidate + found = true + break + } + _ = opened.Close() + } + if !found { + return false + } + } + if info.IsDir() { + return false + } + seeker, ok := opened.(interface { + Read([]byte) (int, error) + Seek(int64, int) (int64, error) + }) + if !ok { + return false + } + response.Header().Del(ws.usedDeviceIdKey) + response.Header().Del(ws.usedSessionIdKey) + contentType := mime.TypeByExtension(filepath.Ext(name)) + if contentType != "" { + response.Header().Set("Content-Type", contentType) + } + if ws.webDevEnabled && strings.HasPrefix(contentType, "text/html") { + data, readErr := io.ReadAll(seeker) + if readErr != nil { + return false + } + response.Header().Set("Last-Modified", info.ModTime().UTC().Format(http.TimeFormat)) + _, _ = response.WriteFiltered(data) + return true + } + http.ServeContent(&staticResponseWriter{response: response}, request.Request, info.Name(), info.ModTime(), seeker) + return true +} + +type staticResponseWriter struct { + response *Response +} + +func (w *staticResponseWriter) Header() http.Header { + return w.response.Header().H +} + +func (w *staticResponseWriter) WriteHeader(statusCode int) { + w.response.WriteHeader(statusCode) +} + +func (w *staticResponseWriter) Write(data []byte) (int, error) { + return w.response.Write(data) +} + +var _ http.ResponseWriter = (*staticResponseWriter)(nil) diff --git a/static_test.go b/static_test.go index 207f6b9..6ff6050 100644 --- a/static_test.go +++ b/static_test.go @@ -8,6 +8,15 @@ import ( "testing" ) +func writeStaticFixture(t *testing.T, content string) http.FileSystem { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(content), 0644); err != nil { + t.Fatal(err) + } + return http.Dir(dir) +} + func TestStaticService(t *testing.T) { // 创建临时测试目录和文件 tempDir, _ := os.MkdirTemp("", "static_test") @@ -77,3 +86,62 @@ func TestHostStaticService(t *testing.T) { t.Errorf("Expected 404 for mismatched host, got %d", w2.Code) } } + +func TestStaticFSHostCandidatesAndRange(t *testing.T) { + ws := NewWebServer() + ws.ReplaceStaticFS("*", map[string]http.FileSystem{"/": writeStaticFixture(t, "default")}) + ws.ReplaceStaticFS(":8081", map[string]http.FileSystem{"/": writeStaticFixture(t, "port")}) + ws.ReplaceStaticFS("aaa.com", map[string]http.FileSystem{"/": writeStaticFixture(t, "host")}) + ws.ReplaceStaticFS("aaa.com:8081", map[string]http.FileSystem{"/": writeStaticFixture(t, "exact")}) + handler := &RouteHandler{ws: ws} + + for _, test := range []struct { + host string + want string + }{ + {"aaa.com:8081", "exact"}, + {"aaa.com:9090", "host"}, + {"other.com:8081", "port"}, + {"other.com:9090", "default"}, + } { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Host = test.host + out := httptest.NewRecorder() + handler.ServeHTTP(out, req) + if out.Code != http.StatusOK || out.Body.String() != test.want { + t.Fatalf("host %s: got %d %q, want 200 %q", test.host, out.Code, out.Body.String(), test.want) + } + } + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Host = "aaa.com:8081" + req.Header.Set("Range", "bytes=1-2") + out := httptest.NewRecorder() + handler.ServeHTTP(out, req) + if out.Code != http.StatusPartialContent || out.Body.String() != "xa" { + t.Fatalf("range response = %d %q", out.Code, out.Body.String()) + } +} + +func TestStaticFSExclusiveStopsFileFallback(t *testing.T) { + ws := NewWebServer() + ws.ReplaceStaticFS("*", map[string]http.FileSystem{"/": writeStaticFixture(t, "default")}) + ws.ReplaceStaticFSExclusive("isolated.example", map[string]http.FileSystem{"/": writeStaticFixture(t, "isolated")}) + handler := &RouteHandler{ws: ws} + + request := httptest.NewRequest(http.MethodGet, "/missing.txt", nil) + request.Host = "isolated.example" + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusNotFound { + t.Fatalf("exclusive host missing file returned %d, want 404", response.Code) + } + + request = httptest.NewRequest(http.MethodGet, "/", nil) + request.Host = "unknown.example" + response = httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusOK || response.Body.String() != "default" { + t.Fatalf("unmatched host = %d %q, want default", response.Code, response.Body.String()) + } +}