5 Commits
Author SHA1 Message Date
chiyi cfecbff8f8 docs: update README for v2 (Chinese intro and guide refresh) 2026-09-03 15:32:13 +08:00
chiyi 41df889a1a v2.0.0: error-channel APIs, correctness fixes, and server hardening
BREAKING CHANGES:
- Guard interface: Guard(ctx Ctx) error; returning error short-circuits
  (subsequent guards and the method no longer execute)
- Wired[T]() (*T, error); New() may return error; failed wiring is sticky
- BindService/BindGuard/BindRoute return error; routes accept guards
  (guards receive merged query/form params as Ctx.State)
- registration panics after Start; Ctx.Ip honors X-Forwarded-For/X-Real-IP
- internal errors sanitized to fixed client messages

FIXES:
- int64 precision loss: /cell data decoded from raw JSON bytes and
  responses serialized with json.Number (no float64 round-trip)
- enum values range-checked before uint8 conversion (256 no longer
  truncates to 0 and slips through)
- logger: files failing name parsing are no longer deleted; log channel
  never blocks request goroutines; ConfigLogger is race-free;
  logWriterWorker unlock bug fixed
- stream writer panics recovered (process no longer crashes);
  streamDone closed exactly once
- generated Go client emits definitions for pointer-to-enum/struct fields
- anonymous structs rejected at registration; isPrivate safe on empty names
- removed dead Result.Id and RequestInfo.Type

ADDITIONS:
- graceful shutdown (Shutdown), StartOn, server timeouts by default
  (Read 60s/Idle 120s/Write off), SetTimeouts/SetMaxConcurrency
