Files
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

282 lines
8.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package fun
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"reflect"
"runtime/debug"
"strings"
"sync"
"github.com/valyala/fasthttp"
)
// handle 处理 HTTP 请求:先匹配自定义路由(BindRoute 精确/通配),未命中走 /cell RPC
func (f *Fun) handle(fastCtx *fasthttp.RequestCtx) {
ctx := &Ctx{RequestCtx: fastCtx}
defer f.handlePanic(ctx)
method, path := string(fastCtx.Method()), string(fastCtx.Path())
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.route, strings.TrimPrefix(path, r.prefix+"/"))
return
}
}
if ctx.path() != "/cell" {
ctx.setStatusCode(fasthttp.StatusNotFound)
return
}
if !ctx.isPost() {
ctx.setStatusCode(fasthttp.StatusMethodNotAllowed)
return
}
body := ctx.postBody()
// Data 以原始字节保存:校验用解码后的 map,业务 DTO 解码用原始字节,
// 大整数不经过 float64 往返,避免 int64 精度丢失
var requestInfo RequestInfo[json.RawMessage]
if err := json.Unmarshal(body, &requestInfo); err != nil {
ctx.send(internalError("invalid request body", err))
return
}
requestInfo.MethodName = firstLetterToUpper(requestInfo.MethodName)
requestInfo.ServiceName = firstLetterToUpper(requestInfo.ServiceName)
if requestInfo.MethodName == "" || requestInfo.ServiceName == "" {
ctx.sendError(errEmptyFields)
return
}
ctx.Ip = clientIP(fastCtx)
ctx.State = requestInfo.State
ctx.MethodName = requestInfo.MethodName
ctx.ServiceName = requestInfo.ServiceName
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 内完成注入
var streamCh chan any
var streamDone chan struct{}
result, err := f.invoke(ctx, &streamCh, &streamDone)
if err != nil {
ctx.sendError(err)
return
}
if streamCh != nil {
fastCtx.Response.Header.SetContentType("application/x-ndjson")
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 {
return false
}
raw, err := lowerKeysFromJSON(data)
if err == nil {
if data, err = json.Marshal(raw); err != nil {
return false
}
}
if _, err := fmt.Fprintf(w, "%s\n", data); err != nil {
return false
}
if err := w.Flush(); err != nil {
return false
}
return true
}
// (T, stream, error)T 作为流的第一条消息下发
if result.Data != nil {
if !writeLine(*result.Data) {
finish()
return
}
}
for message := range streamCh {
if !writeLine(message) {
finish()
return
}
}
finish()
})
return
}
ctx.send(*result)
}
// handlePanic 兜底处理 panic:完整堆栈记日志;
// 业务以 panic 抛出的 fun.Error 原样透传,其余 panic 只回通用错误——
// panic 消息可能包含 SQL/内部路径等细节,不外泄给客户端
func (f *Fun) handlePanic(c *Ctx) {
if v := recover(); v != nil {
var err error
if e, ok := v.(error); ok {
err = e
} else {
err = fmt.Errorf("panic (%s.%s): %v", c.ServiceName, c.MethodName, v)
}
ErrorLogger(err.Error(), "\n"+string(debug.Stack()))
var result Result[any]
if errors.As(err, &result) {
c.sendError(err)
return
}
c.send(internalError("internal error", err))
}
}
// invoke 按 "Service.Method" 查找并调用,返回成功结果
// 流式方法时,业务返回的 *Stream 完成通道注入(streamCh/streamDone 被创建并填充)
// 预期错误(方法不存在、参数缺失、业务失败)以 error 返回,不 panic
func (f *Fun) invoke(c *Ctx, streamCh *chan any, streamDone *chan struct{}) (*Result[any], error) {
key := c.ServiceName + "." + c.MethodName
method, ok := f.methods[key]
if !ok {
return nil, errMethodNotFound
}
if err := f.callGuard(c, c.ServiceName); err != nil {
return nil, err
}
var args []reflect.Value
if method.dtoType != nil {
if c.Data == nil {
return nil, errDTORequired
}
if err := checkDto(method.dtoType, *c.Data, c.MethodName); err != nil {
return nil, err
}
dto := reflect.New(method.dtoType).Elem()
// 优先按原始字节精确解码(不经 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)
}
// 每请求创建新实例并注入 Ctx/Box 依赖,避免并发共享实例
instance := reflect.New(method.serviceType)
f.serviceWired(instance.Elem(), c)
values := instance.Method(method.methodIndex).Call(args)
return callResult(c, values, method, streamCh, streamDone)
}
// callResult 将反射调用结果归一为 Result
// 兼容四种签名:(error)、(T, error)、(stream, error)、(T, stream, error)
// - 末位返回值是 error 且非 nil → 业务失败,返回 error
// - 带 *Stream 的签名:注入推送通道后,仅 (T, stream, error) 返回 T 作为数据
func callResult(c *Ctx, values []reflect.Value, method methodInfo, streamCh *chan any, streamDone *chan struct{}) (*Result[any], error) {
if last := values[len(values)-1]; last.Type().Implements(errorType) {
if !last.IsNil() {
// 业务出错但可能已启动 goroutine 调 Send/Close
// 注入一个已取消的流,让它们立即解除阻塞退出,避免 goroutine 泄漏
if method.isStream {
injectCancelledStream(values, method, streamCh, streamDone)
}
// 业务 Error() 构造的 Result[any] 作为 error 返回,sendError 里原样透传
var result Result[any]
if errors.As(last.Interface().(error), &result) {
return nil, result
}
return nil, last.Interface().(error)
}
values = values[:len(values)-1]
}
if method.isStream {
// (stream, error):流在第 0 位;(T, stream, error):流在第 1 位
streamIdx := 0
if len(values) == 2 {
streamIdx = 1
}
s := values[streamIdx].Interface().(*Stream)
if s == nil {
return nil, errors.New("fun: method returned nil stream")
}
*streamCh = make(chan any)
*streamDone = make(chan struct{})
s.Inject(*streamCh, *streamDone)
// (T, stream, error):返回 T;纯流:不返回数据
if len(values) == 2 {
r := success(values[0].Interface())
return &r, nil
}
r := success(nil)
return &r, nil
}
if len(values) == 0 {
// () error:无数据返回
r := success(nil)
return &r, nil
}
r := success(values[0].Interface())
return &r, nil
}
// injectCancelledStream 业务出错时注入已取消的流通道,
// 使正在 Send/Close 上阻塞的业务 goroutine 立即解除并退出
func injectCancelledStream(values []reflect.Value, method methodInfo, streamCh *chan any, streamDone *chan struct{}) {
streamIdx := 0
if len(values) == 3 { // (T, stream, error)
streamIdx = 1
}
s, ok := values[streamIdx].Interface().(*Stream)
if !ok || s == nil {
return
}
*streamCh = make(chan any)
*streamDone = make(chan struct{})
s.Inject(*streamCh, *streamDone)
close(*streamDone)
}
// convert 将数据转为 JSON 再反序列化到目标类型,避免手写字段映射
func convert(from any, to any) error {
data, err := json.Marshal(from)
if err != nil {
return err
}
return json.Unmarshal(data, to)
}