Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfecbff8f8 | ||
|
|
41df889a1a |
@@ -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 客户端生成与常见坑。
|
||||
@@ -1,119 +1,171 @@
|
||||
package fun
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
// Wired 创建并注册一个依赖实例;auto 标签字段递归注入依赖;存在 New() 则调用
|
||||
func Wired[T any]() *T {
|
||||
// boxEntry 依赖容器条目:装配完成的单例,或粘性初始化错误。
|
||||
// 装配失败的类型记录错误后不再重试,也不把半初始化实例暴露给后续装配
|
||||
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]()
|
||||
data := new(T)
|
||||
if t.Kind() != reflect.Struct {
|
||||
panic("Fun: " + t.Name() + " It must be a structure")
|
||||
}
|
||||
if t.Name() == "" {
|
||||
panic("Fun: Wired requires a named struct type")
|
||||
}
|
||||
if isPrivate(t.Name()) {
|
||||
panic("Fun:" + t.Name() + " cannot be Private")
|
||||
}
|
||||
if newMethod, found := t.MethodByName("New"); found {
|
||||
if newMethod.Type.NumIn() != 1 || newMethod.Type.NumOut() != 0 {
|
||||
panic("Fun:" + t.Name() + " New method must have no parameters and no return values")
|
||||
}
|
||||
}
|
||||
pt := reflect.TypeFor[*T]()
|
||||
checkNewSignature(pt, t.Name())
|
||||
f := GetFun()
|
||||
if box, isWired := f.boxes.Load(reflect.TypeFor[*T]()); isWired {
|
||||
return box.(reflect.Value).Interface().(*T)
|
||||
f.mu.Lock()
|
||||
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)
|
||||
f.boxes.Store(reflect.TypeFor[*T](), v)
|
||||
boxList := map[reflect.Type]bool{}
|
||||
// 先入容器再装配:循环依赖(A→B→A)靠占位引用解开;
|
||||
// 失败时下面覆盖为粘性错误,容器中不留可用半成品
|
||||
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++ {
|
||||
c := t.Field(i)
|
||||
fieldTag := newTag(c.Tag)
|
||||
if _, isAuto := fieldTag.getTag("auto"); isAuto {
|
||||
if dependency, loaded := f.boxes.Load(c.Type); loaded {
|
||||
v.Elem().Field(i).Set(dependency.(reflect.Value))
|
||||
} else {
|
||||
checkBox(c, boxList)
|
||||
f.autowired(v.Elem().Field(i))
|
||||
if _, isAuto := newTag(c.Tag).getTag("auto"); !isAuto {
|
||||
continue
|
||||
}
|
||||
if c.Anonymous {
|
||||
panic("Fun:" + c.Name + " cannot be Anonymous")
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
newMethod := v.MethodByName("New")
|
||||
if newMethod.IsValid() {
|
||||
newMethod.Call(nil)
|
||||
}
|
||||
return data
|
||||
return nil
|
||||
}
|
||||
|
||||
// autowired 递归创建依赖实例并注入 auto 标签字段
|
||||
func (f *Fun) autowired(fieldValue reflect.Value) {
|
||||
instance := reflect.New(fieldValue.Type().Elem())
|
||||
f.boxes.Store(fieldValue.Type(), instance)
|
||||
// autowired 递归创建依赖实例并注入 auto 字段(须持有 f.mu)。
|
||||
// 实例先入容器再装配字段,供循环依赖拿到占位引用;失败时覆盖为粘性错误
|
||||
func (f *Fun) autowired(fieldValue reflect.Value) error {
|
||||
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)
|
||||
structValue := instance.Elem()
|
||||
for i := 0; i < structValue.NumField(); i++ {
|
||||
structField := structValue.Type().Field(i)
|
||||
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 := f.wireStruct(instance.Elem()); err != nil {
|
||||
f.boxes.Store(pt, boxEntry{err: err})
|
||||
return err
|
||||
}
|
||||
newMethod := instance.MethodByName("New")
|
||||
if newMethod.IsValid() {
|
||||
newMethod.Call(nil)
|
||||
if err := callNewIfPresent(instance); err != nil {
|
||||
f.boxes.Store(pt, boxEntry{err: err})
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkBox 校验 auto 注入字段:必须是指针+struct、非匿名、非私有;New() 必须无参无返回值
|
||||
func checkBox(s reflect.StructField, boxList map[reflect.Type]bool) {
|
||||
if _, ok := boxList[s.Type]; ok {
|
||||
// checkNewSignature 校验 New 方法签名:无参数,返回 () 或 (error)。
|
||||
// 在指针类型上查找,兼容值接收器与指针接收器两种定义
|
||||
func checkNewSignature(pt reflect.Type, name string) {
|
||||
m, found := pt.MethodByName("New")
|
||||
if !found {
|
||||
return
|
||||
}
|
||||
boxList[s.Type] = true
|
||||
if s.Anonymous {
|
||||
panic("Fun:" + s.Name + " cannot be Anonymous")
|
||||
mt := m.Type
|
||||
if mt.NumIn() != 1 { // 仅接收者
|
||||
panic("Fun:" + name + " New method must have no parameters")
|
||||
}
|
||||
if s.Type.Kind() != reflect.Ptr || s.Type.Elem().Kind() != reflect.Struct {
|
||||
panic("Fun:" + s.Name + " Must be a pointer and a struct")
|
||||
if mt.NumOut() == 0 {
|
||||
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 依赖
|
||||
func boxWired(service any, f *Fun) {
|
||||
serviceInstance := reflect.New(reflect.TypeOf(service).Elem()).Elem()
|
||||
// callNewIfPresent 调用指针上的 New()(若存在),支持 () 与 () error 两种签名
|
||||
func callNewIfPresent(ptr reflect.Value) error {
|
||||
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++ {
|
||||
field := serviceInstance.Field(i)
|
||||
if field.Type() == ctxType {
|
||||
continue
|
||||
}
|
||||
if field.Type().Kind() == reflect.Ptr && field.Type().Elem().Kind() == reflect.Struct {
|
||||
if _, isWired := f.boxes.Load(field.Type()); !isWired {
|
||||
f.autowired(field)
|
||||
if entry, isWired := f.boxes.Load(field.Type()); isWired {
|
||||
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) {
|
||||
for i := 0; i < serviceInstance.NumField(); i++ {
|
||||
field := serviceInstance.Field(i)
|
||||
@@ -123,7 +175,9 @@ func (f *Fun) serviceWired(serviceInstance reflect.Value, ctx *Ctx) {
|
||||
if field.Type() == ctxType {
|
||||
field.Set(reflect.ValueOf(*ctx))
|
||||
} 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 引用
|
||||
func serviceGuardWired(guard Guard, f *Fun) *any {
|
||||
// serviceGuardWired 创建 Guard 实例并注入 Box 依赖,返回 guard 引用(须持有 f.mu)
|
||||
func serviceGuardWired(guard Guard, f *Fun) (*any, error) {
|
||||
t := reflect.TypeOf(guard).Elem()
|
||||
guardInstance := reflect.New(t).Elem()
|
||||
for i := 0; i < guardInstance.NumField(); i++ {
|
||||
@@ -148,12 +202,21 @@ func serviceGuardWired(guard Guard, f *Fun) *any {
|
||||
if !field.CanSet() {
|
||||
continue
|
||||
}
|
||||
if dependency, ok := f.boxes.Load(field.Type()); ok {
|
||||
field.Set(dependency.(reflect.Value))
|
||||
} else {
|
||||
f.autowired(field)
|
||||
if field.Type() == ctxType {
|
||||
continue
|
||||
}
|
||||
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()
|
||||
return &g
|
||||
return &g, nil
|
||||
}
|
||||
|
||||
+18
-6
@@ -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) {
|
||||
t.Helper()
|
||||
f := New()
|
||||
f.BindService(&BugSvc{})
|
||||
if err := f.BindService(&BugSvc{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if data == nil {
|
||||
data = map[string]any{}
|
||||
}
|
||||
@@ -76,7 +78,9 @@ func TestBugNullableEnum(t *testing.T) {
|
||||
// bug3: 含 () error 方法的代码生成不应 panic,且类型应生成为 Void/void
|
||||
func TestBugGenErrorOnly(t *testing.T) {
|
||||
isolateGeneratorGlobals(t)
|
||||
GetFun().BindService(&BugSvc{})
|
||||
if err := GetFun().BindService(&BugSvc{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
SetOutput(t.TempDir())
|
||||
GenCode(GenGo{}, GenTs{})
|
||||
goSrc, err := os.ReadFile(filepath.Join(getDirectory(), "go", "bug_svc.go"))
|
||||
@@ -98,7 +102,9 @@ func TestBugGenErrorOnly(t *testing.T) {
|
||||
// bug4+5: 响应键应为小写;(T, stream, error) 的 T 应作为流的第一条消息下发
|
||||
func TestBugJsonKeysAndStreamFirst(t *testing.T) {
|
||||
f := New()
|
||||
f.BindService(&BugSvc{})
|
||||
if err := f.BindService(&BugSvc{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go f.Start(39003)
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
@@ -150,7 +156,9 @@ func (s *NullSlicSvc) Save(dto NullSlicDto) (string, error) { return "ok", nil }
|
||||
|
||||
func TestBugSliceNull(t *testing.T) {
|
||||
f := New()
|
||||
f.BindService(&NullSlicSvc{})
|
||||
if err := f.BindService(&NullSlicSvc{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := map[string]any{"tags": nil}
|
||||
c := &Ctx{Ip: "1", MethodName: "Save", ServiceName: "NullSlicSvc", Data: &data}
|
||||
var streamCh chan any
|
||||
@@ -177,7 +185,9 @@ func (s *LeakSvc) Fail() (*Stream, error) {
|
||||
|
||||
func TestBugStreamLeak(t *testing.T) {
|
||||
f := New()
|
||||
f.BindService(&LeakSvc{})
|
||||
if err := f.BindService(&LeakSvc{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := &Ctx{Ip: "1", MethodName: "Fail", ServiceName: "LeakSvc"}
|
||||
var streamCh chan any
|
||||
var streamDone chan struct{}
|
||||
@@ -202,7 +212,9 @@ func (s *CollideSvc) Cookie() (string, error) { return "cookie", nil }
|
||||
|
||||
func TestBugMethodNameCollision(t *testing.T) {
|
||||
f := New()
|
||||
f.BindService(&CollideSvc{})
|
||||
if err := f.BindService(&CollideSvc{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := f.methods["CollideSvc.Cookie"]; !ok {
|
||||
t.Fatal("Cookie method dropped due to name collision with fasthttp.RequestCtx")
|
||||
}
|
||||
|
||||
@@ -3,12 +3,16 @@ package fun
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"reflect"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
func isPrivate(value string) bool {
|
||||
if value == "" {
|
||||
return false // 匿名类型名:交由各处的具名校验给出明确报错,不在此越界 panic
|
||||
}
|
||||
return !unicode.IsUpper([]rune(value)[0])
|
||||
}
|
||||
|
||||
@@ -36,6 +40,9 @@ func checkType(t reflect.Type) {
|
||||
}
|
||||
}
|
||||
case reflect.Struct:
|
||||
if t.Name() == "" {
|
||||
panic("fun: anonymous struct types are not supported, define a named type")
|
||||
}
|
||||
if t.NumField() == 0 {
|
||||
panic("fun: " + t.Name() + " must have at least one field")
|
||||
}
|
||||
@@ -49,7 +56,11 @@ func checkType(t reflect.Type) {
|
||||
case reflect.Slice:
|
||||
checkType(t.Elem())
|
||||
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
|
||||
}
|
||||
|
||||
// checkEnumValue 运行时校验枚举值是否在范围内
|
||||
// checkEnumValue 运行时校验枚举值是否在范围内。
|
||||
// 必须在原始数值上先判范围再转 uint8:否则 256/512 等越界值先被截断成
|
||||
// 合法小值(256→0),绕过范围检查后静默落到错误的枚举项上
|
||||
func checkEnumValue(t reflect.Type, value any, name string) error {
|
||||
var max uint8
|
||||
enumValue := reflect.New(t).Elem()
|
||||
@@ -131,35 +144,67 @@ func checkEnumValue(t reflect.Type, value any, name string) error {
|
||||
} else {
|
||||
max = uint8(len(enumValue.Interface().(enum).Names()))
|
||||
}
|
||||
outOfRange := callError(errors.New("Fun:" + name + " Dto value out of range"))
|
||||
var num uint8
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
if v < 0 || v != math.Trunc(v) || v >= float64(max) {
|
||||
return outOfRange
|
||||
}
|
||||
num = uint8(v)
|
||||
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:
|
||||
num = v
|
||||
case uint16:
|
||||
if v >= uint16(max) {
|
||||
return outOfRange
|
||||
}
|
||||
num = uint8(v)
|
||||
case uint32:
|
||||
if v >= uint32(max) {
|
||||
return outOfRange
|
||||
}
|
||||
num = uint8(v)
|
||||
case uint64:
|
||||
if v >= uint64(max) {
|
||||
return outOfRange
|
||||
}
|
||||
num = uint8(v)
|
||||
case int:
|
||||
if v < 0 || v >= int(max) {
|
||||
return outOfRange
|
||||
}
|
||||
num = uint8(v)
|
||||
case int8:
|
||||
if v < 0 || int(v) >= int(max) {
|
||||
return outOfRange
|
||||
}
|
||||
num = uint8(v)
|
||||
case int16:
|
||||
if v < 0 || int(v) >= int(max) {
|
||||
return outOfRange
|
||||
}
|
||||
num = uint8(v)
|
||||
case int32:
|
||||
if v < 0 || int64(v) >= int64(max) {
|
||||
return outOfRange
|
||||
}
|
||||
num = uint8(v)
|
||||
case int64:
|
||||
if v < 0 || v >= int64(max) {
|
||||
return outOfRange
|
||||
}
|
||||
num = uint8(v)
|
||||
default:
|
||||
return callError(errors.New("Fun:" + name + " Dto enum value type is not supported"))
|
||||
}
|
||||
if num >= max {
|
||||
return callError(errors.New("Fun:" + name + " Dto value out of range"))
|
||||
return outOfRange
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package fun
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"reflect"
|
||||
@@ -18,6 +19,10 @@ type Ctx struct {
|
||||
ServiceName string
|
||||
Data *map[string]any
|
||||
RequestCtx *fasthttp.RequestCtx
|
||||
|
||||
// rawData 请求 data 字段的原始 JSON 字节(HTTP 路径填充)。
|
||||
// 业务 DTO 解码优先用它:不经 map[string]any 的 float64 往返,大整数无精度丢失
|
||||
rawData []byte
|
||||
}
|
||||
|
||||
var ctxType = reflect.TypeFor[Ctx]()
|
||||
@@ -49,10 +54,14 @@ func (c *Ctx) send(result Result[any]) {
|
||||
_, _ = c.write(out)
|
||||
}
|
||||
|
||||
// lowerKeysFromJSON 解析 JSON 后递归把所有对象键转为首字母小写
|
||||
// lowerKeysFromJSON 解析 JSON 后递归把所有对象键转为首字母小写。
|
||||
// 数字以 json.Number 原文保留,不落入 float64——否则响应侧 int64
|
||||
// 超过 2^53 会丢精度(9007199254740993 → ...992)
|
||||
func lowerKeysFromJSON(data []byte) (any, error) {
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.UseNumber()
|
||||
var raw any
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
if err := dec.Decode(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return lowerKeys(raw), nil
|
||||
|
||||
+18
-3
@@ -1,6 +1,6 @@
|
||||
# fun 框架(github.com/cyi-cc/fun)使用文档
|
||||
|
||||
> 适用版本:**v1.3.2**(当前最新发布)。基于 fasthttp 的单端点 RPC 框架,
|
||||
> 适用版本:**v1.3.3**(当前最新发布)。基于 fasthttp 的单端点 RPC 框架,
|
||||
> 自带依赖注入、Guard 鉴权、NDJSON 流式响应、自定义路由与 TypeScript 客户端生成。
|
||||
|
||||
## 版本沿革
|
||||
@@ -11,6 +11,7 @@
|
||||
| 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. 启动与服务注册
|
||||
|
||||
@@ -77,7 +78,19 @@ f.BindRoute("GET", "/image/*", func(c *fun.RouteCtx) error {
|
||||
```
|
||||
|
||||
- 精确路由优先于通配符;`/cell` 保留;方法大小写不敏感。
|
||||
- 查询参数与 form 表单合并进 `c.Param(name)`;multipart 不支持(转 base64 走 /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) // 64MB;0 或负数恢复 fasthttp 默认 4MB
|
||||
f.BindRoute("POST", "/upload", func(c *fun.RouteCtx) error {
|
||||
c.RequestCtx.WriteString("…")
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
## 6. 流式响应(NDJSON)
|
||||
|
||||
@@ -106,8 +119,10 @@ fun.GenCode(fun.GenTs{})
|
||||
|
||||
- `BindServiceForGen` 不触发 Box 装配,生成命令**不需要数据库/Redis 在运行**。
|
||||
- 生成确定性:service/method/imports 全部源端排序,重复生成字节一致。
|
||||
- 产物:`client.ts`(Client + `result<T>`)、每服务一个 `<service>.ts`、DTO/View 类型、
|
||||
- 产物固定落在 `<out>/ts/` 子目录(用 `GenGo` 则是 `<out>/go/`):
|
||||
`client.ts`(Client + `result<T>`)、每服务一个 `<service>.ts`、DTO/View 类型、
|
||||
`fun.ts`(`api.create(url)` 聚合入口,服务属性首字母小写)。
|
||||
需要拍平到目录根时,生成后自行把文件从 `ts/` 上移一层。
|
||||
|
||||
### 每调用选项(v1.3.2)
|
||||
|
||||
|
||||
@@ -23,4 +23,5 @@ var (
|
||||
errMethodNotFound = errors.New("method not found")
|
||||
errEmptyFields = errors.New("serviceName and methodName cannot be empty")
|
||||
errDTORequired = errors.New("method requires a DTO but none provided")
|
||||
errInvalidData = errors.New("invalid request data") // 详细原因只记服务端日志
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,37 +1,69 @@
|
||||
package fun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
type Fun struct {
|
||||
methods map[string]methodInfo
|
||||
routes map[string]RouteHandler // 自定义路由:"GET /path" → 处理器(精确匹配)
|
||||
wildcardRoutes map[string][]wildcardRoute
|
||||
boxes *sync.Map // 依赖容器:reflect.Type → reflect.Value
|
||||
guards []*any // 全局 Guard
|
||||
serviceGuards map[string][]*any // 服务级 Guard,按服务名
|
||||
bodyLimit int // 请求体上限(字节);0 = fasthttp 默认 4MB
|
||||
routes map[string]boundRoute // 自定义路由:"GET /path" → 绑定的处理器与 Guard(精确匹配)
|
||||
wildcardRoutes map[string][]wildcardRoute // 通配路由,按 HTTP 方法
|
||||
boxes *sync.Map // 依赖容器:reflect.Type → boxEntry(单例或粘性错误)
|
||||
guards []*any // 全局 Guard
|
||||
serviceGuards map[string][]*any // 服务级 Guard,按服务名
|
||||
bodyLimit int // 请求体上限(字节);0 = fasthttp 默认 4MB
|
||||
|
||||
readTimeout time.Duration // 读超时,默认 60s(slowloris 防线)
|
||||
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 以 "/*" 结尾注册):
|
||||
// prefix 如 "/image",匹配 prefix 与 prefix 下任意子路径
|
||||
type wildcardRoute struct {
|
||||
prefix string
|
||||
handler RouteHandler
|
||||
prefix string
|
||||
route boundRoute
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -39,34 +71,48 @@ var (
|
||||
streamType = reflect.TypeFor[*Stream]()
|
||||
)
|
||||
|
||||
var fun *Fun
|
||||
var (
|
||||
fun *Fun
|
||||
funMu sync.Mutex
|
||||
)
|
||||
|
||||
// methodInfo 已注册方法的元信息
|
||||
type methodInfo struct {
|
||||
serviceType reflect.Type // 服务值类型(非指针),每请求新建实例
|
||||
methodIndex int // 方法在实例上的反射索引
|
||||
dtoType reflect.Type // DTO 参数类型,无参数时为 nil
|
||||
isStream bool // 返回签名带 *Stream,走 RequestStreamType
|
||||
isStream bool // 返回签名带 *Stream,响应走 NDJSON 流式
|
||||
}
|
||||
|
||||
func New() *Fun {
|
||||
f := &Fun{
|
||||
func newFun() *Fun {
|
||||
return &Fun{
|
||||
methods: map[string]methodInfo{},
|
||||
routes: map[string]RouteHandler{},
|
||||
routes: map[string]boundRoute{},
|
||||
wildcardRoutes: map[string][]wildcardRoute{},
|
||||
boxes: &sync.Map{},
|
||||
serviceGuards: map[string][]*any{},
|
||||
readTimeout: 60 * time.Second,
|
||||
idleTimeout: 120 * time.Second,
|
||||
// writeTimeout 保持 0:流式响应可能长时间推送,写超时会掐断连接
|
||||
}
|
||||
}
|
||||
|
||||
func New() *Fun {
|
||||
f := newFun()
|
||||
funMu.Lock()
|
||||
if fun == nil {
|
||||
fun = f
|
||||
}
|
||||
funMu.Unlock()
|
||||
return f
|
||||
}
|
||||
|
||||
// GetFun 返回默认 Fun 实例,未初始化时自动创建
|
||||
// GetFun 返回默认 Fun 实例,未初始化时自动创建(并发安全)
|
||||
func GetFun() *Fun {
|
||||
funMu.Lock()
|
||||
defer funMu.Unlock()
|
||||
if fun == nil {
|
||||
fun = New()
|
||||
fun = newFun()
|
||||
}
|
||||
return fun
|
||||
}
|
||||
@@ -76,18 +122,30 @@ func GetFun() *Fun {
|
||||
// - 参数:最多一个,且必须是 struct(作为 DTO)
|
||||
// - 返回值:只支持四种签名——(error)、(T, error)、(stream, error)、(T, stream, error)
|
||||
//
|
||||
// guardList 为该服务绑定的 Guard,方法调用前按注册顺序执行
|
||||
func (f *Fun) BindService(service any, guardList ...Guard) {
|
||||
// guardList 为该服务绑定的 Guard,方法调用前按注册顺序执行。
|
||||
// 依赖装配失败(New() 返回 error)以 error 返回,由调用方决定退出或降级;
|
||||
// 用法错误(非结构体指针、非法签名)仍为 panic,等价编译期检查
|
||||
func (f *Fun) BindService(service any, guardList ...Guard) error {
|
||||
t, name := serviceType(service)
|
||||
boxWired(service, f)
|
||||
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)
|
||||
serviceGuards = append(serviceGuards, serviceGuardWired(guard, f))
|
||||
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
|
||||
@@ -166,27 +224,90 @@ func (f *Fun) bindServiceMethods(t reflect.Type, name string) {
|
||||
}
|
||||
|
||||
// BindGuard 注册全局 Guard,对所有服务生效
|
||||
func (f *Fun) BindGuard(guard Guard) {
|
||||
func (f *Fun) BindGuard(guard Guard) error {
|
||||
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
|
||||
func (f *Fun) callGuard(c *Ctx, serviceName string) {
|
||||
// callGuard 按全局 → 服务级顺序执行 Guard,首个非 nil error 短路返回
|
||||
func (f *Fun) callGuard(c *Ctx, serviceName string) error {
|
||||
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] {
|
||||
(*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 120s(slowloris 防线,SetTimeouts 可调),
|
||||
// WriteTimeout 默认不限制,长流式响应不会被掐断。
|
||||
// 优雅停机用 Shutdown;重复 Start panic
|
||||
func (f *Fun) Start(port uint16) {
|
||||
server := &fasthttp.Server{Handler: f.handle}
|
||||
if f.bodyLimit > 0 {
|
||||
server.MaxRequestBodySize = f.bodyLimit
|
||||
f.StartOn(fmt.Sprintf(":%d", port))
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
if err := server.ListenAndServe(fmt.Sprintf(":%d", port)); err != nil {
|
||||
srv := f.newServer()
|
||||
f.server.Store(srv)
|
||||
f.mu.Unlock()
|
||||
if err := srv.ListenAndServe(addr); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
+14
-5
@@ -29,8 +29,9 @@ var guardHit = false
|
||||
|
||||
type TestGuard struct{}
|
||||
|
||||
func (g *TestGuard) Guard(ctx Ctx) {
|
||||
func (g *TestGuard) Guard(ctx Ctx) error {
|
||||
guardHit = true
|
||||
return nil
|
||||
}
|
||||
|
||||
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) {
|
||||
f := New()
|
||||
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"}
|
||||
data := map[string]any{"name": "tom", "age": 1}
|
||||
c.Data = &data
|
||||
@@ -79,7 +82,9 @@ func TestCtxBoxInject(t *testing.T) {
|
||||
|
||||
func TestCheckDtoRequired(t *testing.T) {
|
||||
f := New()
|
||||
f.BindService(&TestSvc{})
|
||||
if err := f.BindService(&TestSvc{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := &Ctx{Ip: "x", MethodName: "Hello", ServiceName: "TestSvc"}
|
||||
data := map[string]any{"age": 1}
|
||||
c.Data = &data
|
||||
@@ -94,7 +99,9 @@ func TestCheckDtoRequired(t *testing.T) {
|
||||
|
||||
func TestGenCode(t *testing.T) {
|
||||
isolateGeneratorGlobals(t)
|
||||
GetFun().BindService(&TestSvc{})
|
||||
if err := GetFun().BindService(&TestSvc{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
SetOutput(t.TempDir())
|
||||
GenCode(GenGo{}, GenTs{})
|
||||
if _, err := os.Stat(filepath.Join(getDirectory(), "go", "test_svc.go")); err != nil {
|
||||
@@ -107,7 +114,9 @@ func TestGenCode(t *testing.T) {
|
||||
|
||||
func startServer(t *testing.T, port uint16) *Fun {
|
||||
f := New()
|
||||
f.BindService(&TestSvc{})
|
||||
if err := f.BindService(&TestSvc{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go f.Start(port)
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
return f
|
||||
|
||||
@@ -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 {
|
||||
ctx.genStruct(fieldType)
|
||||
}
|
||||
|
||||
+10
-6
@@ -95,9 +95,11 @@ func TestBindServiceForGenDoesNotInitializeDependencies(t *testing.T) {
|
||||
func TestGeneratedTypeScriptSignaturesAndImports(t *testing.T) {
|
||||
isolateGeneratorGlobals(t)
|
||||
f := GetFun()
|
||||
f.BindService(&ZebraGenSvc{})
|
||||
f.BindService(&MixedGenSvc{})
|
||||
f.BindService(&AlphaGenSvc{})
|
||||
for _, svc := range []any{&ZebraGenSvc{}, &MixedGenSvc{}, &AlphaGenSvc{}} {
|
||||
if err := f.BindService(svc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
SetOutput(t.TempDir())
|
||||
GenCode(GenTs{})
|
||||
|
||||
@@ -168,9 +170,11 @@ func TestGeneratedTypeScriptSignaturesAndImports(t *testing.T) {
|
||||
func TestGeneratedSourcesAreDeterministic(t *testing.T) {
|
||||
isolateGeneratorGlobals(t)
|
||||
f := GetFun()
|
||||
f.BindService(&ZebraGenSvc{})
|
||||
f.BindService(&AlphaGenSvc{})
|
||||
f.BindService(&MixedGenSvc{})
|
||||
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{})
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
package fun
|
||||
|
||||
// Guard 方法调用前的拦截器。
|
||||
//
|
||||
// 返回 nil 放行;返回 error 短路:后续 Guard 与业务方法不再执行,
|
||||
// error 走统一 Result 错误响应(返回 fun.Error(code, msg) 可携带错误码)。
|
||||
// 不再需要通过写响应或 panic 表达拒绝
|
||||
type Guard interface {
|
||||
Guard(ctx Ctx)
|
||||
Guard(ctx Ctx) error
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"reflect"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
@@ -18,13 +19,13 @@ func (f *Fun) handle(fastCtx *fasthttp.RequestCtx) {
|
||||
defer f.handlePanic(ctx)
|
||||
|
||||
method, path := string(fastCtx.Method()), string(fastCtx.Path())
|
||||
if handler, ok := f.routes[method+" "+path]; ok {
|
||||
f.handleRoute(fastCtx, handler, "")
|
||||
if r, ok := f.routes[method+" "+path]; ok {
|
||||
f.handleRoute(fastCtx, r, "")
|
||||
return
|
||||
}
|
||||
for _, r := range f.wildcardRoutes[method] {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -39,9 +40,11 @@ func (f *Fun) handle(fastCtx *fasthttp.RequestCtx) {
|
||||
}
|
||||
|
||||
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 {
|
||||
ctx.sendError(err)
|
||||
ctx.send(internalError("invalid request body", err))
|
||||
return
|
||||
}
|
||||
requestInfo.MethodName = firstLetterToUpper(requestInfo.MethodName)
|
||||
@@ -51,11 +54,21 @@ func (f *Fun) handle(fastCtx *fasthttp.RequestCtx) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Ip = ctx.remoteIP().String()
|
||||
ctx.Ip = clientIP(fastCtx)
|
||||
ctx.State = requestInfo.State
|
||||
ctx.MethodName = requestInfo.MethodName
|
||||
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)
|
||||
// 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("Connection", "keep-alive")
|
||||
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 {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
@@ -94,24 +118,26 @@ func (f *Fun) handle(fastCtx *fasthttp.RequestCtx) {
|
||||
// (T, stream, error):T 作为流的第一条消息下发
|
||||
if result.Data != nil {
|
||||
if !writeLine(*result.Data) {
|
||||
close(streamDone)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
}
|
||||
for message := range streamCh {
|
||||
if !writeLine(message) {
|
||||
close(streamDone)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
}
|
||||
close(streamDone)
|
||||
finish()
|
||||
})
|
||||
return
|
||||
}
|
||||
ctx.send(*result)
|
||||
}
|
||||
|
||||
// handlePanic 兜底处理 panic:归一为 error 后写回错误响应,并记录完整堆栈日志
|
||||
// handlePanic 兜底处理 panic:完整堆栈记日志;
|
||||
// 业务以 panic 抛出的 fun.Error 原样透传,其余 panic 只回通用错误——
|
||||
// panic 消息可能包含 SQL/内部路径等细节,不外泄给客户端
|
||||
func (f *Fun) handlePanic(c *Ctx) {
|
||||
if v := recover(); v != nil {
|
||||
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)
|
||||
}
|
||||
ErrorLogger(err.Error(), "\n"+string(debug.Stack()))
|
||||
c.sendError(err)
|
||||
var result Result[any]
|
||||
if errors.As(err, &result) {
|
||||
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
|
||||
}
|
||||
|
||||
f.callGuard(c, c.ServiceName)
|
||||
if err := f.callGuard(c, c.ServiceName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var args []reflect.Value
|
||||
if method.dtoType != nil {
|
||||
@@ -146,8 +179,17 @@ func (f *Fun) invoke(c *Ctx, streamCh *chan any, streamDone *chan struct{}) (*Re
|
||||
return nil, err
|
||||
}
|
||||
dto := reflect.New(method.dtoType).Elem()
|
||||
if err := convert(c.Data, dto.Addr().Interface()); err != nil {
|
||||
return nil, err
|
||||
// 优先按原始字节精确解码(不经 float64);直接构造 Ctx 调 invoke(无 rawData)时回退 map 往返
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
// ---- 真实 IP:X-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 从查询参数取 token(State 合并)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -24,14 +24,15 @@ const (
|
||||
)
|
||||
|
||||
var logChan = make(chan string, 100)
|
||||
var logWg sync.WaitGroup
|
||||
|
||||
const (
|
||||
TerminalMode uint8 = iota
|
||||
FileMode
|
||||
)
|
||||
|
||||
var logMutex sync.Mutex
|
||||
var logMutex sync.Mutex // 串行化实际写出(后台 worker 与通道满时的同步兜底)
|
||||
|
||||
var loggerMu sync.RWMutex // 保护 logger 配置的读写,消除 ConfigLogger 数据竞争
|
||||
|
||||
type Logger struct {
|
||||
Level uint8
|
||||
@@ -59,11 +60,12 @@ func init() {
|
||||
func logWriterWorker() {
|
||||
for text := range logChan {
|
||||
logMutex.Lock()
|
||||
if logger.Mode == FileMode {
|
||||
if currentLogger().Mode == FileMode {
|
||||
fileLogger(text)
|
||||
} else {
|
||||
fmt.Println(text)
|
||||
}
|
||||
logMutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,13 +76,20 @@ func deleteLogWorker() {
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if logger.Mode == FileMode {
|
||||
if currentLogger().Mode == FileMode {
|
||||
cleanupExpiredLogs()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// currentLogger 读取当前日志配置快照,避免与 ConfigLogger 的数据竞争
|
||||
func currentLogger() Logger {
|
||||
loggerMu.RLock()
|
||||
defer loggerMu.RUnlock()
|
||||
return logger
|
||||
}
|
||||
|
||||
func getLogFilePath() string {
|
||||
if logger.LogFilePath == "" {
|
||||
return "./log"
|
||||
@@ -89,7 +98,8 @@ func getLogFilePath() string {
|
||||
}
|
||||
|
||||
func cleanupExpiredLogs() {
|
||||
if logger.ExpireLogsDays <= 0 {
|
||||
cfg := currentLogger()
|
||||
if cfg.ExpireLogsDays <= 0 {
|
||||
return
|
||||
}
|
||||
_, err := os.Stat(getLogFilePath())
|
||||
@@ -124,24 +134,24 @@ func cleanupExpiredLogs() {
|
||||
}
|
||||
}
|
||||
|
||||
// getFileNameInfo 解析日志文件名 "日期.log.序号"。
|
||||
// 解析失败仅视为非托管文件并跳过——绝不删除:日志目录里用户放入的
|
||||
// 任何无关文件(配置、说明)不属于框架管辖范围
|
||||
func getFileNameInfo(name string) fileName {
|
||||
fileNameParts := strings.Split(name, ".log.")
|
||||
if len(fileNameParts) != 2 {
|
||||
deleteLog(name)
|
||||
return fileName{}
|
||||
}
|
||||
dateLayout := "2006-01-02"
|
||||
dateString := fileNameParts[0]
|
||||
fileDate, err := time.Parse(dateLayout, dateString)
|
||||
if err != nil {
|
||||
deleteLog(name)
|
||||
return fileName{}
|
||||
}
|
||||
indexString := fileNameParts[1]
|
||||
indexString = strings.TrimSuffix(indexString, ".log")
|
||||
fileIndex, err := strconv.ParseInt(indexString, 10, 32)
|
||||
if err != nil {
|
||||
deleteLog(name)
|
||||
return fileName{}
|
||||
}
|
||||
return fileName{
|
||||
@@ -189,10 +199,11 @@ func fileLogger(text string) {
|
||||
}
|
||||
|
||||
func removeOldestLogFile(entries []os.DirEntry) {
|
||||
if logger.MaxNumberFiles == 0 {
|
||||
cfg := currentLogger()
|
||||
if cfg.MaxNumberFiles == 0 {
|
||||
return
|
||||
}
|
||||
if uint64(len(entries)) < logger.MaxNumberFiles {
|
||||
if uint64(len(entries)) < cfg.MaxNumberFiles {
|
||||
return
|
||||
}
|
||||
var newEntries []fileName
|
||||
@@ -202,10 +213,10 @@ func removeOldestLogFile(entries []os.DirEntry) {
|
||||
newEntries = append(newEntries, fileNameInfo)
|
||||
}
|
||||
}
|
||||
if uint64(len(newEntries)) < logger.MaxNumberFiles {
|
||||
if uint64(len(newEntries)) < cfg.MaxNumberFiles {
|
||||
return
|
||||
}
|
||||
delNum := uint64(len(newEntries)) - logger.MaxNumberFiles + 1
|
||||
delNum := uint64(len(newEntries)) - cfg.MaxNumberFiles + 1
|
||||
sort.Slice(newEntries, func(i, j int) bool {
|
||||
if 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)
|
||||
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))
|
||||
if fileInfo, err := os.Stat(currentFile); err == nil {
|
||||
maxSizeBytes := int64(logger.MaxSizeFile) * 1024 * 1024
|
||||
@@ -263,7 +274,9 @@ func getNextLogFile(dirPath, dateStr string, text string) (string, error) {
|
||||
}
|
||||
|
||||
func ConfigLogger(log Logger) {
|
||||
loggerMu.Lock()
|
||||
logger = log
|
||||
loggerMu.Unlock()
|
||||
}
|
||||
|
||||
func getCurrentTime() string {
|
||||
@@ -304,7 +317,8 @@ func getLevelName(level uint8) string {
|
||||
}
|
||||
|
||||
func sendLogWorker(level uint8, message []any) {
|
||||
if logger.Level >= level {
|
||||
cfg := currentLogger()
|
||||
if cfg.Level >= level {
|
||||
var text1 strings.Builder
|
||||
for _, m := range message {
|
||||
var msgStr string
|
||||
@@ -350,8 +364,18 @@ func sendLogWorker(level uint8, message []any) {
|
||||
text1.WriteString(msgStr + " ")
|
||||
}
|
||||
text := "[" + getCurrentTime() + "] [" + padString(getLevelName(level), 7) + "] " + getMethodNameLogger() + text1.String()
|
||||
logWg.Add(1)
|
||||
logChan <- text
|
||||
select {
|
||||
case logChan <- text:
|
||||
default:
|
||||
// 通道满时同步写出兜底:日志绝不阻塞(也绝不丢弃)请求处理 goroutine
|
||||
logMutex.Lock()
|
||||
if cfg.Mode == FileMode {
|
||||
fileLogger(text)
|
||||
} else {
|
||||
fmt.Println(text)
|
||||
}
|
||||
logMutex.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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+1,float64 无法精确表示
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,8 @@
|
||||
package fun
|
||||
|
||||
const (
|
||||
RequestNormalType uint8 = iota
|
||||
RequestStreamType
|
||||
)
|
||||
|
||||
type RequestInfo[T any] struct {
|
||||
MethodName string
|
||||
ServiceName string
|
||||
Data *T
|
||||
State map[string]string
|
||||
Type uint8
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ const (
|
||||
)
|
||||
|
||||
type Result[T any] struct {
|
||||
Id string `json:"id,omitempty"`
|
||||
Code *uint16 `json:"code,omitempty"`
|
||||
Data *T `json:"data,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}
|
||||
}
|
||||
|
||||
// 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
|
||||
func success(data any) Result[any] {
|
||||
return Result[any]{Data: nonNil(data), Status: successCode}
|
||||
|
||||
@@ -14,6 +14,12 @@ import (
|
||||
// (如支付回调要求的纯文本 "success" 应答)。
|
||||
type RouteHandler func(ctx *RouteCtx) error
|
||||
|
||||
// boundRoute 路由绑定的处理器与其 Guard
|
||||
type boundRoute struct {
|
||||
handler RouteHandler
|
||||
guards []*any
|
||||
}
|
||||
|
||||
// RouteCtx 自定义路由上下文:Data 合并了 URL 查询参数与 POST 表单参数(表单优先),
|
||||
// 支付回调等第三方以 form-urlencoded 回调的场景可直接 Param 取值。
|
||||
// Wildcard 为通配符路由(/prefix/*)匹配到的剩余路径(不含前导 "/")。
|
||||
@@ -32,12 +38,15 @@ func (c *RouteCtx) Param(name string) string {
|
||||
// BindRoute 注册自定义路由(方法大小写不敏感;path 精确匹配,或以 "/*" 结尾做前缀通配),
|
||||
// 用于 GET 直链、健康检查、支付回调等无法走 POST /cell RPC 的场景。
|
||||
//
|
||||
// - guardList 为该路由绑定的 Guard,处理器前按注册顺序执行:
|
||||
// Guard 收到的 Ctx.State 已合并 URL 查询与表单参数(token 放查询参数即可鉴权),
|
||||
// 返回 error 时短路——处理器不执行,error 走统一 Result 错误响应
|
||||
// - path 必须以 "/" 开头;/cell 为 RPC 保留路径,不可注册
|
||||
// - 通配符形式如 "/image/*":匹配 "/image/a/b.png" 等任意子路径,
|
||||
// 匹配到的剩余路径(去掉前导 "/",如 "a/b.png")经 RouteCtx.Wildcard 取出
|
||||
// - 同一 方法+路径 重复注册直接 panic
|
||||
// - 与 BindService 一致,需在 Start 前完成注册(启动阶段单线程)
|
||||
func (f *Fun) BindRoute(method, path string, handler RouteHandler) {
|
||||
// - Guard 依赖装配失败以 error 返回;非法参数与重复注册仍 panic
|
||||
// - 须在 Start 前完成注册
|
||||
func (f *Fun) BindRoute(method, path string, handler RouteHandler, guardList ...Guard) error {
|
||||
if handler == 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/*" {
|
||||
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 == "" || strings.HasSuffix(prefix, "/") {
|
||||
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))
|
||||
}
|
||||
}
|
||||
f.wildcardRoutes[method] = append(f.wildcardRoutes[method], wildcardRoute{prefix: prefix, handler: handler})
|
||||
return
|
||||
f.wildcardRoutes[method] = append(f.wildcardRoutes[method], wildcardRoute{prefix: prefix, route: br})
|
||||
return nil
|
||||
}
|
||||
key := method + " " + path
|
||||
if _, exists := f.routes[key]; exists {
|
||||
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),
|
||||
// 处理器返回 error 时按统一 Result 格式输出错误响应;wildcard 为通配路由匹配的剩余路径
|
||||
func (f *Fun) handleRoute(fastCtx *fasthttp.RequestCtx, handler RouteHandler, wildcard string) {
|
||||
// 先按序执行路由 Guard(State 即合并参数,token 放查询参数即可鉴权),
|
||||
// 任一 Guard 返回 error 则短路;处理器返回 error 时按统一 Result 格式输出错误响应
|
||||
func (f *Fun) handleRoute(fastCtx *fasthttp.RequestCtx, r boundRoute, wildcard string) {
|
||||
data := map[string]string{}
|
||||
fastCtx.QueryArgs().VisitAll(func(k, v []byte) {
|
||||
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) {
|
||||
data[string(k)] = string(v)
|
||||
})
|
||||
if err := handler(&RouteCtx{RequestCtx: fastCtx, Data: data, Wildcard: wildcard}); err != nil {
|
||||
(&Ctx{RequestCtx: fastCtx}).sendError(err)
|
||||
ctx := &Ctx{RequestCtx: fastCtx, Ip: clientIP(fastCtx), State: data}
|
||||
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
@@ -12,7 +12,9 @@ import (
|
||||
func startRouteServer(t *testing.T, port uint16) *Fun {
|
||||
t.Helper()
|
||||
f := New()
|
||||
f.BindService(&TestSvc{})
|
||||
if err := f.BindService(&TestSvc{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// GET:查询参数 + 纯文本自定义响应
|
||||
f.BindRoute("GET", "/ping", func(ctx *RouteCtx) error {
|
||||
|
||||
@@ -66,7 +66,9 @@ func streamPost(t *testing.T, url, method string) (*http.Response, []byte) {
|
||||
|
||||
func TestServerStreamContentTypeAndEmptyStream(t *testing.T) {
|
||||
f := New()
|
||||
f.BindService(&ProtocolStreamSvc{})
|
||||
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)
|
||||
@@ -78,7 +80,9 @@ func TestServerStreamContentTypeAndEmptyStream(t *testing.T) {
|
||||
|
||||
func TestServerStreamSetupErrorsUseResultProtocol(t *testing.T) {
|
||||
f := New()
|
||||
f.BindService(&ProtocolStreamSvc{})
|
||||
if err := f.BindService(&ProtocolStreamSvc{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
url := serveFun(t, f)
|
||||
for _, test := range []struct {
|
||||
method string
|
||||
|
||||
+1
-2
@@ -16,7 +16,6 @@ import (
|
||||
|
||||
// Result 统一响应结构
|
||||
type Result[T any] struct {
|
||||
Id string
|
||||
Code *uint16
|
||||
Data *T
|
||||
Msg *string
|
||||
@@ -114,7 +113,7 @@ func Request[T any](c *Client, serviceName string, methodName string, dto ...any
|
||||
v := any(*out.Data)
|
||||
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 {
|
||||
if err := i(serviceName, methodName, anyResult); err != nil {
|
||||
return Result[T]{Status: 2, Msg: ptr(err.Error())}
|
||||
|
||||
+50
-6
@@ -3,8 +3,52 @@ package fun
|
||||
type templateTs struct{}
|
||||
|
||||
func (ctx templateTs) genClientTemplate() string {
|
||||
return `export type result<T> = {
|
||||
id?: string;
|
||||
return `// 大整数安全 JSON:超过 Number.MAX_SAFE_INTEGER(2^53-1)的整数字面量
|
||||
// 解析为 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;
|
||||
data?: T;
|
||||
msg?: string;
|
||||
@@ -130,7 +174,7 @@ function parseResult(response: Response, text: string): result<any> {
|
||||
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(body);
|
||||
value = parseLossless(body);
|
||||
} catch {
|
||||
if (!response.ok) return externalFailure(response, excerpt(body));
|
||||
const type = mediaType(response);
|
||||
@@ -260,7 +304,7 @@ export class Client {
|
||||
|
||||
let body: string;
|
||||
try {
|
||||
const serialized = JSON.stringify({
|
||||
const serialized = stringifyLossless({
|
||||
serviceName,
|
||||
methodName,
|
||||
data: dto,
|
||||
@@ -350,7 +394,7 @@ export class Client {
|
||||
|
||||
let body: string;
|
||||
try {
|
||||
const serialized = JSON.stringify({
|
||||
const serialized = stringifyLossless({
|
||||
serviceName,
|
||||
methodName,
|
||||
data: dto,
|
||||
@@ -464,7 +508,7 @@ export class Client {
|
||||
if (!payload) return;
|
||||
let data: T;
|
||||
try {
|
||||
data = JSON.parse(payload) as T;
|
||||
data = parseLossless(payload) as T;
|
||||
} catch (error) {
|
||||
failed = failure(1, ` + "`Invalid NDJSON at line ${lineNumber}: ${excerpt(payload)}`" + `);
|
||||
cause = error;
|
||||
|
||||
@@ -2,28 +2,31 @@ package fun
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
// getIP 获取客户端真实 IP
|
||||
// 优先级:X-Forwarded-For > X-Real-IP > RemoteAddr
|
||||
func getIP(r *http.Request) string {
|
||||
// 1. 优先获取真实 IP(多层代理时取最后一个非空段)
|
||||
if ip := lastNonEmpty(r.Header.Get("X-Forwarded-For")); ip != "" {
|
||||
// clientIP 解析客户端真实 IP。
|
||||
// 优先级:X-Forwarded-For > X-Real-IP > RemoteAddr。
|
||||
// 部署在反向代理(nginx 等)后时由代理写入这两个头;
|
||||
// 直连无代理头时回退到连接对端地址
|
||||
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)
|
||||
}
|
||||
|
||||
// 2. X-Real-IP(通常由 Nginx 设置)
|
||||
if ip := strings.TrimSpace(r.Header.Get("X-Real-IP")); ip != "" {
|
||||
// 2. X-Real-IP(通常由 nginx 设置)
|
||||
if ip := strings.TrimSpace(string(ctx.Request.Header.Peek("X-Real-IP"))); ip != "" {
|
||||
return toLoopback(ip)
|
||||
}
|
||||
|
||||
// 3. 最终回退到 RemoteAddr(兼容带端口、IPv6 方括号、无端口)
|
||||
if ip := hostOf(r.RemoteAddr); ip != "" {
|
||||
return toLoopback(ip)
|
||||
// 3. 回退到连接对端地址;无对端或未指定地址(0.0.0.0,测试/直驱场景)按本机处理
|
||||
if remote := ctx.RemoteIP(); remote != nil && !remote.IsUnspecified() {
|
||||
return toLoopback(remote.String())
|
||||
}
|
||||
|
||||
return "127.0.0.1"
|
||||
}
|
||||
|
||||
@@ -39,18 +42,6 @@ func lastNonEmpty(xff string) string {
|
||||
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,其余原样返回
|
||||
func toLoopback(ip string) string {
|
||||
if parsed := net.ParseIP(ip); parsed != nil && parsed.IsLoopback() {
|
||||
|
||||
Reference in New Issue
Block a user