碎碎念
这一部分开始,可能就会要求有一些开发经验了;我尽量写的平民化一些,在我认为可能有些不懂得地方我说出一些名词的解释。但是我也有可能会忽略这个玩意,因为每个人接触过的范围都不一样,而且我也不一定样样都考虑到。总有一些地方有人知道,有人不知道。
本项目用到的 AI 地方采用 GLM-4.7 模型,由于设计期间我给出自己的完整思维链,AI 只是辅助工作,智谱完全够工作要求了。也另一个方向说明一下若自己的思维体系完整是否能够胜任工作,而不是考虑无限叠加心智模型更高的模型来替代自己的思考。
其实对于 Minecraft 服务器来说,全套体系统一的话当然是用 Java 写,但是贫穷限制了我的想象(bushi。我后面不可能说我为了写多套业务(后面不只是登录的东西),专门给服务器扩容吧,并不合适,就单单 Go 跑起来 100M 运行内存占用不到他就完胜了(又不会像 C++ 之类的那么难写,而且还有 Java 互联网环境的优点),还有其他一些东西,也顺带是 Go 的一些练手了,Java 项目实在是写的有点多了,包括后面的 Minecraft 插件/Mod 也得要 Java 来写。
所以,在这里就毫无疑问选择相对来说互联网兼容性比较优秀,云原生高并发优秀的 Go 语言了。而且这玩意也我熟悉的语言。所以 Yggdrasil 这部分的内容我就直接打算用 Go 来写了。
体系结构
为什么要讲体系结构。就拿 AI 来说,AI 的训练语料库中,训练的内容基本都是属于优秀的开发示例,最佳实践的内容。目前写代码里面,应该很少有完全自己写的内容,怎么着 AI 都会进来参合几脚。所以至少你选定语言后,要根据语言或者及其所用的框架最佳实践方案或者最常用的被论证过可行的方向进行。(虽然我鼓励自己可以创建一套自己的行为,其实这里很能学习到很多架构有关思想的内容。但是长时间用下来,可能也会慢慢回到最佳实践,毕竟被前人经历过无数次迭代给出的结果。如果只是尝试捣鼓的话,推荐可以自己创建一套自己的体系结构,虽然有可能错。但是在计算机开发领域里面,就不存在没有错的情况。)
注意:在 “体系结构” 里,如果我没有提及使用 AI 辅助的时候,基本都是我个人决策的,做体系结构的时候就是对整体进行规划。从我用 AI 的主旨里面,自己的思维一定占据主导链,所以整体初始架构必须自己属于熟悉的范围,否则上来 AI 直接做很容易导致后续自己不好看懂这个项目是什么。(所以这里用到 AI 的地方,我一定会声明)
首先,先定位我们这一套服务是什么?怎么对外提供开放?需要做到什么?
这一套是为用户提供服务,属于经典的对外提供 RESTful API 接口类型。
并且使用 Go 语言进行设计,用户的体量并不会到达惊人的体系(就算达到 Go 云原生的机制可以直接多机部署,而不需要涉及到微服务),所以不需要考虑 DDD 架构;采用现代的 Layered Architecture 架构即可。
‧ DDD架构:领域驱动设计(DDD)是一种软件设计方法,它专注于根据领域专家的意见,对软件进行建模,使其与特定领域相匹配。DDD反对使用单一的统一模型;相反,它将大型系统划分为多个限界上下文,每个上下文都有自己的模型。
‧ 三层架构(Three-Tier Architecture):分层架构就是为了符合“高内聚,低耦合”思想,把各个功能模块划分为表示层(UI)、业务逻辑层(BLL)和数据访问层(DAL)三层架构,各层之间采用接口相互访问,并通过对象模型的实体类(Model)作为数据传递的载体,不同的对象模型的实体类一般对应于数据库的不同表,实体类的属性与数据库表的字段名一致。
‧ 分层架构(Layered Architecture):分层架构是一种以“高内聚、低耦合”为目标的结构化设计方式,将系统按职责拆分为若干逻辑层,每一层只关注自身职责,并通过明确的接口与相邻层交互。
我的碎碎念:这里其实我的目的是举例一下架构选择是如何思考的,因为我最多的用到的就是 DDD 、 MVVC(或者改进后三层架构)以及分层架构方式,这里就是直接拿出来这几个进行举例【实际上并不是非常合理,因为没有什么比较相近的进行选择。目的只是想表达出来这部分很多情况例如我在写第一篇的时候我其实就想好要用三层架构了,只是把 我“直觉”(因为实际定结构的时候这部分一定要思考的) 部分写出来会比较好。如果在自己立项的时候实在不知道有什么或者用什么好,或者有什么推荐的,可以试试例如总结出我第一部分的需求部分作为 Prompt 个 AI 来辅助你的决策。】
需要做到为 FrontLeaves(我后续开设 MC 服务器的名字),提供基础的玩家账户登录的作用。在 Web 端进行注册与登录。Minecraft Launcher(我的世界启动器)发起游戏账户登录,Minecraft Server(我的世界服务器)进行游戏账户校验。
为什么直接选择分层架构,而不是 DDD?换句话说,为什么先做单体而不是微服务?
对这个项目来说,答案很简单:收益不够覆盖成本 (时间成本以及运行时的服务器资源开销成本)。
这个服务核心内容是将认证进行处理管理(OIDC/OAuth2 回调,用户档案,缓存读写),属于 I/O 密集的业务场景。在这个场景里如果遇到性能问题,那最先刷爆的也可能是 DB/Redis 而不是这个进程。
而且 DDD 适合多团队并行开发,这玩意基本就一个人写,就算最多最多拉感兴趣的人顶多两三个,按功能分发就好了,还不涉及多团队。真这么搞,说不定项目还没跑起来,最后因为这个问题导致项目无法完成暴毙了。所以不需要考虑 DDD 架构所带来的收益不足以我选用它。
并且这个来说,受众面绝对不会非常大。目前最大的 Minecraft 服务器 Hypixel。体量你真做得到吗,就算真做到了。按 Go 的体量直接进行多机部署不是更方便。本身也不是专门做 Minecraft 服务器的团队,初期并没有这么大的精力做那么细致。不然就会出现准备阶段超高的时间开销,导致最终无限拖延还可能导致项目直接结束呢。对si不对。
最终决策选择的是 分层架构 + 单体(可后期 DockerCompose 单机多体/多体部署),那么整体的结构模式应该如下图所示
其工程目录可以是这样
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 ❯ tree -d . ├── api ├── internal │ ├── app │ │ ├── middleware │ │ ├── route │ │ └── startup │ │ └── prepare │ ├── constant │ ├── entity │ ├── handler │ ├── logic │ ├── models │ └── repository │ └── cache ├── pkg │ ├── ctx │ └── utility └── proto
经验分享
该部分只是单纯我的经验分享,因为这里整合了我的 bamboo-base-go 脚手架,这个项目基于我这个脚手架来简化项目开发流程。若有需要的话,可以查看文档:竹简文档-BambooBase
所以,我认为可以讲一下我是如何从零开始架构一个项目的,以及如何依赖我自己做的这个脚手架来提效。
碎碎念:现在写文章除了发到我的博客还会发到一些其他地方,博客可以识别设置的 color 其他平台会防止 XSS 攻击会拒绝掉用户文章注入 HTML 的内容,这一篇开始我自己的“碎碎念”我就明确写出来了,在博客里有颜色区分
碎碎念:这里总结的 BambooBaseGo 脚手架,基本是几个月下来(这个项目最早是 2025 年 6 月时候做的基础的工具库而已,后续开始是实习后开始向工程化转变,就开始逐渐弄成脚手架的样式,这样可以很大程度上减少我用我上述架构的方式。(所以这个项目是根据我的开发习惯去整合的一个脚手架,毕竟 Go 那个作风,你说他好也好,说他不好也不好。还是有很多东西要写一大坨)
脚手架
首先先简单介绍一下我这个脚手架最核心的地方是什么(其实只要是企业里面基本都有自己的脚手架,基本都包含这些,只是有企业的风格而已,这里是我的风格)。
下面有一些地方用到了我这个库,如果你看到了出现 x 开头的,例如 xResult 啊, xReg 之类的,那就是我的 bamboo-base-go 脚手架内的内容。我感觉这个文章里面实在不是很好贴出来。文档里面都写了有,可以详细看一下。
响应处理: 这个应该就是最基础的基础了吧,这里就是制作一个 BaseResponse 然后在 handler 进行构造返回的时候就可以使用 xResult 下面给一个参考示例。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 func GetUser (ctx *gin.Context) { user := User{ ID: 1 , Name: "Bamboo" , } xResult.SuccessHasData(ctx, "获取成功" , user) }
上下文管理: 这部分的内容相对来说比较杂,他主要处理在初始化阶段的一些内容,将指针放入 Go Context 向下传递。这样在下面的部分可以通过(本质上) context.Value() 获取到具体的指针信息,就可以使用初始化部分的池。不需要反复声明。详细部分可以参考 竹简文档-上下文管理机制
剩下的部分,可以直接看文档,这里一一说会说的特别多。这里给一份简单的 Prompt,你可以向能够使用 Fetch 或 WebSeach 工具的 AI 进行询问,总结概要内容。
1 2 3 现在,请你分析该站点的 bamboo-base-go 脚手架地址:https://doc.x-lf.com/llms.txt 在这里面所索引到的 path 地址,可以匹配 https://doc.x-lf.com/llms.mdx/docs/<path> 获取具体文章内容。 现在你的任务是总结 bamboo-base-go 可以做什么东西。
代码规范
这里算是日积月累吧,这里的规范并不是说你必须要要完完全全按照最佳实践的方式去实践。因为最佳实践是别人的实践,不是你的最佳实践!最主要的是培养自己的习惯
你可以继续往下阅读下面有一个初版的 AI 生成的 Go Struct 文件以及我跟 AI 对话后和自己修改后最终的 Go Struct 文件。一眼看上去清爽很多,不能说满足大部分人,至少要满足自己(当然满足自己的前提要符合基本规范,不能说连最基础的规范都不遵循就去做自定义的内容,自己的代码规范和最佳实践一定是在多数人验证后的细节修改【其实我下面的并不会让你体验到,因为还没到正式业务的开发过程】)。
现在 AI 出来了,很多时候都会让 AI 帮忙设计,AI 来替代你思考内容。那这里最终构建出来的样子是 AI 推理出来所认为的人类定义的规范是什么。他满足大部分人或者牺牲部分人增强可读性来做的一些内容。并不是你可以接受的内容,你有权保持疑问,对于一些不舒服的地方你可以做成自己习惯舒服的,不是一定任何内容都是别人是最好的!
数据表设计
这里面的设计我依然是举例一些例子进行,不是全部写。我主要就是写一两个就可以了,其他应该自行举一反三(并不是保姆类教程);培养自己的思考比较重要的哦。
这里我把数据库设计提前了,按照我个人的想法来说,项目整体跑起来,至少有一个东西先依附运行一下,所以在这里我就选择了数据表的内容。不然不做数据表直接写业务层面也会因为缺少数据表没法写完整逻辑链。(常规来看,不需要写这一部分,直接初始化部分初始化完。这个项目已经具备初步可业务开发的能力了。)
一起来回想一下,我们需要什么?在这里我粘贴一下我「第一弹」写的模糊需求的内容。
由于我自己有一套自己写好的 OIDC OAuth2 服务,所以我并不打算这里实现统一认证服务,但是基于这个逻辑,依然是保证 Yggdrasil 对应后续其他例如工单之类的内容都可以有保证唯一 ID 的信息
那么,他的核心需求就是 “登录” 。首要目的,或者说一定要实现的内容就是实现 Minecraft Server 以及 Minecraft Launcher 端的登录态的桥梁。剩下可以按需随意进行扩展。
最终就可以整理还需要的功能需求如下:**用户体系、皮肤体系、服务体系、成就体系、消息推送体系、商城体系、开放接口体系、论坛体系(后期了)**等【这里说“等”是我没想到的,因为实现上面部分的功能已经完成我所设想的体系了,基本足够了】。
碎碎念:实际上,需求这一部分,实际上正式项目组需要做需求池,就算没有需求池也要明确列出来需求清单。我第一弹里面这样的写法是期望你(读者)可以跟着我(作者)思路更好想,尽可能站在一个角度思考内容(毕竟我不是做保姆教程,我期望教的东西是经验积累的方式和思维模式)。
那么,现在抓关键词,我有自己的 OIDC OAuth2 服务 , 用户游戏账号模式是 1:N(这个是可行性分析分析出来的) , 核心内容是登录
我们先从这三个进行落脚点分析
对于自己已经存在了 OAuth2 服务了,也就是说明授权认证的工作并不是在本项目实现的,本项目只需要接入(OIDC OAuth2 默认采用授权码授权模式)回调拿到的 code 换取 AT(AccessToken) 和 RT(RefreshToken) 作为有效期认证即可。
用户游戏账号模式是 1:N,也就是单个 OAuth2 登录的用户可以创建多个游戏账号;
当前最主要的内容是完成客户端可以登录,服务端可以实现认证的部分,以及用户可以通过 OAuth2 登录成功拿到实际的登录态。
在这里,不要跟我扯软删除或者不需要外键约束(这个项目没有大到企业级别,并且我不期望留下没有用的用户数据,企业才有分析价值,我留着软删除的数据几百年看不到一次,并且没有什么数据严格到需要有后悔药,用户的操作失误不能归类在我们头上(不属于支付业务高敏感度项目);如果你要跟我扯溯源,溯源不是这里该做的,类似于指令监控,方块破坏监控才是需要溯源的东西),所以我不期望采用软删除策略,所以我会直接使用 GORM 特性直接设置外键约束。
这里为不懂的朋友解释一下软删除和“外键”约束的强制逻辑管理。这里的外键约束是我通常的说法,实际上说的内容就是“约束”。
原因一:对于企业层面来说,数据流转量比较大(数据比较多),加入外检会导致存入或者修改阶段进行另一张表的溯源查询。那么其逻辑链就是(简化图)
flowchart LR
A[目标期望存储] --> B[存在外键约束关系]
B --> C[找到被约束表进行查询]
C --> D[存在/创建]
添加外键约束一定会造成多一次被约束表的查询。那么假设所有的操作都是 “1”(增删改查);正常这个表添加一个数据,那么就是直接存储,关系值是 1;如果其中有一个字段有外键约束,那么在这个期间就会多一次操作进行目标表集训查询,最终执行逻辑关系值就是 2 。
那么这里来解释一下和常规的查询一个数据再插入的关系。对于外键约束,实际上当这个字段设置为外键约束的时候,这个外键约束部分会单独构建一个 B+Tree 索引关系,这个查询效率相对来说是非常高的,对于数据量可控的场景下,不存在爆炸的情况,如果你非得要这样增强逻辑外键自己来维护的成本那没办法。
那么再说第二个比较重要的原因,唯一索引约束。请注意不是普通索引是唯一索引。唯一索引的范围是当前表下是否存在第二个重复值,并不能说我排除了软删除的 deleted_at 字段后再去比较。那么这个时候查询的数据找不到数据在 deleted_at 为空的情况,那么就判断可以插入(目前主流的 orm 都会对软删除的部分自动过滤,不需要加入条件,所以查找基本是找不出来的),最终存储的时候就会爆出来 DuplicateKey 导致无法插入。(你可以说那么我设置联合唯一约束,约束设置为原本的加上 deleted_at 字段;这个情况大部分时候是可行的,但是就只有一种情况,两个玩家【举例】几乎在同一时刻执行某个接口并且都在相近的时间片内执行数据库操作,那么最终还在同一毫秒内,导致一致的唯一键,这里就会出现小概率事件,能避免就不要做。)
回归正题,根据上面所说的内容,可以分析出来最终的样式。
碎碎念:其实我很想直接说出来结果,这部分写过很多次了,一下子就在我里面映射出来应该怎么设计了。我这里也是尽量把“直觉”写出来,所以说的是分析出来最终的样式(不是结果)。
这里为了存储 OAuth2 登录的用户,所以需要一个用户表。为什么需要这个表?因为可以判断这个用户是否是第一次在你写的这个项目(平台)登录过,当然你也可以完全不用设计这个表,只是对于一些高扩展性的内容你没有办法实现(或者说实现下来比直接创一张用户表难管理)。例如:我在第一弹说过,多个游戏账号,但是游戏登录密码只有一个,那么这个密码就可以设计到用户表,而不用涉及到游戏账号内。
这里拆解完毕,所以当前阶段初步判定为两张表,一张用户表(user),一张游戏账号(game_profile)表。现在开始分析其需要填入的字段有什么。
根据 OIDC OAuth2 提供的标准,我这个项目需要对接提取到的 scope 有 profile,email,phone 三个值,也就是对于 profile 可以拿到基础的用户信息,以及额外的邮箱和手机号扩展信息(这样可以方便客户端登录)。
那么基础的就可以设计出来如下所示(请注意,因为 go gorm 可以直接根据结构体创建表,所以不需要自己写建表语句,这里描述的是抽象描述,最后我会让 AI 完善一下缺少的参数,保证稳健【但这个过程是自己思考的】)。
请注意,下面的 AI 提问部分,可以参考我的历史文章 凌中的锋雨 | 了解 AI 及其工具运行逻辑来自我提效 我会按照我这里文章我写的逻辑进行 Prompt 构建(毕竟知道其原理,可以更清楚 AI 是怎么干活的)。
「声明」下面是让 AI 帮忙创建这两张表,因为具体参数他写的清楚(这一类属于自己来可以做,就算不清楚的参数,依然可以查询文档自己实现,那就不属于思考范围,属于技术和熟练度范畴)。
在 AI 提问之前,由于 Minecraft Yggdrasil 并不像 OAuth2 啊那些非常出名,但是 AI 基本猜测是知道的(根据时间来说有很多年了,所以猜测可以,这里可以提前问一下你是否知道 XXX【我这里的意思并不是什么东西都可以问,有些东西你[AI]知我知,直接用 2026 了再蠢的 AI 也不会蠢到 GPT-3.5 时候,这里是确认他的训练语料库的了解情况】)。
1 2 3 4 5 现在,项目处于架构设计阶段,你现在需要注意到 internal/entity 部分,这里是放 gorm 与数据库关系的实体(entity),你需要在这里创建一个 user.go 和 game_profile.go 表。 解释:本项目采用 OIDC OAuth2 作为用户登录的途径(有且仅有这个),用户登录应该是 回跳 拿到 code 换取用户信息,存入或更新 user,scope 范围 profile,email,phone,然后游戏内密码也保存到 user(并非站点登录密码);game_profile 是游戏账号。 你现在限制范围是 internal/entity 任何的超出行为将会视作禁止。
这里最终生成的内容其实我还要进行修改的,下面是贴出来原先 AI 输出的内容。为什么要修改?因为他不符合我的开发风格,我要从这里就做好我自己的开发风格,后面他继续写东西的时候就可以参考已有的内容写出类似规范的内容。(不过我这里疏漏了让他探索 bamboo-base-go 库,所以它最终做的结构体并不会使用我脚手架内置的内容)
下面是实际的内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 package entityimport ( "time" "gorm.io/gorm" ) type User struct { ID uint `gorm:"primaryKey;autoIncrement" json:"id"` OIDCSubject string `gorm:"unique;not null;type:varchar(255)" json:"oidc_subject"` Username string `gorm:"not null;type:varchar(255)" json:"username"` Email *string `gorm:"type:varchar(255)" json:"email"` Phone *string `gorm:"type:varchar(32)" json:"phone"` GamePassword string `gorm:"not null;type:varchar(255)" json:"-"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` GameProfiles []GameProfile `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"game_profiles,omitempty"` } func (User) TableName() string { return "user" }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 package entityimport ( "time" "gorm.io/gorm" ) type GameProfile struct { ID uint `gorm:"primaryKey;autoIncrement" json:"id"` UserID uint `gorm:"not null;index" json:"user_id"` UUID string `gorm:"unique;not null;type:varchar(36)" json:"uuid"` Name string `gorm:"not null;type:varchar(32)" json:"name"` SkinURL *string `gorm:"type:varchar(512)" json:"skin_url"` CapeURL *string `gorm:"type:varchar(512)" json:"cape_url"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` User User `gorm:"constraint:OnDelete:CASCADE" json:"user,omitempty"` } func (GameProfile) TableName() string { return "game_profile" }
现在自己调整一下(其实这里用 AI 就是懒得写这个基础结构样子 hhh),这里包含了我自己的调整,对于类似于 index:,comment: 就是让 AI 自己调整的。下面部分的 func (_ *User) GetGene() xSnowflake.Gene 就是我的脚手架部分了,在这个数据不存在自己创建的时候,主键就会根据这里赋予的基因序列按照雪花算法生成主键
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 package entityimport ( "time" xModels "github.com/bamboo-services/bamboo-base-go/models" xSnowflake "github.com/bamboo-services/bamboo-base-go/snowflake" ) type User struct { xModels.BaseEntity Username string `gorm:"not null;type:varchar(255);comment:用户用户名" json:"username"` Email *string `gorm:"type:varchar(255);comment:用户邮箱;index:idx_email" json:"email"` Phone *string `gorm:"type:varchar(32);comment:用户手机号;index:idx_phone" json:"phone"` GamePassword string `gorm:"not null;type:varchar(255);comment:游戏账户密码" json:"-"` HasBan bool `gorm:"not null;type:boolean;default:false;comment:用户是否被封禁禁止登录" json:"has_ban"` JailedAt *time.Time `gorm:"type:timestamptz;comment:用户被监禁的时间" json:"jailed_at,omitempty"` GameProfiles []GameProfile `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE;comment:游戏档案关联" json:"game_profiles,omitempty"` } func (_ *User) GetGene() xSnowflake.Gene { return xSnowflake.GeneUser }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 package entityimport ( xModels "github.com/bamboo-services/bamboo-base-go/models" xSnowflake "github.com/bamboo-services/bamboo-base-go/snowflake" bConst "github.com/frontleaves-mc/frontleaves-yggleaf/internal/constant" ) type GameProfile struct { xModels.BaseEntity UserID xSnowflake.SnowflakeID `gorm:"not null;index:idx_user_id;comment:关联用户ID" json:"user_id"` UUID string `gorm:"unique;not null;type:varchar(36);comment:Minecraft UUID" json:"uuid"` Name string `gorm:"not null;type:varchar(32);comment:游戏内用户名" json:"name"` SkinURL *string `gorm:"type:varchar(512);comment:皮肤URL" json:"skin_url"` CapeURL *string `gorm:"type:varchar(512);comment:披风URL" json:"cape_url"` User User `gorm:"constraint:OnDelete:CASCADE;comment:关联用户" json:"user,omitempty"` } func (_ *GameProfile) GetGene() xSnowflake.Gene { return bConst.GeneForGameProfile }
细节的你应该可以发现,他原本写了 TableName 但是修改后我删掉了这个内容(如果你对这里有疑问,可以接着往下看项目初始化的部分,我会在那边说明);xModels.BaseEntity 是我 bamboo-base-go 的内容,这里包含 ID(xSnowflake.SnowflakeID), CreatedAt(time.Time), UpdatedAt(time.Time) 不含 DeletedAt,当然这个脚手架内也存在支持软删除的 xModels.BaseEntityWithSoftDelete
这里,乍眼一看,没啥问题。那就没有问题了,如果有问题再进行修改(这里意思解释是:不可能时时刻刻样样俱到,你也不是在学校打算法比赛,必须一次成功。代码本身就是一次次迭代的结果,只是这个迭代要可控)。
请注意,下面写 SKILL 是插叙,在这个阶段可以使用 AI 来优化一下后面的流程。因为自己已经走过一次了,就有完整的路径了~
现在看上去是不是正经多了,而且看的也很舒服。那么这里只是最初的两个表而已,后面还需要创建其他的表。那我们是不是可以将后续的建表行为构建成一个 SKILL,后续有需要建表的时候就可以直接引入这个就好了(不需要这样一步步引导,当然心智模型比较高的其实不需要花费那么多精力,甚至不需要那么多 SKILL,他自己就知道怎么着。这里是按照工程样式来引导怎么好好利用工具)。
「声明」下面是发送给 AI 的内容,并且在这里使用的 ClaudeCode 使用 PlanMode 进行计划来做(新建 /clear)一个窗口后做,禁止旧上下文干扰,引入过多校正无关 Prompt 部分。
1 2 现在,你已经熟悉了 @internal/entity/game_profile.go @internal/entity/user.go ,你现在需要在本项目创建一个 .claude/skills/ 创建一个文件夹叫做 fyl-entity-build 文件夹,在里面创建 SKILL.md 你 需要了解规范 https://code.claude.com/docs/zh-CN/skills 完成如何根据需求构建 SKILL
这个时候,其实已经生成的差不多,但是最后发现参考资料是错的
1 2 3 ## 参考资料 - bamboo-base-go: https://github.com/bamboo-services/bamboo-base-go- Claude Code Skills 文档: https://code.claude.com/docs/zh-CN/skills
在这里就重新给了一次提示词,因为我的 bamboo-base-go 脚手架,我写了文档,这个文档可以输出能够 LLM 读懂的 Markdown 文件内容,所以可以补充提示词内容是
1 2 3 4 5 注意,bamboo-base-go 参考资料替换为这个: https://doc.x-lf.com/llms.txt 全局资料对于查找到具体的路径可以写入 https://doc.x-lf.com/llms.mdx/<search_path> 另外,若你认为 Prompt 资料不完整的情况,可以使用 AskUserQuestion 向用户进行提问。
最终得出的完整 SKILL 内容是
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 --- name: fyl-entity-build description: 根据口头描述快速生成符合项目规范的 Go Entity 代码。当用户说"创建一个实体"、"添加 entity"、"新建数据模型"或类似请求时自动激活。 argument-hint: [ entity-name ] allowed-tools: Read, Write, Edit, AskUserQuestion --- # Entity 构建技能 (fyl-entity-build) 嘿~ 需要创建新的 Entity 吗?只需口头描述一下,我就能帮你生成符合 frontleaves-yggleaf 项目规范的 Go 实体代码! (´∀`) ## 快速开始 ``` fyl-entity-build 创建一个 Player 实体 ``` 然后我会根据项目规范询问你需要的字段和配置,或者你可以直接告诉我: ``` fyl-entity-build 创建一个 Player 实体,包含 UUID、Name、Level 字段,属于 User ``` --- ## 交互流程 当你说「创建一个实体」时,我会: 1. **收集基本信息** - 实体名称(自动转为 PascalCase) - 实体描述(中文)2. **询问字段定义** - 字段名、类型、是否可空 - 特殊要求(唯一索引、默认值等)3. **询问关联关系** (可选) - 是否属于其他实体(belongs_to) - 是否拥有多个子实体(has_ many)4. **生成代码** - 输出到 `internal/entity/<snake_case>.go` - 提醒 Gene 常量定义--- ## 支持的字段类型 | 描述 | Go 类型 | 说明 | |--------------------------|--------------------------|---------| | `string` | `string` | 字符串 | | `int` | `int` | 整数 | | `int64` | `int64` | 64位整数 | | `uint` | `uint` | 无符号整数 | | `xSnowflake.SnowflakeID` | `xSnowflake.SnowflakeID` | 雪花算法 ID | | `bool` | `bool` | 布尔值 | | `float` | `float64` | 浮点数 | | `time` | `time.Time` | 时间戳 | | `decimal` | `float64` | 小数 | --- ## 使用 AskUserQuestion 收集信息 当用户的描述不够完整时,使用 `AskUserQuestion` 工具主动询问: ### 常见询问场景 ```yaml # 询问字段类型 questions: - question: "UUID 字段需要唯一约束吗?" header: "唯一约束" options: - label: "是,唯一" description: "添加 unique 约束,防止重复" - label: "否,可重复" description: "允许相同值存在" multiSelect: false # 询问是否可空 questions: - question: "LastSeen 字段是否可空?" header: "可空类型" options: - label: "可空" description: "使用 *time.Time 指针类型" - label: "不可空" description: "使用 time.Time 类型" multiSelect: false # 询问关联关系 questions: - question: "Player 需要关联哪些实体?" header: "关联关系" options: - label: "属于 User" description: "添加 UserID 外键,属于一个用户" - label: "拥有多个 GameProfile" description: "一对多关系" multiSelect: true ``` ### 询问时机 | 情况 | 询问内容 | |----------|-----------------------------| | 字段类型不明确 | 确认 Go 类型(string/int/bool 等) | | 字段约束不明确 | 确认是否 unique、not null、默认值 | | 关系不明确 | 确认是否属于其他实体、是否有一对多关系 | | Gene 不明确 | 确认使用内置 Gene 还是自定义 | --- ## 常用字段模板 | 场景 | GORM 标签 | JSON 标签 | |--------|---------------------------------------------------------|-------------------------------| | 非空字符串 | `gorm:"not null;type:varchar(255);comment:说明"` | `json:"field_name"` | | 可空字符串 | `gorm:"type:varchar(512);comment:说明"` | `json:"field_name,omitempty"` | | 唯一字符串 | `gorm:"unique;not null;type:varchar(36);comment:说明"` | `json:"field_name"` | | 整数 | `gorm:"not null;default:1;comment:说明"` | `json:"field_name"` | | 布尔 | `gorm:"not null;type:boolean;default:false;comment:说明"` | `json:"field_name"` | | 时间戳 | `gorm:"type:timestamptz;comment:说明"` | `json:"field_name,omitempty"` | | 外键 | `gorm:"not null;index:idx_user_id;comment:说明"` | `json:"user_id"` | | 密码(敏感) | `gorm:"not null;type:varchar(255);comment:说明"` | `json:"-"` | --- ## 外键关系模板 ### belongs_to(多对一) ```go UserID xSnowflake.SnowflakeID `gorm:"not null;index:idx_ user_id;comment:关联用户ID" json:"user_ id"`User User `gorm:"constraint:OnDelete:CASCADE;comment:关联用户" json:"user,omitempty"` ``` ### has_many(一对多) ``` goGameProfiles []GameProfile `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE;comment:游戏档案关联" json:"game_profiles,omitempty"` ``` --- ## GetGene 方法模板 ``` go// GetGene 返回 xSnowflake.Gene,用于标识该实体在 ID 生成时使用的基因类型。 func (_ *EntityName) GetGene() xSnowflake.Gene { return xSnowflake.GeneUser // 内置类型 // 或 return bConst.GeneForXXX // 自定义类型(需要在 constant 中定义) } ``` ### 常用 Gene 类型 | Gene 值 | 用途 | |-----------------------------|----------| | `xSnowflake.GeneUser` | 用户实体 | | `xSnowflake.GeneDefault` | 默认/通用实体 | | `bConst.GeneForGameProfile` | 游戏档案(32) | --- ## 完整生成示例 ### 用户输入 ``` 创建一个 Player 实体,包含: - UUID(唯一) - Name(游戏内玩家名) - Level(等级,默认1) - LastSeen(最后在线时间,可空) 属于 User ``` ### 生成结果 **文件** : `internal/entity/player.go````go package entity import ( "time" xModels "github.com/bamboo-services/bamboo-base-go/models" xSnowflake "github.com/bamboo-services/bamboo-base-go/snowflake" bConst "github.com/frontleaves-mc/frontleaves-yggleaf/internal/constant" ) // Player 玩家实体,包含 UUID、名称、等级等游戏内信息。 type Player struct { xModels.BaseEntity // 嵌入基础实体字段 UserID xSnowflake.SnowflakeID `gorm:"not null;index:idx_user_id;comment:关联用户ID" json:"user_id"` UUID string `gorm:"unique;not null;type:varchar(36);comment:Minecraft UUID" json:"uuid"` Name string `gorm:"not null;type:varchar(32);comment:游戏内玩家名" json:"name"` Level int `gorm:"not null;default:1;comment:玩家等级" json:"level"` LastSeen * time.Time `gorm:"type:timestamptz;comment:最后在线时间" json:"last_seen,omitempty"` // ---------- // 外键约束 // ---------- User User `gorm:"constraint:OnDelete:CASCADE;comment:关联用户" json:"user,omitempty"` } // GetGene 返回 xSnowflake.Gene,用于标识该实体在 ID 生成时使用的基因类型。 func (_ *Player) GetGene() xSnowflake.Gene { return bConst.GeneForPlayer // 需要在 internal/constant/gene_number.go 中定义 } ``` **提醒** : 记得在 `internal/constant/gene_number.go` 中添加 Gene 常量:```go const ( GeneForGameProfile xSnowflake.Gene = 32 GeneForPlayer xSnowflake.Gene = 64 // 新增 ) ``` --- ## 注意事项 💖 1. **Gene 常量** : 自定义 Gene 需要在 `internal/constant/gene_number.go` 中定义 2. **外键删除策略** : 默认使用 `OnDelete:CASCADE` 3. **指针类型** : 可空字段自动添加 `omitempty` JSON 标签 4. **敏感字段** : 密码等使用 `json:"-"` 隐藏 5. **不确定时** : 使用 AskUserQuestion 询问用户,不要擅自猜测 6. **新表时刻** : 若创建全新的表,需要写入 internal/startup/startup_database.go 的 AutoMigrate --- ## 参考资料 - **bamboo-base-go 全局文档** : https://doc.x-lf.com/llms.txt - **具体路径查询** : https://doc.x-lf.com/llms.mdx/<search_path > ### 查询示例 | 需要查找的内容 | 查询 URL | |-------------------|-----------------------------------------------------| | BaseEntity 定义 | https://doc.x-lf.com/llms.mdx/models/base_entity.go | | Snowflake Gene 类型 | https://doc.x-lf.com/llms.mdx/snowflake/gene.go | | 所有可导出类型 | https://doc.x-lf.com/llms.txt | 嘿嘿~ 开始创建你的 Entity 吧!\(^o^)/
诶,那么我做完这个了,突然想起来,我少了第三张表,叫做角色表(说实话,真忘了,不过再怎么样,都会想起来的,并不是说没考虑到怎么样考虑到怎么样,这是不可缺少的内容,只要你突然考虑到如果管理员如何管理用户的时候,就会想到这个)。
现在再进行思考一下,对于角色表需要怎么定义;这个角色管的是哪里?
这个角色,默认是普通用户是一定的有的,服主(超管)是一个,管理员是一个【我们暂定这三个】。具体怎么分配就看后面如何处理。毕竟做成表是很容易扩展的(实际上来说,可以做成常量,不做成表形式也是可以的,只是这个是我的习惯)。
碎碎念:其实我的 OIDC OAuth2 我做了标签,可以用标签在这里当做角色的。但是我没做好 gRPC 的 SDK,所以在这里就直接自己实现一个吧。(还是太懒了,这个一直没去做,懂了最近一直在写文章,恨/doge)
「声明」 那么就尝试用我们这一个新的 SKILL 来制作这个结构体。在这里依然是新开一个对话窗口使用 PlanMode 进行。
1 2 /fyl-entity-build role 现在你需要制作一个角色表,不需要继承基类,让 Name 当做主键,设置 varchar(32) 不能大于 32 位(预检查 BeforeCreate 时候检查是否符合 ^[A-Z_]{2,32}) 随后有一个 display_name 展示名字, description 描述,以及一个 CreatedAt 即可。不需要 UpdatedAt 这东西不怎么会变。 如何关联,不需要 many2many 模式,直接在 user.go 文件中添加一个 Role 字段做外键约束即可。
这么设计的初衷是,角色表是基本不会出现接口的添加和修改的,如果需要修改都是直接修改表信息了。所以不需要提供那么厉害的雪花算法之类,还会麻烦表管理。
设计 Name 要求只能最大 32 意思就是,要求最终角色看上去应该是这样 SUPER_ADMIN, ADMIN, PLAYER 类似这样
直接得出结果如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 package entityimport ( "regexp" "time" "gorm.io/gorm" ) type RoleName string const ( RoleSuperAdmin RoleName = "SUPER_ADMIN" RoleAdmin RoleName = "ADMIN" RolePlayer RoleName = "PLAYER" ) func (rN RoleName) String() string { return string (rN) } var ( roleRegex = regexp.MustCompile(`^[A-Z_]{2,32}$` ) ) type Role struct { Name RoleName `gorm:"primaryKey;type:varchar(32);comment:角色名称" json:"name"` DisplayName string `gorm:"not null;type:varchar(64);comment:角色显示名称" json:"display_name"` Description string `gorm:"not null;type:varchar(255);comment:角色描述" json:"description"` CreatedAt time.Time `gorm:"not null;type:timestamptz;autoCreateTime:milli;comment:创建时间" json:"-"` } func (r *Role) BeforeCreate(_ *gorm.DB) error { if !roleRegex.MatchString(r.Name.String()) { return &RoleNameError{Name: r.Name.String()} } return nil } type RoleNameError struct { Name string } func (e *RoleNameError) Error() string { return "无效角色名称:必须匹配 ^[A-Z_]{2,32}" }
现在保证了对于用户阶段的小逻辑是闭环的。但是现在项目还是没有办法跑起来,现在我们让项目可以跑起来先是最重要的。
碎碎念:呃,这里因为角色表变动非常少,并且在我这里角色的修改绝对不会出现在业务跑起来的情况下发起修改。也就是只有程序代码硬更新的时候进行更新【我的个人习惯会专门建表而已,其实对于这个规模来说是可以直接用常量或者类枚举(Go 没有枚举)方式来实现】
项目初始化
该部分高度依赖 bamboo-base-go 脚手架初始化的部分,主要就是因为项目初始化有很多地方需要处理。直接写比较麻烦,在这里才将其抽象化出来。把一些内置可以执行的交给 bamboo-base-go 自己执行,随后我们需要初始化的就是类似于 Database,Redis 这一类的内容。
下面很多东西直接贴出代码,如果有感兴趣的话。后面可以单独出一份如何考虑设计这个脚手架的。虽然说忘记了中间的迭代过程(实话实说就是懒,因为这不是本次文章的内容。真花时间进去写的话是可以写出来的)。
这里叫做初始化,也可以叫做项目启动的前置准备。
现在,可以看到我的项目中是这样的(画一下简化版结构树)
1 2 3 4 5 6 7 8 9 ❯ tree -d . ├── api ├── internal │ ├── app ... │ │ └── startup │ │ └── prepare ...
我要在这里 internal/app/startup 这里面,实现初始化的内容。
在我的脚手架中,最初一版的脚手架已经全部初始化完毕了下面给出一个完整的启动例子
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 package mainimport ( xReg "github.com/bamboo-services/bamboo-base-go/register" xResult "github.com/bamboo-services/bamboo-base-go/result" "github.com/gin-gonic/gin" ) func main () { reg := xReg.Register() reg.Serve.GET("/api/status" , func (c *gin.Context) { xResult.SuccessHasData(c, "服务正常" , gin.H{ "status" : "running" , }) }) reg.Serve.Run(":8080" ) }
在这里,是完整的使用这个脚手架启动的过程。只需要注意到 xReg.Register() 就可以了。在这里会返回一个
1 2 3 4 5 type Reg struct { Context context.Context Serve *gin.Engine }
所以制作二次初始化(项目内再次初始化注入内容到 Context 我这里定义属于第二次初始化),时候需要获取到这里的内容。
需要什么
其实,这里这样的写法,也变相在解释我的脚手架内的初始化部分是如何实现的。这里的写法其实无异于就是再实现一次初始化的部分。
项目初始化阶段,你需要思考的是实际项目中需要什么。这里我讲的是我项目在第一弹描述的 Minecraft Yggdrasil 的内容。也就是至少需要初始化 Database 以及 Cache。
什么内容需要初始化?
一般来说,情况一:能够从环境变量或者配置文件直接读取的内容,为了避免实际业务代码中直接混入读取机密环境变量的位置,都应该使用初始化提前初始化;情况二:长链接与性能问题,在这部分代码执行的时候会出现性能的开销,而且处理完后会一直保持存在状态,只有结束需要 Close 或 Down 的内容,需要进行初始化(资源持久化)。
那么在这里假设就没想到其他内容,就先初始化 Database 和 Cache 的内容吧。
请注意下面的代码样式是我自己总结的内容,至于怎么思考出来的,基本不记得了。我直接给出最终成果内容,可以停下来思考一下(因为这是我制作 bamboo-base-go 的时候想出来的,还不错就一直沿用了)。
实际代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 package startupimport ( "context" xReg "github.com/bamboo-services/bamboo-base-go/register" "github.com/gin-gonic/gin" "github.com/redis/go-redis/v9" "gorm.io/gorm" ) type Reg struct { Context context.Context Serve *gin.Engine DB *gorm.DB RDB *redis.Client } func newRegister (reg *xReg.Reg) *Reg { return &Reg{ Context: reg.Context, Serve: reg.Serve, } } func Register (reg *xReg.Reg) *Reg { businessReg := newRegister(reg) businessReg.databaseInit() businessReg.nosqlInit() businessReg.businessContextInit() businessReg.businessDataPrepare() return businessReg }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 package startupimport ( "log/slog" "strings" xEnv "github.com/bamboo-services/bamboo-base-go/env" xLog "github.com/bamboo-services/bamboo-base-go/log" "github.com/frontleaves-mc/frontleaves-yggleaf/internal/entity" "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/schema" ) var migrateTables = []interface {}{ &entity.Role{}, &entity.User{}, &entity.GameProfile{}, } func (r *Reg) databaseInit() { log := xLog.WithName(xLog.NamedINIT) log.Debug(r.Context, "正在连接数据库..." ) pgDsnBuilder := strings.Builder{} pgDsnBuilder.WriteString("host=" ) pgDsnBuilder.WriteString(xEnv.GetEnvString(xEnv.DatabaseHost, "localhost" )) pgDsnBuilder.WriteString(" user=" ) pgDsnBuilder.WriteString(xEnv.GetEnvString(xEnv.DatabaseUser, "postgres" )) pgDsnBuilder.WriteString(" password=" ) pgDsnBuilder.WriteString(xEnv.GetEnvString(xEnv.DatabasePass, "" )) pgDsnBuilder.WriteString(" dbname=" ) pgDsnBuilder.WriteString(xEnv.GetEnvString(xEnv.DatabaseName, "postgres" )) pgDsnBuilder.WriteString(" port=" ) pgDsnBuilder.WriteString(xEnv.GetEnvString(xEnv.DatabasePort, "5432" )) pgDsnBuilder.WriteString(" TimeZone=" ) pgDsnBuilder.WriteString(xEnv.GetEnvString(xEnv.DatabaseTimezone, "Asia/Shanghai" )) pgDsnBuilder.WriteString(" sslmode=disable" ) db, err := gorm.Open(postgres.Open(pgDsnBuilder.String()), &gorm.Config{ NamingStrategy: schema.NamingStrategy{ TablePrefix: xEnv.GetEnvString(xEnv.DatabasePrefix, "fyl_" ), SingularTable: true , }, Logger: xLog.NewSlogLogger(slog.Default().WithGroup(xLog.NamedREPO), xLog.GormLoggerConfig{ SlowThreshold: 200 , LogLevel: xLog.LevelInfo, Colorful: false , IgnoreRecordNotFoundError: true , }), }) if err != nil { log.SugarPanic(r.Context, "连接数据库失败" , "gorm_err" , err.Error()) } err = db.AutoMigrate(migrateTables...) if err != nil { log.SugarPanic(r.Context, "数据表自动迁移失败" , "gorm_err" , err.Error()) } log.Info(r.Context, "数据库连接成功" ) r.DB = db }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 package startupimport ( xEnv "github.com/bamboo-services/bamboo-base-go/env" xLog "github.com/bamboo-services/bamboo-base-go/log" "github.com/redis/go-redis/v9" ) func (r *Reg) nosqlInit() { log := xLog.WithName(xLog.NamedINIT) log.Debug(r.Context, "正在连接缓存..." ) rdb := redis.NewClient(&redis.Options{ Addr: xEnv.GetEnvString(xEnv.NoSqlHost, "localhost" ) + ":" + xEnv.GetEnvString(xEnv.NoSqlPort, "6379" ), Password: xEnv.GetEnvString(xEnv.NoSqlPass, "" ), DB: xEnv.GetEnvInt(xEnv.NoSqlDB, 0 ), PoolSize: xEnv.GetEnvInt(xEnv.NoSqlPoolSize, 10 ), }) log.Info(r.Context, "缓存连接成功" ) r.RDB = rdb }
上面展示的地方是基础的两件套的内容。其中一个是数据库的引入,另外一个是缓存的引入(采用 PostgreSQL 和 Redis)。然后第一个文件是统一进行管理所有的 startup。最终的结构树大概如这样所示
1 2 3 4 5 6 7 8 9 ./internal/app/startup ├── prepare │ ├── prepare_role.go │ └── prepare.go ├── startup_context.go ├── startup_database.go ├── startup_prepare.go ├── startup_redis.go └── startup.go
碎碎念:解答一下上面说的为什么不需要数据表准备的时候不需要定义 TableName ,因为在 startup_database 中,设置了 schema.NamingStrategy{ SingularTable: true }, 这里采用单数表名字。并且默认就是采用结构体驼峰名字自动转化为蛇形命名法方式。除非有比较特殊的名字或者强迫症需要保持一致的需要写,例如 “OAuth2Github” 那么就会解析为 “o_auth_2_github” 就会比较抽象 hhh。
我对 prepare 进行解释一下吧,prepare 是数据准备阶段使用的。这里我只举例 prepare_role.go 文件,在这里每次启动都会执行一次 startup_prepare.go 所以会带动执行这个文件内部所引用的 prepare 文件夹,从而执行里面的顺序执行过程(顺序执行过程由 prepare.go 文件定义)。
下面是 prepare_role.go 的文件示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 package prepareimport ( "github.com/frontleaves-mc/frontleaves-yggleaf/internal/entity" ) func (p *Prepare) prepareRole() { p.db.Model(&entity.Role{}).Where("name = ?" , entity.RoleSuperAdmin).Save(&entity.Role{ Name: entity.RoleSuperAdmin, DisplayName: "超级管理员" , Description: "最高级别的系统管理员,拥有所有权限" , }) p.db.Model(&entity.Role{}).Where("name = ?" , entity.RoleAdmin).Save(&entity.Role{ Name: entity.RoleAdmin, DisplayName: "管理员" , Description: "系统管理员,拥有大部分管理权限" , }) p.db.Model(&entity.Role{}).Where("name = ?" , entity.RolePlayer).Save(&entity.Role{ Name: entity.RolePlayer, DisplayName: "玩家" , Description: "玩家,拥有基本使用权限" , }) }
因为这里采用了 .Save(value interface{}) 对于 Gorm 的 .Save 来说,每次启动执行到 Save 阶段,都会向数据库检查是否存在这个主键,如果存在则尝试 Update 更新,如果不存在则进行 Insert 插入,所以如果有修改只需要向这里修改和补充就可以了(如果需要可靠性比较高,做双机热备差时间启动就可以实现目的,这个 Docker Compose 来很快的)。
请注意哦,因为我这里主键是 Name 所以他检查是 Name 是否存在,来确认执行是 Insert 还是 Update。对于雪花算法主键来说不能这么通常的使用方式。
其他配置
剩下都是杂七杂八的配置了,这里没有什么特别好说的了。无非最主要就是有一个 internal/route 需要处理,这里作为路由引入层。
这里举例两个文件即可,其中一个是引入配置层,一个是具体的路由层。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 package routeimport ( "context" xEnv "github.com/bamboo-services/bamboo-base-go/env" xMiddle "github.com/bamboo-services/bamboo-base-go/middleware" xReg "github.com/bamboo-services/bamboo-base-go/register" xRoute "github.com/bamboo-services/bamboo-base-go/route" "github.com/frontleaves-mc/frontleaves-yggleaf/internal/app/startup" "github.com/gin-gonic/gin" "github.com/redis/go-redis/v9" "gorm.io/gorm" ) type route struct { engine *gin.Engine db *gorm.DB rdb *redis.Client context context.Context } func NewRoute (xReg *xReg.Reg, reg *startup.Reg, context context.Context) { r := &route{ engine: xReg.Serve, db: reg.DB, rdb: reg.RDB, context: context, } r.engine.NoMethod(xRoute.NoMethod) r.engine.NoRoute(xRoute.NoRoute) r.engine.Use(xMiddle.ResponseMiddleware) r.engine.Use(xMiddle.ReleaseAllCors) r.engine.Use(xMiddle.AllowOption) if xEnv.GetEnvBool(xEnv.Debug, false ) { swaggerRegister(r.engine) } { apiRouter := r.engine.Group("/api/v1" ) r.authRouter(apiRouter) } }
1 2 3 4 5 6 7 8 func (r *route) authRouter(route *gin.RouterGroup) { group := route.Group("/auth" ) authHandler := handler.NewAuthHandler(r.db, r.rdb, r.context) group.GET("/demo" , authHandler.ThisDemo) }
这里还不着急讲跟 middleware 中间件有关的内容,中间件(后面开发的时候我会说从什么角度来说考虑为什么要中间件)。中间件有一个我认为比较核心的东西,就是当你能够自己抽象出来避免重复造东西的时候最常用到。
在 bamboo-base-go 中,因为在我的设计里面,最终基本都是打包成 docker 运行的,但是为了开发阶段能够读取环境变量,所以还引入了一个依赖 github.com/joho/godotenv 允许直接运行的时候获取 .env 文件当做环境变量读入。这样就跟 docker 或者说 docker-compose.yaml 直接使用的情况是一样的了(也可以给一些不期望使用 docker build 直接二进制运行的选择方式)。
那么就可以配置 .env 的内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 # ============================================ # 应用基础配置 (Application Settings) # ============================================ # 调试模式 (Debug Mode) # 可选值: true, false, 1, 0, yes, no, on, off XLF_DEBUG=true # 监听地址 (Listen Address) XLF_HOST=0.0.0.0 # 监听端口 (Listen Port) XLF_PORT=5577 # ============================================ # 数据库配置 (Database Settings) [可选/Optional] # ============================================ # 数据库主机地址 [必填] DATABASE_HOST=localhost # 数据库端口 DATABASE_PORT=5432 # 数据库用户名 [必填] DATABASE_USER=frontleaves-yggleaf # 数据库密码 [必填] DATABASE_PASS=frontleaves-yggleaf # 数据库名称 [必填] DATABASE_NAME=frontleaves-yggleaf # 数据库表前缀 DATABASE_PREFIX=fyl_ # ============================================ # Redis 配置 (Redis Settings) [可选/Optional] # ============================================ # Redis 主机地址 NOSQL_HOST=localhost # Redis 端口 NOSQL_PORT=6379 # Redis 用户名 (ACL 模式) NOSQL_USER= # Redis 密码 NOSQL_PASS= # Redis 数据库编号 (0-15) NOSQL_DATABASE=1 # Redis 键前缀 NOSQL_PREFIX=fyl: # Redis 连接池 REDIS_POOL_SIZE=100
主包配置
那么,现在初始化的部分已经配置好了。直接贴出代码。这样主包就会显得非常简洁。而且非常好以后做定时 Task 任务制等需要独立协程运行时的内容。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 package mainimport ( "context" "errors" "log/slog" "net/http" "os" "os/signal" "sync" "syscall" xEnv "github.com/bamboo-services/bamboo-base-go/env" xLog "github.com/bamboo-services/bamboo-base-go/log" xReg "github.com/bamboo-services/bamboo-base-go/register" "github.com/frontleaves-mc/frontleaves-yggleaf/internal/app/route" "github.com/frontleaves-mc/frontleaves-yggleaf/internal/app/startup" ) func main () { reg := xReg.Register() start := startup.Register(reg) log := xLog.WithName(xLog.NamedMAIN) ctx, cancel := context.WithCancel(reg.Context) defer cancel() sigChan := make (chan os.Signal, 1 ) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) route.NewRoute(reg, start, reg.Context) getHost := xEnv.GetEnvString(xEnv.Host, "localhost" ) getPort := xEnv.GetEnvString(xEnv.Port, "5566" ) server := &http.Server{ Addr: getHost + ":" + getPort, Handler: reg.Serve, } engineSync := sync.WaitGroup{} engineSync.Add(1 ) go func () { defer engineSync.Done() log.Info(reg.Context, "服务器已成功启动" , slog.String("addr" , "http(s)://" +server.Addr)) if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Error(reg.Context, err.Error()) } }() go func () { <-sigChan cancel() log.Warn(reg.Context, "正在关闭 HTTP 服务器..." ) if err := server.Shutdown(ctx); err != nil { log.Error(reg.Context, err.Error()) } }() engineSync.Wait() log.Info(reg.Context, "所有服务已安全退出" ) return }
写在最后
这一弹,我写的其实不算很顺畅。主要是在这里我经常想到一些扩展可能性的一些概念。又或者说我引入的 bamboo-base-go 脚手架,直接引入不做解释的话,可能会不清楚我这里为什么可以突然使用(如果看得懂 Go 代码的朋友那一定没问题),如果不太熟悉 Go 只是跟着我的流程走来进行思路汇集的话,可能会因为我一直想引入一些外部依赖的内容(我又一直在克制,认为有些东西不需要。不然又变成了其他项目或者其他内容的底层摸索类型的文章)。
不过这个项目我也开源的,在这里。
https://github.com/frontleaves-mc/frontleaves-yggleaf
如果文章有些地方展示不明显的话,可以克隆下来自己跑一下。如果是 unix 系电脑的话,直接配置了 Makefile 不存在 make 的内容安装一下 make 就可以使用了。
make dev 就可以直接启动了。
1 2 3 4 5 6 7 8 9 10 ❯ make 锋楪YggLeaf - 可用命令 开发命令: make swag - 生成 Swagger 文档 make run - 运行程序 make dev - 生成文档并运行 (推荐) 示例: make dev
既然我已经写出来了第二弹,也就是说我确实把他当做一个系列来写了。吸取一些前面的教训,让朋友们更好读一些。有些地方我也尽量从啥也不会的角度出发来引导从思维出发进行考虑。而不是直接提供一些结果(实在不好意思,因为引入 bamboo-base-go 脚手架的问题,这里的篇幅都可以单独拎出来讲一次了)。
目前阶段是创建这个项目然后根据设计的内容构造出能够启动的项目而已,仅仅到可以启动。还不存在任何业务的过程中。
现在已有的第一弹,主要讲的是对这个项目的边界界定和需要做的内容界定。这里开始基础东西构造(打地基)。下一弹就是我工程规范的样式来写几个实践接口,以及考虑到一些地方如何进行拆解。如何从完整一些逻辑抽象内容出来。
后面应该还有不少弹,估计有的写了。作为工程角度来看,其实能写的非常多,加油吧期待后面写的更好吧~