Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c412e551c4 | ||
|
|
0a3fe19a56 |
11
CHANGELOG.md
11
CHANGELOG.md
@ -1,5 +1,16 @@
|
|||||||
# CHANGELOG - go/service
|
# 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`,静态文件等确实需要输出过滤器处理的内容可显式进入缓冲流程。
|
||||||
|
- **低代码字节桥接**: 新增 `Response.WriteBytes`,可将动态运行时的字节数组无损写入 SSE 或二进制响应。
|
||||||
|
|
||||||
## v1.5.22 (2026-07-19)
|
## v1.5.22 (2026-07-19)
|
||||||
- **服务发现升级**: 升级 `discover` 至 `v1.5.8`,继承 Redis 类型化注册、心跳、注销和节点拉取流程。
|
- **服务发现升级**: 升级 `discover` 至 `v1.5.8`,继承 Redis 类型化注册、心跳、注销和节点拉取流程。
|
||||||
- **依赖对齐**: 随模块图同步升级 `cast`、`config`、`crypto`、`encoding`、`file`、`http`、`id`、`log`、`rand`、`safe`、`shell` 的 patch 版本。
|
- **依赖对齐**: 随模块图同步升级 `cast`、`config`、`crypto`、`encoding`、`file`、`http`、`id`、`log`、`rand`、`safe`、`shell` 的 patch 版本。
|
||||||
|
|||||||
@ -75,11 +75,17 @@ func main() {
|
|||||||
|
|
||||||
### 4. 增强插件
|
### 4. 增强插件
|
||||||
- **静态文件**: `service.Static("/ui", "./static_dir")` 或 `service.Host("example.com").Static("/ui", "./static_dir")`
|
- **静态文件**: `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")`
|
- **URL 重写**: `service.Rewrite("/old", "/new")`
|
||||||
- **反向代理**: `service.Proxy(0, "/api", "other_app", "/api")`
|
- **反向代理**: `service.Proxy(0, "/api", "other_app", "/api")`
|
||||||
- **文档生成**: `service.MakeDocument()` 返回全量接口描述
|
- **文档生成**: `service.MakeDocument()` 返回全量接口描述
|
||||||
- **依赖注入**: `service.GetInjectT[T]()` 快速获取已注入的对象或组件
|
- **依赖注入**: `service.GetInjectT[T]()` 快速获取已注入的对象或组件
|
||||||
|
|
||||||
|
低代码需要转发二进制或 SSE 流时,使用 `response.WriteBytes(chunk)` 原样写入动态运行时提供的字节数组;文本输出继续使用 `response.WriteString(text)`。`Write`、`WriteBytes` 和 `WriteString` 都直接写入响应并保持流式行为,不会被全局输出过滤器强制缓冲;确实需要过滤器处理的内容应显式使用 `response.WriteFiltered(bytes)`。
|
||||||
|
|
||||||
|
自定义静态源必须返回可 `Seek` 的 `http.File`。生产模式下静态内容直接流式输出并绕过 output filter;`EnableWebDev` 仅会为 HTML 显式缓冲内容,用于注入开发热刷新脚本。
|
||||||
|
|
||||||
## 配置指南 (ServiceConfig)
|
## 配置指南 (ServiceConfig)
|
||||||
|
|
||||||
详细配置项可查阅 `config.go` 中的 `ServiceConfig` 结构。通过 `config.Load` 支持从 `env.yml` 或环境变量加载。
|
详细配置项可查阅 `config.go` 中的 `ServiceConfig` 结构。通过 `config.Load` 支持从 `env.yml` 或环境变量加载。
|
||||||
|
|||||||
19
TEST.md
19
TEST.md
@ -1,5 +1,17 @@
|
|||||||
# Service Module Test Report
|
# 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` 验证直接响应即使存在输出过滤器也保持实时写入。
|
||||||
|
- `TestResponseWriteFilteredOptsIntoOutputFilterBuffer` 验证显式过滤写入仍可由静态 HTML 注入等后置过滤器处理。
|
||||||
|
- `go test -v ./...` 与 `go test -bench=. ./...` 全部通过。
|
||||||
|
|
||||||
## v1.5.22 验证
|
## v1.5.22 验证
|
||||||
- `go list -m all`:模块图使用 `discover v1.5.8`、`redis v1.5.11` 与稳定版 `redigo v1.9.3`。
|
- `go list -m all`:模块图使用 `discover v1.5.8`、`redis v1.5.11` 与稳定版 `redigo v1.9.3`。
|
||||||
- `go test -v ./...` 与 `go test -bench=. ./...` 全部通过。
|
- `go test -v ./...` 与 `go test -bench=. ./...` 全部通过。
|
||||||
@ -9,9 +21,9 @@
|
|||||||
- `go test -v ./...` 与 `go test -bench=. ./...` 全部通过。
|
- `go test -v ./...` 与 `go test -bench=. ./...` 全部通过。
|
||||||
|
|
||||||
## 性能测试 (Benchmark)
|
## 性能测试 (Benchmark)
|
||||||
- 测试日期: 2026-07-19
|
- 测试日期: 2026-08-17
|
||||||
- 版本: v1.5.22
|
- 版本: v1.5.23
|
||||||
- 指标: `BenchmarkRouting`: **2269 ns/op**
|
- 指标: `BenchmarkRouting`: **2312 ns/op**
|
||||||
- 环境: Darwin / Apple M3 Max
|
- 环境: Darwin / Apple M3 Max
|
||||||
|
|
||||||
## 单元测试覆盖 (Unit Test)
|
## 单元测试覆盖 (Unit Test)
|
||||||
@ -39,6 +51,7 @@
|
|||||||
- [x] Client Keys: Device-Id/Session-Id 应答头条件化(请求有则不应答)、静态文件/WebSocket 仅 Cookie
|
- [x] Client Keys: Device-Id/Session-Id 应答头条件化(请求有则不应答)、静态文件/WebSocket 仅 Cookie
|
||||||
- [x] Cookie 头智能过滤: 排除列表中 key 从 Cookie 内容中剔除,保留业务 Cookie
|
- [x] Cookie 头智能过滤: 排除列表中 key 从 Cookie 内容中剔除,保留业务 Cookie
|
||||||
- [x] Response Body: 200 响应和 dev 模式下 keepBody 捕获
|
- [x] Response Body: 200 响应和 dev 模式下 keepBody 捕获
|
||||||
|
- [x] Response Dynamic Bytes: 低代码动态数组通过 `WriteBytes` 写入时保持 UTF-8 字节不变
|
||||||
|
|
||||||
## 基础设施对齐验证
|
## 基础设施对齐验证
|
||||||
- [x] 成功集成 `apigo.cc/go/cast` 用于参数解析与类型强转。
|
- [x] 成功集成 `apigo.cc/go/cast` 用于参数解析与类型强转。
|
||||||
|
|||||||
65
handler.go
65
handler.go
@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"reflect"
|
"reflect"
|
||||||
@ -152,6 +153,11 @@ func (rh *RouteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s, wsc = ws.findService(r.Method, host, path, args)
|
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)
|
// 4. 参数解析 (Form & Body)
|
||||||
parseRequestArgs(request, args)
|
parseRequestArgs(request, args)
|
||||||
@ -204,7 +210,12 @@ func (rh *RouteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
filter:
|
filter:
|
||||||
// 7. 后置过滤器 (即使 response.changed 也要执行,比如静态文件的 HTML 注入)
|
// Direct response writes are already on the wire and intentionally bypass
|
||||||
|
// output filters. Static responses opt in through WriteFiltered.
|
||||||
|
if response.changed && !response.filteredWrite && result == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 7. 后置过滤器
|
||||||
for _, filter := range ws.outFilters {
|
for _, filter := range ws.outFilters {
|
||||||
newResult, done := filter(args, request, response, result, requestLogger)
|
newResult, done := filter(args, request, response, result, requestLogger)
|
||||||
if newResult != nil {
|
if newResult != nil {
|
||||||
@ -223,7 +234,7 @@ filter:
|
|||||||
// 过滤器模式:所有内容都应该从 result 或 response.body 中写出
|
// 过滤器模式:所有内容都应该从 result 或 response.body 中写出
|
||||||
if result != nil {
|
if result != nil {
|
||||||
outputResult(response, result)
|
outputResult(response, result)
|
||||||
} else if response.changed {
|
} else if response.filteredWrite {
|
||||||
response.PhysicalWrite(response.body)
|
response.PhysicalWrite(response.body)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -235,21 +246,57 @@ filter:
|
|||||||
}
|
}
|
||||||
|
|
||||||
func hostOnly(host string) string {
|
func hostOnly(host string) string {
|
||||||
h, _, _ := strings.Cut(host, ":")
|
h, _, err := net.SplitHostPort(host)
|
||||||
|
if err == nil {
|
||||||
|
return strings.Trim(h, "[]")
|
||||||
|
}
|
||||||
|
if strings.Count(host, ":") == 1 {
|
||||||
|
h, _, _ = strings.Cut(host, ":")
|
||||||
return h
|
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) {
|
func (ws *WebServer) findService(method, host, path string, args map[string]any) (*webServiceType, *websocketServiceType) {
|
||||||
ws.webServicesLock.RLock()
|
ws.webServicesLock.RLock()
|
||||||
defer ws.webServicesLock.RUnlock()
|
defer ws.webServicesLock.RUnlock()
|
||||||
|
|
||||||
// 1. 准备 Host 候选列表: "host:port", "host", ":port", "*"
|
// 1. 准备 Host 候选列表: "host:port", "host", ":port", "*"
|
||||||
hostOnly, port, _ := strings.Cut(host, ":")
|
hosts := hostCandidates(host)
|
||||||
hosts := []string{host}
|
|
||||||
if port != "" {
|
|
||||||
hosts = append(hosts, hostOnly, ":"+port)
|
|
||||||
}
|
|
||||||
hosts = append(hosts, "*")
|
|
||||||
|
|
||||||
// 2. 匹配 Web Service
|
// 2. 匹配 Web Service
|
||||||
for _, h := range hosts {
|
for _, h := range hosts {
|
||||||
|
|||||||
@ -81,3 +81,25 @@ func TestServeHTTP_Panic(t *testing.T) {
|
|||||||
t.Errorf("Expected status 500, got %d", w.Code)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
84
response.go
84
response.go
@ -4,8 +4,10 @@ import (
|
|||||||
"apigo.cc/go/cast"
|
"apigo.cc/go/cast"
|
||||||
"apigo.cc/go/file"
|
"apigo.cc/go/file"
|
||||||
"apigo.cc/go/jsmod"
|
"apigo.cc/go/jsmod"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"reflect"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Response 封装 http.ResponseWriter
|
// Response 封装 http.ResponseWriter
|
||||||
@ -16,6 +18,7 @@ type Response struct {
|
|||||||
body []byte `js:"-"`
|
body []byte `js:"-"`
|
||||||
outLen int `js:"-"`
|
outLen int `js:"-"`
|
||||||
changed bool `js:"-"`
|
changed bool `js:"-"`
|
||||||
|
filteredWrite bool `js:"-"`
|
||||||
headerWritten bool `js:"-"`
|
headerWritten bool `js:"-"`
|
||||||
dontLog200 bool `js:"-"`
|
dontLog200 bool `js:"-"`
|
||||||
dontLogArgs []string `js:"-"`
|
dontLogArgs []string `js:"-"`
|
||||||
@ -61,12 +64,6 @@ func (r *Response) Write(bytes []byte) (int, error) {
|
|||||||
r.changed = true
|
r.changed = true
|
||||||
r.outLen += len(bytes)
|
r.outLen += len(bytes)
|
||||||
|
|
||||||
// 如果有输出过滤器,我们必须先缓冲,不能直接写入网线,否则会导致重复输出
|
|
||||||
if r.server != nil && r.server.hasOutFilter {
|
|
||||||
r.body = append(r.body, bytes...)
|
|
||||||
return len(bytes), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 缓冲 body 用于日志记录
|
// 缓冲 body 用于日志记录
|
||||||
r.keepBody(bytes)
|
r.keepBody(bytes)
|
||||||
|
|
||||||
@ -80,6 +77,81 @@ func (r *Response) Write(bytes []byte) (int, error) {
|
|||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WriteFiltered buffers bytes for registered output filters. Direct Write calls
|
||||||
|
// remain streamable and bypass output filters.
|
||||||
|
func (r *Response) WriteFiltered(bytes []byte) (int, error) {
|
||||||
|
if r.server == nil || !r.server.hasOutFilter {
|
||||||
|
return r.Write(bytes)
|
||||||
|
}
|
||||||
|
r.checkWriteHeader()
|
||||||
|
r.changed = true
|
||||||
|
r.filteredWrite = true
|
||||||
|
r.outLen += len(bytes)
|
||||||
|
r.body = append(r.body, bytes...)
|
||||||
|
return len(bytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteBytes writes a byte array received through a dynamic runtime without
|
||||||
|
// converting its bytes to text first. This keeps UTF-8 stream chunks intact.
|
||||||
|
func (r *Response) WriteBytes(value any) (int, error) {
|
||||||
|
bytes, err := responseBytes(value)
|
||||||
|
if err != nil {
|
||||||
|
return 0, jsmod.MakeError(err)
|
||||||
|
}
|
||||||
|
return r.Write(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func responseBytes(value any) ([]byte, error) {
|
||||||
|
if value == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if bytes, ok := value.([]byte); ok {
|
||||||
|
return bytes, nil
|
||||||
|
}
|
||||||
|
if text, ok := value.(string); ok {
|
||||||
|
return []byte(text), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rv := reflect.ValueOf(value)
|
||||||
|
if rv.Kind() != reflect.Array && rv.Kind() != reflect.Slice {
|
||||||
|
return nil, fmt.Errorf("response bytes must be a string or byte array, got %T", value)
|
||||||
|
}
|
||||||
|
bytes := make([]byte, rv.Len())
|
||||||
|
for i := 0; i < rv.Len(); i++ {
|
||||||
|
item := rv.Index(i)
|
||||||
|
for item.IsValid() && (item.Kind() == reflect.Interface || item.Kind() == reflect.Pointer) {
|
||||||
|
if item.IsNil() {
|
||||||
|
return nil, fmt.Errorf("response byte at index %d is nil", i)
|
||||||
|
}
|
||||||
|
item = item.Elem()
|
||||||
|
}
|
||||||
|
if !item.IsValid() {
|
||||||
|
return nil, fmt.Errorf("response byte at index %d is nil", i)
|
||||||
|
}
|
||||||
|
switch item.Kind() {
|
||||||
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||||
|
if item.Uint() > 255 {
|
||||||
|
return nil, fmt.Errorf("response byte at index %d is out of range", i)
|
||||||
|
}
|
||||||
|
bytes[i] = byte(item.Uint())
|
||||||
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||||
|
if item.Int() < 0 || item.Int() > 255 {
|
||||||
|
return nil, fmt.Errorf("response byte at index %d is out of range", i)
|
||||||
|
}
|
||||||
|
bytes[i] = byte(item.Int())
|
||||||
|
case reflect.Float32, reflect.Float64:
|
||||||
|
n := item.Float()
|
||||||
|
if n < 0 || n > 255 || n != float64(byte(n)) {
|
||||||
|
return nil, fmt.Errorf("response byte at index %d is out of range", i)
|
||||||
|
}
|
||||||
|
bytes[i] = byte(n)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("response byte at index %d has type %s", i, item.Kind())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bytes, nil
|
||||||
|
}
|
||||||
|
|
||||||
// keepBody 缓冲数据用于日志记录,限制大小防止内存问题
|
// keepBody 缓冲数据用于日志记录,限制大小防止内存问题
|
||||||
func (r *Response) keepBody(bytes []byte) {
|
func (r *Response) keepBody(bytes []byte) {
|
||||||
maxBuf := 200
|
maxBuf := 200
|
||||||
|
|||||||
44
response_test.go
Normal file
44
response_test.go
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResponseWriteBytes(t *testing.T) {
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
response := NewResponse(recorder, nil)
|
||||||
|
want := "data: 第一条\n\n"
|
||||||
|
input := make([]any, len([]byte(want)))
|
||||||
|
for i, value := range []byte(want) {
|
||||||
|
input[i] = int64(value)
|
||||||
|
}
|
||||||
|
if _, err := response.WriteBytes(input); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := recorder.Body.String(); got != want {
|
||||||
|
t.Fatalf("body = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResponseWriteBypassesOutputFilterBuffer(t *testing.T) {
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
response := NewResponse(recorder, &WebServer{hasOutFilter: true})
|
||||||
|
if _, err := response.Write([]byte("stream")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if recorder.Body.String() != "stream" || response.filteredWrite {
|
||||||
|
t.Fatalf("direct write was buffered: wire=%q buffer=%q", recorder.Body.String(), response.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResponseWriteFilteredOptsIntoOutputFilterBuffer(t *testing.T) {
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
response := NewResponse(recorder, &WebServer{hasOutFilter: true})
|
||||||
|
if _, err := response.WriteFiltered([]byte("html")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if recorder.Body.Len() != 0 || !response.filteredWrite || string(response.body) != "html" {
|
||||||
|
t.Fatalf("filtered write was not buffered: wire=%q buffer=%q", recorder.Body.String(), response.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -46,6 +46,7 @@ type WebServer struct {
|
|||||||
regexWebServices map[string][]*webServiceType
|
regexWebServices map[string][]*webServiceType
|
||||||
webServicesLock sync.RWMutex
|
webServicesLock sync.RWMutex
|
||||||
webServicesList []*webServiceType
|
webServicesList []*webServiceType
|
||||||
|
fallbackService *webServiceType
|
||||||
|
|
||||||
websocketServices map[string]map[string]*websocketServiceType
|
websocketServices map[string]map[string]*websocketServiceType
|
||||||
websocketServicesLock sync.RWMutex
|
websocketServicesLock sync.RWMutex
|
||||||
@ -73,6 +74,8 @@ type WebServer struct {
|
|||||||
dynamicStatics map[string]map[string]*string
|
dynamicStatics map[string]map[string]*string
|
||||||
hostStatics map[string][]*staticType
|
hostStatics map[string][]*staticType
|
||||||
staticsByHostLock sync.RWMutex
|
staticsByHostLock sync.RWMutex
|
||||||
|
staticFSByHost map[string]map[string]http.FileSystem
|
||||||
|
staticFSExclusive map[string]bool
|
||||||
|
|
||||||
// 过滤器与拦截器
|
// 过滤器与拦截器
|
||||||
inFilters []func(*map[string]any, *Request, *Response, *log.Logger) any
|
inFilters []func(*map[string]any, *Request, *Response, *log.Logger) any
|
||||||
@ -141,6 +144,8 @@ func NewWebServer() *WebServer {
|
|||||||
fileStatics: make(map[string]map[string]*string),
|
fileStatics: make(map[string]map[string]*string),
|
||||||
dynamicStatics: make(map[string]map[string]*string),
|
dynamicStatics: make(map[string]map[string]*string),
|
||||||
hostStatics: make(map[string][]*staticType),
|
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)),
|
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),
|
injectObjects: make(map[reflect.Type]any),
|
||||||
injectFunctions: make(map[reflect.Type]func() any),
|
injectFunctions: make(map[reflect.Type]func() any),
|
||||||
|
|||||||
20
service.go
20
service.go
@ -155,6 +155,25 @@ func (ws *WebServer) Register(method, path string, serviceFunc any) *webServiceT
|
|||||||
return ws.Host("*").Register(method, path, serviceFunc)
|
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 "*")
|
// RegisterWebsocket 注册一个 WebSocket 服务 (使用默认 Host "*")
|
||||||
func RegisterWebsocket(path string, serviceFunc any) *websocketServiceType {
|
func RegisterWebsocket(path string, serviceFunc any) *websocketServiceType {
|
||||||
return DefaultServer.RegisterWebsocket(path, serviceFunc)
|
return DefaultServer.RegisterWebsocket(path, serviceFunc)
|
||||||
@ -632,4 +651,3 @@ func (ws *WebServer) initWebDev(logger *log.Logger) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
198
static.go
198
static.go
@ -3,14 +3,22 @@ package service
|
|||||||
import (
|
import (
|
||||||
"apigo.cc/go/file"
|
"apigo.cc/go/file"
|
||||||
"apigo.cc/go/log"
|
"apigo.cc/go/log"
|
||||||
|
"io"
|
||||||
"mime"
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
pathpkg "path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type staticFSMatch struct {
|
||||||
|
fileSystem http.FileSystem
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
// Static 注册静态文件目录
|
// Static 注册静态文件目录
|
||||||
func (hc *HostContext) Static(path, rootPath string) *HostContext {
|
func (hc *HostContext) Static(path, rootPath string) *HostContext {
|
||||||
host := hc.host
|
host := hc.host
|
||||||
@ -30,6 +38,61 @@ func (ws *WebServer) Static(path, rootPath string) {
|
|||||||
ws.Host("*").Static(path, rootPath)
|
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 为指定域名注册静态文件目录
|
// StaticByHost 为指定域名注册静态文件目录
|
||||||
func StaticByHost(path, rootPath, host string) {
|
func StaticByHost(path, rootPath, host string) {
|
||||||
DefaultServer.StaticByHost(path, rootPath, host)
|
DefaultServer.StaticByHost(path, rootPath, host)
|
||||||
@ -81,13 +144,48 @@ func (ws *WebServer) getStaticFilePath(requestPath, host string) string {
|
|||||||
ws.staticsByHostLock.RLock()
|
ws.staticsByHostLock.RLock()
|
||||||
defer ws.staticsByHostLock.RUnlock()
|
defer ws.staticsByHostLock.RUnlock()
|
||||||
|
|
||||||
// 优先匹配指定域名的配置
|
for _, candidate := range hostCandidates(host) {
|
||||||
if filePath := ws.findMatchedPathSorted(ws.hostStatics[host], requestPath); filePath != "" {
|
if candidate == "*" {
|
||||||
|
candidate = ""
|
||||||
|
}
|
||||||
|
if filePath := ws.findMatchedPathSorted(ws.hostStatics[candidate], requestPath); filePath != "" {
|
||||||
return filePath
|
return filePath
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// 匹配全局配置
|
func (ws *WebServer) getStaticFSMatches(requestPath, host string) []staticFSMatch {
|
||||||
return ws.findMatchedPathSorted(ws.hostStatics[""], requestPath)
|
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 {
|
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 {
|
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)
|
filePath := ws.getStaticFilePath(requestPath, request.Host)
|
||||||
if filePath == "" {
|
if filePath == "" {
|
||||||
return false
|
return false
|
||||||
@ -159,6 +262,93 @@ func (ws *WebServer) processStatic(requestPath string, request *Request, respons
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ws.webDevEnabled {
|
||||||
|
_, _ = response.WriteFiltered(data)
|
||||||
|
} else {
|
||||||
_, _ = response.Write(data)
|
_, _ = response.Write(data)
|
||||||
|
}
|
||||||
return true
|
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)
|
||||||
|
|||||||
@ -8,6 +8,15 @@ import (
|
|||||||
"testing"
|
"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) {
|
func TestStaticService(t *testing.T) {
|
||||||
// 创建临时测试目录和文件
|
// 创建临时测试目录和文件
|
||||||
tempDir, _ := os.MkdirTemp("", "static_test")
|
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)
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user