V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
爱意满满的作品展示区。
idrunk
V2EX  ›  分享创造

用 Golang 写了个通用路由器,除了能路由 HTTP 协议外,还能路由 Websocket/Tcp/Udp 等协议,欢迎体验

  •  
  •   idrunk ·
    idrunk · 1 天前 · 674 次点击

    求职时发现 Golang 岗位挺多,本身对它也挺感兴趣,于是花了一个月时间,把我的 DCE 用 Golang 升级重构了遍,来学习练手。于是,DCE-GO诞生了。(仍然求职中,go/rust/全栈,远程/深圳,春节不休,邮箱:aGlAaWRydW5rLm5ldA==,欢迎联系)


    DCE-GO 是一个功能强大的通用路由库,不仅支持 HTTP 协议,还能路由 CLI 、WebSocket 、TCP/UDP 等非标准协议。它采用模块化设计,按功能划分为以下核心模块:

    1. 路由器模块
      作为 DCE 的核心模块,定义了 API 、上下文及路由器库,同时提供了转换器、可路由协议等接口,确保灵活性和扩展性。

    2. 可路由协议模块
      封装了多种常见协议的可路由实现,包括 HTTP 、CLI 、WebSocket 、TCP 、UDP 、QUIC 等,满足多样化场景需求。

    3. 转换器模块
      内置 JSON 和模板转换器,支持串行数据的序列化与反序列化,以及传输对象与实体对象的双向转换。

    4. 会话管理器模块
      定义了基础会话、用户会话、连接会话及自重生会话接口,并提供了 Redis 和共享内存的实现类库,方便开发者快速集成。

    5. 工具模块
      提供了一系列实用工具,简化开发流程。

    DCE-GO 的所有功能特性均配有详细用例,位于 _examples 目录下。其路由性能与 Gin 相当,具体性能测试报告可查看 ab 测试结果,其中端口 2046 为 DCE 的测试结果。

    DCE-GO 源自 DCE-RUST,而两者均基于 DCE-PHP 的核心路由模块升级而来。DCE-PHP 是一个完整的网络编程框架,现已停止更新,其核心功能已迁移至 DCE-RUST 和 DCE-GO 。目前,DCE-GO 的功能版本较新,未来 DCE-RUST 将与之同步。

    DCE 致力于打造一个高效、开放、安全的通用路由库,欢迎社区贡献,共同推动其发展。


    TODO

    • 优化 JS 版 WebSocket 可路由协议客户端,完善各协议的 Golang 客户端实现。
    • 升级控制器前后置事件接口,支持与程序接口绑定。
    • 完善数字路径支持。
    • 调整弹性数字函数为结构方法式。
    • 研究可路由协议中支持自定义业务属性的可能性。
    • 升级 DCE-RUST 功能版本。
    • 校验优化 AI 生成的文档,逐步完善。

    使用示例

    以下是一个简单的 TCP 请求响应示例,利用dce/router实现,基本涵盖了DCE所有核心特性的使用方法。通过此示例,开发者可以快速上手并理解DCE的强大功能与灵活性。

    测试步骤: 1: 新建go.mod

    module example
    go 1.23.3
    
    require (
    	github.com/idrunk/dce-go v0.1.1
    )
    

    2: 在同目录新建main.go并粘贴尾部的代码

    3: 打开命令行终端并cd到同目录,执行go mod tidy自动引入依赖模块

    4: 在命令行终端同目录下执行go run . tcp start启动 TCP 服务器

    5: 新建一个终端cd到同目录,执行go run main.go sign,输入用户名密码登录(可随便输,按回车完成,会自动注册)

    6: 第5步成功将响应一个“SESSION-ID”,复制该 ID ,替换到此命令go run main.go signer $SESSION_ID回车执行,获取脱敏的登录者信息

    package main
    
    import (
    	"bufio"
    	"encoding/json"
    	"fmt"
    	"log/slog"
    	"net"
    	"os"
    	"slices"
    	"strings"
    
    	"github.com/idrunk/dce-go/converter"
    	"github.com/idrunk/dce-go/proto"
    	"github.com/idrunk/dce-go/proto/flex"
    	"github.com/idrunk/dce-go/router"
    	"github.com/idrunk/dce-go/session"
    	"github.com/idrunk/dce-go/util"
    )
    
    func main() {
    	// 启动一个 TCP 服务器:go run main.go tcp start
       // 你还可以指定绑定 IP 与端口,如:go run main.go tcp start 0.0.0.0:2048
       // 配置一条 CliRouter 路由规则,绑定处理方法,在该方法中启动一个 TCP 服务器
    	proto.CliRouter.Push("tcp/start/{address?}", func(c *proto.Cli) {
    		bindServer()
    
    		addr := c.ParamOr("address", ":2048")
    		listener, err := net.Listen("tcp", addr)
    		if err != nil {
    			panic(err.Error())
    		}
    		defer listener.Close()
    
    		fmt.Printf("tcp server start at %s\n", addr)
    		for {
    			conn, err := listener.Accept()
    			if err != nil {
    				slog.Warn(fmt.Sprintf("accept error: %s", err))
    				continue
    			}
    			go func(conn net.Conn) {
    				defer conn.Close()
    				// Connection sessions are used to store the connection information for sending message across hosts to clients in a distributed environment.
                // 新建一个影子会话,影子会话仅用于长连接,用于将连接信息记录到 Session 中,以便跨主机向目标客户端发消息
    				shadow, err := session.NewShmSession[Member](nil, session.DefaultTtlMinutes)
    				if err != nil {
    					slog.Warn(fmt.Sprintf("new session error: %s", err))
    					return
    				}
    				shadow.Connect(conn.LocalAddr().String(), conn.RemoteAddr().String())
                // 连接断开时从会话中删除连接相关信息
    				defer shadow.Disconnect()
    				for {
                   // 读取解包 TCP 输入流,路由定位 API ,并调用控制函数处理,按需响应处理结果
    					if !flex.TcpRouter.Route(conn, map[string]any{"$shadowSession": shadow}) {
    						break
    					}
    				}
    			}(conn)
    		}
    	})
    	bindClient()
       // 取命令行参数,路由定位 API ,并调用控制函数处理,按需响应(输出)处理结果
    	proto.CliRoute(1)
    }
    
    func bindServer() {
       // 设置 TcpRouter 控制处理前置事件(当前无法与特定`Api`绑定,需自行判断,下个大版本将支持绑定指定`Api`)
    	flex.TcpRouter.SetEventHandler(func(c *flex.Tcp) error {
          // 从可路由协议对象上下文取影子会话,必定能取到所以直接推定为会话指针
    		shadow, _ := c.Rp.CtxData("$shadowSession")
    		rs := shadow.(*session.ShmSession[Member])
          // 用影子会话克隆一个请求会话(影子会话可能过旧,此法方法将更新之,并将影子会话临时记录的连接信息同步到请求会话)
    		cloned, err := rs.CloneForRequest(c.Rp.Sid())
    		if err != nil {
             // 克隆失败返回错误(返回错误将阻止控制函数与`AfterController`调用)
    			return err
    		}
    		se := cloned.(*session.ShmSession[Member])
    		if roles := util.MapSeqFrom[any, uint16](c.Api.ExtrasBy("roles")).Map(func(i any) uint16 {
    			return uint16(i.(int))
    		}).Collect(); len(roles) > 0 {
    			// Roles configured means need to login
             // 若配置了`roles`意味着需要登录
    			if member, ok := se.User(); !ok {
                // 无法从会话取到用户信息意味着未登录,返回相应错误
    				return util.Openly(401, "need to login")
    			} else if !slices.Contains(roles, member.Role) {
                // 授权`roles`中不包含当前会员角色意味着无权,返回相应错误
    				return util.Openly(403, "no permission")
    			} else if newer, err := session.NewAutoRenew(se).TryRenew(); err != nil {
                // 自更新会话遇到错误则返回(自更新并非会话过期更新续命,而是在会话有效期内,短时间高频率的更新会话 ID ,以增强会话安全性)
    				return err
    			} else if newer {
    				// Logged session need to auto renew to enhance security
                // 若自更新成功,则设置响应心的会话 ID
    				c.Rp.SetRespSid(se.Id())
    			}
    		}
          // 将会话指针设置到可路由协议对象上下文,以便在控制器函数等中直接获取,而无需重新创建
    		c.Rp.SetSession(se)
    		return nil
    	}, nil)
    
       // 配置一条 TcpRouter 路由规则,绑定服务端登录 API 。
       // (`Push`方法绑定的都是自动响应式 API ,若无需自动响应,请用`PushApi`方法并指定`Api.Responsive`为`false`)
    	flex.TcpRouter.Push("sign", func(c *flex.Tcp) {
          // 新建一个 JSON 转换器,该转换器仅将请求数据序列转换为对象,不作传输对象与实体对象转换,不作响应转换
          // (在上一个版本( DCE-RUST )中,序列与 DTO 对象转换逻辑,是集成在路由流程中自动处理的,这在无需转换的情况下,
          // 也需做处理判断,并且需在整个路由流携带 DTO 泛型类型,不太合理,所以在新版提取到单独的转换器模块了,可随时按需调用)
    		jc := converter.JsonConverterSame[*flex.TcpProtocol, Member, router.DoNotConvert](c)
          // 获取请求数据并转换为 Member 对象
    		signInfo, ok := jc.Parse()
    		if !ok {
    			return
    		}
          // 若入参不全,则设置响应失败状态并直接返回(转换器的全部响应方法,包括此处的`jc.Fail`,
          // 只记录响应数据,必定返回 true ,方便直接用`return`退出无返回参数的控制器)
    		if (len(signInfo.Name) == 0 || len(signInfo.Password) == 0) && jc.Fail("name or password is empty", 0) {
    			return
    		}
          // 以名称从列表取会员信息,若未取到,则自动注册一个(示例代码,为方便用了非线程安全的 map ,请勿在意)
    		member, ok := members[signInfo.Name]
    		if !ok {
    			// Notfound then auto register
    			memberId++
    			member = signInfo
    			member.Id = memberId
    			member.Role = 1
    			members[member.Name] = member
    		}
    		if member.Password != signInfo.Password && jc.Fail("password error", 0) {
    			return
    		}
    		// Must be have a session obj after `BeforeController` event, so we no need to check nil
          // 在`BeforeController`中必定成功创建并绑定了 Session 对象,否则不会调用处理函数,所以此处无需判断可直接推定为 Session 对象指针
    		se := c.Rp.Session().(*session.ShmSession[Member])
          // 将会员信息记录到 Session 中实现登录
    		if err := se.Login(member, 0); err != nil && jc.Fail(err.Error(), 0) {
    			return
    		}
    		// Must be have a new session id after `UserSession.Login()`
          // 登录后必定产生一个新的 SID ,设置响应,以便可路由协议自动将其附加到响应头
    		c.Rp.SetRespSid(se.Id())
          // 设置成功响应(登录成功会将会员信息记录到 Session 中,用户通过该 SID 即可取到会员信息)
    		jc.Success(nil)
    	})
    
    	// Bind an api with Path: signer, roles: [1]
       // 配置一条 TcpRouter 路由规则,绑定取登录者信息 API
       //(用`Path`函数新建一个`Api`对象,并授权`roles`为`[1]`。若无需自动响应,请追加调用`Unresponsive`方法)
    	flex.TcpRouter.PushApi(router.Path("signer").Append("roles", 1), func(c *flex.Tcp) {
          // 新建一个 JSON 转换器,该转换器不作请求转换,仅作响应转换
    		jc := converter.JsonConverterNoParse[*flex.TcpProtocol, Member, Signer](c)
    		sess := c.Rp.Session().(*session.ShmSession[Member])
    		// Member info can be obtained here, so there is no need to check
          // 由于`Api`配置了授权角色,且在`BeforeController`中会进行鉴权,未登录或无权的用户都无法进到此,所以必定能取到用户而无需判断
    		member, _ := sess.User()
    		// Response the member, it can be convert to Signer struct automatically
          // 设置响应数据。转换器将自动转换 Member 对象为脱敏的 Signer 对象,并自动序列化为 JSON
    		jc.Response(member)
    	})
    }
    
    func bindClient() {
    	// 从命令行通过 Tcp 客户端登录:go run main.go sign
       // 配置一条 CliRouter 路由规则,支持`sign`路径路由(你还可以通过 port=$PORT 来指定服务器端口,如`go run main.go sign port=3000`)
    	proto.CliRouter.Push("sign", func(c *proto.Cli) {
    		reader := bufio.NewReader(os.Stdin)
    		signInfo := Member{}
    		fmt.Print("Enter username: ")
    		username, _ := reader.ReadString('\n')
    		signInfo.Name = strings.TrimSpace(username)
    		fmt.Print("Enter password: ")
    		password, _ := reader.ReadString('\n')
    		signInfo.Password = strings.TrimSpace(password)
    		reqBody, err := json.Marshal(signInfo)
          // Json 化表单并发送登录请求
    		if err != nil && c.SetError(err) {
    			return
    		} else if resp := request(c, "sign", reqBody, ""); resp != nil {
             // 若服务端响应了 SID ,则记录到 Cli 可路由上下文,以便响应时输出
    			c.Rp.SetRespSid(resp.Sid)
             // 响应登录成功(响应对于 Cli 即为打印到控制台)
    			c.WriteString("Signed in successfully")
    		}
    	})
    
    	// 从命令行通过 Tcp 客户端取登录者信息:go run main.go signer $SESSION_ID
       // (`{sid?}`是一个可选路径变量,但其实是必填的,配为可选是为了方便在控制器中输出具体提示)
    	proto.CliRouter.Push("signer/{sid?}", func(c *proto.Cli) {
    		sid := c.Param("sid")
    		if len(sid) == 0 {
    			panic("Session ID is required")
    		}
    		if resp := request(c, "signer", nil, sid); resp == nil {
    			c.SetError(util.Closed0("Request failed"))
    		} else if resp.Code == 0 {
    			var signer Signer
    			if err := json.Unmarshal(resp.Body, &signer); err != nil && c.SetError(err) {
    				return
    			} else {
    				// Just response the signer info if the session is logged in
    				c.WriteString(fmt.Sprintf("Signer: %v", signer))
    			}
    		} else {
             // 若登录失败,则设置错误,Cli 可路由协议会自动响应打印
    			c.SetError(util.Openly(int(resp.Code), resp.Message))
    		}
    	})
    }
    
    // It's a simple example, need to mapping request id and the response callback if the server is async
    // 这只是一个简单的客户端请求示例,实际使用应创建一个请求 ID 与响应回调的映射,以应对异步编程时可能的错序响应问题
    // (在`_examples`下有 js 版请求响应式的 flex-websocket 客户端的封装可供参考,后续 DCE 会提供 GO 版封装)
    func request(c *proto.Cli, path string, reqBody []byte, sid string) *flex.Package {
       // ID 传入`-1`将生成自增 ID
    	pkg := flex.NewPackage(path, reqBody, sid, -1)
    	conn, _ := net.Dial("tcp", "127.0.0.1:"+c.Rp.ArgOr("port", "2048"))
    	defer conn.Close()
    	if _, err := conn.Write(pkg.Serialize()); err != nil && c.SetError(err) {
    		return nil
    	}
       // 从输出流提取字节序并解码为弹性可路由包
    	resp, err := flex.PackageDeserialize(bufio.NewReader(conn))
    	if err != nil && c.SetError(err) {
    		return nil
    	}
    	return resp
    }
    
    var memberId uint64 = 0
    
    var members map[string]Member = make(map[string]Member)
    
    type Member struct {
    	Id       uint64
    	Role     uint16
    	Name     string `json:"name"`
    	Password string `json:"password"`
    }
    
    // 实现`UidGetter`接口,以便在`UserSession`中获取`uid`以作相应绑定
    func (m Member) Uid() uint64 {
    	return m.Id
    }
    
    type Signer struct {
    	Name string `json:"name"`
    }
    
    // Member entity converted to transfer object desensitization
    // 实现`From[S, T any]`接口以便在`JsonConverter`中自动转换`Member`为`Signer`脱敏
    func (m Signer) From(member Member) (Signer, error) {
    	m.Name = member.Name
    	return m, nil
    }
    
    6 条回复    2025-01-22 13:47:59 +08:00
    superchijinpeng
        1
    superchijinpeng  
       1 天前
    支持 lb 吗
    zjsxwc
        2
    zjsxwc  
       1 天前
    谁能解释下,这个库的使用目的是什么,使用场景是什么,我土鳖了
    脱敏的登录者 是啥用途?
    SGL
        3
    SGL  
       1 天前
    mark 一下,不晓得使用场景
    idrunk
        4
    idrunk  
    OP
       12 小时 43 分钟前
    @superchijinpeng 不支持,目前只是个路由库,可通过 nginx 等实现。有想过实现一个类库级别的网关,实现 loadbalance 等,短期应该不会做。
    idrunk
        5
    idrunk  
    OP
       12 小时 38 分钟前
    @zjsxwc 路由器,http 路由器知道吗,比如将`GET /home`路由到`func home()`,我这个是除了 HTTP 外也能路由其他全部协议,目前内置支持了一些常用的,没内置的可自行实现接口来支持 DCE-GO 来路由。
    “脱敏”是转换器应用的业务场景之一,要将用户信息输出到前台要脱敏吧,就可以用转换器转成一个不带敏感信息的传输对象( Entity to DTO )
    idrunk
        6
    idrunk  
    OP
       12 小时 33 分钟前
    @SGL 当你想基于 Websocket 编程时可能需要,http 路由器有很多,但 websocket 等基本没有,用 DCE 就可以像 HTTP 编程一样编写 Websocket 接口,可以用同一套鉴权类库来鉴权等。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   1159 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 22ms · UTC 18:21 · PVG 02:21 · LAX 10:21 · JFK 13:21
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.