1.java类扫描工具的使用场景
在使用java开发应用的时候,我们可以使用类扫描器,扫描所有已加载的文件,对符合目标的class进行过滤。典型地,spring基于此种方式,扫描带有指定注解的类进行实例初始化与自动注入。
例如,jforgame框架大量使用类扫描工具,进行消息注册,路由实例化等等。例如,所有的消息,只要拥有Message注解,便会自动注册到客户端与服务器的消息工厂里。如下:
java
@MessageMeta(cmd = 55555)
public class ReqLogin implements Message {
private long playerId;
public long getPlayerId() {
return playerId;
}
public void setPlayerId(long playerId) {
this.playerId = playerId;
}
}
2.使用go扫描结构体
2.1手动注册结构体的不便之处
如果没有结构体扫描工具,当我们需要实现同样的逻辑,例如,注册所有的通信协议。我们不得不采用以下的方式。
Go
network.RegisterMessage(protos.CmdHeroReqRecruit, &protos.ReqHeroRecruit{})
network.RegisterMessage(protos.CmdHeroResRecruit, &protos.ResHeroRecruit{})
network.RegisterMessage(protos.CmdHeroPushAllHero, &protos.PushAllHeroInfo{})
network.RegisterMessage(protos.CmdHeroReqLevelUp, &protos.ReqHeroLevelUp{})
network.RegisterMessage(protos.CmdHeroResLevelUp, &protos.ResHeroLevelUp{})
network.RegisterMessage(protos.CmdHeroPushAdd, &protos.PushHeroAdd{})
network.RegisterMessage(protos.CmdHeroPushAttrChange, &protos.PushHeroAttrChange{})
每个模块,对需要手动注册所有的通信协议,非常麻烦。
为此,我们探索一种可以自动扫描结果体的方式。
2.2.使用ast解析源文件
go不是传统的oop语言,网上查询,也没有找到一种可以在运行期,查找已注册的结构体的API。为此,我们不得不采用直接解析源代码文件的方式。例如,使用正则的方式,扫描指定目录的所有的文件,将属于struct定义的所有结构体进行统计预处理。遗憾的是,每个人写代码的风格差异性比较大,使用正则的方式,解析的难度非常大。
幸运的是, go自带的ast工具库,可以很好的解决这个问题。
Go语言的AST工具库概述
Go语言的ast(Abstract Syntax Tree,抽象语法树)工具库是标准库go/ast的一部分,用于解析和分析Go源代码的结构。该库提供了对Go代码的语法树表示,允许开发者通过程序化的方式操作和分析代码结构,常用于代码生成、静态分析、格式化工具等场景。
go/ast库能够将Go源代码解析为抽象语法树,每个节点代表代码中的元素(如函数、变量、表达式等)。例如,ast.File表示一个Go文件,ast.FuncDecl表示函数声明。
使用ast解析源代码文件
Go
// parseGoFile 通用AST解析
func (b *BaseGenerator) parseGoFile(filePath string) ([]structInfo, error) {
fset := token.NewFileSet()
node, err := parser.ParseFile(fset, filePath, nil, parser.ParseComments)
if err != nil {
return nil, err
}
cm := ast.NewCommentMap(fset, node, node.Comments)
var structInfos []structInfo
ast.Inspect(node, func(n ast.Node) bool {
ts, ok := n.(*ast.TypeSpec)
if !ok {
return true
}
structType, ok := ts.Type.(*ast.StructType)
if !ok {
return true
}
structName := ts.Name.Name
structLine := fset.Position(ts.Pos()).Line
var fields []structField
if structType.Fields != nil {
for _, field := range structType.Fields.List {
var fieldName string
if len(field.Names) > 0 {
fieldName = field.Names[0].Name
} else {
continue
}
if fieldName == "_" {
continue
}
fieldType := b.getFieldTypeStr(field.Type)
fieldComment := b.getCommentText(field.Comment)
jsonTag := ""
if field.Tag != nil {
tagStr := strings.Trim(field.Tag.Value, "`")
jsonTag = b.extractJsonTag(tagStr)
}
fields = append(fields, structField{
Name: fieldName,
Type: fieldType,
Comment: fieldComment,
JsonTag: jsonTag,
})
}
}
// 提取结构体注释(按行号过滤)
var structComment string
commentGroups := cm.Filter(ts).Comments()
for _, cg := range commentGroups {
for _, c := range cg.List {
commentLine := fset.Position(c.Pos()).Line
if commentLine > structLine {
continue
}
text := strings.TrimSpace(c.Text)
if strings.HasPrefix(text, "//go:") {
continue
}
text = strings.TrimPrefix(text, "//")
text = strings.TrimPrefix(text, "/*")
text = strings.TrimSuffix(text, "*/")
text = strings.TrimSpace(text)
if text != "" {
if structComment != "" {
structComment += "\n"
}
structComment += text
}
}
}
if structComment == "" {
structComment = b.getCommentText(ts.Doc)
if structComment == "" {
structComment = b.getCommentText(ts.Comment)
}
}
structInfos = append(structInfos, structInfo{
Name: structName,
Comment: structComment,
Fields: fields,
})
return true
})
return structInfos, nil
}
对指定的文件目录,解析得到所有关于通信消息struct的元信息。
Go
// structField 存储单个结构体字段信息
type structField struct {
Name string // 字段名(如 Channel)
Type string // Go 类型(如 int)
Comment string // 注释(如 发送频道:1个人 2世界)
JsonTag string // JSON Tag(如 channel)
}
// structInfo 存储单个结构体信息
type structInfo struct {
Name string // 结构体名(如 ReqChat)
Comment string // 结构体注释(如 聊天请求)
Fields []structField // 字段列表
}
聊聊几行代码,就能自动扫描所有人结构体,非常方便。
2.3.生产环境扫描"曲线救国"
开发阶段,使用该工具,确实实现了预期目标,但遗憾的是,由于我们采用的是,基于源代码解析的方式。当程序部署到生产环境,由于所有的源代码已经被编译打包,已经没有了源代码文件,这种方式,一下子失去了解析的基础,宣告失败。
聪明的朋友,可能想到一些解决问题的技巧。笔者想到的是,在开发阶段,将所有已扫描的信息收集起来,反过来翻译成go源代码,如此在打包的时候,就能将所有已解析好的数据被go程序所识别,翻译出的目标代码如下所示:
Go
package protos
type ReqHeroRecruit struct {
_ struct{} `cmd_ref:"CmdHeroReqRecruit" type:"req"`
Times int32 `json:"times"`
}
type ResHeroRecruit struct {
_ struct{} `cmd_ref:"CmdHeroResRecruit" type:"res"`
Code int32 `json:"code"`
RewardInfos []*RewardInfo `json:"rewardInfos"`
}
type PushAllHeroInfo struct {
_ struct{} `cmd_ref:"CmdHeroPushAllHero" type:"push"`
Heros []*HeroInfo `json:"heros"`
}
type AttrInfo struct {
AttrType string `json:"attrType"`
Value int32 `json:"value"`
}
type HeroInfo struct {
Id int32 `json:"id"`
Level int32 `json:"level"`
Position int32 `json:"position"`
Stage int32 `json:"stage"`
Exp int32 `json:"exp"`
Hp int32 `json:"hp"`
Attrs []AttrInfo `json:"attrs"`
Fight int32 `json:"fight"`
}
type ReqHeroLevelUp struct {
_ struct{} `cmd_ref:"CmdHeroReqLevelUp" type:"req"`
HeroId int32 `json:"heroId"`
ToLevel int32 `json:"toLevel"`
}
这些代码是通过ast解析源文件生成的"二次源代码",有点类似于"元编程"。我们只需要在开发环境,先启动一次,自动生成上面的源代码文件,然后第二次启动,便能将以上的文件作为源文件,由go程序加载。此种方式,也实现了生成环境的部署需求。
思想成型了,代码也就应运而生,比较简单,如下所示:
Go
func (b *BaseGenerator) GenerateRegisterFromTags(goDir string, outputFile string, msgConsts map[string]int) error {
if msgConsts == nil || len(msgConsts) == 0 {
msgConsts = b.buildMsgConstMap(goDir + "\\" + "message.go")
}
files, err := os.ReadDir(goDir)
if err != nil {
return fmt.Errorf("读取Go目录失败:%w", err)
}
type entry struct{ Type string; Cmd int; FileName string ;}
entries := make([]entry, 0, 64)
var buf bytes.Buffer
buf.WriteString("package protos\n\n")
buf.WriteString("import (\n\t\"io/github/gforgame/network\"\n)\n\n")
buf.WriteString("func init() {\n")
fset := token.NewFileSet()
for _, file := range files {
if file.IsDir() || !strings.HasSuffix(file.Name(), ".go") {
continue
}
filePath := goDir + "\\" + file.Name()
node, err := parser.ParseFile(fset, filePath, nil, 0)
if err != nil {
continue
}
ast.Inspect(node, func(n ast.Node) bool {
ts, ok := n.(*ast.TypeSpec)
if !ok {
return true
}
st, ok := ts.Type.(*ast.StructType)
if !ok {
return true
}
typeName := ts.Name.Name
if st.Fields != nil {
for _, fld := range st.Fields.List {
if fld.Tag == nil {
continue
}
tagStr := strings.Trim(fld.Tag.Value, "`")
tags := b.parseAllTags(tagStr)
cmd := 0
found := false
if ref, ok := tags["cmd_ref"]; ok {
if v, ok2 := msgConsts[ref]; ok2 {
cmd = v
found = true
}
} else if s, ok := tags["cmd"]; ok {
if v, err := strconv.Atoi(s); err == nil {
cmd = v
found = true
}
}
if found {
entries = append(entries, entry{Type: typeName, Cmd: cmd, FileName: file.Name()})
break
}
}
}
return true
})
}
grouped := make(map[string][]entry)
for _, e := range entries {
grouped[e.FileName] = append(grouped[e.FileName], e)
}
for fileName, list := range grouped {
buf.WriteString(fmt.Sprintf("\t// ----from %s----\n", fileName))
for _, e := range list {
buf.WriteString(fmt.Sprintf("\tnetwork.RegisterMessage(%d, &%s{})\n", e.Cmd, e.Type))
}
buf.WriteString("\n")
}
buf.WriteString("}\n")
if err := os.WriteFile(outputFile, buf.Bytes(), 0644); err != nil {
return fmt.Errorf("写入文件失败:%w", err)
}
return nil
}
详细的代码,请移步:
实际的使用方式,还将解析的协议元信息,进一步翻译成客户端需要的协议源文件,包含typescript或csharp版本。
当然,解决go自动扫描结构体的方式,可能还有更好的实现方式,受笔者水平所限。欢迎评论区交流,不胜感激~~