- big-int-safe JSON in the generated TS client (>2^53 as BigInt)
- example/ demo services and cmd/genexample artifact generator
2026-09-03 15:32:07 +08:00
chiyi 9019798382 v1.3.3: add SetBodyLimit for large custom-route request bodies (multipart uploads) 2026-08-21 22:29:41 +08:00
chiyi 095cb859a5 v1.3.2: add request context and generation-only binding 2026-08-21 19:12:40 +08:00
chiyi eb290cc88c v1.3.1: improve TypeScript client reliability 2026-08-21 15:06:11 +08:00
29 changed files with 3008 additions and 275 deletions
+32
View File
@@ -0,0 +1,32 @@
# fun
基于 [fasthttp](https://github.com/valyala/fasthttp) 的单端点 RPC 框架。业务请求统一走
`POST /cell`,按 `ServiceName.MethodName` 反射调用;自带依赖注入、Guard 鉴权、
NDJSON 流式响应、自定义路由与 TypeScript 客户端生成。
## 特性
- **单端点 RPC**`POST /cell`,方法签名 `(error)``(T, error)``(stream, error)``(T, stream, error)`
- **依赖注入**`fun.Wired[T]()` 建单例,`auto` 标签字段递归装配,`New()` 初始化连接资源
- **Guard 鉴权**:全局 + 服务级中间件,panic 兜底转统一错误响应
- **NDJSON 流式**`*fun.Stream` 逐行推送,支持首条消息 + 后续流
- **自定义路由**v1.3.0+):`BindRoute` 注册 GET/POST 回调、健康检查、通配符路径
- **请求体上限控制**v1.3.3+):`SetBodyLimit` 支持大体积 multipart 上传
- **TypeScript 客户端生成**`BindServiceForGen` + `GenCode(fun.GenTs{})`,免基础设施即可生成,产物带 `result<T>` 归一化错误与拦截器
## 快速开始
```go
func main() {
f := fun.GetFun()
f.BindService(&UserSvc{}) // 服务结构体嵌入 fun.Ctx,导出方法即 RPC 端点
cfg := fun.Wired[config.Config]()
go f.Start(cfg.ListenPort()) // fasthttp 监听,RPC 只响应 POST /cell
}
```
## 文档
完整使用文档见 [docs/README.zh.md](docs/README.zh.md)DTO 规则、线协议与 Result、
Guard、自定义路由、流式响应、TS 客户端生成与常见坑。
+142 -79
View File
@@ -1,119 +1,171 @@
package fun package fun
import ( import (
"fmt"
"reflect" "reflect"
) )
// Wired 创建并注册一个依赖实例;auto 标签字段递归注入依赖;存在 New() 则调用 // boxEntry 依赖容器条目:装配完成的单例,或粘性初始化错误。
func Wired[T any]() *T { // 装配失败的类型记录错误后不再重试,也不把半初始化实例暴露给后续装配
type boxEntry struct {
val reflect.Value
err error
}
// Wired 创建并注册一个依赖实例;auto 标签字段递归注入依赖,存在 New() 则调用。
// New 支持 () 与 () error 两种签名,返回非 nil error 即装配失败。
// 失败以 error 返回;同类型再次 Wired 返回同一错误(粘性),避免启动期反复重连
func Wired[T any]() (*T, error) {
t := reflect.TypeFor[T]() t := reflect.TypeFor[T]()
data := new(T)
if t.Kind() != reflect.Struct { if t.Kind() != reflect.Struct {
panic("Fun: " + t.Name() + " It must be a structure") panic("Fun: " + t.Name() + " It must be a structure")
} }
if t.Name() == "" {
panic("Fun: Wired requires a named struct type")
}
if isPrivate(t.Name()) { if isPrivate(t.Name()) {
panic("Fun:" + t.Name() + " cannot be Private") panic("Fun:" + t.Name() + " cannot be Private")
} }
if newMethod, found := t.MethodByName("New"); found { pt := reflect.TypeFor[*T]()
if newMethod.Type.NumIn() != 1 || newMethod.Type.NumOut() != 0 { checkNewSignature(pt, t.Name())
panic("Fun:" + t.Name() + " New method must have no parameters and no return values")
}
}
f := GetFun() f := GetFun()
if box, isWired := f.boxes.Load(reflect.TypeFor[*T]()); isWired { f.mu.Lock()
return box.(reflect.Value).Interface().(*T) defer f.mu.Unlock()
if entry, ok := f.boxes.Load(pt); ok {
e := entry.(boxEntry)
if e.err != nil {
return nil, e.err
} }
return e.val.Interface().(*T), nil
}
data := new(T)
v := reflect.ValueOf(data) v := reflect.ValueOf(data)
f.boxes.Store(reflect.TypeFor[*T](), v) // 先入容器再装配:循环依赖(A→B→A)靠占位引用解开;
boxList := map[reflect.Type]bool{} // 失败时下面覆盖为粘性错误,容器中不留可用半成品
f.boxes.Store(pt, boxEntry{val: v})
if err := f.wireStruct(v.Elem()); err != nil {
f.boxes.Store(pt, boxEntry{err: err})
return nil, err
}
if err := callNewIfPresent(v); err != nil {
f.boxes.Store(pt, boxEntry{err: err})
return nil, err
}
return data, nil
}
// wireStruct 注入 auto 标签字段;依赖缺失时递归装配(须持有 f.mu)
func (f *Fun) wireStruct(structValue reflect.Value) error {
t := structValue.Type()
for i := 0; i < t.NumField(); i++ { for i := 0; i < t.NumField(); i++ {
c := t.Field(i) c := t.Field(i)
fieldTag := newTag(c.Tag) if _, isAuto := newTag(c.Tag).getTag("auto"); !isAuto {
if _, isAuto := fieldTag.getTag("auto"); isAuto { continue
if dependency, loaded := f.boxes.Load(c.Type); loaded { }
v.Elem().Field(i).Set(dependency.(reflect.Value)) if c.Anonymous {
} else { panic("Fun:" + c.Name + " cannot be Anonymous")
checkBox(c, boxList) }
f.autowired(v.Elem().Field(i)) if entry, loaded := f.boxes.Load(c.Type); loaded {
e := entry.(boxEntry)
if e.err != nil {
return e.err
}
structValue.Field(i).Set(e.val)
continue
}
if err := f.autowired(structValue.Field(i)); err != nil {
return err
} }
} }
} return nil
newMethod := v.MethodByName("New")
if newMethod.IsValid() {
newMethod.Call(nil)
}
return data
} }
// autowired 递归创建依赖实例并注入 auto 标签字段 // autowired 递归创建依赖实例并注入 auto 字段(须持有 f.mu)。
func (f *Fun) autowired(fieldValue reflect.Value) { // 实例先入容器再装配字段,供循环依赖拿到占位引用;失败时覆盖为粘性错误
instance := reflect.New(fieldValue.Type().Elem()) func (f *Fun) autowired(fieldValue reflect.Value) error {
f.boxes.Store(fieldValue.Type(), instance) if fieldValue.Kind() != reflect.Ptr || fieldValue.Type().Elem().Kind() != reflect.Struct {
panic("Fun: auto field " + fieldValue.Type().String() + " must be a pointer to a struct")
}
if isPrivate(fieldValue.Type().Elem().Name()) {
panic("Fun:" + fieldValue.Type().Elem().Name() + " cannot be Private")
}
pt := fieldValue.Type()
checkNewSignature(pt, pt.Elem().Name())
instance := reflect.New(pt.Elem())
f.boxes.Store(pt, boxEntry{val: instance})
fieldValue.Set(instance) fieldValue.Set(instance)
structValue := instance.Elem() if err := f.wireStruct(instance.Elem()); err != nil {
for i := 0; i < structValue.NumField(); i++ { f.boxes.Store(pt, boxEntry{err: err})
structField := structValue.Type().Field(i) return err
fieldTag := newTag(structField.Tag)
if _, isAuto := fieldTag.getTag("auto"); isAuto {
if dependency, loaded := f.boxes.Load(structField.Type); loaded {
structValue.Field(i).Set(dependency.(reflect.Value))
} else {
f.autowired(structValue.Field(i))
} }
if err := callNewIfPresent(instance); err != nil {
f.boxes.Store(pt, boxEntry{err: err})
return err
} }
} return nil
newMethod := instance.MethodByName("New")
if newMethod.IsValid() {
newMethod.Call(nil)
}
} }
// checkBox 校验 auto 注入字段:必须是指针+struct、非匿名、非私有;New() 必须无参无返回值 // checkNewSignature 校验 New 方法签名:无参数,返回 () 或 (error)。
func checkBox(s reflect.StructField, boxList map[reflect.Type]bool) { // 在指针类型上查找,兼容值接收器与指针接收器两种定义
if _, ok := boxList[s.Type]; ok { func checkNewSignature(pt reflect.Type, name string) {
m, found := pt.MethodByName("New")
if !found {
return return
} }
boxList[s.Type] = true mt := m.Type
if s.Anonymous { if mt.NumIn() != 1 { // 仅接收者
panic("Fun:" + s.Name + " cannot be Anonymous") panic("Fun:" + name + " New method must have no parameters")
} }
if s.Type.Kind() != reflect.Ptr || s.Type.Elem().Kind() != reflect.Struct { if mt.NumOut() == 0 {
panic("Fun:" + s.Name + " Must be a pointer and a struct") return
}
if isPrivate(s.Name) {
panic("Fun:" + s.Name + " cannot be Private")
}
if newMethod, found := s.Type.MethodByName("New"); found {
if newMethod.Type.NumIn() != 1 || newMethod.Type.NumOut() != 0 {
panic("Fun:" + s.Name + " New method must have no parameters and no return values")
}
}
for i := 0; i < s.Type.Elem().NumField(); i++ {
f := s.Type.Elem().Field(i)
fieldTag := newTag(f.Tag)
if _, isAuto := fieldTag.getTag("auto"); isAuto {
checkBox(f, boxList)
} }
if mt.NumOut() == 1 && mt.Out(0) == errorType {
return
} }
panic("Fun:" + name + " New method must return nothing or error")
} }
// boxWired 注册期预初始化服务结构体字段中的 Box 依赖 // callNewIfPresent 调用指针上的 New()(若存在),支持 () 与 () error 两种签名
func boxWired(service any, f *Fun) { func callNewIfPresent(ptr reflect.Value) error {
serviceInstance := reflect.New(reflect.TypeOf(service).Elem()).Elem() m := ptr.MethodByName("New")
if !m.IsValid() {
return nil
}
out := m.Call(nil)
if len(out) == 1 {
if err, ok := out[0].Interface().(error); ok {
return err
}
}
return nil
}
// boxWired 注册期预初始化服务结构体字段中的 Box 依赖(须持有 f.mu)。
// 字段对应类型装配失败时向上返回 error
func boxWired(service any, f *Fun) error {
svcType := reflect.TypeOf(service).Elem()
serviceInstance := reflect.New(svcType).Elem()
for i := 0; i < serviceInstance.NumField(); i++ { for i := 0; i < serviceInstance.NumField(); i++ {
field := serviceInstance.Field(i) field := serviceInstance.Field(i)
if field.Type() == ctxType { if field.Type() == ctxType {
continue continue
} }
if field.Type().Kind() == reflect.Ptr && field.Type().Elem().Kind() == reflect.Struct { if field.Type().Kind() == reflect.Ptr && field.Type().Elem().Kind() == reflect.Struct {
if _, isWired := f.boxes.Load(field.Type()); !isWired { if entry, isWired := f.boxes.Load(field.Type()); isWired {
f.autowired(field) if e := entry.(boxEntry); e.err != nil {
return e.err
}
continue
}
if err := f.autowired(field); err != nil {
return fmt.Errorf("fun: wire %s.%s: %w", svcType.Name(), svcType.Field(i).Name, err)
} }
} }
} }
return nil
} }
// serviceWired 每请求把 Ctx 与 Box 依赖注入到新创建的服务实例 // serviceWired 每请求把 Ctx 与 Box 依赖注入到新创建的服务实例(只读容器,无锁)
func (f *Fun) serviceWired(serviceInstance reflect.Value, ctx *Ctx) { func (f *Fun) serviceWired(serviceInstance reflect.Value, ctx *Ctx) {
for i := 0; i < serviceInstance.NumField(); i++ { for i := 0; i < serviceInstance.NumField(); i++ {
field := serviceInstance.Field(i) field := serviceInstance.Field(i)
@@ -123,7 +175,9 @@ func (f *Fun) serviceWired(serviceInstance reflect.Value, ctx *Ctx) {
if field.Type() == ctxType { if field.Type() == ctxType {
field.Set(reflect.ValueOf(*ctx)) field.Set(reflect.ValueOf(*ctx))
} else if dependency, ok := f.boxes.Load(field.Type()); ok { } else if dependency, ok := f.boxes.Load(field.Type()); ok {
field.Set(dependency.(reflect.Value)) if e := dependency.(boxEntry); e.err == nil {
field.Set(e.val)
}
} }
} }
} }
@@ -139,8 +193,8 @@ func checkGuard(guard Guard) {
} }
} }
// serviceGuardWired 创建 Guard 实例并注入 Box 依赖,返回 guard 引用 // serviceGuardWired 创建 Guard 实例并注入 Box 依赖,返回 guard 引用(须持有 f.mu
func serviceGuardWired(guard Guard, f *Fun) *any { func serviceGuardWired(guard Guard, f *Fun) (*any, error) {
t := reflect.TypeOf(guard).Elem() t := reflect.TypeOf(guard).Elem()
guardInstance := reflect.New(t).Elem() guardInstance := reflect.New(t).Elem()
for i := 0; i < guardInstance.NumField(); i++ { for i := 0; i < guardInstance.NumField(); i++ {
@@ -148,12 +202,21 @@ func serviceGuardWired(guard Guard, f *Fun) *any {
if !field.CanSet() { if !field.CanSet() {
continue continue
} }
if dependency, ok := f.boxes.Load(field.Type()); ok { if field.Type() == ctxType {
field.Set(dependency.(reflect.Value)) continue
} else { }
f.autowired(field) if entry, ok := f.boxes.Load(field.Type()); ok {
e := entry.(boxEntry)
if e.err != nil {
return nil, e.err
}
field.Set(e.val)
} else if field.Kind() == reflect.Ptr && field.Type().Elem().Kind() == reflect.Struct {
if err := f.autowired(field); err != nil {
return nil, err
}
} }
} }
g := guardInstance.Addr().Interface() g := guardInstance.Addr().Interface()
return &g return &g, nil
} }
+19 -6
View File
@@ -39,7 +39,9 @@ func (s *BugSvc) Ticker() (string, *Stream, error) {
func bugInvoke(t *testing.T, method string, data map[string]any) (*Result[any], error) { func bugInvoke(t *testing.T, method string, data map[string]any) (*Result[any], error) {
t.Helper() t.Helper()
f := New() f := New()
f.BindService(&BugSvc{}) if err := f.BindService(&BugSvc{}); err != nil {
t.Fatal(err)
}
if data == nil { if data == nil {
data = map[string]any{} data = map[string]any{}
} }
@@ -75,7 +77,10 @@ func TestBugNullableEnum(t *testing.T) {
// bug3: 含 () error 方法的代码生成不应 panic,且类型应生成为 Void/void // bug3: 含 () error 方法的代码生成不应 panic,且类型应生成为 Void/void
func TestBugGenErrorOnly(t *testing.T) { func TestBugGenErrorOnly(t *testing.T) {
GetFun().BindService(&BugSvc{}) isolateGeneratorGlobals(t)
if err := GetFun().BindService(&BugSvc{}); err != nil {
t.Fatal(err)
}
SetOutput(t.TempDir()) SetOutput(t.TempDir())
GenCode(GenGo{}, GenTs{}) GenCode(GenGo{}, GenTs{})
goSrc, err := os.ReadFile(filepath.Join(getDirectory(), "go", "bug_svc.go")) goSrc, err := os.ReadFile(filepath.Join(getDirectory(), "go", "bug_svc.go"))
@@ -97,7 +102,9 @@ func TestBugGenErrorOnly(t *testing.T) {
// bug4+5: 响应键应为小写;(T, stream, error) 的 T 应作为流的第一条消息下发 // bug4+5: 响应键应为小写;(T, stream, error) 的 T 应作为流的第一条消息下发
func TestBugJsonKeysAndStreamFirst(t *testing.T) { func TestBugJsonKeysAndStreamFirst(t *testing.T) {
f := New() f := New()
f.BindService(&BugSvc{}) if err := f.BindService(&BugSvc{}); err != nil {
t.Fatal(err)
}
go f.Start(39003) go f.Start(39003)
time.Sleep(300 * time.Millisecond) time.Sleep(300 * time.Millisecond)
@@ -149,7 +156,9 @@ func (s *NullSlicSvc) Save(dto NullSlicDto) (string, error) { return "ok", nil }
func TestBugSliceNull(t *testing.T) { func TestBugSliceNull(t *testing.T) {
f := New() f := New()
f.BindService(&NullSlicSvc{}) if err := f.BindService(&NullSlicSvc{}); err != nil {
t.Fatal(err)
}
data := map[string]any{"tags": nil} data := map[string]any{"tags": nil}
c := &Ctx{Ip: "1", MethodName: "Save", ServiceName: "NullSlicSvc", Data: &data} c := &Ctx{Ip: "1", MethodName: "Save", ServiceName: "NullSlicSvc", Data: &data}
var streamCh chan any var streamCh chan any
@@ -176,7 +185,9 @@ func (s *LeakSvc) Fail() (*Stream, error) {
func TestBugStreamLeak(t *testing.T) { func TestBugStreamLeak(t *testing.T) {
f := New() f := New()
f.BindService(&LeakSvc{}) if err := f.BindService(&LeakSvc{}); err != nil {
t.Fatal(err)
}
c := &Ctx{Ip: "1", MethodName: "Fail", ServiceName: "LeakSvc"} c := &Ctx{Ip: "1", MethodName: "Fail", ServiceName: "LeakSvc"}
var streamCh chan any var streamCh chan any
var streamDone chan struct{} var streamDone chan struct{}
@@ -201,7 +212,9 @@ func (s *CollideSvc) Cookie() (string, error) { return "cookie", nil }
func TestBugMethodNameCollision(t *testing.T) { func TestBugMethodNameCollision(t *testing.T) {
f := New() f := New()
f.BindService(&CollideSvc{}) if err := f.BindService(&CollideSvc{}); err != nil {
t.Fatal(err)
}
if _, ok := f.methods["CollideSvc.Cookie"]; !ok { if _, ok := f.methods["CollideSvc.Cookie"]; !ok {
t.Fatal("Cookie method dropped due to name collision with fasthttp.RequestCtx") t.Fatal("Cookie method dropped due to name collision with fasthttp.RequestCtx")
} }
+49 -4
View File
@@ -3,12 +3,16 @@ package fun
import ( import (
"errors" "errors"
"fmt" "fmt"
"math"
"reflect" "reflect"
"strings" "strings"
"unicode" "unicode"
) )
func isPrivate(value string) bool { func isPrivate(value string) bool {
if value == "" {
return false // 匿名类型名:交由各处的具名校验给出明确报错,不在此越界 panic
}
return !unicode.IsUpper([]rune(value)[0]) return !unicode.IsUpper([]rune(value)[0])
} }
@@ -36,6 +40,9 @@ func checkType(t reflect.Type) {
} }
} }
case reflect.Struct: case reflect.Struct:
if t.Name() == "" {
panic("fun: anonymous struct types are not supported, define a named type")
}
if t.NumField() == 0 { if t.NumField() == 0 {
panic("fun: " + t.Name() + " must have at least one field") panic("fun: " + t.Name() + " must have at least one field")
} }
@@ -49,7 +56,11 @@ func checkType(t reflect.Type) {
case reflect.Slice: case reflect.Slice:
checkType(t.Elem()) checkType(t.Elem())
default: default:
panic("fun:Unsupported types " + t.Name()) name := t.Name()
if name == "" {
name = t.String() // 匿名/内建组合类型(如 interface{})给出可读的名字
}
panic("fun:Unsupported types " + name)
} }
} }
@@ -122,7 +133,9 @@ func checkDto(dtoType reflect.Type, dtoMap any, methodName string) error {
return nil return nil
} }
// checkEnumValue 运行时校验枚举值是否在范围内 // checkEnumValue 运行时校验枚举值是否在范围内
// 必须在原始数值上先判范围再转 uint8:否则 256/512 等越界值先被截断成
// 合法小值(256→0),绕过范围检查后静默落到错误的枚举项上
func checkEnumValue(t reflect.Type, value any, name string) error { func checkEnumValue(t reflect.Type, value any, name string) error {
var max uint8 var max uint8
enumValue := reflect.New(t).Elem() enumValue := reflect.New(t).Elem()
@@ -131,35 +144,67 @@ func checkEnumValue(t reflect.Type, value any, name string) error {
} else { } else {
max = uint8(len(enumValue.Interface().(enum).Names())) max = uint8(len(enumValue.Interface().(enum).Names()))
} }
outOfRange := callError(errors.New("Fun:" + name + " Dto value out of range"))
var num uint8 var num uint8
switch v := value.(type) { switch v := value.(type) {
case float64: case float64:
if v < 0 || v != math.Trunc(v) || v >= float64(max) {
return outOfRange
}
num = uint8(v) num = uint8(v)
case float32: case float32:
num = uint8(v) f := float64(v)
if f < 0 || f != math.Trunc(f) || f >= float64(max) {
return outOfRange
}
num = uint8(f)
case uint8: case uint8:
num = v num = v
case uint16: case uint16:
if v >= uint16(max) {
return outOfRange
}
num = uint8(v) num = uint8(v)
case uint32: case uint32:
if v >= uint32(max) {
return outOfRange
}
num = uint8(v) num = uint8(v)
case uint64: case uint64:
if v >= uint64(max) {
return outOfRange
}
num = uint8(v) num = uint8(v)
case int: case int:
if v < 0 || v >= int(max) {
return outOfRange
}
num = uint8(v) num = uint8(v)
case int8: case int8:
if v < 0 || int(v) >= int(max) {
return outOfRange
}
num = uint8(v) num = uint8(v)
case int16: case int16:
if v < 0 || int(v) >= int(max) {
return outOfRange
}
num = uint8(v) num = uint8(v)
case int32: case int32:
if v < 0 || int64(v) >= int64(max) {
return outOfRange
}
num = uint8(v) num = uint8(v)
case int64: case int64:
if v < 0 || v >= int64(max) {
return outOfRange
}
num = uint8(v) num = uint8(v)
default: default:
return callError(errors.New("Fun:" + name + " Dto enum value type is not supported")) return callError(errors.New("Fun:" + name + " Dto enum value type is not supported"))
} }
if num >= max { if num >= max {
return callError(errors.New("Fun:" + name + " Dto value out of range")) return outOfRange
} }
return nil return nil
} }
+589
View File
@@ -0,0 +1,589 @@
package fun
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestTypeScriptClientBehavior(t *testing.T) {
node, err := exec.LookPath("node")
if err != nil {
t.Skip("node is not installed")
}
probe := exec.Command(node, "--experimental-strip-types", "--input-type=module", "-e",
`if (typeof fetch !== "function" || typeof ReadableStream !== "function") process.exit(1)`)
if err := probe.Run(); err != nil {
t.Skip("node does not support TypeScript stripping and web streams")
}
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "client.ts"), []byte(templateTs{}.genClientTemplate()), 0o644); err != nil {
t.Fatal(err)
}
const script = `
import assert from "node:assert/strict";
import { Client } from "./client.ts";
const json = (value, status = 200) => new Response(JSON.stringify(value), {
status,
headers: { "Content-Type": "application/json" },
});
const ndjson = body => new Response(body, {
headers: { "Content-Type": "application/x-ndjson; charset=utf-8" },
});
const client = new Client("http://example.test///");
const seen = [];
let cleanupCheck;
client.addResponseInterceptor((_service, _method, result) => {
if (cleanupCheck) {
assert.equal(cleanupCheck(), true);
cleanupCheck = undefined;
}
seen.push(result.status);
});
const expectSeen = status => assert.deepEqual(seen.splice(0), [status]);
let fetchInit;
globalThis.fetch = async (url, init) => {
fetchInit = { url, init };
return json({ status: 0, data: "ok" });
};
let result = await client.request("Svc", "fetchShape", undefined, { signal: new AbortController().signal });
assert.equal(result.status, 0);
assert.equal(fetchInit.url, "http://example.test/cell");
assert.equal(fetchInit.init.method, "POST");
assert.equal(fetchInit.init.headers["Content-Type"], "application/json");
assert.ok(fetchInit.init.signal);
assert.deepEqual(JSON.parse(fetchInit.init.body), { serviceName: "Svc", methodName: "fetchShape" });
expectSeen(0);
globalThis.fetch = async () => json({ status: 2, code: 4001, msg: "business" }, 503);
result = await client.request("Svc", "business");
assert.deepEqual(result, { status: 2, code: 4001, msg: "business" });
expectSeen(2);
globalThis.fetch = async () => json({ status: 1, msg: "framework" }, 500);
result = await client.request("Svc", "framework");
assert.deepEqual(result, { status: 1, msg: "framework" });
expectSeen(1);
globalThis.fetch = async () => new Response("<html>gateway timeout</html>", { status: 504 });
result = await client.request("Svc", "gatewayTimeout");
assert.equal(result.status, 5);
assert.match(result.msg, /504/);
expectSeen(5);
globalThis.fetch = async () => new Response("request timeout", { status: 408 });
result = await client.request("Svc", "gateway408");
assert.equal(result.status, 5);
expectSeen(5);
globalThis.fetch = async () => json({ status: 5, msg: "server timeout" });
result = await client.request("Svc", "serverTimeout");
assert.equal(result.status, 5);
assert.equal(result.msg, "server timeout");
expectSeen(5);
globalThis.fetch = async () => new Response("bad gateway", { status: 502 });
result = await client.request("Svc", "gateway");
assert.equal(result.status, 4);
expectSeen(4);
globalThis.fetch = async () => new Response("", { status: 200 });
result = await client.request("Svc", "empty");
assert.equal(result.status, 1);
assert.match(result.msg, /Empty/);
expectSeen(1);
globalThis.fetch = async () => new Response("not-json", { status: 200 });
result = await client.request("Svc", "invalidJson");
assert.equal(result.status, 1);
assert.match(result.msg, /Invalid JSON/);
expectSeen(1);
globalThis.fetch = async () => new Response("<html>login</html>", {
status: 200,
headers: { "Content-Type": "text/html" },
});
result = await client.request("Svc", "html");
assert.equal(result.status, 1);
assert.match(result.msg, /HTML/);
expectSeen(1);
globalThis.fetch = async () => json({ data: 1 });
result = await client.request("Svc", "invalidResult");
assert.equal(result.status, 1);
expectSeen(1);
globalThis.fetch = async () => { throw new TypeError("DNS failed"); };
result = await client.request("Svc", "network");
assert.equal(result.status, 4);
assert.match(result.msg, /DNS failed/);
expectSeen(4);
globalThis.fetch = async () => new Response(new ReadableStream({
pull(controller) { controller.error(new Error("body read failed")); },
}));
result = await client.request("Svc", "bodyRead");
assert.equal(result.status, 4);
assert.match(result.msg, /body read failed/);
expectSeen(4);
const manualAbort = new AbortController();
manualAbort.abort();
globalThis.fetch = async () => { throw new DOMException("aborted", "AbortError"); };
result = await client.request("Svc", "abort", undefined, { signal: manualAbort.signal });
assert.equal(result.status, 4);
assert.match(result.msg, /aborted/i);
expectSeen(4);
const timeoutAbort = new AbortController();
timeoutAbort.abort(new DOMException("timed out", "TimeoutError"));
result = await client.request("Svc", "timeoutSignal", undefined, { signal: timeoutAbort.signal });
assert.equal(result.status, 5);
expectSeen(5);
globalThis.fetch = async () => { throw new DOMException("timed out", "TimeoutError"); };
result = await client.request("Svc", "timeoutError");
assert.equal(result.status, 5);
expectSeen(5);
const requestInterceptorClient = new Client("http://example.test");
let requestInterceptorSeen;
requestInterceptorClient.addRequestInterceptor((_service, _method, state, dto) => {
state.token = "abc";
requestInterceptorSeen = dto;
});
requestInterceptorClient.addResponseInterceptor((_service, _method, value) => ({ ...value, msg: "intercepted" }));
globalThis.fetch = async (_url, init) => {
const payload = JSON.parse(init.body);
assert.deepEqual(payload.state, { token: "abc" });
return json({ status: 0 });
};
result = await requestInterceptorClient.request("Svc", "interceptors", { value: 1 });
assert.deepEqual(requestInterceptorSeen, { value: 1 });
assert.equal(result.msg, "intercepted");
const stateClient = new Client("http://example.test");
stateClient.setState({ shared: "global", globalOnly: "yes" });
const stateContexts = new Map();
const nativeResponses = new Map();
const payloads = new Map();
let releaseFirst;
const firstMayContinue = new Promise(resolve => { releaseFirst = resolve; });
let firstInterceptorEntered;
const firstDidEnter = new Promise(resolve => { firstInterceptorEntered = resolve; });
let firstStateReference;
stateClient.addRequestInterceptor(async (_service, method, state, dto) => {
state.interceptor = dto.id;
if (method === "first") {
firstStateReference = state;
firstInterceptorEntered();
await firstMayContinue;
}
state.completed = dto.id;
});
stateClient.addResponseInterceptor((_service, method, _value, context) => {
stateContexts.set(method, context);
});
globalThis.fetch = async (_url, init) => {
const payload = JSON.parse(init.body);
payloads.set(payload.methodName, payload);
const response = json({ status: 0, data: payload.methodName });
nativeResponses.set(payload.methodName, response);
return response;
};
const firstOverride = { shared: "first", requestOnly: "one" };
const firstRequest = stateClient.request("Svc", "first", { id: "one" }, { state: firstOverride });
await firstDidEnter;
firstOverride.shared = "mutated outside";
const secondRequest = stateClient.request("Svc", "second", { id: "two" }, {
state: { shared: "second", requestOnly: "two" },
});
assert.equal((await secondRequest).status, 0);
releaseFirst();
assert.equal((await firstRequest).status, 0);
assert.deepEqual(payloads.get("first").state, {
shared: "first",
globalOnly: "yes",
requestOnly: "one",
interceptor: "one",
completed: "one",
});
assert.deepEqual(payloads.get("second").state, {
shared: "second",
globalOnly: "yes",
requestOnly: "two",
interceptor: "two",
completed: "two",
});
firstStateReference.shared = "mutated after snapshot";
for (const method of ["first", "second"]) {
const context = stateContexts.get(method);
assert.deepEqual(context.requestState, payloads.get(method).state);
assert.equal(Object.isFrozen(context.requestState), true);
assert.equal(Object.isFrozen(context), true);
assert.equal(context.response, nativeResponses.get(method));
assert.ok(context.response instanceof Response);
}
assert.equal(stateContexts.get("first").requestState.shared, "first");
const throwingState = Object.defineProperty({}, "token", {
enumerable: true,
get() { throw new Error("state read failed"); },
});
const stateFailureClient = new Client("http://example.test");
let stateFailureSeen = [];
stateFailureClient.addResponseInterceptor((_service, _method, value, context) => {
stateFailureSeen.push([value.status, context.requestState]);
});
globalThis.fetch = async () => assert.fail("fetch must not run for state failure");
result = await stateFailureClient.request("Svc", "requestStateFailure", undefined, { state: throwingState });
assert.equal(result.status, 1);
assert.match(result.msg, /prepare request state/i);
result = await stateFailureClient.stream("Svc", "streamStateFailure", undefined, () => {}, { state: throwingState });
assert.equal(result.status, 1);
assert.match(result.msg, /prepare request state/i);
assert.deepEqual(stateFailureSeen, [[1, {}], [1, {}]]);
const snapshotFailureClient = new Client("http://example.test");
snapshotFailureClient.addRequestInterceptor((_service, _method, state) => {
Object.defineProperty(state, "broken", {
enumerable: true,
get() { throw new Error("snapshot read failed"); },
});
});
globalThis.fetch = async () => assert.fail("fetch must not run for snapshot failure");
result = await snapshotFailureClient.request("Svc", "requestSnapshotFailure");
assert.equal(result.status, 1);
assert.match(result.msg, /snapshot request state/i);
result = await snapshotFailureClient.stream("Svc", "streamSnapshotFailure", undefined, () => {});
assert.equal(result.status, 1);
assert.match(result.msg, /snapshot request state/i);
const contextClient = new Client("http://example.test");
contextClient.setState({ base: "global" });
const outcomeContexts = new Map();
contextClient.addResponseInterceptor((_service, method, _value, context) => {
outcomeContexts.set(method, context);
});
contextClient.addRequestInterceptor((_service, method, state) => {
state.method = method;
if (method === "requestHookFailure") throw new Error("request context hook");
});
globalThis.fetch = async () => assert.fail("fetch must not run before serialization");
const contextCyclic = {};
contextCyclic.self = contextCyclic;
result = await contextClient.request("Svc", "serializeContext", contextCyclic, { state: { base: "request" } });
assert.equal(result.status, 1);
assert.deepEqual(outcomeContexts.get("serializeContext").requestState, {
base: "request",
method: "serializeContext",
});
assert.equal(outcomeContexts.get("serializeContext").response, undefined);
result = await contextClient.request("Svc", "requestHookFailure", undefined, { state: { request: "hook" } });
assert.equal(result.status, 1);
assert.deepEqual(outcomeContexts.get("requestHookFailure").requestState, {
base: "global",
request: "hook",
method: "requestHookFailure",
});
assert.equal(outcomeContexts.get("requestHookFailure").response, undefined);
globalThis.fetch = async () => { throw new TypeError("offline"); };
result = await contextClient.request("Svc", "networkContext", undefined, { state: { request: "network" } });
assert.equal(result.status, 4);
assert.deepEqual(outcomeContexts.get("networkContext").requestState, {
base: "global",
request: "network",
method: "networkContext",
});
assert.equal(outcomeContexts.get("networkContext").response, undefined);
const bodyReadResponse = new Response(new ReadableStream({
pull(controller) { controller.error(new Error("context body read failed")); },
}));
globalThis.fetch = async () => bodyReadResponse;
result = await contextClient.request("Svc", "bodyReadContext", undefined, { state: { request: "body" } });
assert.equal(result.status, 4);
assert.deepEqual(outcomeContexts.get("bodyReadContext").requestState, {
base: "global",
request: "body",
method: "bodyReadContext",
});
assert.equal(outcomeContexts.get("bodyReadContext").response, bodyReadResponse);
const failingRequestInterceptor = new Client("http://example.test");
let normalized = [];
failingRequestInterceptor.addRequestInterceptor(() => { throw new Error("request hook"); });
failingRequestInterceptor.addResponseInterceptor((_s, _m, value) => { normalized.push(value.status); });
result = await failingRequestInterceptor.request("Svc", "hook");
assert.equal(result.status, 1);
assert.match(result.msg, /request hook/);
assert.deepEqual(normalized, [1]);
const failingResponseInterceptor = new Client("http://example.test");
normalized = [];
failingResponseInterceptor.addResponseInterceptor(() => { throw new Error("response hook"); });
failingResponseInterceptor.addResponseInterceptor((_s, _m, value) => { normalized.push(value.status); });
globalThis.fetch = async () => json({ status: 2, code: 4003, msg: "original" });
result = await failingResponseInterceptor.request("Svc", "responseHook");
assert.equal(result.status, 1);
assert.match(result.msg, /response hook/);
assert.deepEqual(normalized, [1]);
const cyclic = {};
cyclic.self = cyclic;
globalThis.fetch = async () => assert.fail("fetch must not run for serialization failure");
result = await client.request("Svc", "serialize", cyclic);
assert.equal(result.status, 1);
assert.match(result.msg, /serialize/i);
expectSeen(1);
globalThis.fetch = async () => json({ status: 2, code: 4002, msg: "stream business" }, 422);
result = await client.stream("Svc", "streamBusiness", undefined, () => {});
assert.deepEqual(result, { status: 2, code: 4002, msg: "stream business" });
expectSeen(2);
globalThis.fetch = async () => json({ status: 0 });
result = await client.stream("Svc", "wrongMedia", undefined, () => {});
assert.equal(result.status, 1);
assert.match(result.msg, /x-ndjson/);
expectSeen(1);
const bytes = new TextEncoder().encode('{"text":"你好"}\r\n\n{"n":2}');
const split = bytes.indexOf(0xe5) + 1;
globalThis.fetch = async () => new Response(new ReadableStream({
start(controller) {
controller.enqueue(bytes.slice(0, split));
controller.enqueue(bytes.slice(split));
controller.close();
},
}), { headers: { "Content-Type": "application/x-ndjson; charset=utf-8" } });
const messages = [];
result = await client.stream("Svc", "valid", undefined, value => messages.push(value));
assert.equal(result.status, 0);
assert.deepEqual(messages, [{ text: "你好" }, { n: 2 }]);
expectSeen(0);
let malformedCancelled = false;
globalThis.fetch = async () => new Response(new ReadableStream({
start(controller) { controller.enqueue(new TextEncoder().encode("bad\n")); },
cancel() { malformedCancelled = true; },
}), { headers: { "Content-Type": "application/x-ndjson" } });
cleanupCheck = () => malformedCancelled;
result = await client.stream("Svc", "malformed", undefined, () => {});
assert.equal(result.status, 1);
assert.match(result.msg, /line 1/);
assert.equal(malformedCancelled, true);
expectSeen(1);
globalThis.fetch = async () => ndjson('{"n":1}\n');
result = await client.stream("Svc", "callback", undefined, () => { throw new Error("callback boom"); });
assert.equal(result.status, 1);
assert.match(result.msg, /callback boom/);
expectSeen(1);
globalThis.fetch = async () => new Response(new ReadableStream({
pull(controller) { controller.error(new Error("read boom")); },
}), { headers: { "Content-Type": "application/x-ndjson" } });
result = await client.stream("Svc", "read", undefined, () => {});
assert.equal(result.status, 4);
assert.match(result.msg, /read boom/);
expectSeen(4);
globalThis.fetch = async () => ndjson(new Uint8Array([0xff, 0x0a]));
result = await client.stream("Svc", "utf8", undefined, () => {});
assert.equal(result.status, 1);
assert.match(result.msg, /UTF-8/);
expectSeen(1);
globalThis.fetch = async () => ndjson("");
result = await client.stream("Svc", "emptyStream", undefined, () => assert.fail("empty stream callback"));
assert.deepEqual(result, { status: 0 });
expectSeen(0);
globalThis.fetch = async () => new Response(null, {
headers: { "Content-Type": "application/x-ndjson" },
});
result = await client.stream("Svc", "nullBody", undefined, () => assert.fail("null stream callback"));
assert.deepEqual(result, { status: 0 });
expectSeen(0);
const streamAbort = new AbortController();
streamAbort.abort();
globalThis.fetch = async () => { throw new DOMException("aborted", "AbortError"); };
result = await client.stream("Svc", "abortStream", undefined, () => {}, { signal: streamAbort.signal });
assert.equal(result.status, 4);
expectSeen(4);
const readAbort = new AbortController();
globalThis.fetch = async () => new Response(new ReadableStream({
start(controller) {
readAbort.signal.addEventListener("abort", () => controller.error(new DOMException("aborted", "AbortError")), { once: true });
readAbort.abort();
},
}), { headers: { "Content-Type": "application/x-ndjson" } });
result = await client.stream("Svc", "readAbort", undefined, () => {}, { signal: readAbort.signal });
assert.equal(result.status, 4);
assert.match(result.msg, /aborted/i);
expectSeen(4);
const readTimeout = new AbortController();
readTimeout.abort(new DOMException("timed out", "TimeoutError"));
globalThis.fetch = async () => new Response(new ReadableStream({
pull(controller) { controller.error(new DOMException("aborted", "AbortError")); },
}), { headers: { "Content-Type": "application/x-ndjson" } });
result = await client.stream("Svc", "timeoutStream", undefined, () => {}, { signal: readTimeout.signal });
assert.equal(result.status, 5);
expectSeen(5);
const streamHookClient = new Client("http://example.test");
normalized = [];
streamHookClient.addRequestInterceptor(() => { throw new Error("stream hook"); });
streamHookClient.addResponseInterceptor((_s, _m, value) => { normalized.push(value.status); });
result = await streamHookClient.stream("Svc", "hook", undefined, () => {});
assert.equal(result.status, 1);
assert.deepEqual(normalized, [1]);
const streamStateClient = new Client("http://example.test");
streamStateClient.setState({ shared: "global", globalOnly: "stream" });
const streamContexts = new Map();
const streamResponses = new Map();
const streamPayloads = new Map();
streamStateClient.addRequestInterceptor((_service, method, state) => {
state.interceptor = method;
if (method === "streamHookFailure") throw new Error("stream context hook");
});
streamStateClient.addResponseInterceptor((_service, method, _value, context) => {
streamContexts.set(method, context);
});
globalThis.fetch = async (_url, init) => {
const payload = JSON.parse(init.body);
streamPayloads.set(payload.methodName, payload);
if (payload.methodName === "streamNetwork") throw new TypeError("stream offline");
const response = payload.methodName === "streamWrongMedia"
? json({ status: 0 })
: payload.methodName === "streamReadFailure"
? new Response(new ReadableStream({
pull(controller) { controller.error(new Error("stream context read failed")); },
}), { headers: { "Content-Type": "application/x-ndjson" } })
: ndjson('{"n":1}\n');
streamResponses.set(payload.methodName, response);
return response;
};
result = await streamStateClient.stream("Svc", "streamSuccess", undefined, () => {}, {
state: { shared: "request", requestOnly: "success" },
});
assert.equal(result.status, 0);
result = await streamStateClient.stream("Svc", "streamWrongMedia", undefined, () => {}, {
state: { shared: "wrong-media" },
});
assert.equal(result.status, 1);
result = await streamStateClient.stream("Svc", "streamNetwork", undefined, () => {}, {
state: { shared: "network" },
});
assert.equal(result.status, 4);
result = await streamStateClient.stream("Svc", "streamReadFailure", undefined, () => {}, {
state: { shared: "read" },
});
assert.equal(result.status, 4);
const streamCyclic = {};
streamCyclic.self = streamCyclic;
result = await streamStateClient.stream("Svc", "streamSerialize", streamCyclic, () => {}, {
state: { shared: "serialize" },
});
assert.equal(result.status, 1);
result = await streamStateClient.stream("Svc", "streamHookFailure", undefined, () => {}, {
state: { shared: "hook" },
});
assert.equal(result.status, 1);
for (const method of ["streamSuccess", "streamWrongMedia", "streamNetwork", "streamReadFailure"]) {
const context = streamContexts.get(method);
assert.deepEqual(context.requestState, streamPayloads.get(method).state);
assert.equal(Object.isFrozen(context.requestState), true);
assert.equal(Object.isFrozen(context), true);
}
assert.equal(streamContexts.get("streamSuccess").response, streamResponses.get("streamSuccess"));
assert.equal(streamContexts.get("streamWrongMedia").response, streamResponses.get("streamWrongMedia"));
assert.equal(streamContexts.get("streamReadFailure").response, streamResponses.get("streamReadFailure"));
assert.equal(streamContexts.get("streamNetwork").response, undefined);
assert.deepEqual(streamContexts.get("streamSerialize").requestState, {
shared: "serialize",
globalOnly: "stream",
interceptor: "streamSerialize",
});
assert.equal(streamContexts.get("streamSerialize").response, undefined);
assert.deepEqual(streamContexts.get("streamHookFailure").requestState, {
shared: "hook",
globalOnly: "stream",
interceptor: "streamHookFailure",
});
assert.equal(streamContexts.get("streamHookFailure").response, undefined);
`
scriptPath := filepath.Join(dir, "behavior.mjs")
if err := os.WriteFile(scriptPath, []byte(script), 0o644); err != nil {
t.Fatal(err)
}
command := exec.Command(node, "--experimental-strip-types", scriptPath)
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("TypeScript client behavior failed: %v\n%s", err, output)
}
}
func TestTypeScriptClientStrictTypecheck(t *testing.T) {
tsc, err := exec.LookPath("tsc")
if err != nil {
t.Skip("tsc is not installed")
}
dir := t.TempDir()
path := filepath.Join(dir, "client.ts")
if err := os.WriteFile(path, []byte(templateTs{}.genClientTemplate()), 0o644); err != nil {
t.Fatal(err)
}
usagePath := filepath.Join(dir, "usage.ts")
const usage = `import { Client, type ContextResponseInterceptor, type RequestOptions, type ResponseContext, type ResponseInterceptor, type StreamOptions } from "./client";
const requestOptions: RequestOptions = { state: { token: "request" } };
const streamOptions: StreamOptions = { state: { token: "stream" } };
const legacy: ResponseInterceptor = (_service, _method, result) => result;
const legacyConsumer: (service: string, method: string, result: import("./client").result<any>) => unknown = legacy;
const current: ContextResponseInterceptor = (_service, _method, result, context) => {
const token: string | undefined = context.requestState.token;
const response: Response | undefined = context.response;
void token;
void response;
return result;
};
void legacyConsumer;
const context = {} as ResponseContext;
// @ts-expect-error requestState is readonly
context.requestState.token = "changed";
const client = new Client("/");
client.addResponseInterceptor(legacy);
client.addResponseInterceptor(current);
void client.request("Svc", "method", undefined, requestOptions);
void client.stream("Svc", "method", undefined, () => {}, streamOptions);
`
if err := os.WriteFile(usagePath, []byte(usage), 0o644); err != nil {
t.Fatal(err)
}
command := exec.Command(tsc,
"--strict", "--noEmit", "--target", "ES2022", "--module", "ESNext", "--lib", "ES2022,DOM", path, usagePath)
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("strict TypeScript check failed: %v\n%s", err, output)
}
}
func TestTypeScriptClientSimplicityGate(t *testing.T) {
source := templateTs{}.genClientTemplate() + templateTs{}.genServiceTemplate()
for _, forbidden := range []string{"RpcError", "httpStatus", "result.aborted", "ClientErrorCode", "setTimeout"} {
if strings.Contains(source, forbidden) {
t.Errorf("generated TypeScript templates contain forbidden %q", forbidden)
}
}
if !strings.Contains(source, "Promise<result<void>>") {
t.Fatal("stream methods must resolve result<void>")
}
}
+34
View File
@@ -0,0 +1,34 @@
// genexample 重新生成 example/gen 下的客户端产物(Go + TS)。
// 在仓库根目录执行:go run ./cmd/genexample
package main
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"github.com/cyi-cc/fun"
"github.com/cyi-cc/fun/example/demo"
)
func main() {
f := fun.New()
for _, svc := range []any{&demo.OrderSvc{}, &demo.ChatSvc{}} {
f.BindServiceForGen(svc) // 只登记元信息,不触发依赖装配
}
fun.SetOutput("./example/gen")
fun.GenCode(fun.GenGo{}, fun.GenTs{})
err := filepath.WalkDir("./example/gen", func(path string, d fs.DirEntry, err error) error {
if err == nil && !d.IsDir() {
rel, _ := filepath.Rel("./example/gen", path)
fmt.Println("生成:", filepath.ToSlash(rel))
}
return err
})
if err != nil {
fmt.Fprintln(os.Stderr, "walk output:", err)
os.Exit(1)
}
}
+11 -2
View File
@@ -1,6 +1,7 @@
package fun package fun
import ( import (
"bytes"
"encoding/json" "encoding/json"
"net" "net"
"reflect" "reflect"
@@ -18,6 +19,10 @@ type Ctx struct {
ServiceName string ServiceName string
Data *map[string]any Data *map[string]any
RequestCtx *fasthttp.RequestCtx RequestCtx *fasthttp.RequestCtx
// rawData 请求 data 字段的原始 JSON 字节(HTTP 路径填充)。
// 业务 DTO 解码优先用它:不经 map[string]any 的 float64 往返,大整数无精度丢失
rawData []byte
} }
var ctxType = reflect.TypeFor[Ctx]() var ctxType = reflect.TypeFor[Ctx]()
@@ -49,10 +54,14 @@ func (c *Ctx) send(result Result[any]) {
_, _ = c.write(out) _, _ = c.write(out)
} }
// lowerKeysFromJSON 解析 JSON 后递归把所有对象键转为首字母小写 // lowerKeysFromJSON 解析 JSON 后递归把所有对象键转为首字母小写
// 数字以 json.Number 原文保留,不落入 float64——否则响应侧 int64
// 超过 2^53 会丢精度(9007199254740993 → ...992
func lowerKeysFromJSON(data []byte) (any, error) { func lowerKeysFromJSON(data []byte) (any, error) {
dec := json.NewDecoder(bytes.NewReader(data))
dec.UseNumber()
var raw any var raw any
if err := json.Unmarshal(data, &raw); err != nil { if err := dec.Decode(&raw); err != nil {
return nil, err return nil, err
} }
return lowerKeys(raw), nil return lowerKeys(raw), nil
+169
View File
@@ -0,0 +1,169 @@
# fun 框架(github.com/cyi-cc/fun)使用文档
> 适用版本:**v1.3.3**(当前最新发布)。基于 fasthttp 的单端点 RPC 框架,
> 自带依赖注入、Guard 鉴权、NDJSON 流式响应、自定义路由与 TypeScript 客户端生成。
## 版本沿革
| 版本 | 要点 |
|---|---|
| v1.1.0 | BindRoute 自定义 GET/POST 路由(回调、健康检查) |
| v1.3.0 | BindRoute 通配符路由 `/prefix/*``RouteCtx.Wildcard` 取剩余路径 |
| v1.3.1 | TS 客户端可靠性:所有失败统一归一为 Result 并经过响应拦截器 |
| v1.3.2 | 每请求上下文(request/stream options + `state`)与免基础设施的生成期注册 `BindServiceForGen` |
| v1.3.3 | 新增 `SetBodyLimit`:自定义路由可放宽请求体上限,支持大体积 multipart 上传 |
## 1. 启动与服务注册
```go
func main() {
f := fun.GetFun()
f.BindService(&UserSvc{}, &AuthGuard{}) // 服务级 Guard 可选
f.BindGuard(&LogGuard{}) // 全局 Guard
cfg := fun.Wired[config.Config]() // 创建/获取单例(触发 DI
go f.Start(cfg.ListenPort()) // fasthttp 监听,RPC 只响应 POST /cell
}
```
- 服务结构体嵌入 `fun.Ctx` + 依赖字段(指针结构体字段自动装配)。
- **每请求新建服务实例**并注入依赖,服务内不放共享状态。
- 方法签名四种:`() error``(dto) (T, error)``(dto) (*fun.Stream, error)`
`(dto) (T, *fun.Stream, error)`(首条消息 T + 后续流)。
- 只有导出方法成为端点,注册名 `服务名.方法名`
## 2. DTO 规则(违反即注册期 panic)
- 允许:定宽整型(int8…int64、uint8…uint64)、string、bool、具名 struct、slice、指针。
- **不支持**:普通 `int`/`uint`、float、map、any/interface、匿名结构体、私有字段。
- 非指针且非 slice 字段必传且非 null;可空字段一律 `*T`;小数用字符串传。
- 枚举:`uint8` 底线 + `Names() []string`(可选 `DisplayNames()`)。
- 响应所有键递归转首字母小写,前端直接 camelCase 取值。
## 3. 线协议与 Result
请求:`POST /cell`body `{"serviceName","methodName","data","state"}`
```ts
export type result<T> = {
id?: string; code?: number; data?: T; msg?: string; status: number
}
```
- `status``0` 成功;`1` 框架/协议/基础设施失败;`2` 业务失败(`fun.Error(code,msg)`);
`4` 外部请求失败或调用方取消;`5` 明确的外部超时失败。
- 业务错误:`return nil, fun.Error(4001, "登录失败")` —— code/msg 原样透传。
- 成功空 slice 序列化为 `[]``data` 为 nil 时整个字段省略。
- `Ctx.State`map[string]string)请求往返透传;`Ctx.Ip` 已解析客户端 IP。
## 4. Guardv1.3.2 无变化,推荐用法见 vividai)
```go
func (g *AuthGuard) Guard(ctx fun.Ctx) { /* 校验失败 panic(fun.Error(...)) */ }
f.BindService(&AdminSvc{}, &AuthGuard{})
```
Guard 也是 Box,字段自动注入;panic 被框架兜底转错误响应。
vividai 的用法:**显式端点策略表**(缺省拒绝)+ Guard 从 HttpOnly Cookie 读会话,
并把校验结果经 `RequestCtx.SetUserValue` 传给服务层做对象级授权。
## 5. 自定义路由(v1.3.0+
```go
f.BindRoute("GET", "/image/*", func(c *fun.RouteCtx) error {
key := c.Wildcard // /image/ 之后的剩余路径
c.RequestCtx.WriteString("…") // 纯文本直写;返回 nil 框架不再写
return fun.Error(4001, "…") // 或统一 Result 错误
})
```
- 精确路由优先于通配符;`/cell` 保留;方法大小写不敏感。
- 查询参数与 form 表单合并进 `c.Param(name)``Param` 只解析
`application/x-www-form-urlencoded`multipart 不合并。
- **multipart/大请求体(v1.3.3+**:默认请求体上限为 fasthttp 的 4MB。大体积
multipart 上传用 `SetBodyLimit``Start` 前放宽,处理器里经 `c.RequestCtx`
直接读 multipart 内容(如 `c.RequestCtx.MultipartForm()`):
```go
f.SetBodyLimit(64 << 20) // 64MB0 或负数恢复 fasthttp 默认 4MB
f.BindRoute("POST", "/upload", func(c *fun.RouteCtx) error {
c.RequestCtx.WriteString("…")
return nil
})
```
## 6. 流式响应(NDJSON
```go
st := &fun.Stream{}
go func() {
for _, chunk := range chunks {
if st.Send(chunk) != nil { return } // 连接断开
}
st.Close() // 必须关闭
}()
return st, nil
```
`Content-Type: application/x-ndjson`,每行一个 JSON;合法零消息流正常结束;
业务出错在建流前返回普通 Result;`OnClose` 注册清理回调。
## 7. TS 客户端生成与请求上下文(v1.3.2 核心)
```go
f := fun.GetFun()
f.BindServiceForGen(&UserSvc{}) // 生成期专用:只反射注册方法,不装配任何基础设施
fun.SetOutput("./frontend/src/api")
fun.GenCode(fun.GenTs{})
```
- `BindServiceForGen` 不触发 Box 装配,生成命令**不需要数据库/Redis 在运行**。
- 生成确定性:service/method/imports 全部源端排序,重复生成字节一致。
- 产物固定落在 `<out>/ts/` 子目录(用 `GenGo` 则是 `<out>/go/`):
`client.ts`Client + `result<T>`)、每服务一个 `<service>.ts`、DTO/View 类型、
`fun.ts``api.create(url)` 聚合入口,服务属性首字母小写)。
需要拍平到目录根时,生成后自行把文件从 `ts/` 上移一层。
### 每调用选项(v1.3.2
```ts
export type RequestOptions = { signal?: AbortSignal; state?: Record<string, string> }
export type StreamOptions = { signal?: AbortSignal; state?: Record<string, string> }
const r = await c.userSvc.profile({ signal: ctrl.signal })
c.chatSvc.chat(dto, msg => {...}, { signal: ctrl.signal })
```
- 调用方可传 `AbortSignal`;**框架自身不设任何请求/连接/空闲超时定时器**,
超时由调用方或网关(nginx)决定,框架只负责把失败归一为 Result。
- `state` 为每请求字符串字典:请求拦截器可写入(如会话纪元、请求标识),
响应拦截器经 `context.requestState` 只读快照取回。
### 拦截器(v1.3.2 四参上下文形态)
```ts
c.addRequestInterceptor((svc, m, state) => { state.epoch = myEpoch() })
c.addResponseInterceptor((svc, m, result, context) => {
// context.requestState: Readonly<Record<string,string>>
// context.response?: Response —— 原生 Response(头、状态码可读)
})
```
旧三参签名仍兼容。所有失败(网络、HTTP、HTML、非法 JSON、取消、拦截器异常)
统一归一为 Result 并**必经响应拦截器**,不存在绕过拦截器的错误路径。
## 8. 依赖注入(box.go
- `fun.Wired[T]()`:按 `*T` 建单例;先注入 `fun:"auto"` 字段(缺则递归创建),
再调 `New()`(无参;连接类资源在此初始化,失败可 log.Fatalf)。
- 启动顺序:先 `Wired` 基础配置/平台单例,再 `BindService`
## 9. 常见坑
- DTO 用普通 `int`/float/map → 注册期 panic;用 int64/字符串/指针。
- 非指针字段漏传 → 运行期 "must be a pointer or have a corresponding field"。
- 流式忘记 `Close()` → 客户端挂起;连接断开后 `Send` 返回 error,循环须检查。
- 方法首字母小写 = 不注册;客户端报 method not found。
- vite 代理需重写前缀:`/api/cell → /cell`
- 生成用 `BindService`(而非 `BindServiceForGen`)会把基础设施拉起来 —— 生成命令请用后者。
+1
View File
@@ -23,4 +23,5 @@ var (
errMethodNotFound = errors.New("method not found") errMethodNotFound = errors.New("method not found")
errEmptyFields = errors.New("serviceName and methodName cannot be empty") errEmptyFields = errors.New("serviceName and methodName cannot be empty")
errDTORequired = errors.New("method requires a DTO but none provided") errDTORequired = errors.New("method requires a DTO but none provided")
errInvalidData = errors.New("invalid request data") // 详细原因只记服务端日志
) )
+105
View File
@@ -0,0 +1,105 @@
// Package demo 演示 fun 框架各特性的示例服务集:
// 定宽整型与可空指针字段、枚举、业务错误码、NDJSON 流式(纯流 + 首条消息流)。
// 产物参考见 example/gen(由 cmd/genexample 生成)。
package demo
import (
"github.com/cyi-cc/fun"
)
// OrderStatus 订单状态枚举:uint8 底层 + Names/DisplayNames
type OrderStatus uint8
func (OrderStatus) Names() []string { return []string{"Pending", "Paid", "Shipped"} }
func (OrderStatus) DisplayNames() []string { return []string{"待支付", "已支付", "已发货"} }
// CreateOrderDto 下单参数。规则:非指针字段必传;指针/slice 字段可省略;
// 数值一律定宽整型(int64),小数用 string 传输
type CreateOrderDto struct {
Sku string // 必传
Count int64 // 必传
Note *string // 可空
Status *OrderStatus // 可空,缺省 Pending
Tags []string // 可省略
}
// OrderDto 订单视图。Id 为雪花 ID:全链路 int64 精度,TS 侧解析为 BigInt
type OrderDto struct {
Id int64
Status OrderStatus
Amount string // 小数金额用字符串传输
Items []string
}
type GetOrderDto struct {
Id int64
}
type CancelDto struct {
Id int64
Reason *string
}
// OrderSvc 订单服务:普通请求/响应、无 DTO、error-only、业务错误码
type OrderSvc struct {
fun.Ctx
}
func (s *OrderSvc) Create(dto CreateOrderDto) (OrderDto, error) {
return OrderDto{
Id: 9007199254740993, // 超过 2^53 的大整数示例
Status: OrderStatus(0),
Amount: "199.00",
Items: dto.Tags,
}, nil
}
func (s *OrderSvc) Get(dto GetOrderDto) (OrderDto, error) {
return OrderDto{Id: dto.Id, Status: OrderStatus(1), Amount: "0.01"}, nil
}
// Cancel error-only 签名:无返回数据
func (s *OrderSvc) Cancel(dto CancelDto) error {
if dto.Reason == nil {
// 业务错误:Code/Msg 原样透传给前端(status=2
return fun.Error(4004, "必须填写取消原因")
}
return nil
}
type ChatDto struct {
Prompt string
}
type AskDto struct {
Prompt string
}
// ChatSvc 流式服务:纯流 + 首条消息流两种签名
type ChatSvc struct {
fun.Ctx
}
// Chat 纯流式:响应为 application/x-ndjson,逐行推送
func (s *ChatSvc) Chat(dto ChatDto) (*fun.Stream, error) {
st := &fun.Stream{}
go func() {
for _, chunk := range []string{"你好", ",这是", "流式示例"} {
if err := st.Send(chunk); err != nil {
return // 连接断开
}
}
st.Close() // 必须关闭,否则客户端一直等
}()
return st, nil
}
// Ask (T, stream, error)T 作为流的第一条消息下发
func (s *ChatSvc) Ask(dto AskDto) (string, *fun.Stream, error) {
st := &fun.Stream{}
go func() {
_ = st.Send("思考中...")
st.Close()
}()
return "收到:" + dto.Prompt, st, nil
}
+179 -33
View File
@@ -1,27 +1,69 @@
package fun package fun
import ( import (
"context"
"fmt" "fmt"
"reflect" "reflect"
"sync" "sync"
"sync/atomic"
"time"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
) )
type Fun struct { type Fun struct {
methods map[string]methodInfo methods map[string]methodInfo
routes map[string]RouteHandler // 自定义路由:"GET /path" → 处理器(精确匹配) routes map[string]boundRoute // 自定义路由:"GET /path" → 绑定的处理器与 Guard(精确匹配)
wildcardRoutes map[string][]wildcardRoute wildcardRoutes map[string][]wildcardRoute // 通配路由,按 HTTP 方法
boxes *sync.Map // 依赖容器:reflect.Type → reflect.Value boxes *sync.Map // 依赖容器:reflect.Type → boxEntry(单例或粘性错误)
guards []*any // 全局 Guard guards []*any // 全局 Guard
serviceGuards map[string][]*any // 服务级 Guard,按服务名 serviceGuards map[string][]*any // 服务级 Guard,按服务名
bodyLimit int // 请求体上限(字节);0 = fasthttp 默认 4MB
readTimeout time.Duration // 读超时,默认 60sslowloris 防线)
writeTimeout time.Duration // 写超时,默认 0 不限制(避免掐断长流式响应)
idleTimeout time.Duration // keep-alive 空闲超时,默认 120s
maxConcurrency int // 最大并发连接数;0 = 不限制
server atomic.Pointer[fasthttp.Server]
started atomic.Bool
mu sync.Mutex // 注册与依赖装配互斥:保护 methods/routes/boxes 的写入
}
// SetBodyLimit 设置请求体上限(字节),须在 Start 前调用。
// multipart 上传等大请求体的自定义路由需要时设置;0 或负数恢复默认。
func (f *Fun) SetBodyLimit(n int) {
f.mustNotStarted("SetBodyLimit")
if n < 0 {
n = 0
}
f.bodyLimit = n
}
// SetTimeouts 配置服务器超时,须在 Start 前调用;单项传 0 表示不限制。
// 默认 ReadTimeout 60s / IdleTimeout 120s / WriteTimeout 不限制
func (f *Fun) SetTimeouts(read, write, idle time.Duration) {
f.mustNotStarted("SetTimeouts")
f.mu.Lock()
defer f.mu.Unlock()
f.readTimeout, f.writeTimeout, f.idleTimeout = read, write, idle
}
// SetMaxConcurrency 配置最大并发连接数(0 = 不限制),须在 Start 前调用。
// 防御慢 handler 堆积 goroutine 打爆内存
func (f *Fun) SetMaxConcurrency(n int) {
f.mustNotStarted("SetMaxConcurrency")
f.mu.Lock()
defer f.mu.Unlock()
f.maxConcurrency = n
} }
// wildcardRoute 通配符路由(BindRoute path 以 "/*" 结尾注册): // wildcardRoute 通配符路由(BindRoute path 以 "/*" 结尾注册):
// prefix 如 "/image",匹配 prefix 与 prefix 下任意子路径 // prefix 如 "/image",匹配 prefix 与 prefix 下任意子路径
type wildcardRoute struct { type wildcardRoute struct {
prefix string prefix string
handler RouteHandler route boundRoute
} }
var ( var (
@@ -29,34 +71,48 @@ var (
streamType = reflect.TypeFor[*Stream]() streamType = reflect.TypeFor[*Stream]()
) )
var fun *Fun var (
fun *Fun
funMu sync.Mutex
)
// methodInfo 已注册方法的元信息 // methodInfo 已注册方法的元信息
type methodInfo struct { type methodInfo struct {
serviceType reflect.Type // 服务值类型(非指针),每请求新建实例 serviceType reflect.Type // 服务值类型(非指针),每请求新建实例
methodIndex int // 方法在实例上的反射索引 methodIndex int // 方法在实例上的反射索引
dtoType reflect.Type // DTO 参数类型,无参数时为 nil dtoType reflect.Type // DTO 参数类型,无参数时为 nil
isStream bool // 返回签名带 *Stream走 RequestStreamType isStream bool // 返回签名带 *Stream响应走 NDJSON 流式
} }
func New() *Fun { func newFun() *Fun {
f := &Fun{ return &Fun{
methods: map[string]methodInfo{}, methods: map[string]methodInfo{},
routes: map[string]RouteHandler{}, routes: map[string]boundRoute{},
wildcardRoutes: map[string][]wildcardRoute{}, wildcardRoutes: map[string][]wildcardRoute{},
boxes: &sync.Map{}, boxes: &sync.Map{},
serviceGuards: map[string][]*any{}, serviceGuards: map[string][]*any{},
readTimeout: 60 * time.Second,
idleTimeout: 120 * time.Second,
// writeTimeout 保持 0:流式响应可能长时间推送,写超时会掐断连接
} }
}
func New() *Fun {
f := newFun()
funMu.Lock()
if fun == nil { if fun == nil {
fun = f fun = f
} }
funMu.Unlock()
return f return f
} }
// GetFun 返回默认 Fun 实例,未初始化时自动创建 // GetFun 返回默认 Fun 实例,未初始化时自动创建(并发安全)
func GetFun() *Fun { func GetFun() *Fun {
funMu.Lock()
defer funMu.Unlock()
if fun == nil { if fun == nil {
fun = New() fun = newFun()
} }
return fun return fun
} }
@@ -66,27 +122,52 @@ func GetFun() *Fun {
// - 参数:最多一个,且必须是 struct(作为 DTO) // - 参数:最多一个,且必须是 struct(作为 DTO)
// - 返回值:只支持四种签名——(error)、(T, error)、(stream, error)、(T, stream, error) // - 返回值:只支持四种签名——(error)、(T, error)、(stream, error)、(T, stream, error)
// //
// guardList 为该服务绑定的 Guard,方法调用前按注册顺序执行 // guardList 为该服务绑定的 Guard,方法调用前按注册顺序执行
func (f *Fun) BindService(service any, guardList ...Guard) { // 依赖装配失败(New() 返回 error)以 error 返回,由调用方决定退出或降级;
// 用法错误(非结构体指针、非法签名)仍为 panic,等价编译期检查
func (f *Fun) BindService(service any, guardList ...Guard) error {
t, name := serviceType(service)
f.mu.Lock()
defer f.mu.Unlock()
f.mustNotStarted("BindService")
if err := boxWired(service, f); err != nil {
return err
}
serviceGuards := make([]*any, 0, len(guardList))
for _, guard := range guardList {
checkGuard(guard)
g, err := serviceGuardWired(guard, f)
if err != nil {
return fmt.Errorf("fun: wire guard %T: %w", guard, err)
}
serviceGuards = append(serviceGuards, g)
}
f.serviceGuards[name] = serviceGuards
f.bindServiceMethods(t, name)
return nil
}
// BindServiceForGen registers service metadata for code generation without
// constructing runtime dependencies or guards.
func (f *Fun) BindServiceForGen(service any) {
t, name := serviceType(service)
f.bindServiceMethods(t, name)
}
func serviceType(service any) (reflect.Type, string) {
t := reflect.TypeOf(service) t := reflect.TypeOf(service)
// 必须是指针指向的结构体,匿名类型无法注册 if t == nil || t.Kind() != reflect.Ptr || t.Elem().Kind() != reflect.Struct {
if t.Kind() != reflect.Ptr || t.Elem().Kind() != reflect.Struct {
panic("fun: BindService requires a pointer to a struct") panic("fun: BindService requires a pointer to a struct")
} }
name := t.Elem().Name() name := t.Elem().Name()
if name == "" { if name == "" {
panic("fun: BindService requires a named type") panic("fun: BindService requires a named type")
} }
return t, name
}
boxWired(service, f) func (f *Fun) bindServiceMethods(t reflect.Type, name string) {
serviceGuards := make([]*any, 0, len(guardList))
for _, guard := range guardList {
checkGuard(guard)
serviceGuards = append(serviceGuards, serviceGuardWired(guard, f))
}
f.serviceGuards[name] = serviceGuards
for m := range t.Methods() { for m := range t.Methods() {
m := m m := m
// Ctx 命名持有 *fasthttp.RequestCtx(非嵌入),服务方法集只含业务方法,无需过滤提升方法 // Ctx 命名持有 *fasthttp.RequestCtx(非嵌入),服务方法集只含业务方法,无需过滤提升方法
@@ -143,25 +224,90 @@ func (f *Fun) BindService(service any, guardList ...Guard) {
} }
// BindGuard 注册全局 Guard,对所有服务生效 // BindGuard 注册全局 Guard,对所有服务生效
func (f *Fun) BindGuard(guard Guard) { func (f *Fun) BindGuard(guard Guard) error {
checkGuard(guard) checkGuard(guard)
f.guards = append(f.guards, serviceGuardWired(guard, f)) f.mu.Lock()
defer f.mu.Unlock()
f.mustNotStarted("BindGuard")
g, err := serviceGuardWired(guard, f)
if err != nil {
return fmt.Errorf("fun: wire guard %T: %w", guard, err)
}
f.guards = append(f.guards, g)
return nil
} }
// callGuard 按 全局 → 服务级 顺序执行 Guard // callGuard 按全局 → 服务级顺序执行 Guard,首个非 nil error 短路返回
func (f *Fun) callGuard(c *Ctx, serviceName string) { func (f *Fun) callGuard(c *Ctx, serviceName string) error {
for _, g := range f.guards { for _, g := range f.guards {
(*g).(Guard).Guard(*c) if err := (*g).(Guard).Guard(*c); err != nil {
return err
}
} }
for _, g := range f.serviceGuards[serviceName] { for _, g := range f.serviceGuards[serviceName] {
(*g).(Guard).Guard(*c) if err := (*g).(Guard).Guard(*c); err != nil {
return err
}
}
return nil
}
// mustNotStarted 注册期 API 在 Start 后调用即 panic
// 运行期对 methods/routes 等注册表的读取不持锁,晚注册与并发请求是数据竞争
func (f *Fun) mustNotStarted(op string) {
if f.started.Load() {
panic("fun: " + op + " must be called before Start")
} }
} }
// Start 在指定端口启动 HTTP 服务(阻塞)。
// 默认 ReadTimeout 60s、IdleTimeout 120sslowloris 防线,SetTimeouts 可调),
// WriteTimeout 默认不限制,长流式响应不会被掐断。
// 优雅停机用 Shutdown;重复 Start panic
func (f *Fun) Start(port uint16) { func (f *Fun) Start(port uint16) {
addr := fmt.Sprintf(":%d", port) f.StartOn(fmt.Sprintf(":%d", port))
err := fasthttp.ListenAndServe(addr, f.handle) }
if err != nil {
// StartOn 在指定地址(":8080"、"127.0.0.1:9000" 等)启动服务,语义同 Start
func (f *Fun) StartOn(addr string) {
f.mu.Lock()
if f.started.Swap(true) {
f.mu.Unlock()
panic("fun: Start already called")
}
srv := f.newServer()
f.server.Store(srv)
f.mu.Unlock()
if err := srv.ListenAndServe(addr); err != nil {
panic(err.Error()) panic(err.Error())
} }
} }
// Shutdown 优雅停机:停止接受新连接,等待在途请求(含流式响应)完成或 ctx 超时。
// 未启动或已停机时为空操作
func (f *Fun) Shutdown(ctx context.Context) error {
if s := f.server.Load(); s != nil {
return s.ShutdownWithContext(ctx)
}
return nil
}
func (f *Fun) newServer() *fasthttp.Server {
srv := &fasthttp.Server{Handler: f.handle}
if f.bodyLimit > 0 {
srv.MaxRequestBodySize = f.bodyLimit
}
if f.readTimeout > 0 {
srv.ReadTimeout = f.readTimeout
}
if f.writeTimeout > 0 {
srv.WriteTimeout = f.writeTimeout
}
if f.idleTimeout > 0 {
srv.IdleTimeout = f.idleTimeout
}
if f.maxConcurrency > 0 {
srv.Concurrency = f.maxConcurrency
}
return srv
}
+15 -5
View File
@@ -29,8 +29,9 @@ var guardHit = false
type TestGuard struct{} type TestGuard struct{}
func (g *TestGuard) Guard(ctx Ctx) { func (g *TestGuard) Guard(ctx Ctx) error {
guardHit = true guardHit = true
return nil
} }
func (s *TestSvc) Hello(dto TestDto) (string, error) { func (s *TestSvc) Hello(dto TestDto) (string, error) {
@@ -58,7 +59,9 @@ func (s *TestSvc) Count(dto TestDto) (*Stream, error) {
func TestCtxBoxInject(t *testing.T) { func TestCtxBoxInject(t *testing.T) {
f := New() f := New()
guardHit = false guardHit = false
f.BindService(&TestSvc{}, &TestGuard{}) if err := f.BindService(&TestSvc{}, &TestGuard{}); err != nil {
t.Fatal(err)
}
c := &Ctx{Ip: "1.2.3.4", MethodName: "Hello", ServiceName: "TestSvc"} c := &Ctx{Ip: "1.2.3.4", MethodName: "Hello", ServiceName: "TestSvc"}
data := map[string]any{"name": "tom", "age": 1} data := map[string]any{"name": "tom", "age": 1}
c.Data = &data c.Data = &data
@@ -79,7 +82,9 @@ func TestCtxBoxInject(t *testing.T) {
func TestCheckDtoRequired(t *testing.T) { func TestCheckDtoRequired(t *testing.T) {
f := New() f := New()
f.BindService(&TestSvc{}) if err := f.BindService(&TestSvc{}); err != nil {
t.Fatal(err)
}
c := &Ctx{Ip: "x", MethodName: "Hello", ServiceName: "TestSvc"} c := &Ctx{Ip: "x", MethodName: "Hello", ServiceName: "TestSvc"}
data := map[string]any{"age": 1} data := map[string]any{"age": 1}
c.Data = &data c.Data = &data
@@ -93,7 +98,10 @@ func TestCheckDtoRequired(t *testing.T) {
} }
func TestGenCode(t *testing.T) { func TestGenCode(t *testing.T) {
GetFun().BindService(&TestSvc{}) isolateGeneratorGlobals(t)
if err := GetFun().BindService(&TestSvc{}); err != nil {
t.Fatal(err)
}
SetOutput(t.TempDir()) SetOutput(t.TempDir())
GenCode(GenGo{}, GenTs{}) GenCode(GenGo{}, GenTs{})
if _, err := os.Stat(filepath.Join(getDirectory(), "go", "test_svc.go")); err != nil { if _, err := os.Stat(filepath.Join(getDirectory(), "go", "test_svc.go")); err != nil {
@@ -106,7 +114,9 @@ func TestGenCode(t *testing.T) {
func startServer(t *testing.T, port uint16) *Fun { func startServer(t *testing.T, port uint16) *Fun {
f := New() f := New()
f.BindService(&TestSvc{}) if err := f.BindService(&TestSvc{}); err != nil {
t.Fatal(err)
}
go f.Start(port) go f.Start(port)
time.Sleep(300 * time.Millisecond) time.Sleep(300 * time.Millisecond)
return f return f
+13 -3
View File
@@ -6,6 +6,7 @@ import (
"path/filepath" "path/filepath"
"reflect" "reflect"
"regexp" "regexp"
"sort"
"strings" "strings"
"text/template" "text/template"
) )
@@ -33,8 +34,8 @@ type genMethod struct {
isStream bool isStream bool
} }
// serviceGroups 按服务名分组已注册方法 // serviceGroups 按服务名和方法名稳定分组已注册方法
func (f *Fun) serviceGroups() map[string][]*genMethod { func (f *Fun) serviceGroups() []*genSvc {
groups := map[string][]*genMethod{} groups := map[string][]*genMethod{}
for key, m := range f.methods { for key, m := range f.methods {
parts := strings.SplitN(key, ".", 2) parts := strings.SplitN(key, ".", 2)
@@ -47,7 +48,14 @@ func (f *Fun) serviceGroups() map[string][]*genMethod {
isStream: m.isStream, isStream: m.isStream,
}) })
} }
return groups
services := make([]*genSvc, 0, len(groups))
for name, methods := range groups {
sort.Slice(methods, func(i, j int) bool { return methods[i].name < methods[j].name })
services = append(services, &genSvc{name: name, methods: methods})
}
sort.Slice(services, func(i, j int) bool { return services[i].name < services[j].name })
return services
} }
type genType struct { type genType struct {
@@ -79,6 +87,7 @@ type genServiceType struct {
GenMethodTypeList []*genMethodType GenMethodTypeList []*genMethodType
GenImport []*genImportType GenImport []*genImportType
IsIncludeProxy bool IsIncludeProxy bool
IsIncludeRequest bool
IsIncludeStream bool IsIncludeStream bool
} }
@@ -103,6 +112,7 @@ func deduplicateServiceImports(imports []*genImportType) []*genImportType {
result = append(result, imp) result = append(result, imp)
} }
} }
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
return result return result
} }
+8 -3
View File
@@ -132,13 +132,13 @@ func (ctx GenGo) genDefaultService() {
f := GetFun() f := GetFun()
genContext := genType{GenServiceList: []*genServiceType{}} genContext := genType{GenServiceList: []*genServiceType{}}
for svcName, methods := range f.serviceGroups() { for _, svc := range f.serviceGroups() {
serviceContext := &genServiceType{ serviceContext := &genServiceType{
ServiceName: svcName, ServiceName: svc.name,
GenMethodTypeList: []*genMethodType{}, GenMethodTypeList: []*genMethodType{},
} }
genContext.GenServiceList = append(genContext.GenServiceList, serviceContext) genContext.GenServiceList = append(genContext.GenServiceList, serviceContext)
ctx.genService(&genSvc{name: svcName, methods: methods}, serviceContext) ctx.genService(svc, serviceContext)
} }
genCode(ctx.template.genDefaultServiceTemplate(), "fun", genContext, ctx.getName()) genCode(ctx.template.genDefaultServiceTemplate(), "fun", genContext, ctx.getName())
} }
@@ -175,6 +175,11 @@ func (ctx GenGo) genStruct(t reflect.Type) *genImportType {
}) })
} }
// 指针字段解引用后再检查嵌套类型,与 TS 生成器一致:
// 否则 *Enum/*Struct 字段的定义文件会被漏生成(Kind 是 Ptr,三个分支全跳过)
if fieldType.Kind() == reflect.Ptr {
fieldType = fieldType.Elem()
}
if fieldType.Kind() == reflect.Struct { if fieldType.Kind() == reflect.Struct {
ctx.genStruct(fieldType) ctx.genStruct(fieldType)
} }
+220
View File
@@ -0,0 +1,220 @@
package fun
import (
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"testing"
)
type AlphaGenDto struct {
Value string
}
type ZebraGenDto struct {
Value string
}
type AlphaGenSvc struct{}
func (*AlphaGenSvc) Zebra(dto ZebraGenDto) (AlphaGenDto, error) { return AlphaGenDto{}, nil }
func (*AlphaGenSvc) Alpha(dto AlphaGenDto) (ZebraGenDto, error) { return ZebraGenDto{}, nil }
func (*AlphaGenSvc) Ping() error { return nil }
type MixedGenSvc struct{}
func (*MixedGenSvc) Request() (string, error) { return "", nil }
func (*MixedGenSvc) Stream() (*Stream, error) { return &Stream{}, nil }
type ZebraGenSvc struct{}
func (*ZebraGenSvc) Watch() (*Stream, error) { return &Stream{}, nil }
type GenOnlyDependency struct{}
func (*GenOnlyDependency) New() { panic("generation initialized a runtime dependency") }
type DependencyGenSvc struct {
Dependency *GenOnlyDependency
}
func (*DependencyGenSvc) Ping() error { return nil }
func isolateGeneratorGlobals(t *testing.T) {
t.Helper()
oldFun, oldDirectory := fun, directory
fun = nil
directory = "./gen"
t.Cleanup(func() {
fun = oldFun
directory = oldDirectory
})
}
func generatedFiles(t *testing.T, root string) map[string]string {
t.Helper()
files := map[string]string{}
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
body, err := os.ReadFile(path)
if err != nil {
return err
}
rel, err := filepath.Rel(root, path)
if err != nil {
return err
}
files[rel] = string(body)
return nil
})
if err != nil {
t.Fatal(err)
}
return files
}
func TestBindServiceForGenDoesNotInitializeDependencies(t *testing.T) {
isolateGeneratorGlobals(t)
f := GetFun()
f.BindServiceForGen(&DependencyGenSvc{})
if _, ok := f.methods["DependencyGenSvc.Ping"]; !ok {
t.Fatal("generation-only service method was not registered")
}
if _, ok := f.boxes.Load(reflect.TypeFor[*GenOnlyDependency]()); ok {
t.Fatal("generation-only registration stored a runtime dependency")
}
}
func TestGeneratedTypeScriptSignaturesAndImports(t *testing.T) {
isolateGeneratorGlobals(t)
f := GetFun()
for _, svc := range []any{&ZebraGenSvc{}, &MixedGenSvc{}, &AlphaGenSvc{}} {
if err := f.BindService(svc); err != nil {
t.Fatal(err)
}
}
SetOutput(t.TempDir())
GenCode(GenTs{})
read := func(name string) string {
t.Helper()
body, err := os.ReadFile(filepath.Join(getDirectory(), "ts", name))
if err != nil {
t.Fatal(err)
}
return string(body)
}
client := read("client.ts")
for _, want := range []string{
`state?: Record<string, string>;`,
`export type ResponseContext = {`,
`readonly requestState: Readonly<Record<string, string>>;`,
`readonly response?: Response;`,
`state = { ...this.state, ...options?.state };`,
`interceptor(serviceName, methodName, current, context)`,
} {
if !strings.Contains(client, want) {
t.Errorf("client.ts missing %q:\n%s", want, client)
}
}
alpha := read("alphaGenSvc.ts")
if first := strings.SplitN(alpha, "\n", 2)[0]; first != `import { Client, type result, type RequestOptions } from "./client";` {
t.Fatalf("unexpected request-only imports: %s", first)
}
for _, want := range []string{
`async ping(options?: RequestOptions): Promise<result<void>>`,
`this.client.request<void>("alphaGenSvc", "ping", undefined, options)`,
`async alpha(dto:alphaGenDto, options?: RequestOptions): Promise<result<zebraGenDto>>`,
`this.client.request<zebraGenDto>("alphaGenSvc", "alpha", dto, options)`,
} {
if !strings.Contains(alpha, want) {
t.Errorf("alphaGenSvc.ts missing %q:\n%s", want, alpha)
}
}
if strings.Index(alpha, `import type alphaGenDto`) > strings.Index(alpha, `import type zebraGenDto`) {
t.Fatalf("DTO imports are not sorted:\n%s", alpha)
}
if strings.Index(alpha, `async alpha`) > strings.Index(alpha, `async ping`) ||
strings.Index(alpha, `async ping`) > strings.Index(alpha, `async zebra`) {
t.Fatalf("methods are not sorted:\n%s", alpha)
}
stream := read("zebraGenSvc.ts")
if first := strings.SplitN(stream, "\n", 2)[0]; first != `import { Client, type result, type StreamOptions } from "./client";` {
t.Fatalf("unexpected stream-only imports: %s", first)
}
for _, want := range []string{
`async watch(onMessage: (data: any) => unknown, options?: StreamOptions): Promise<result<void>>`,
`this.client.stream<any>("zebraGenSvc", "watch", undefined, onMessage, options)`,
} {
if !strings.Contains(stream, want) {
t.Errorf("zebraGenSvc.ts missing %q:\n%s", want, stream)
}
}
mixed := read("mixedGenSvc.ts")
if first := strings.SplitN(mixed, "\n", 2)[0]; first != `import { Client, type result, type RequestOptions, type StreamOptions } from "./client";` {
t.Fatalf("unexpected mixed imports: %s", first)
}
}
func TestGeneratedSourcesAreDeterministic(t *testing.T) {
isolateGeneratorGlobals(t)
f := GetFun()
for _, svc := range []any{&ZebraGenSvc{}, &AlphaGenSvc{}, &MixedGenSvc{}} {
if err := f.BindService(svc); err != nil {
t.Fatal(err)
}
}
root := t.TempDir()
SetOutput(root)
GenCode(GenGo{}, GenTs{})
first := generatedFiles(t, root)
GenCode(GenGo{}, GenTs{})
second := generatedFiles(t, root)
if len(first) != len(second) {
t.Fatalf("generated file count changed: %d != %d", len(first), len(second))
}
for name, body := range first {
if second[name] != body {
t.Errorf("generated file changed between runs: %s", name)
}
}
tsFun := first[filepath.Join("ts", "fun.ts")]
positions := []int{
strings.Index(tsFun, `import alphaGenSvc`),
strings.Index(tsFun, `import mixedGenSvc`),
strings.Index(tsFun, `import zebraGenSvc`),
}
if !sort.IntsAreSorted(positions) || positions[0] < 0 {
t.Fatalf("TypeScript services are not sorted:\n%s", tsFun)
}
goFun := first[filepath.Join("go", "fun.go")]
positions = []int{
strings.Index(goFun, "AlphaGenSvc *AlphaGenSvc"),
strings.Index(goFun, "MixedGenSvc *MixedGenSvc"),
strings.Index(goFun, "ZebraGenSvc *ZebraGenSvc"),
}
if !sort.IntsAreSorted(positions) || positions[0] < 0 {
t.Fatalf("Go services are not sorted:\n%s", goFun)
}
goService := first[filepath.Join("go", "alpha_gen_svc.go")]
positions = []int{
strings.Index(goService, "func (ctx *AlphaGenSvc) Alpha("),
strings.Index(goService, "func (ctx *AlphaGenSvc) Ping("),
strings.Index(goService, "func (ctx *AlphaGenSvc) Zebra("),
}
if !sort.IntsAreSorted(positions) || positions[0] < 0 {
t.Fatalf("Go methods are not sorted:\n%s", goService)
}
}
+5 -3
View File
@@ -56,6 +56,7 @@ func (ctx GenTs) genService(svc *genSvc, serviceContext *genServiceType) {
argsText += ",dto" argsText += ",dto"
nestedImports = append(nestedImports, ctx.genStruct(gm.dtoType)) nestedImports = append(nestedImports, ctx.genStruct(gm.dtoType))
} }
serviceContext.IsIncludeRequest = true
serviceContext.GenMethodTypeList = append(serviceContext.GenMethodTypeList, &genMethodType{ serviceContext.GenMethodTypeList = append(serviceContext.GenMethodTypeList, &genMethodType{
MethodName: firstLetterToLower(gm.name), MethodName: firstLetterToLower(gm.name),
ReturnValueText: returnValueText, ReturnValueText: returnValueText,
@@ -83,6 +84,7 @@ func (ctx GenTs) genService(svc *genSvc, serviceContext *genServiceType) {
nestedImports = ctx.genReturnTypes(returnType, nestedImports) nestedImports = ctx.genReturnTypes(returnType, nestedImports)
} }
} else { } else {
serviceContext.IsIncludeRequest = true
t := firstLetterToLower(ctx.typeToTemplateType(returnType)) t := firstLetterToLower(ctx.typeToTemplateType(returnType))
if !strings.Contains(t, "[]") && strings.Contains(t, "[") { if !strings.Contains(t, "[]") && strings.Contains(t, "[") {
returnValueText = getGenericTypeName(t) + parseGenericTypeParams(t) returnValueText = getGenericTypeName(t) + parseGenericTypeParams(t)
@@ -151,13 +153,13 @@ func (ctx GenTs) genDefaultService() {
f := GetFun() f := GetFun()
genContext := genType{GenServiceList: []*genServiceType{}} genContext := genType{GenServiceList: []*genServiceType{}}
for svcName, methods := range f.serviceGroups() { for _, svc := range f.serviceGroups() {
serviceContext := &genServiceType{ serviceContext := &genServiceType{
ServiceName: firstLetterToLower(svcName), ServiceName: firstLetterToLower(svc.name),
GenMethodTypeList: []*genMethodType{}, GenMethodTypeList: []*genMethodType{},
} }
genContext.GenServiceList = append(genContext.GenServiceList, serviceContext) genContext.GenServiceList = append(genContext.GenServiceList, serviceContext)
ctx.genService(&genSvc{name: svcName, methods: methods}, serviceContext) ctx.genService(svc, serviceContext)
} }
genCode(ctx.template.genClientTemplate(), "client", nil, ctx.getName()) genCode(ctx.template.genClientTemplate(), "client", nil, ctx.getName())
genCode(ctx.template.genDefaultServiceTemplate(), "fun", genContext, ctx.getName()) genCode(ctx.template.genDefaultServiceTemplate(), "fun", genContext, ctx.getName())
+6 -1
View File
@@ -1,5 +1,10 @@
package fun package fun
// Guard 方法调用前的拦截器。
//
// 返回 nil 放行;返回 error 短路:后续 Guard 与业务方法不再执行,
// error 走统一 Result 错误响应(返回 fun.Error(code, msg) 可携带错误码)。
// 不再需要通过写响应或 panic 表达拒绝
type Guard interface { type Guard interface {
Guard(ctx Ctx) Guard(ctx Ctx) error
} }
+56 -14
View File
@@ -8,6 +8,7 @@ import (
"reflect" "reflect"
"runtime/debug" "runtime/debug"
"strings" "strings"
"sync"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
) )
@@ -18,13 +19,13 @@ func (f *Fun) handle(fastCtx *fasthttp.RequestCtx) {
defer f.handlePanic(ctx) defer f.handlePanic(ctx)
method, path := string(fastCtx.Method()), string(fastCtx.Path()) method, path := string(fastCtx.Method()), string(fastCtx.Path())
if handler, ok := f.routes[method+" "+path]; ok { if r, ok := f.routes[method+" "+path]; ok {
f.handleRoute(fastCtx, handler, "") f.handleRoute(fastCtx, r, "")
return return
} }
for _, r := range f.wildcardRoutes[method] { for _, r := range f.wildcardRoutes[method] {
if path == r.prefix || strings.HasPrefix(path, r.prefix+"/") { if path == r.prefix || strings.HasPrefix(path, r.prefix+"/") {
f.handleRoute(fastCtx, r.handler, strings.TrimPrefix(path, r.prefix+"/")) f.handleRoute(fastCtx, r.route, strings.TrimPrefix(path, r.prefix+"/"))
return return
} }
} }
@@ -39,9 +40,11 @@ func (f *Fun) handle(fastCtx *fasthttp.RequestCtx) {
} }
body := ctx.postBody() body := ctx.postBody()
var requestInfo RequestInfo[map[string]any] // Data 以原始字节保存:校验用解码后的 map,业务 DTO 解码用原始字节,
// 大整数不经过 float64 往返,避免 int64 精度丢失
var requestInfo RequestInfo[json.RawMessage]
if err := json.Unmarshal(body, &requestInfo); err != nil { if err := json.Unmarshal(body, &requestInfo); err != nil {
ctx.sendError(err) ctx.send(internalError("invalid request body", err))
return return
} }
requestInfo.MethodName = firstLetterToUpper(requestInfo.MethodName) requestInfo.MethodName = firstLetterToUpper(requestInfo.MethodName)
@@ -51,11 +54,21 @@ func (f *Fun) handle(fastCtx *fasthttp.RequestCtx) {
return return
} }
ctx.Ip = ctx.remoteIP().String() ctx.Ip = clientIP(fastCtx)
ctx.State = requestInfo.State ctx.State = requestInfo.State
ctx.MethodName = requestInfo.MethodName ctx.MethodName = requestInfo.MethodName
ctx.ServiceName = requestInfo.ServiceName ctx.ServiceName = requestInfo.ServiceName
ctx.Data = requestInfo.Data if requestInfo.Data != nil {
var dataMap map[string]any
if err := json.Unmarshal(*requestInfo.Data, &dataMap); err != nil {
ctx.send(internalError("invalid request data", err))
return
}
if dataMap != nil { // "data": null 视为未提供数据
ctx.Data = &dataMap
ctx.rawData = *requestInfo.Data
}
}
// 流式方法:响应保持打开,以 NDJSON 行推送(Streamable HTTP // 流式方法:响应保持打开,以 NDJSON 行推送(Streamable HTTP
// streamCh != nil 表示流式方法;业务返回的 *Stream 在 invoke 内完成注入 // streamCh != nil 表示流式方法;业务返回的 *Stream 在 invoke 内完成注入
@@ -72,6 +85,17 @@ func (f *Fun) handle(fastCtx *fasthttp.RequestCtx) {
fastCtx.Response.Header.Set("Cache-Control", "no-cache") fastCtx.Response.Header.Set("Cache-Control", "no-cache")
fastCtx.Response.Header.Set("Connection", "keep-alive") fastCtx.Response.Header.Set("Connection", "keep-alive")
fastCtx.SetBodyStreamWriter(func(w *bufio.Writer) { fastCtx.SetBodyStreamWriter(func(w *bufio.Writer) {
// 流式写出运行在 fasthttp 的写出 goroutine 上,handle 的 defer 兜不住:
// 这里必须自行 recover,否则业务 Send 的值 MarshalJSON panic 会击穿进程。
// finish 保证 streamDone 恰好 close 一次,解除业务 Send 阻塞
var closeDone sync.Once
finish := func() { closeDone.Do(func() { close(streamDone) }) }
defer func() {
if v := recover(); v != nil {
ErrorLogger(fmt.Sprintf("fun: stream writer panic (%s.%s): %v", ctx.ServiceName, ctx.MethodName, v), "\n"+string(debug.Stack()))
finish()
}
}()
writeLine := func(v any) bool { writeLine := func(v any) bool {
data, err := json.Marshal(v) data, err := json.Marshal(v)
if err != nil { if err != nil {
@@ -94,24 +118,26 @@ func (f *Fun) handle(fastCtx *fasthttp.RequestCtx) {
// (T, stream, error)T 作为流的第一条消息下发 // (T, stream, error)T 作为流的第一条消息下发
if result.Data != nil { if result.Data != nil {
if !writeLine(*result.Data) { if !writeLine(*result.Data) {
close(streamDone) finish()
return return
} }
} }
for message := range streamCh { for message := range streamCh {
if !writeLine(message) { if !writeLine(message) {
close(streamDone) finish()
return return
} }
} }
close(streamDone) finish()
}) })
return return
} }
ctx.send(*result) ctx.send(*result)
} }
// handlePanic 兜底处理 panic归一为 error 后写回错误响应,并记录完整堆栈日志 // handlePanic 兜底处理 panic:完整堆栈日志
// 业务以 panic 抛出的 fun.Error 原样透传,其余 panic 只回通用错误——
// panic 消息可能包含 SQL/内部路径等细节,不外泄给客户端
func (f *Fun) handlePanic(c *Ctx) { func (f *Fun) handlePanic(c *Ctx) {
if v := recover(); v != nil { if v := recover(); v != nil {
var err error var err error
@@ -121,7 +147,12 @@ func (f *Fun) handlePanic(c *Ctx) {
err = fmt.Errorf("panic (%s.%s): %v", c.ServiceName, c.MethodName, v) err = fmt.Errorf("panic (%s.%s): %v", c.ServiceName, c.MethodName, v)
} }
ErrorLogger(err.Error(), "\n"+string(debug.Stack())) ErrorLogger(err.Error(), "\n"+string(debug.Stack()))
var result Result[any]
if errors.As(err, &result) {
c.sendError(err) c.sendError(err)
return
}
c.send(internalError("internal error", err))
} }
} }
@@ -135,7 +166,9 @@ func (f *Fun) invoke(c *Ctx, streamCh *chan any, streamDone *chan struct{}) (*Re
return nil, errMethodNotFound return nil, errMethodNotFound
} }
f.callGuard(c, c.ServiceName) if err := f.callGuard(c, c.ServiceName); err != nil {
return nil, err
}
var args []reflect.Value var args []reflect.Value
if method.dtoType != nil { if method.dtoType != nil {
@@ -146,8 +179,17 @@ func (f *Fun) invoke(c *Ctx, streamCh *chan any, streamDone *chan struct{}) (*Re
return nil, err return nil, err
} }
dto := reflect.New(method.dtoType).Elem() dto := reflect.New(method.dtoType).Elem()
if err := convert(c.Data, dto.Addr().Interface()); err != nil { // 优先按原始字节精确解码(不经 float64);直接构造 Ctx 调 invoke(无 rawData)时回退 map 往返
return nil, err var decodeErr error
if len(c.rawData) > 0 {
decodeErr = json.Unmarshal(c.rawData, dto.Addr().Interface())
} else {
decodeErr = convert(*c.Data, dto.Addr().Interface())
}
if decodeErr != nil {
// 客户端只收通用提示;详细原因记服务端日志,不外泄 Go 类型等内部信息
ErrorLogger("fun: decode request data (" + c.ServiceName + "." + c.MethodName + "): ", decodeErr.Error())
return nil, errInvalidData
} }
args = append(args, dto) args = append(args, dto)
} }
+343
View File
@@ -0,0 +1,343 @@
package fun
// 硬化批次回归测试:真实 IP、路由 Guard、started 护栏与优雅停机、
// 内部错误脱敏、流式 writer panic 兜底、匿名 struct 拒绝、TS 大整数往返
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"github.com/valyala/fasthttp"
)
// ---- 真实 IPX-Forwarded-For > X-Real-IP > RemoteAddr ----
func TestClientIP(t *testing.T) {
mk := func(headers map[string]string, remote string) *fasthttp.RequestCtx {
fc := &fasthttp.RequestCtx{}
for k, v := range headers {
fc.Request.Header.Set(k, v)
}
if remote != "" {
fc.SetRemoteAddr(&net.TCPAddr{IP: net.ParseIP(remote), Port: 1234})
}
return fc
}
if got := clientIP(mk(map[string]string{"X-Forwarded-For": "1.1.1.1, 2.2.2.2"}, "")); got != "2.2.2.2" {
t.Fatalf("XFF last segment: got %q", got)
}
if got := clientIP(mk(map[string]string{"X-Real-IP": "3.3.3.3"}, "")); got != "3.3.3.3" {
t.Fatalf("X-Real-IP: got %q", got)
}
if got := clientIP(mk(nil, "9.9.9.9")); got != "9.9.9.9" {
t.Fatalf("RemoteAddr fallback: got %q", got)
}
if got := clientIP(mk(nil, "")); got != "127.0.0.1" {
t.Fatalf("no remote addr: got %q", got)
}
if got := clientIP(mk(map[string]string{"X-Real-IP": "::1"}, "")); got != "127.0.0.1" {
t.Fatalf("loopback normalize: got %q", got)
}
}
type EchoIpSvc struct {
Ctx
}
func (s *EchoIpSvc) Get() (string, error) { return s.Ip, nil }
func TestClientIPEndtoEnd(t *testing.T) {
f := New()
if err := f.BindService(&EchoIpSvc{}); err != nil {
t.Fatal(err)
}
go f.Start(39015)
time.Sleep(300 * time.Millisecond)
defer f.Shutdown(context.Background())
req, err := http.NewRequest("POST", "http://127.0.0.1:39015/cell",
strings.NewReader(`{"serviceName":"EchoIpSvc","methodName":"Get"}`))
if err != nil {
t.Fatal(err)
}
req.Header.Set("X-Forwarded-For", "203.0.113.9")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var out Result[any]
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatal(err)
}
if out.Status != 0 || out.Data == nil || (*out.Data).(string) != "203.0.113.9" {
t.Fatalf("client ip not honored: %+v", out)
}
}
// ---- 路由 Guard:短路 + 查询参数进 State ----
type RouteTokenGuard struct{}
func (g *RouteTokenGuard) Guard(ctx Ctx) error {
if ctx.State["token"] != "ok" {
return Error(4401, "unauthorized")
}
return nil
}
func TestRouteGuard(t *testing.T) {
f := New()
if err := f.BindService(&TestSvc{}); err != nil {
t.Fatal(err)
}
handlerRan := false
open := func(c *RouteCtx) error {
handlerRan = true
c.RequestCtx.WriteString("open")
return nil
}
secret := func(c *RouteCtx) error {
handlerRan = true
c.RequestCtx.WriteString("secret")
return nil
}
files := func(c *RouteCtx) error {
handlerRan = true
c.RequestCtx.WriteString("file:" + c.Wildcard)
return nil
}
if err := f.BindRoute("GET", "/open", open, &OrderFirstGuard{}); err != nil {
t.Fatal(err)
}
if err := f.BindRoute("GET", "/secret", secret, &OrderRejectGuard{}); err != nil {
t.Fatal(err)
}
if err := f.BindRoute("GET", "/file/*", files, &RouteTokenGuard{}); err != nil {
t.Fatal(err)
}
do := func(path string) *fasthttp.RequestCtx {
fc := &fasthttp.RequestCtx{}
fc.Request.Header.SetMethod("GET")
fc.Request.SetRequestURI(path)
f.handle(fc)
return fc
}
handlerRan = false
if fc := do("/open"); !handlerRan || string(fc.Response.Body()) != "open" {
t.Fatalf("passing guard: ran=%v body=%q", handlerRan, fc.Response.Body())
}
handlerRan = false
fc := do("/secret")
if handlerRan {
t.Fatal("handler must not run when guard rejects")
}
if body := string(fc.Response.Body()); !strings.Contains(body, `"code":4003`) || !strings.Contains(body, `"status":2`) {
t.Fatalf("guard rejection body: %s", body)
}
// 通配路由 + Guard 从查询参数取 tokenState 合并)
handlerRan = false
if fc := do("/file/a/b.txt?token=ok"); !handlerRan || string(fc.Response.Body()) != "file:a/b.txt" {
t.Fatalf("wildcard with token: ran=%v body=%q", handlerRan, fc.Response.Body())
}
handlerRan = false
if fc := do("/file/a.txt"); handlerRan || !strings.Contains(string(fc.Response.Body()), `"code":4401`) {
t.Fatal("missing token must be rejected by route guard")
}
}
// ---- started 护栏 + 优雅停机 ----
func TestStartedGuardAndShutdown(t *testing.T) {
f := New()
if err := f.BindService(&TestSvc{}); err != nil {
t.Fatal(err)
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := uint16(ln.Addr().(*net.TCPAddr).Port)
_ = ln.Close()
go f.Start(port)
time.Sleep(300 * time.Millisecond)
if err := f.Shutdown(context.Background()); err != nil {
t.Fatalf("shutdown: %v", err)
}
if err := f.Shutdown(context.Background()); err != nil {
t.Fatalf("second shutdown should be no-op: %v", err)
}
catch := func(fn func()) (msg string) {
defer func() { msg = fmt.Sprint(recover()) }()
fn()
return ""
}
if m := catch(func() { _ = f.BindService(&TestSvc{}) }); m == "" {
t.Fatal("BindService after Start must panic")
}
if m := catch(func() { _ = f.BindRoute("GET", "/x", func(*RouteCtx) error { return nil }) }); m == "" {
t.Fatal("BindRoute after Start must panic")
}
if m := catch(func() { f.SetTimeouts(time.Second, 0, time.Second) }); m == "" {
t.Fatal("SetTimeouts after Start must panic")
}
}
// ---- 内部错误脱敏:客户端只收固定提示,不泄露 Go 内部细节 ----
func TestInternalErrorsSanitized(t *testing.T) {
f := New()
if err := f.BindService(&PrecisionSvc{}); err != nil {
t.Fatal(err)
}
go f.Start(39016)
time.Sleep(300 * time.Millisecond)
defer f.Shutdown(context.Background())
post := func(body string) string {
t.Helper()
resp, err := http.Post("http://127.0.0.1:39016/cell", "application/json", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return string(b)
}
// data 字段类型错误(字符串进 int64)→ 通用提示,不泄露 unmarshal/类型名
got := post(`{"serviceName":"PrecisionSvc","methodName":"Get","data":{"id":"not-a-number"}}`)
if !strings.Contains(got, "invalid request data") {
t.Fatalf("expected sanitized message, got: %s", got)
}
for _, leak := range []string{"unmarshal", "int64", "PrecisionDto", "Go struct"} {
if strings.Contains(got, leak) {
t.Fatalf("internal detail %q leaked: %s", leak, got)
}
}
// 损坏的请求体 → 通用提示
got = post(`{"serviceName":`)
if !strings.Contains(got, "invalid request body") {
t.Fatalf("expected sanitized body message, got: %s", got)
}
}
// ---- 流式 writer panic 兜底:进程不崩、服务器仍可用 ----
type boomMarshaler struct{}
func (boomMarshaler) MarshalJSON() ([]byte, error) { panic("boom-json") }
type PanicStreamSvc struct{}
func (s *PanicStreamSvc) Go() (*Stream, error) {
st := &Stream{}
go func() {
_ = st.Send(boomMarshaler{})
st.Close()
}()
return st, nil
}
func TestStreamWriterPanicRecovered(t *testing.T) {
f := New()
if err := f.BindService(&PanicStreamSvc{}); err != nil {
t.Fatal(err)
}
go f.Start(39017)
time.Sleep(300 * time.Millisecond)
defer f.Shutdown(context.Background())
resp, err := http.Post("http://127.0.0.1:39017/cell", "application/json",
strings.NewReader(`{"serviceName":"PanicStreamSvc","methodName":"Go"}`))
if err != nil {
t.Fatalf("first stream request: %v", err)
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
// panic 被兜底后服务器必须仍然可用
resp2, err := http.Post("http://127.0.0.1:39017/cell", "application/json",
strings.NewReader(`{"serviceName":"PanicStreamSvc","methodName":"Go"}`))
if err != nil {
t.Fatalf("server died after stream writer panic: %v", err)
}
_, _ = io.Copy(io.Discard, resp2.Body)
_ = resp2.Body.Close()
}
// ---- 匿名 struct 注册期拒绝 / isPrivate 空名安全 ----
func TestAnonymousStructRejected(t *testing.T) {
type withAnon struct {
Inner struct{ A string }
}
defer func() {
if recover() == nil {
t.Fatal("anonymous struct must be rejected at registration")
}
}()
checkType(reflect.TypeFor[withAnon]())
_ = isPrivate("") // 空名不再越界 panic
}
// ---- TS 客户端大整数往返(node 真实执行生成物) ----
func TestTypeScriptBigIntRoundTrip(t *testing.T) {
node, err := exec.LookPath("node")
if err != nil {
t.Skip("node is not installed")
}
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "client.ts"), []byte(templateTs{}.genClientTemplate()), 0o644); err != nil {
t.Fatal(err)
}
const script = `
import { Client } from "./client.ts";
// 响应侧:mock 返回原文 JSON(不经 JS number),大整数必须解析为 BigInt
globalThis.fetch = async () => new Response('{"status":0,"data":{"id":9007199254740993}}', {
headers: { "Content-Type": "application/json" },
});
const client = new Client("http://example.test");
const result = await client.request("Svc", "BigInt");
if (typeof result.data.id !== "bigint") throw new Error("expected bigint, got " + typeof result.data.id);
if (result.data.id !== 9007199254740993n) throw new Error("bigint value lost");
// 请求侧:dto 里的 BigInt 序列化为数字字面量;普通数值不受影响
let requestBody;
globalThis.fetch = async (_url, init) => {
requestBody = init.body;
return new Response('{"status":0}', { headers: { "Content-Type": "application/json" } });
};
await client.request("Svc", "BigInt", { id: 9007199254740993n });
if (!requestBody.includes('"id":9007199254740993')) throw new Error("bigint not serialized: " + requestBody);
await client.request("Svc", "Small", { id: 42 });
if (!requestBody.includes('"id":42')) throw new Error("small int broken: " + requestBody);
`
scriptPath := filepath.Join(dir, "bigint.mjs")
if err := os.WriteFile(scriptPath, []byte(script), 0o644); err != nil {
t.Fatal(err)
}
if output, err := exec.Command(node, "--experimental-strip-types", scriptPath).CombinedOutput(); err != nil {
t.Fatalf("bigint round trip failed: %v\n%s", err, output)
}
}
+40 -16
View File
@@ -24,14 +24,15 @@ const (
) )
var logChan = make(chan string, 100) var logChan = make(chan string, 100)
var logWg sync.WaitGroup
const ( const (
TerminalMode uint8 = iota TerminalMode uint8 = iota
FileMode FileMode
) )
var logMutex sync.Mutex var logMutex sync.Mutex // 串行化实际写出(后台 worker 与通道满时的同步兜底)
var loggerMu sync.RWMutex // 保护 logger 配置的读写,消除 ConfigLogger 数据竞争
type Logger struct { type Logger struct {
Level uint8 Level uint8
@@ -59,11 +60,12 @@ func init() {
func logWriterWorker() { func logWriterWorker() {
for text := range logChan { for text := range logChan {
logMutex.Lock() logMutex.Lock()
if logger.Mode == FileMode { if currentLogger().Mode == FileMode {
fileLogger(text) fileLogger(text)
} else { } else {
fmt.Println(text) fmt.Println(text)
} }
logMutex.Unlock()
} }
} }
@@ -74,13 +76,20 @@ func deleteLogWorker() {
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
if logger.Mode == FileMode { if currentLogger().Mode == FileMode {
cleanupExpiredLogs() cleanupExpiredLogs()
} }
} }
} }
} }
// currentLogger 读取当前日志配置快照,避免与 ConfigLogger 的数据竞争
func currentLogger() Logger {
loggerMu.RLock()
defer loggerMu.RUnlock()
return logger
}
func getLogFilePath() string { func getLogFilePath() string {
if logger.LogFilePath == "" { if logger.LogFilePath == "" {
return "./log" return "./log"
@@ -89,7 +98,8 @@ func getLogFilePath() string {
} }
func cleanupExpiredLogs() { func cleanupExpiredLogs() {
if logger.ExpireLogsDays <= 0 { cfg := currentLogger()
if cfg.ExpireLogsDays <= 0 {
return return
} }
_, err := os.Stat(getLogFilePath()) _, err := os.Stat(getLogFilePath())
@@ -124,24 +134,24 @@ func cleanupExpiredLogs() {
} }
} }
// getFileNameInfo 解析日志文件名 "日期.log.序号"。
// 解析失败仅视为非托管文件并跳过——绝不删除:日志目录里用户放入的
// 任何无关文件(配置、说明)不属于框架管辖范围
func getFileNameInfo(name string) fileName { func getFileNameInfo(name string) fileName {
fileNameParts := strings.Split(name, ".log.") fileNameParts := strings.Split(name, ".log.")
if len(fileNameParts) != 2 { if len(fileNameParts) != 2 {
deleteLog(name)
return fileName{} return fileName{}
} }
dateLayout := "2006-01-02" dateLayout := "2006-01-02"
dateString := fileNameParts[0] dateString := fileNameParts[0]
fileDate, err := time.Parse(dateLayout, dateString) fileDate, err := time.Parse(dateLayout, dateString)
if err != nil { if err != nil {
deleteLog(name)
return fileName{} return fileName{}
} }
indexString := fileNameParts[1] indexString := fileNameParts[1]
indexString = strings.TrimSuffix(indexString, ".log") indexString = strings.TrimSuffix(indexString, ".log")
fileIndex, err := strconv.ParseInt(indexString, 10, 32) fileIndex, err := strconv.ParseInt(indexString, 10, 32)
if err != nil { if err != nil {
deleteLog(name)
return fileName{} return fileName{}
} }
return fileName{ return fileName{
@@ -189,10 +199,11 @@ func fileLogger(text string) {
} }
func removeOldestLogFile(entries []os.DirEntry) { func removeOldestLogFile(entries []os.DirEntry) {
if logger.MaxNumberFiles == 0 { cfg := currentLogger()
if cfg.MaxNumberFiles == 0 {
return return
} }
if uint64(len(entries)) < logger.MaxNumberFiles { if uint64(len(entries)) < cfg.MaxNumberFiles {
return return
} }
var newEntries []fileName var newEntries []fileName
@@ -202,10 +213,10 @@ func removeOldestLogFile(entries []os.DirEntry) {
newEntries = append(newEntries, fileNameInfo) newEntries = append(newEntries, fileNameInfo)
} }
} }
if uint64(len(newEntries)) < logger.MaxNumberFiles { if uint64(len(newEntries)) < cfg.MaxNumberFiles {
return return
} }
delNum := uint64(len(newEntries)) - logger.MaxNumberFiles + 1 delNum := uint64(len(newEntries)) - cfg.MaxNumberFiles + 1
sort.Slice(newEntries, func(i, j int) bool { sort.Slice(newEntries, func(i, j int) bool {
if newEntries[i].LoggerTime != newEntries[j].LoggerTime { if newEntries[i].LoggerTime != newEntries[j].LoggerTime {
return newEntries[i].LoggerTime < newEntries[j].LoggerTime return newEntries[i].LoggerTime < newEntries[j].LoggerTime
@@ -247,7 +258,7 @@ func getNextLogFile(dirPath, dateStr string, text string) (string, error) {
removeOldestLogFile(entries) removeOldestLogFile(entries)
return filepath.Join(dirPath, dateStr+".log.1"), nil return filepath.Join(dirPath, dateStr+".log.1"), nil
} }
if logger.MaxSizeFile > 0 && maxIndex > 0 { if currentLogger().MaxSizeFile > 0 && maxIndex > 0 {
currentFile := filepath.Join(dirPath, fmt.Sprintf("%s.log.%d", dateStr, maxIndex)) currentFile := filepath.Join(dirPath, fmt.Sprintf("%s.log.%d", dateStr, maxIndex))
if fileInfo, err := os.Stat(currentFile); err == nil { if fileInfo, err := os.Stat(currentFile); err == nil {
maxSizeBytes := int64(logger.MaxSizeFile) * 1024 * 1024 maxSizeBytes := int64(logger.MaxSizeFile) * 1024 * 1024
@@ -263,7 +274,9 @@ func getNextLogFile(dirPath, dateStr string, text string) (string, error) {
} }
func ConfigLogger(log Logger) { func ConfigLogger(log Logger) {
loggerMu.Lock()
logger = log logger = log
loggerMu.Unlock()
} }
func getCurrentTime() string { func getCurrentTime() string {
@@ -304,7 +317,8 @@ func getLevelName(level uint8) string {
} }
func sendLogWorker(level uint8, message []any) { func sendLogWorker(level uint8, message []any) {
if logger.Level >= level { cfg := currentLogger()
if cfg.Level >= level {
var text1 strings.Builder var text1 strings.Builder
for _, m := range message { for _, m := range message {
var msgStr string var msgStr string
@@ -350,8 +364,18 @@ func sendLogWorker(level uint8, message []any) {
text1.WriteString(msgStr + " ") text1.WriteString(msgStr + " ")
} }
text := "[" + getCurrentTime() + "] [" + padString(getLevelName(level), 7) + "] " + getMethodNameLogger() + text1.String() text := "[" + getCurrentTime() + "] [" + padString(getLevelName(level), 7) + "] " + getMethodNameLogger() + text1.String()
logWg.Add(1) select {
logChan <- text case logChan <- text:
default:
// 通道满时同步写出兜底:日志绝不阻塞(也绝不丢弃)请求处理 goroutine
logMutex.Lock()
if cfg.Mode == FileMode {
fileLogger(text)
} else {
fmt.Println(text)
}
logMutex.Unlock()
}
} }
} }
+247
View File
@@ -0,0 +1,247 @@
package fun
// 本文件是 v1.3.3 之后五项修复的回归测试:
// 1. 枚举越界值先截断后判范围,256/512 等被洗成合法小值绕过校验
// 2. /cell 请求 data 经 map[string]any 往返,int64 超过 2^53 精度丢失
// 3. Guard 无 error 返回,无法干净短路
// 4. 依赖装配无 error 通道且容器留半初始化实例
// 5. 日志解析失败的文件名直接删除用户文件
import (
"errors"
"net/http"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
// ---- 1. 枚举:越界值必须被拒绝,不能截断洗白 ----
func TestEnumOutOfRangeRejected(t *testing.T) {
typ := reflect.TypeFor[BugStatus]() // Names: A, B → 合法值 0/1
mustReject := []any{
float64(-1), float64(2), float64(3), float64(255),
float64(256), float64(257), float64(258), float64(1e9), float64(1.5),
uint16(256), uint32(300), int(-2), int64(999),
}
for _, v := range mustReject {
if err := checkEnumValue(typ, v, "BugStatus"); err == nil {
t.Fatalf("value %v (%T) should be rejected", v, v)
}
}
mustPass := []any{float64(0), float64(1), uint8(1), int(0), int64(1)}
for _, v := range mustPass {
if err := checkEnumValue(typ, v, "BugStatus"); err != nil {
t.Fatalf("value %v (%T) should pass: %v", v, v, err)
}
}
}
// ---- 2. int64 精度:原始字节解码,不经 float64 ----
type PrecisionDto struct {
Id int64
}
type PrecisionSvc struct{}
func (s *PrecisionSvc) Get(dto PrecisionDto) (int64, error) { return dto.Id, nil }
func TestInt64PrecisionInvoke(t *testing.T) {
f := New()
if err := f.BindService(&PrecisionSvc{}); err != nil {
t.Fatal(err)
}
const big = int64(9007199254740993) // 2^53+1float64 无法精确表示
c := &Ctx{Ip: "1", MethodName: "Get", ServiceName: "PrecisionSvc"}
data := map[string]any{"id": float64(big)} // 校验视图只查存在性
c.Data = &data
c.rawData = []byte(`{"id":9007199254740993}`) // HTTP 路径的真实输入
var streamCh chan any
var streamDone chan struct{}
res, err := f.invoke(c, &streamCh, &streamDone)
if err != nil {
t.Fatalf("invoke: %v", err)
}
if got := (*res.Data).(int64); got != big {
t.Fatalf("precision lost: got %d want %d", got, big)
}
}
func TestInt64PrecisionE2E(t *testing.T) {
f := New()
if err := f.BindService(&PrecisionSvc{}); err != nil {
t.Fatal(err)
}
go f.Start(39011)
time.Sleep(300 * time.Millisecond)
resp, err := http.Post("http://127.0.0.1:39011/cell", "application/json",
strings.NewReader(`{"serviceName":"PrecisionSvc","methodName":"Get","data":{"id":9007199254740993}}`))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body := make([]byte, 512)
n, _ := resp.Body.Read(body)
if !strings.Contains(string(body[:n]), "9007199254740993") {
t.Fatalf("int64 precision lost in response: %s", string(body[:n]))
}
}
// ---- 3. Guard:返回 error 短路,业务方法不得执行 ----
var guardOrderLog []string
type OrderFirstGuard struct{}
func (g *OrderFirstGuard) Guard(ctx Ctx) error {
guardOrderLog = append(guardOrderLog, "first")
return nil
}
type OrderRejectGuard struct{}
func (g *OrderRejectGuard) Guard(ctx Ctx) error {
guardOrderLog = append(guardOrderLog, "reject")
return Error(4003, "denied")
}
type OrderLastGuard struct{}
func (g *OrderLastGuard) Guard(ctx Ctx) error {
guardOrderLog = append(guardOrderLog, "last") // 短路后不应执行
return nil
}
var guardedMethodRan bool
type GuardedSvc struct {
Ctx
}
func (s *GuardedSvc) Ping() error {
guardedMethodRan = true
return nil
}
func TestGuardShortCircuit(t *testing.T) {
f := New()
if err := f.BindService(&GuardedSvc{}, &OrderFirstGuard{}, &OrderRejectGuard{}, &OrderLastGuard{}); err != nil {
t.Fatal(err)
}
guardOrderLog = nil
guardedMethodRan = false
c := &Ctx{Ip: "1", MethodName: "Ping", ServiceName: "GuardedSvc"}
var streamCh chan any
var streamDone chan struct{}
_, err := f.invoke(c, &streamCh, &streamDone)
if err == nil {
t.Fatal("expected guard rejection")
}
if guardedMethodRan {
t.Fatal("business method must not run after guard rejection")
}
if len(guardOrderLog) != 2 || guardOrderLog[0] != "first" || guardOrderLog[1] != "reject" {
t.Fatalf("guards after rejection must not run: %v", guardOrderLog)
}
var result Result[any]
if !errors.As(err, &result) {
t.Fatalf("error should carry Result, got %T", err)
}
if result.Code == nil || *result.Code != 4003 || result.Status != errorCode {
t.Fatalf("unexpected result: %+v", result)
}
}
// ---- 4. 依赖装配:error 通道 + 粘性失败 ----
var wOkBoxNewCalled bool
type WOkBox struct{}
func (b *WOkBox) New() error { wOkBoxNewCalled = true; return nil }
type WFailBox struct{}
func (b *WFailBox) New() error { return errors.New("connect refused") }
type WParentBox struct {
Fail *WFailBox `fun:"auto"`
}
type WSvc struct {
Ctx
Fail *WFailBox
}
func (s *WSvc) Ping() error { return nil }
func TestWiredErrorAndStickyFailure(t *testing.T) {
old := fun
fun = New()
defer func() { fun = old }()
wOkBoxNewCalled = false
if _, err := Wired[WOkBox](); err != nil {
t.Fatalf("ok box: %v", err)
}
if !wOkBoxNewCalled {
t.Fatal("New() not called")
}
_, err := Wired[WFailBox]()
if err == nil || !strings.Contains(err.Error(), "connect refused") {
t.Fatalf("expected failure, got %v", err)
}
// 粘性错误:重复 Wired 返回同一错误,不重试
if _, err2 := Wired[WFailBox](); err2 == nil || err2.Error() != err.Error() {
t.Fatalf("sticky failure expected, got %v then %v", err, err2)
}
// 失败类型不暴露半初始化实例
if entry, ok := fun.boxes.Load(reflect.TypeFor[*WFailBox]()); ok {
if e := entry.(boxEntry); e.err == nil {
t.Fatal("failed box must not hold a usable instance")
}
}
}
func TestWiredNestedFailurePropagates(t *testing.T) {
old := fun
fun = New()
defer func() { fun = old }()
if _, err := Wired[WParentBox](); err == nil || !strings.Contains(err.Error(), "connect refused") {
t.Fatalf("nested failure should propagate, got %v", err)
}
}
func TestBindServiceWireFailurePropagates(t *testing.T) {
f := New()
if err := f.BindService(&WSvc{}); err == nil {
t.Fatal("BindService should propagate wiring failure")
}
}
// ---- 5. 日志:解析失败的文件名不删除 ----
func TestLoggerKeepsUnknownFiles(t *testing.T) {
dir := t.TempDir()
keep := filepath.Join(dir, "notes.txt")
if err := os.WriteFile(keep, []byte("keep me"), 0644); err != nil {
t.Fatal(err)
}
ConfigLogger(Logger{Level: TraceLevel, Mode: FileMode, LogFilePath: dir, ExpireLogsDays: 3})
defer ConfigLogger(Logger{Level: TraceLevel, Mode: TerminalMode})
// 写入路径:getNextLogFile 遍历目录解析文件名,旧逻辑会删除解析失败的文件
fileLogger("[test] hello")
// 清理路径
cleanupExpiredLogs()
if _, err := os.Stat(keep); err != nil {
t.Fatalf("unknown file deleted: %v", err)
}
}
-6
View File
@@ -1,14 +1,8 @@
package fun package fun
const (
RequestNormalType uint8 = iota
RequestStreamType
)
type RequestInfo[T any] struct { type RequestInfo[T any] struct {
MethodName string MethodName string
ServiceName string ServiceName string
Data *T Data *T
State map[string]string State map[string]string
Type uint8
} }
+7 -1
View File
@@ -12,7 +12,6 @@ const (
) )
type Result[T any] struct { type Result[T any] struct {
Id string `json:"id,omitempty"`
Code *uint16 `json:"code,omitempty"` Code *uint16 `json:"code,omitempty"`
Data *T `json:"data,omitempty"` Data *T `json:"data,omitempty"`
Msg *string `json:"msg,omitempty"` Msg *string `json:"msg,omitempty"`
@@ -40,6 +39,13 @@ func callError(err error) Result[any] {
return Result[any]{Msg: new(err.Error()), Status: cellErrorCode} return Result[any]{Msg: new(err.Error()), Status: cellErrorCode}
} }
// internalError 框架内部错误脱敏:客户端只收到固定提示,
// 完整错误(JSON 解析细节、Go 类型名等)记服务端日志,不外泄
func internalError(clientMsg string, err error) Result[any] {
ErrorLogger("fun: internal error: ", err.Error())
return Result[any]{Msg: &clientMsg, Status: cellErrorCode}
}
// success 构造成功响应,空切片规范化为 [] 而不是 null // success 构造成功响应,空切片规范化为 [] 而不是 null
func success(data any) Result[any] { func success(data any) Result[any] {
return Result[any]{Data: nonNil(data), Status: successCode} return Result[any]{Data: nonNil(data), Status: successCode}
+43 -10
View File
@@ -14,6 +14,12 @@ import (
// (如支付回调要求的纯文本 "success" 应答)。 // (如支付回调要求的纯文本 "success" 应答)。
type RouteHandler func(ctx *RouteCtx) error type RouteHandler func(ctx *RouteCtx) error
// boundRoute 路由绑定的处理器与其 Guard
type boundRoute struct {
handler RouteHandler
guards []*any
}
// RouteCtx 自定义路由上下文:Data 合并了 URL 查询参数与 POST 表单参数(表单优先), // RouteCtx 自定义路由上下文:Data 合并了 URL 查询参数与 POST 表单参数(表单优先),
// 支付回调等第三方以 form-urlencoded 回调的场景可直接 Param 取值。 // 支付回调等第三方以 form-urlencoded 回调的场景可直接 Param 取值。
// Wildcard 为通配符路由(/prefix/*)匹配到的剩余路径(不含前导 "/")。 // Wildcard 为通配符路由(/prefix/*)匹配到的剩余路径(不含前导 "/")。
@@ -32,12 +38,15 @@ func (c *RouteCtx) Param(name string) string {
// BindRoute 注册自定义路由(方法大小写不敏感;path 精确匹配,或以 "/*" 结尾做前缀通配), // BindRoute 注册自定义路由(方法大小写不敏感;path 精确匹配,或以 "/*" 结尾做前缀通配),
// 用于 GET 直链、健康检查、支付回调等无法走 POST /cell RPC 的场景。 // 用于 GET 直链、健康检查、支付回调等无法走 POST /cell RPC 的场景。
// //
// - guardList 为该路由绑定的 Guard,处理器前按注册顺序执行:
// Guard 收到的 Ctx.State 已合并 URL 查询与表单参数(token 放查询参数即可鉴权),
// 返回 error 时短路——处理器不执行,error 走统一 Result 错误响应
// - path 必须以 "/" 开头;/cell 为 RPC 保留路径,不可注册 // - path 必须以 "/" 开头;/cell 为 RPC 保留路径,不可注册
// - 通配符形式如 "/image/*":匹配 "/image/a/b.png" 等任意子路径, // - 通配符形式如 "/image/*":匹配 "/image/a/b.png" 等任意子路径,
// 匹配到的剩余路径(去掉前导 "/",如 "a/b.png")经 RouteCtx.Wildcard 取出 // 匹配到的剩余路径(去掉前导 "/",如 "a/b.png")经 RouteCtx.Wildcard 取出
// - 同一 方法+路径 重复注册直接 panic // - Guard 依赖装配失败以 error 返回;非法参数与重复注册 panic
// - 与 BindService 一致,需在 Start 前完成注册(启动阶段单线程) // - 在 Start 前完成注册
func (f *Fun) BindRoute(method, path string, handler RouteHandler) { func (f *Fun) BindRoute(method, path string, handler RouteHandler, guardList ...Guard) error {
if handler == nil { if handler == nil {
panic("fun: BindRoute handler cannot be nil") panic("fun: BindRoute handler cannot be nil")
} }
@@ -51,6 +60,21 @@ func (f *Fun) BindRoute(method, path string, handler RouteHandler) {
if path == "/cell" || path == "/cell/*" { if path == "/cell" || path == "/cell/*" {
panic("fun: /cell is reserved for RPC") panic("fun: /cell is reserved for RPC")
} }
f.mu.Lock()
defer f.mu.Unlock()
f.mustNotStarted("BindRoute")
br := boundRoute{handler: handler}
for _, guard := range guardList {
checkGuard(guard)
g, err := serviceGuardWired(guard, f)
if err != nil {
return fmt.Errorf("fun: wire route guard %T: %w", guard, err)
}
br.guards = append(br.guards, g)
}
if prefix, ok := strings.CutSuffix(path, "/*"); ok { if prefix, ok := strings.CutSuffix(path, "/*"); ok {
if prefix == "" || strings.HasSuffix(prefix, "/") { if prefix == "" || strings.HasSuffix(prefix, "/") {
panic(fmt.Sprintf("fun: BindRoute wildcard path %q invalid (no trailing '/' allowed before /*)", path)) panic(fmt.Sprintf("fun: BindRoute wildcard path %q invalid (no trailing '/' allowed before /*)", path))
@@ -60,19 +84,21 @@ func (f *Fun) BindRoute(method, path string, handler RouteHandler) {
panic(fmt.Sprintf("fun: route %s %s/* already bound", method, prefix)) panic(fmt.Sprintf("fun: route %s %s/* already bound", method, prefix))
} }
} }
f.wildcardRoutes[method] = append(f.wildcardRoutes[method], wildcardRoute{prefix: prefix, handler: handler}) f.wildcardRoutes[method] = append(f.wildcardRoutes[method], wildcardRoute{prefix: prefix, route: br})
return return nil
} }
key := method + " " + path key := method + " " + path
if _, exists := f.routes[key]; exists { if _, exists := f.routes[key]; exists {
panic(fmt.Sprintf("fun: route %s already bound", key)) panic(fmt.Sprintf("fun: route %s already bound", key))
} }
f.routes[key] = handler f.routes[key] = br
return nil
} }
// handleRoute 执行自定义路由:合并查询与表单参数(application/x-www-form-urlencoded), // handleRoute 执行自定义路由:合并查询与表单参数(application/x-www-form-urlencoded),
// 处理器返回 error 时按统一 Result 格式输出错误响应;wildcard 为通配路由匹配的剩余路径 // 先按序执行路由 Guard(State 即合并参数,token 放查询参数即可鉴权),
func (f *Fun) handleRoute(fastCtx *fasthttp.RequestCtx, handler RouteHandler, wildcard string) { // 任一 Guard 返回 error 则短路;处理器返回 error 时按统一 Result 格式输出错误响应
func (f *Fun) handleRoute(fastCtx *fasthttp.RequestCtx, r boundRoute, wildcard string) {
data := map[string]string{} data := map[string]string{}
fastCtx.QueryArgs().VisitAll(func(k, v []byte) { fastCtx.QueryArgs().VisitAll(func(k, v []byte) {
data[string(k)] = string(v) data[string(k)] = string(v)
@@ -80,7 +106,14 @@ func (f *Fun) handleRoute(fastCtx *fasthttp.RequestCtx, handler RouteHandler, wi
fastCtx.PostArgs().VisitAll(func(k, v []byte) { fastCtx.PostArgs().VisitAll(func(k, v []byte) {
data[string(k)] = string(v) data[string(k)] = string(v)
}) })
if err := handler(&RouteCtx{RequestCtx: fastCtx, Data: data, Wildcard: wildcard}); err != nil { ctx := &Ctx{RequestCtx: fastCtx, Ip: clientIP(fastCtx), State: data}
(&Ctx{RequestCtx: fastCtx}).sendError(err) for _, g := range r.guards {
if err := (*g).(Guard).Guard(*ctx); err != nil {
ctx.sendError(err)
return
}
}
if err := r.handler(&RouteCtx{RequestCtx: fastCtx, Data: data, Wildcard: wildcard}); err != nil {
ctx.sendError(err)
} }
} }
+3 -1
View File
@@ -12,7 +12,9 @@ import (
func startRouteServer(t *testing.T, port uint16) *Fun { func startRouteServer(t *testing.T, port uint16) *Fun {
t.Helper() t.Helper()
f := New() f := New()
f.BindService(&TestSvc{}) if err := f.BindService(&TestSvc{}); err != nil {
t.Fatal(err)
}
// GET:查询参数 + 纯文本自定义响应 // GET:查询参数 + 纯文本自定义响应
f.BindRoute("GET", "/ping", func(ctx *RouteCtx) error { f.BindRoute("GET", "/ping", func(ctx *RouteCtx) error {
+113
View File
@@ -0,0 +1,113 @@
package fun
import (
"encoding/json"
"errors"
"io"
"net"
"net/http"
"strings"
"testing"
"github.com/valyala/fasthttp"
)
type ProtocolStreamSvc struct{}
func (*ProtocolStreamSvc) Empty() (*Stream, error) {
stream := &Stream{}
go stream.Close()
return stream, nil
}
func (*ProtocolStreamSvc) Before() (*Stream, error) {
return nil, errors.New("before stream")
}
func (*ProtocolStreamSvc) Business() (*Stream, error) {
return nil, Error(4201, "business before stream")
}
func serveFun(t *testing.T, f *Fun) string {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() { done <- fasthttp.Serve(listener, f.handle) }()
t.Cleanup(func() {
_ = listener.Close()
<-done
})
return "http://" + listener.Addr().String()
}
func streamPost(t *testing.T, url, method string) (*http.Response, []byte) {
t.Helper()
body := strings.NewReader(`{"serviceName":"ProtocolStreamSvc","methodName":"` + method + `"}`)
request, err := http.NewRequest(http.MethodPost, url+"/cell", body)
if err != nil {
t.Fatal(err)
}
request.Header.Set("Content-Type", "application/json")
request.Close = true
response, err := http.DefaultClient.Do(request)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
data, err := io.ReadAll(response.Body)
if err != nil {
t.Fatal(err)
}
return response, data
}
func TestServerStreamContentTypeAndEmptyStream(t *testing.T) {
f := New()
if err := f.BindService(&ProtocolStreamSvc{}); err != nil {
t.Fatal(err)
}
response, body := streamPost(t, serveFun(t, f), "Empty")
if got := response.Header.Get("Content-Type"); got != "application/x-ndjson" {
t.Fatalf("Content-Type = %q", got)
}
if len(body) != 0 {
t.Fatalf("empty stream returned %q", body)
}
}
func TestServerStreamSetupErrorsUseResultProtocol(t *testing.T) {
f := New()
if err := f.BindService(&ProtocolStreamSvc{}); err != nil {
t.Fatal(err)
}
url := serveFun(t, f)
for _, test := range []struct {
method string
status uint8
code uint16
msg string
}{
{method: "Before", status: 1, msg: "before stream"},
{method: "Business", status: 2, code: 4201, msg: "business before stream"},
} {
t.Run(test.method, func(t *testing.T) {
response, body := streamPost(t, url, test.method)
if strings.HasPrefix(response.Header.Get("Content-Type"), "application/x-ndjson") {
t.Fatalf("setup error used stream Content-Type: %q", response.Header.Get("Content-Type"))
}
var result Result[any]
if err := json.Unmarshal(body, &result); err != nil {
t.Fatalf("invalid Result body %q: %v", body, err)
}
if result.Status != test.status || result.Msg == nil || *result.Msg != test.msg {
t.Fatalf("unexpected Result: %+v", result)
}
if test.code != 0 && (result.Code == nil || *result.Code != test.code) {
t.Fatalf("code = %v, want %d", result.Code, test.code)
}
})
}
}
+1 -2
View File
@@ -16,7 +16,6 @@ import (
// Result 统一响应结构 // Result 统一响应结构
type Result[T any] struct { type Result[T any] struct {
Id string
Code *uint16 Code *uint16
Data *T Data *T
Msg *string Msg *string
@@ -114,7 +113,7 @@ func Request[T any](c *Client, serviceName string, methodName string, dto ...any
v := any(*out.Data) v := any(*out.Data)
anyData = &v anyData = &v
} }
anyResult := Result[any]{Id: out.Id, Code: out.Code, Data: anyData, Msg: out.Msg, Status: out.Status} anyResult := Result[any]{Code: out.Code, Data: anyData, Msg: out.Msg, Status: out.Status}
for _, i := range c.responseInterceptors { for _, i := range c.responseInterceptors {
if err := i(serviceName, methodName, anyResult); err != nil { if err := i(serviceName, methodName, anyResult); err != nil {
return Result[T]{Status: 2, Msg: ptr(err.Error())} return Result[T]{Status: 2, Msg: ptr(err.Error())}
+536 -55
View File
@@ -3,8 +3,52 @@ package fun
type templateTs struct{} type templateTs struct{}
func (ctx templateTs) genClientTemplate() string { func (ctx templateTs) genClientTemplate() string {
return `export type result<T> = { return `// 大整数安全 JSON:超过 Number.MAX_SAFE_INTEGER2^53-1)的整数字面量
id?: string; // 解析为 BigInt,序列化时 BigInt 还原为数字字面量——雪花 ID 等不再丢精度。
// 自包含实现,不引入运行时依赖;如需换 json-bigint,替换下面两个函数即可
const BIGINT_PREFIX = "\u0000fun-bigint:";
function reviveBigint(value: any): any {
if (typeof value === "string" && value.startsWith(BIGINT_PREFIX) &&
/^-?\d+$/.test(value.slice(BIGINT_PREFIX.length))) {
return BigInt(value.slice(BIGINT_PREFIX.length));
}
if (Array.isArray(value)) return value.map(reviveBigint);
if (value !== null && typeof value === "object") {
for (const key of Object.keys(value)) value[key] = reviveBigint(value[key]);
return value;
}
return value;
}
function parseLossless(text: string): any {
// 先把超出安全范围的大整数包成带哨兵的字符串(正则的字符串分支优先,
// 字符串内容里的数字不受影响),JSON.parse 后再还原为 BigInt
const guarded = text.replace(
/"(?:[^"\\]|\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4}))*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g,
token => {
if (token.startsWith('"')) return token;
if (!/[.eE]/.test(token) && !Number.isSafeInteger(Number(token))) {
// 哨兵必须以转义形式 \\u0000 写入文本(JSON 字符串禁止裸控制字符),
// JSON.parse 解码后即为 BIGINT_PREFIX(真实 NUL 开头)
return '"\\u0000fun-bigint:' + token + '"';
}
return token;
}
);
return reviveBigint(JSON.parse(guarded));
}
function stringifyLossless(value: any): string | undefined {
const raw = JSON.stringify(value, (_key, v) =>
typeof v === "bigint" ? BIGINT_PREFIX + v.toString() : v
);
if (raw === undefined) return undefined;
// 哨兵字符串在序列化文本中形如 "\u0000fun-bigint:123"NUL 被转义),去引号还原为数字字面量
return raw.replace(/"\\u0000fun-bigint:(-?\d+)"/g, "$1");
}
export type result<T> = {
code?: number; code?: number;
data?: T; data?: T;
msg?: string; msg?: string;
@@ -12,7 +56,17 @@ func (ctx templateTs) genClientTemplate() string {
}; };
export type resultStatus = 0 | 1 | 2 | 4 | 5; export type resultStatus = 0 | 1 | 2 | 4 | 5;
// 0 成功;1 框架错误;2 业务错误;4 网络错误;5 超时 // 0 success; 1 framework/client protocol error; 2 business error; 4 external request error; 5 external timeout
export type RequestOptions = {
signal?: AbortSignal;
state?: Record<string, string>;
};
export type StreamOptions = {
signal?: AbortSignal;
state?: Record<string, string>;
};
export type RequestInterceptor = ( export type RequestInterceptor = (
serviceName: string, serviceName: string,
@@ -21,18 +75,127 @@ export type RequestInterceptor = (
dto?: any dto?: any
) => Promise<void> | void; ) => Promise<void> | void;
// 返回新 result 将替换原结果继续向下传递(可用于集中换 token / 错误处理) export type ResponseContext = {
readonly requestState: Readonly<Record<string, string>>;
readonly response?: Response;
};
export type ResponseInterceptor = ( export type ResponseInterceptor = (
serviceName: string, serviceName: string,
methodName: string, methodName: string,
result: result<any> result: result<any>
) => Promise<result<any> | void> | result<any> | void; ) => Promise<result<any> | void> | result<any> | void;
export type ContextResponseInterceptor = (
serviceName: string,
methodName: string,
result: result<any>,
context: ResponseContext
) => Promise<result<any> | void> | result<any> | void;
function messageOf(error: unknown): string {
if (error instanceof Error && error.message) return error.message;
if (typeof error === "string" && error) return error;
return "unknown error";
}
function failure(status: resultStatus, msg: string): result<any> {
return { status, msg };
}
function isTimeout(error: unknown, signal?: AbortSignal): boolean {
const errorName = error !== null && typeof error === "object"
? (error as { name?: unknown }).name
: undefined;
const reason = signal?.reason;
const reasonName = reason !== null && typeof reason === "object"
? (reason as { name?: unknown }).name
: undefined;
return errorName === "TimeoutError" || reasonName === "TimeoutError";
}
function requestFailure(error: unknown, signal: AbortSignal | undefined, stream: boolean): result<any> {
if (isTimeout(error, signal)) {
return failure(5, stream ? "Stream timed out" : "Request timed out");
}
if (signal?.aborted === true) {
return failure(4, stream ? "Stream aborted" : "Request aborted");
}
const kind = stream ? "External stream request" : "External request";
return failure(4, ` + "`${kind} failed: ${messageOf(error)}`" + `);
}
function isResult(value: unknown): value is result<any> {
return value !== null && typeof value === "object" &&
typeof (value as { status?: unknown }).status === "number";
}
function mediaType(response: Response): string {
return (response.headers.get("content-type") || "").split(";", 1)[0].trim().toLowerCase();
}
function excerpt(text: string, limit = 180): string {
const value = text.replace(/\s+/g, " ").trim();
return value.length <= limit ? value : ` + "`${value.slice(0, limit)}...`" + `;
}
function externalFailure(response: Response, detail?: string): result<any> {
const timeout = response.status === 408 || response.status === 504;
const statusText = response.statusText || (timeout ? "timeout" : "request failed");
const suffix = detail ? ` + "`: ${detail}`" + ` : "";
return failure(timeout ? 5 : 4, ` + "`HTTP ${response.status} ${statusText}${suffix}`" + `);
}
function responseReadFailure(
error: unknown,
response: Response,
signal: AbortSignal | undefined,
stream: boolean
): result<any> {
if (isTimeout(error, signal)) {
return failure(5, stream ? "Stream timed out" : "Request timed out");
}
if (signal?.aborted === true) {
return failure(4, stream ? "Stream aborted" : "Request aborted");
}
const kind = stream ? "Stream" : "Response body";
return response.ok
? failure(4, ` + "`${kind} failed: ${messageOf(error)}`" + `)
: externalFailure(response, ` + "`response body failed: ${messageOf(error)}`" + `);
}
function parseResult(response: Response, text: string): result<any> {
const body = text.trim();
if (!body) {
return response.ok
? failure(1, "Empty response body")
: externalFailure(response);
}
let value: unknown;
try {
value = parseLossless(body);
} catch {
if (!response.ok) return externalFailure(response, excerpt(body));
const type = mediaType(response);
if (type === "text/html" || /^\s*(?:<!doctype\s+html|<html\b)/i.test(body)) {
return failure(1, ` + "`Unexpected HTML response: ${excerpt(body)}`" + `);
}
return failure(1, ` + "`Invalid JSON response: ${excerpt(body)}`" + `);
}
if (!isResult(value)) {
return response.ok
? failure(1, "Invalid fun response")
: externalFailure(response, "invalid fun response");
}
return value;
}
export class Client { export class Client {
private url: string; private url: string;
private state: Record<string, string> = {}; private state: Record<string, string> = {};
private requestInterceptors: RequestInterceptor[] = []; private requestInterceptors: RequestInterceptor[] = [];
private responseInterceptors: ResponseInterceptor[] = []; private responseInterceptors: ContextResponseInterceptor[] = [];
constructor(url: string) { constructor(url: string) {
this.url = url.replace(/\/+$/, ""); this.url = url.replace(/\/+$/, "");
@@ -42,81 +205,399 @@ export class Client {
this.state = state; this.state = state;
} }
addRequestInterceptor(i: RequestInterceptor) { addRequestInterceptor(interceptor: RequestInterceptor) {
this.requestInterceptors.push(i); this.requestInterceptors.push(interceptor);
} }
addResponseInterceptor(i: ResponseInterceptor) { addResponseInterceptor(interceptor: ResponseInterceptor): void;
this.responseInterceptors.push(i); addResponseInterceptor(interceptor: ContextResponseInterceptor): void;
addResponseInterceptor(interceptor: ResponseInterceptor | ContextResponseInterceptor) {
this.responseInterceptors.push(interceptor as ContextResponseInterceptor);
} }
async request<T>(serviceName: string, methodName: string, dto?: any): Promise<result<T>> { private async interceptResponse(
const state: Record<string, string> = { ...this.state }; serviceName: string,
for (const i of this.requestInterceptors) { methodName: string,
await i(serviceName, methodName, state, dto); initial: result<any>,
} requestState: Readonly<Record<string, string>>,
let out: result<T>; response?: Response
): Promise<result<any>> {
let current = initial;
const context: ResponseContext = Object.freeze(
response === undefined ? { requestState } : { requestState, response }
);
for (const interceptor of this.responseInterceptors) {
try { try {
const res = await fetch(this.url + "/cell", { const replaced = await interceptor(serviceName, methodName, current, context);
if (replaced) current = replaced;
} catch (error) {
current = failure(1, ` + "`Response interceptor failed: ${messageOf(error)}`" + `);
}
}
return current;
}
private async interceptRequest(
serviceName: string,
methodName: string,
state: Record<string, string>,
dto: any
): Promise<void> {
for (const interceptor of this.requestInterceptors) {
await interceptor(serviceName, methodName, state, dto);
}
}
private snapshotState(state: Record<string, string>): Readonly<Record<string, string>> {
return Object.freeze({ ...state });
}
async request<T>(
serviceName: string,
methodName: string,
dto?: any,
options?: RequestOptions
): Promise<result<T>> {
let state: Record<string, string>;
try {
state = { ...this.state, ...options?.state };
} catch (error) {
return await this.interceptResponse(
serviceName,
methodName,
failure(1, ` + "`Could not prepare request state: ${messageOf(error)}`" + `),
Object.freeze({})
) as result<T>;
}
try {
await this.interceptRequest(serviceName, methodName, state, dto);
} catch (error) {
let requestState: Readonly<Record<string, string>>;
try {
requestState = this.snapshotState(state);
} catch (snapshotError) {
return await this.interceptResponse(
serviceName,
methodName,
failure(1, ` + "`Could not snapshot request state: ${messageOf(snapshotError)}`" + `),
Object.freeze({})
) as result<T>;
}
return await this.interceptResponse(
serviceName,
methodName,
failure(1, ` + "`Request interceptor failed: ${messageOf(error)}`" + `),
requestState
) as result<T>;
}
let requestState: Readonly<Record<string, string>>;
try {
requestState = this.snapshotState(state);
} catch (error) {
return await this.interceptResponse(
serviceName,
methodName,
failure(1, ` + "`Could not snapshot request state: ${messageOf(error)}`" + `),
Object.freeze({})
) as result<T>;
}
let body: string;
try {
const serialized = stringifyLossless({
serviceName,
methodName,
data: dto,
...(Object.keys(requestState).length ? { state: requestState } : {}),
});
if (serialized === undefined) throw new Error("serialization produced no output");
body = serialized;
} catch (error) {
return await this.interceptResponse(
serviceName,
methodName,
failure(1, ` + "`Could not serialize request: ${messageOf(error)}`" + `),
requestState
) as result<T>;
}
let output: result<any>;
let response: Response | undefined;
try {
response = await fetch(` + "`${this.url}/cell`" + `, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ serviceName, methodName, data: dto, ...(Object.keys(state).length ? { state } : {}) }), body,
signal: options?.signal,
}); });
out = (await res.json()) as result<T>; try {
} catch (e: any) { output = parseResult(response, await response.text());
out = { status: 4, msg: (e && e.message) || "网络错误" } as result<T>; } catch (error) {
output = responseReadFailure(error, response, options?.signal, false);
} }
let cur: result<any> = out as result<any>; } catch (error) {
for (const i of this.responseInterceptors) { output = requestFailure(error, options?.signal, false);
const replaced = await i(serviceName, methodName, cur);
if (replaced) cur = replaced;
} }
return cur as result<T>; return await this.interceptResponse(serviceName, methodName, output, requestState, response) as result<T>;
} }
async stream<T>( async stream<T>(
serviceName: string, serviceName: string,
methodName: string, methodName: string,
dto: any | undefined, dto: any | undefined,
onMessage: (data: T) => void onMessage: (data: T) => unknown,
): Promise<void> { options?: StreamOptions
const state: Record<string, string> = { ...this.state }; ): Promise<result<void>> {
for (const i of this.requestInterceptors) { let state: Record<string, string>;
await i(serviceName, methodName, state, dto); try {
state = { ...this.state, ...options?.state };
} catch (error) {
return await this.interceptResponse(
serviceName,
methodName,
failure(1, ` + "`Could not prepare request state: ${messageOf(error)}`" + `),
Object.freeze({})
);
} }
const res = await fetch(this.url + "/cell", { try {
await this.interceptRequest(serviceName, methodName, state, dto);
} catch (error) {
let requestState: Readonly<Record<string, string>>;
try {
requestState = this.snapshotState(state);
} catch (snapshotError) {
return await this.interceptResponse(
serviceName,
methodName,
failure(1, ` + "`Could not snapshot request state: ${messageOf(snapshotError)}`" + `),
Object.freeze({})
);
}
return await this.interceptResponse(
serviceName,
methodName,
failure(1, ` + "`Request interceptor failed: ${messageOf(error)}`" + `),
requestState
);
}
let requestState: Readonly<Record<string, string>>;
try {
requestState = this.snapshotState(state);
} catch (error) {
return await this.interceptResponse(
serviceName,
methodName,
failure(1, ` + "`Could not snapshot request state: ${messageOf(error)}`" + `),
Object.freeze({})
);
}
let body: string;
try {
const serialized = stringifyLossless({
serviceName,
methodName,
data: dto,
...(Object.keys(requestState).length ? { state: requestState } : {}),
});
if (serialized === undefined) throw new Error("serialization produced no output");
body = serialized;
} catch (error) {
return await this.interceptResponse(
serviceName,
methodName,
failure(1, ` + "`Could not serialize request: ${messageOf(error)}`" + `),
requestState
);
}
let response: Response;
try {
response = await fetch(` + "`${this.url}/cell`" + `, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ serviceName, methodName, data: dto, ...(Object.keys(state).length ? { state } : {}) }), body,
signal: options?.signal,
}); });
if (!res.ok) return; } catch (error) {
const anyResult: result<any> = { status: 0 }; return await this.interceptResponse(
for (const i of this.responseInterceptors) { serviceName,
await i(serviceName, methodName, anyResult); methodName,
requestFailure(error, options?.signal, true),
requestState
);
} }
if (!res.body) return;
const reader = res.body.getReader(); if (!response.ok) {
const decoder = new TextDecoder(); let text: string;
try {
text = await response.text();
} catch (error) {
return await this.interceptResponse(
serviceName,
methodName,
responseReadFailure(error, response, options?.signal, true),
requestState,
response
);
}
return await this.interceptResponse(
serviceName,
methodName,
parseResult(response, text),
requestState,
response
);
}
if (mediaType(response) !== "application/x-ndjson") {
let text: string;
try {
text = await response.text();
} catch (error) {
return await this.interceptResponse(
serviceName,
methodName,
responseReadFailure(error, response, options?.signal, true),
requestState,
response
);
}
const rpcResult = parseResult(response, text);
return await this.interceptResponse(
serviceName,
methodName,
rpcResult.status === 0
? failure(1, "Expected application/x-ndjson response")
: rpcResult,
requestState,
response
);
}
if (!response.body) {
return await this.interceptResponse(
serviceName,
methodName,
{ status: 0 },
requestState,
response
);
}
let reader: ReadableStreamDefaultReader<Uint8Array>;
try {
reader = response.body.getReader();
} catch (error) {
return await this.interceptResponse(
serviceName,
methodName,
responseReadFailure(error, response, options?.signal, true),
requestState,
response
);
}
const decoder = new TextDecoder("utf-8", { fatal: true });
let buffer = ""; let buffer = "";
let lineNumber = 0;
let failed: result<any> | undefined;
let cause: unknown;
const emitLine = async (line: string) => {
const payload = line.replace(/\r$/, "").trim();
if (!payload) return;
let data: T;
try {
data = parseLossless(payload) as T;
} catch (error) {
failed = failure(1, ` + "`Invalid NDJSON at line ${lineNumber}: ${excerpt(payload)}`" + `);
cause = error;
return;
}
try {
await onMessage(data);
} catch (error) {
failed = failure(1, ` + "`Stream callback failed: ${messageOf(error)}`" + `);
cause = error;
}
};
try {
for (;;) { for (;;) {
const { done, value } = await reader.read(); let part: ReadableStreamReadResult<Uint8Array>;
if (done) break; try {
buffer += decoder.decode(value, { stream: true }); part = await reader.read();
const lines = buffer.split("\n"); } catch (error) {
buffer = lines.pop() ?? ""; cause = error;
for (const line of lines) { if (isTimeout(error, options?.signal)) {
const payload = line.trim(); failed = failure(5, "Stream timed out");
if (!payload) continue; } else if (options?.signal?.aborted === true) {
const data = JSON.parse(payload) as T; failed = failure(4, "Stream aborted");
onMessage(data); } else {
failed = failure(4, ` + "`Stream read failed: ${messageOf(error)}`" + `);
}
break;
}
if (part.done) break;
try {
buffer += decoder.decode(part.value, { stream: true });
} catch (error) {
failed = failure(1, ` + "`Invalid UTF-8 stream data: ${messageOf(error)}`" + `);
cause = error;
break;
}
for (;;) {
const newline = buffer.indexOf("\n");
if (newline < 0) break;
const line = buffer.slice(0, newline);
buffer = buffer.slice(newline + 1);
lineNumber++;
await emitLine(line);
if (failed) break;
}
if (failed) break;
}
if (!failed) {
try {
buffer += decoder.decode();
} catch (error) {
failed = failure(1, ` + "`Invalid UTF-8 stream data: ${messageOf(error)}`" + `);
cause = error;
} }
} }
if (!failed && buffer.length > 0) {
lineNumber++;
await emitLine(buffer);
}
} finally {
if (failed) {
try {
await reader.cancel(cause);
} catch {
// The reader may already be closed by the runtime.
}
}
try {
reader.releaseLock();
} catch {
// The reader may already be errored or released.
}
}
return await this.interceptResponse(
serviceName,
methodName,
failed || { status: 0 },
requestState,
response
);
} }
}` }`
} }
func (ctx templateTs) genDefaultServiceTemplate() string { func (ctx templateTs) genDefaultServiceTemplate() string {
return `import { Client, type result } from "./client"; return `import { Client } from "./client";
{{- range .GenServiceList}} {{- range .GenServiceList}}
import {{.ServiceName}} from "./{{.ServiceName}}"; import {{.ServiceName}} from "./{{.ServiceName}}";
{{- end}} {{- end}}
@@ -138,7 +619,7 @@ export default class api {
} }
func (ctx templateTs) genServiceTemplate() string { func (ctx templateTs) genServiceTemplate() string {
return `import { Client, type result } from "./client" return `import { Client{{if .IsIncludeRequest}}, type result, type RequestOptions{{end}}{{if .IsIncludeStream}}{{if not .IsIncludeRequest}}, type result{{end}}, type StreamOptions{{end}} } from "./client";
{{- range .GenImport}} {{- range .GenImport}}
import type {{.Name}} from "./{{.Name}}"; import type {{.Name}} from "./{{.Name}}";
{{- end}} {{- end}}
@@ -150,10 +631,10 @@ export default class {{.ServiceName}} {
} }
{{- $serviceName := .ServiceName }} {{- $serviceName := .ServiceName }}
{{- range .GenMethodTypeList}} {{- range .GenMethodTypeList}}
{{if .IsStream }}async {{.MethodName}}({{.DtoText}}{{if .DtoText}},{{end}}onMessage: (data: {{.GenericTypeText}}) => void): Promise<void> { {{if .IsStream }}async {{.MethodName}}({{if .DtoText}}{{.DtoText}}, {{end}}onMessage: (data: {{.GenericTypeText}}) => unknown, options?: StreamOptions): Promise<result<void>> {
return await this.client.stream<{{.GenericTypeText}}>("{{$serviceName}}", "{{.MethodName}}", {{if .DtoText}}dto{{else}}undefined{{end}}, onMessage) return await this.client.stream<{{.GenericTypeText}}>("{{$serviceName}}", "{{.MethodName}}", {{if .DtoText}}dto{{else}}undefined{{end}}, onMessage, options)
}{{else}}async {{.MethodName}}({{.DtoText}}): Promise<{{.ReturnValueText}}> { }{{else}}async {{.MethodName}}({{if .DtoText}}{{.DtoText}}, {{end}}options?: RequestOptions): Promise<{{.ReturnValueText}}> {
return await this.client.request<{{.GenericTypeText}}>("{{$serviceName}}", "{{.MethodName}}"{{.ArgsText}}) return await this.client.request<{{.GenericTypeText}}>("{{$serviceName}}", "{{.MethodName}}", {{if .DtoText}}dto{{else}}undefined{{end}}, options)
}{{end}} }{{end}}
{{- end}} {{- end}}
}` }`
+15 -24
View File
@@ -2,28 +2,31 @@ package fun
import ( import (
"net" "net"
"net/http"
"strings" "strings"
"github.com/valyala/fasthttp"
) )
// getIP 获取客户端真实 IP // clientIP 解析客户端真实 IP
// 优先级:X-Forwarded-For > X-Real-IP > RemoteAddr // 优先级:X-Forwarded-For > X-Real-IP > RemoteAddr
func getIP(r *http.Request) string { // 部署在反向代理(nginx 等)后时由代理写入这两个头;
// 1. 优先获取真实 IP(多层代理时取最后一个非空段) // 直连无代理头时回退到连接对端地址
if ip := lastNonEmpty(r.Header.Get("X-Forwarded-For")); ip != "" { func clientIP(ctx *fasthttp.RequestCtx) string {
// 1. X-Forwarded-For 取最后一个非空段:
// 该段由离服务最近的一层代理追加,是代理链中最可信的一段
if ip := lastNonEmpty(string(ctx.Request.Header.Peek("X-Forwarded-For"))); ip != "" {
return toLoopback(ip) return toLoopback(ip)
} }
// 2. X-Real-IP(通常由 Nginx 设置) // 2. X-Real-IP(通常由 nginx 设置)
if ip := strings.TrimSpace(r.Header.Get("X-Real-IP")); ip != "" { if ip := strings.TrimSpace(string(ctx.Request.Header.Peek("X-Real-IP"))); ip != "" {
return toLoopback(ip) return toLoopback(ip)
} }
// 3. 最终回退到 RemoteAddr(兼容带端口、IPv6 方括号、无端口) // 3. 回退到连接对端地址;无对端或未指定地址(0.0.0.0,测试/直驱场景)按本机处理
if ip := hostOf(r.RemoteAddr); ip != "" { if remote := ctx.RemoteIP(); remote != nil && !remote.IsUnspecified() {
return toLoopback(ip) return toLoopback(remote.String())
} }
return "127.0.0.1" return "127.0.0.1"
} }
@@ -39,18 +42,6 @@ func lastNonEmpty(xff string) string {
return "" return ""
} }
// hostOf 从 RemoteAddr 中提取 IP 部分
// "203.0.113.9:4567" → "203.0.113.9""[::1]:4567" → "::1"
// "198.51.100.88"(无端口)→ 原样返回
func hostOf(remoteAddr string) string {
raw := strings.TrimSpace(remoteAddr)
host, _, err := net.SplitHostPort(raw)
if err == nil && host != "" {
return host
}
return raw
}
// toLoopback 回环地址统一返回 127.0.0.1,其余原样返回 // toLoopback 回环地址统一返回 127.0.0.1,其余原样返回
func toLoopback(ip string) string { func toLoopback(ip string) string {
if parsed := net.ParseIP(ip); parsed != nil && parsed.IsLoopback() { if parsed := net.ParseIP(ip); parsed != nil && parsed.IsLoopback() {