Compare commits
1
Commits
cfecbff8f8
..
v1.3.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4993502bc |
@@ -12,6 +12,7 @@ NDJSON 流式响应、自定义路由与 TypeScript 客户端生成。
|
|||||||
- **NDJSON 流式**:`*fun.Stream` 逐行推送,支持首条消息 + 后续流
|
- **NDJSON 流式**:`*fun.Stream` 逐行推送,支持首条消息 + 后续流
|
||||||
- **自定义路由**(v1.3.0+):`BindRoute` 注册 GET/POST 回调、健康检查、通配符路径
|
- **自定义路由**(v1.3.0+):`BindRoute` 注册 GET/POST 回调、健康检查、通配符路径
|
||||||
- **请求体上限控制**(v1.3.3+):`SetBodyLimit` 支持大体积 multipart 上传
|
- **请求体上限控制**(v1.3.3+):`SetBodyLimit` 支持大体积 multipart 上传
|
||||||
|
- **CORS 跨域**(v1.3.5+):`f.CORS(origins ...string)` 白名单按需放行,预检 204 直答,覆盖 `/cell` 与全部自定义路由
|
||||||
- **TypeScript 客户端生成**:`BindServiceForGen` + `GenCode(fun.GenTs{})`,免基础设施即可生成,产物带 `result<T>` 归一化错误与拦截器
|
- **TypeScript 客户端生成**:`BindServiceForGen` + `GenCode(fun.GenTs{})`,免基础设施即可生成,产物带 `result<T>` 归一化错误与拦截器
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package fun
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/valyala/fasthttp"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
corsMaxAge = "86400" // 预检结果缓存 24h,减少浏览器 OPTIONS 探测
|
||||||
|
)
|
||||||
|
|
||||||
|
// CORS 配置跨域来源白名单,须在 Start 前调用;作用于全部端点(/cell、自定义路由与通配路由)。
|
||||||
|
// 按需放行:仅列出的来源会被放行——请求 Origin 命中白名单时回显该来源并允许
|
||||||
|
// 携带凭据(Cookie),未命中则不附加 CORS 头(浏览器侧自然拦截)。
|
||||||
|
//
|
||||||
|
// f.CORS("https://a.com", "https://b.com")
|
||||||
|
//
|
||||||
|
// 预检请求(OPTIONS 且带 Access-Control-Request-Method 头)直接以 204 应答,
|
||||||
|
// 不进入路由与 /cell 处理。
|
||||||
|
func (f *Fun) CORS(origins ...string) {
|
||||||
|
f.mustNotStarted("CORS")
|
||||||
|
whitelist := make(map[string]struct{}, len(origins))
|
||||||
|
for _, o := range origins {
|
||||||
|
if o != "" {
|
||||||
|
whitelist[strings.ToLower(o)] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.corsOrigins = whitelist
|
||||||
|
}
|
||||||
|
|
||||||
|
// corsEnabled CORS 是否已配置
|
||||||
|
func (f *Fun) corsEnabled() bool {
|
||||||
|
return len(f.corsOrigins) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCors 处理 CORS:Origin 命中白名单的请求附加跨域响应头,
|
||||||
|
// 预检请求(OPTIONS + Access-Control-Request-Method)在此直接应答并返回 true。
|
||||||
|
func (f *Fun) handleCors(fastCtx *fasthttp.RequestCtx) bool {
|
||||||
|
if !f.corsEnabled() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
origin := string(fastCtx.Request.Header.Peek("Origin"))
|
||||||
|
if origin == "" {
|
||||||
|
return false // 同源请求与服务器到服务器调用,无跨域语义
|
||||||
|
}
|
||||||
|
if _, ok := f.corsOrigins[strings.ToLower(origin)]; !ok {
|
||||||
|
return false // 未命中白名单:不加 CORS 头,交给浏览器拦截
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &fastCtx.Response.Header
|
||||||
|
h.Set("Access-Control-Allow-Origin", origin)
|
||||||
|
h.Set("Access-Control-Allow-Credentials", "true")
|
||||||
|
// 放行结果随 Origin 变化,防中间层缓存错配
|
||||||
|
h.Add("Vary", "Origin")
|
||||||
|
|
||||||
|
if !fastCtx.IsOptions() || fastCtx.Request.Header.Peek("Access-Control-Request-Method") == nil {
|
||||||
|
return false // 实际请求:头已附加,继续正常路由
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预检请求:回显浏览器声明的目标方法与请求头,24h 内同请求免预检
|
||||||
|
h.Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
|
if reqHeaders := fastCtx.Request.Header.Peek("Access-Control-Request-Headers"); len(reqHeaders) > 0 {
|
||||||
|
h.Set("Access-Control-Allow-Headers", string(reqHeaders))
|
||||||
|
} else {
|
||||||
|
h.Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
}
|
||||||
|
h.Set("Access-Control-Max-Age", corsMaxAge)
|
||||||
|
fastCtx.SetStatusCode(fasthttp.StatusNoContent)
|
||||||
|
return true
|
||||||
|
}
|
||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
package fun
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func startCorsServer(t *testing.T, port uint16, withCors bool) *Fun {
|
||||||
|
t.Helper()
|
||||||
|
f := New()
|
||||||
|
if err := f.BindService(&TestSvc{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if withCors {
|
||||||
|
f.CORS("https://a.com", "HTTPS://B.Com") // 大小写归一化
|
||||||
|
}
|
||||||
|
f.BindRoute("GET", "/ping", func(ctx *RouteCtx) error {
|
||||||
|
ctx.RequestCtx.WriteString("pong")
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
go f.Start(port)
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func corsGet(t *testing.T, url, origin string) *http.Response {
|
||||||
|
t.Helper()
|
||||||
|
req, _ := http.NewRequest("GET", url, nil)
|
||||||
|
if origin != "" {
|
||||||
|
req.Header.Set("Origin", origin)
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCorsWhitelistEcho(t *testing.T) {
|
||||||
|
startCorsServer(t, 39201, true)
|
||||||
|
resp := corsGet(t, "http://127.0.0.1:39201/ping", "https://a.com")
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://a.com" {
|
||||||
|
t.Fatalf("allow-origin = %q, want echo", got)
|
||||||
|
}
|
||||||
|
if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "true" {
|
||||||
|
t.Fatalf("allow-credentials = %q", got)
|
||||||
|
}
|
||||||
|
if got := resp.Header.Get("Vary"); !strings.Contains(got, "Origin") {
|
||||||
|
t.Fatalf("vary = %q, want Origin", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 白名单大小写不敏感
|
||||||
|
resp2 := corsGet(t, "http://127.0.0.1:39201/ping", "https://B.COM")
|
||||||
|
defer resp2.Body.Close()
|
||||||
|
if got := resp2.Header.Get("Access-Control-Allow-Origin"); got != "https://B.COM" {
|
||||||
|
t.Fatalf("allow-origin = %q, want case-insensitive match", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCorsOriginNotWhitelisted(t *testing.T) {
|
||||||
|
startCorsServer(t, 39202, true)
|
||||||
|
resp := corsGet(t, "http://127.0.0.1:39202/ping", "https://evil.com")
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d(未命中白名单请求本身仍应正常处理)", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||||
|
t.Fatalf("allow-origin = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCorsPreflight(t *testing.T) {
|
||||||
|
startCorsServer(t, 39203, true)
|
||||||
|
req, _ := http.NewRequest("OPTIONS", "http://127.0.0.1:39203/cell", nil)
|
||||||
|
req.Header.Set("Origin", "https://a.com")
|
||||||
|
req.Header.Set("Access-Control-Request-Method", "POST")
|
||||||
|
req.Header.Set("Access-Control-Request-Headers", "content-type, x-custom")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Fatalf("status = %d, want 204", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://a.com" {
|
||||||
|
t.Fatalf("allow-origin = %q", got)
|
||||||
|
}
|
||||||
|
if got := resp.Header.Get("Access-Control-Allow-Methods"); !strings.Contains(got, "POST") {
|
||||||
|
t.Fatalf("allow-methods = %q, want POST", got)
|
||||||
|
}
|
||||||
|
// 请求头回显
|
||||||
|
if got := resp.Header.Get("Access-Control-Allow-Headers"); !strings.Contains(got, "x-custom") {
|
||||||
|
t.Fatalf("allow-headers = %q, want echo x-custom", got)
|
||||||
|
}
|
||||||
|
if got := resp.Header.Get("Access-Control-Max-Age"); got != "86400" {
|
||||||
|
t.Fatalf("max-age = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCorsHeadersOnCellRpc(t *testing.T) {
|
||||||
|
startCorsServer(t, 39204, true)
|
||||||
|
body := `{"ServiceName":"TestSvc","MethodName":"Hello","Data":{"Name":"tom","Age":1}}`
|
||||||
|
req, _ := http.NewRequest("POST", "http://127.0.0.1:39204/cell", bytes.NewBufferString(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Origin", "https://a.com")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://a.com" {
|
||||||
|
t.Fatalf("/cell 响应缺少 CORS 头: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCorsNotConfigured(t *testing.T) {
|
||||||
|
startCorsServer(t, 39205, false)
|
||||||
|
resp := corsGet(t, "http://127.0.0.1:39205/ping", "https://a.com")
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
|
||||||
|
t.Fatalf("未配置 CORS 却有 allow-origin = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
-6
@@ -1,6 +1,6 @@
|
|||||||
# fun 框架(github.com/cyi-cc/fun)使用文档
|
# fun 框架(github.com/cyi-cc/fun)使用文档
|
||||||
|
|
||||||
> 适用版本:**v1.3.3**(当前最新发布)。基于 fasthttp 的单端点 RPC 框架,
|
> 适用版本:**v1.3.5**(当前最新发布)。基于 fasthttp 的单端点 RPC 框架,
|
||||||
> 自带依赖注入、Guard 鉴权、NDJSON 流式响应、自定义路由与 TypeScript 客户端生成。
|
> 自带依赖注入、Guard 鉴权、NDJSON 流式响应、自定义路由与 TypeScript 客户端生成。
|
||||||
|
|
||||||
## 版本沿革
|
## 版本沿革
|
||||||
@@ -12,6 +12,8 @@
|
|||||||
| v1.3.1 | TS 客户端可靠性:所有失败统一归一为 Result 并经过响应拦截器 |
|
| v1.3.1 | TS 客户端可靠性:所有失败统一归一为 Result 并经过响应拦截器 |
|
||||||
| v1.3.2 | 每请求上下文(request/stream options + `state`)与免基础设施的生成期注册 `BindServiceForGen` |
|
| v1.3.2 | 每请求上下文(request/stream options + `state`)与免基础设施的生成期注册 `BindServiceForGen` |
|
||||||
| v1.3.3 | 新增 `SetBodyLimit`:自定义路由可放宽请求体上限,支持大体积 multipart 上传 |
|
| v1.3.3 | 新增 `SetBodyLimit`:自定义路由可放宽请求体上限,支持大体积 multipart 上传 |
|
||||||
|
| v1.3.4 | 错误通道 API、正确性修复与服务端加固 |
|
||||||
|
| v1.3.5 | 新增 `CORS`:来源白名单按需放行,预检 204 直答,覆盖 `/cell` 与全部自定义路由 |
|
||||||
|
|
||||||
## 1. 启动与服务注册
|
## 1. 启动与服务注册
|
||||||
|
|
||||||
@@ -92,7 +94,22 @@ f.BindRoute("POST", "/upload", func(c *fun.RouteCtx) error {
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
## 6. 流式响应(NDJSON)
|
## 6. CORS 跨域
|
||||||
|
|
||||||
|
```go
|
||||||
|
f.CORS("https://a.com", "https://b.com") // Start 前调用,可变参数白名单
|
||||||
|
```
|
||||||
|
|
||||||
|
- **按需放行**:请求 Origin 命中白名单(大小写不敏感)才回显该来源并允许
|
||||||
|
携带凭据(`Access-Control-Allow-Credentials: true`);未命中不附加任何
|
||||||
|
CORS 头,交给浏览器自然拦截。
|
||||||
|
- **覆盖全部端点**:`/cell`、自定义路由与通配路由统一生效;预检请求
|
||||||
|
(OPTIONS + `Access-Control-Request-Method`)由框架直接以 204 应答,
|
||||||
|
不进入业务处理;请求头按 `Access-Control-Request-Headers` 回显,
|
||||||
|
预检结果缓存 24h。
|
||||||
|
- 未调用 `CORS` 则不附加任何跨域头,行为与之前完全一致。
|
||||||
|
|
||||||
|
## 7. 流式响应(NDJSON)
|
||||||
|
|
||||||
```go
|
```go
|
||||||
st := &fun.Stream{}
|
st := &fun.Stream{}
|
||||||
@@ -108,7 +125,7 @@ return st, nil
|
|||||||
`Content-Type: application/x-ndjson`,每行一个 JSON;合法零消息流正常结束;
|
`Content-Type: application/x-ndjson`,每行一个 JSON;合法零消息流正常结束;
|
||||||
业务出错在建流前返回普通 Result;`OnClose` 注册清理回调。
|
业务出错在建流前返回普通 Result;`OnClose` 注册清理回调。
|
||||||
|
|
||||||
## 7. TS 客户端生成与请求上下文(v1.3.2 核心)
|
## 8. TS 客户端生成与请求上下文(v1.3.2 核心)
|
||||||
|
|
||||||
```go
|
```go
|
||||||
f := fun.GetFun()
|
f := fun.GetFun()
|
||||||
@@ -153,17 +170,17 @@ c.addResponseInterceptor((svc, m, result, context) => {
|
|||||||
旧三参签名仍兼容。所有失败(网络、HTTP、HTML、非法 JSON、取消、拦截器异常)
|
旧三参签名仍兼容。所有失败(网络、HTTP、HTML、非法 JSON、取消、拦截器异常)
|
||||||
统一归一为 Result 并**必经响应拦截器**,不存在绕过拦截器的错误路径。
|
统一归一为 Result 并**必经响应拦截器**,不存在绕过拦截器的错误路径。
|
||||||
|
|
||||||
## 8. 依赖注入(box.go)
|
## 9. 依赖注入(box.go)
|
||||||
|
|
||||||
- `fun.Wired[T]()`:按 `*T` 建单例;先注入 `fun:"auto"` 字段(缺则递归创建),
|
- `fun.Wired[T]()`:按 `*T` 建单例;先注入 `fun:"auto"` 字段(缺则递归创建),
|
||||||
再调 `New()`(无参;连接类资源在此初始化,失败可 log.Fatalf)。
|
再调 `New()`(无参;连接类资源在此初始化,失败可 log.Fatalf)。
|
||||||
- 启动顺序:先 `Wired` 基础配置/平台单例,再 `BindService`。
|
- 启动顺序:先 `Wired` 基础配置/平台单例,再 `BindService`。
|
||||||
|
|
||||||
## 9. 常见坑
|
## 10. 常见坑
|
||||||
|
|
||||||
- DTO 用普通 `int`/float/map → 注册期 panic;用 int64/字符串/指针。
|
- DTO 用普通 `int`/float/map → 注册期 panic;用 int64/字符串/指针。
|
||||||
- 非指针字段漏传 → 运行期 "must be a pointer or have a corresponding field"。
|
- 非指针字段漏传 → 运行期 "must be a pointer or have a corresponding field"。
|
||||||
- 流式忘记 `Close()` → 客户端挂起;连接断开后 `Send` 返回 error,循环须检查。
|
- 流式忘记 `Close()` → 客户端挂起;连接断开后 `Send` 返回 error,循环须检查。
|
||||||
- 方法首字母小写 = 不注册;客户端报 method not found。
|
- 方法首字母小写 = 不注册;客户端报 method not found。
|
||||||
- vite 代理需重写前缀:`/api/cell → /cell`。
|
- vite 代理需重写前缀:`/api/cell → /cell`;或服务端 `f.CORS` 加白名单放开浏览器直连。
|
||||||
- 生成用 `BindService`(而非 `BindServiceForGen`)会把基础设施拉起来 —— 生成命令请用后者。
|
- 生成用 `BindService`(而非 `BindServiceForGen`)会把基础设施拉起来 —— 生成命令请用后者。
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ type Fun struct {
|
|||||||
idleTimeout time.Duration // keep-alive 空闲超时,默认 120s
|
idleTimeout time.Duration // keep-alive 空闲超时,默认 120s
|
||||||
maxConcurrency int // 最大并发连接数;0 = 不限制
|
maxConcurrency int // 最大并发连接数;0 = 不限制
|
||||||
|
|
||||||
|
corsOrigins map[string]struct{} // CORS 来源白名单(小写比较);nil/空表示未开启
|
||||||
|
|
||||||
server atomic.Pointer[fasthttp.Server]
|
server atomic.Pointer[fasthttp.Server]
|
||||||
started atomic.Bool
|
started atomic.Bool
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ func (f *Fun) handle(fastCtx *fasthttp.RequestCtx) {
|
|||||||
ctx := &Ctx{RequestCtx: fastCtx}
|
ctx := &Ctx{RequestCtx: fastCtx}
|
||||||
defer f.handlePanic(ctx)
|
defer f.handlePanic(ctx)
|
||||||
|
|
||||||
|
// CORS 置于路由之前:预检请求在此直接应答,实际请求附加跨域头后继续正常分发
|
||||||
|
if f.handleCors(fastCtx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
method, path := string(fastCtx.Method()), string(fastCtx.Path())
|
method, path := string(fastCtx.Method()), string(fastCtx.Path())
|
||||||
if r, ok := f.routes[method+" "+path]; ok {
|
if r, ok := f.routes[method+" "+path]; ok {
|
||||||
f.handleRoute(fastCtx, r, "")
|
f.handleRoute(fastCtx, r, "")
|
||||||
|
|||||||
Reference in New Issue
Block a user