feat(service): 增加动态站点路由能力(by AI)

This commit is contained in:
Star 2026-08-22 18:23:16 +08:00
parent 0a3fe19a56
commit c412e551c4
9 changed files with 376 additions and 15 deletions

View File

@ -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`,静态文件等确实需要输出过滤器处理的内容可显式进入缓冲流程。

View File

@ -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` 或环境变量加载。

View File

@ -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` 验证直接响应即使存在输出过滤器也保持实时写入。

View File

@ -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 {

View File

@ -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)
}
}
}

View File

@ -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),

View File

@ -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) {
})
})
}

202
static.go
View File

@ -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)

View File

@ -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())
}
}