每个后端开发者在接手一个新项目时,大概都经历过这种场面:从 Spring Initializr 生成一个空壳工程,然后开始漫长的“组装”之路——配统一返回体、配全局异常处理、配 Redis 工具类、写 JWT 拦截器……一套下来,正经业务一行没写,时间全花在搬砖上。今天想聊的 Sun Frame,就是为了解决这件事而生的一个个人开源项目:一个基于 SpringBoot 的轻量级开发框架,核心思路是把高频通用能力收拢成可插拔的 starter,让新项目能在 5 分钟内进入业务开发。这篇文章我会从设计思路、自动装配原理、实操过程到避坑记录,完整过一遍这个框架,适合正在规划自己脚手架的人,也适合做毕设、做内部管理系统但不想天天重复造轮子的朋友。
1. Sun Frame 的由来:为什么要自己做一个轻量级框架
1.1 从重复劳动到自研脚手架
在 SpringBoot 已经统治 Java 后端的今天,按理说项目初始化应该很轻松了,但实际体验并不是这样。Spring Initializr 能帮你生成的是“结构正确”的工程,里面没有统一响应体,没有全局异常处理,没有接口日志,没有鉴权骨架。这些代码每个项目都要写,但每个项目写出来的版本千奇百怪。
我见过不少公司的项目,光一个 Result 类就有三种格式,有的用 Map 直接往里面塞 code、msg、data,有的是各自封装了个 ResponseUtil。这些问题到联调阶段就开始爆发:前端要适配多个团队的不同接口规范,Mock 数据都写得想死。Sun Frame 最初的想法很简单——把我自己在多个项目里沉淀的那套通用代码,整理成一个足够轻、不绑架业务、可以按需引入的框架。
另一个触发点是市面上现成的脚手架。大而全的框架功能确实丰富,单是代码生成、权限管理、定时任务就有一大堆,但拿到手之后会发现问题:模块之间耦合明显,很多功能当前项目根本用不到,光删代码就得删半天。对于中小系统、个人项目、课程设计、毕业设计这类场景,其实我们需要的是一个“中间态”的解决方案:比 Spring Initializr 多提供一些约定和通用组件,又不像重型脚手架那样一上来就全家桶。Sun Frame 就定位在这个中间态。
1.2 框架定位与设计原则
Sun Frame 不是什么颠覆性技术,它更像是一名后端老兵的项目习惯的表达方式。框架定了四个原则,这几个原则贯穿了所有模块的设计:
- 轻量:核心工程不引入任何重量级中间件作为强制依赖,Redis、MinIO 这些外部组件全部按需通过 starter 引入。
- 低侵入:你不会被迫继承某个 BaseController,也不会被要求必须实现某个框架接口。框架提供的类,你愿意用就用,不愿意用可以直接绕过。
- 约定优先:统一返回体、统一异常、统一日志格式等通过默认配置生效,但如果项目有需要,可以改。
- 面向真实业务:框架里的每个模块都是从实际业务里抽出来的东西,不是为“设计感”凑出来的抽象。
这里也说明一下适用边界。Sun Frame 适合管理后台、内容管理类 API 服务、教学项目、个人工具类 Web 应用;如果要做海量并发、复杂分布式任务调度这类高难度场景,那需要的不是这种轻量框架,而是更完整的中台能力。想清楚边界,才不会被“什么都能干”的心态拖垮。
2. 核心模块与自动装配原理拆解
2.1 整体模块划分
Sun Frame 采用多模块 Maven 结构,目的是让各部分可以独立发布、独立使用。目前分为这样几个模块:
| 模块 | 职责 | 依赖范围 | 主要功能 |
|---|---|---|---|
| sun-frame-common | 基础公共工程 | 无外部中间件依赖 | 统一返回体 Result、错误码枚举、业务异常体系、通用工具类、用户上下文 |
| sun-frame-web | Web 层通用配置 | common + spring-boot-starter-web | 全局异常处理、参数校验统一处理、CORS 策略、接口日志、请求追踪 |
| sun-frame-jwt | 认证鉴权模块 | common + spring-boot-starter-security(可选)或拦截器 | JWT 生成/解析、@RequireLogin 注解、白名单放行、登录用户注入 |
| sun-frame-redis-spring-boot-starter | Redis 能力封装 | common + spring-data-redis | RedisTemplate 序列化、分布式锁、缓存工具方法 |
| sun-frame-minio-spring-boot-starter | MinIO 对象存储封装 | common + minio | 文件上传、下载、删除、预签名 URL、Bucket 管理 |
| sun-frame-mybatis-spring-boot-starter | 持久层增强 | common + mybatis-plus | 分页插件配置、字段自动填充、MyBatis-Plus 常用能力初始化 |
这种模块化设计带来的直接好处是:一个“最简可启动”的 Sun Frame 工程,只需要引入 common 和 web 两个模块,够了。其他能力,比如对象存储,需要时再加一行依赖,不需要时一点侵入都没有。这跟我前面说的“可插拔”是对应上的。
2.2 自动装配的工作原理
先补一个基础认知。SpringBoot 与普通 Spring 的一个巨大差异在于“自动配置”。SpringBoot 项目里的 @SpringBootApplication 注解,核心其实是三个注解的组合:@SpringBootConfiguration、@ComponentScan、@EnableAutoConfiguration。前两个好理解,扫描配置类嘛,关键是第三个 @EnableAutoConfiguration,它是整个自动装备体系的总开关。
自动配置的加载逻辑是:SpringBoot 启动时,SpringFactoriesLoader 会从 classpath 里扫描所有 META-INF 目录下的配置文件。在 SpringBoot 2.7 之前,对应的文件叫META-INF/spring.factories,在里面通过org.springframework.boot.autoconfigure.EnableAutoConfiguration=\配置类的全限定名列表来注册自动配置类。从 2.7 开始,SpringBoot 提供了新的注册机制META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports,这个文件里直接一行一个自动配置类全限定名。3.x 之后就全面切到新的 imports 文件了。
Sun Frame 的每个 starter 在做自动配置时用的是下面这套完整链路:
第一步,定义配置属性类,比如 MinIO 的接入参数。配置项前缀定义为sun.minio,使用者只需要在 application.yml 里写 sun.minio.endpoint、sun.minio.access-key、sun.minio.secret-key、sun.minio.bucket 等,SpringBoot 就会把这些配置值绑定成配置类的属性。这里的关键点是@ConfigurationProperties(prefix = "sun.minio")这个注解。
第二步,写真正的自动配置类。这个类必须用 @AutoConfiguration 注解标记,同时配合条件注解来决定是否生效。比如只有 classpath 里存在 MinioClient 类时才加载 MinIO 相关配置,对应注解是@ConditionalOnClass(MinioClient.class);再看配置项里有没有打开开关,对应@ConditionalOnProperty(prefix = "sun.minio", name = "enabled", havingValue = "true", matchIfMissing = true);最后用@ConditionalOnMissingBean保证如果使用者已经自己定义过同类型 Bean,框架就不再覆盖。
第三步,在 resources 目录下新建META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件,内容写入自动配置类全限定名。这一步经常有人忘记,或者是文件名写错、目录层级写错,最后自动配置静默失败,查半天才发现是注册文件有问题。
Sun Frame 选择这套机制,而不是直接写一堆 @Component 让启动时全量扫描,原因很简单:可插拔能力依赖注册机制。架子搭好,引入依赖就生效,去掉依赖就消失,完全不用改业务代码。对使用者来说,框架的存在感被降到了最低。
2.3 内置通用组件的封装思路
统一返回体系是 Sun Frame 最基础的一个模块,也是我觉得最“普惠”的设计。Result 类是一个泛型封装,包含 code、message、data、traceId 四个字段。code 是业务错误码,message 是给前端或者调用方看的信息,data 是真正的业务数据,traceId 则用来串联日志链路。所有的成功返回都走Result.success(data),业务异常走Result.failed(code, message)。这个模型不新鲜,但统一的价值很大,前后端联调时接口结构一致,Swagger/API 文档也更好维护。
全局异常处理这块,我用@RestControllerAdvice做了统一出口。核心是三个异常处理器:第一个是业务异常处理器,捕获 sun-frame-common 里定义的 BizException,直接按异常里携带的错误码返回;第二个是参数校验处理器,捕获 MethodArgumentNotValidException 和 ConstraintViolationException,把校验失败的具体字段信息提取出来返回,而不是给前端甩一个笼统的“参数错误”;第三个是兜底处理器,捕获 Exception,记录完整堆栈后统一返回“系统繁忙”这种安全信息。作为框架使用者,你只需要抛异常或者加校验注解,返回什么格式框架帮你管好了。
Redis 模块里,一个很容易踩坑的点是序列化。Spring Data Redis 默认用 JDK 序列化,key 会变成\xAC\xED\x00\x05t\x00...这种乱码,而且 JDK 序列化对象体积大、跨语言困难。Sun Frame 里默认把 key 的序列化器换成 StringRedisSerializer,value 的序列化器换成 Jackson 的 GenericJackson2JsonRedisSerializer,同时注入自有的 RedisUtils 组件,封装了缓存查询、缓存写入、分布式锁等常用操作。字段填充、分页这些 MyBatis-Plus 的能力,也在持久层 starter 里直接配好,引入依赖后分页查询不用再额外注册拦截器。
3. 实操:从零搭建一个 Sun Frame 服务
3.1 项目创建与依赖引入
先动手把项目拉起来。Sun Frame 的工程代码在 Gitee/GitHub 上开源,拿到代码后先做本地安装:进入根目录执行 maven 构建命令mvn clean install -DskipTests。这个命令会把 common、web、jwt、redis、minio、mybatis 等模块全部构建并安装到本地 Maven 仓库。之后新建业务项目时,只需要像引普通依赖一样引入 Sun Frame 的模块即可。
新建业务项目时,pom.xml 大概长这样:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.sunframe</groupId> <artifactId>sun-frame-web</artifactId> <version>1.0.0</version> </dependency> <dependency> <groupId>org.sunframe</groupId> <artifactId>sun-frame-redis-spring-boot-starter</artifactId> <version>1.0.0</version> </dependency> <dependency> <groupId>org.sunframe</groupId> <artifactId>sun-frame-mybatis-spring-boot-starter</artifactId> <version>1.0.0</version> </dependency> </dependencies>这个 pom 除了引入 SpringBoot 官方父工程,其他就是 Sun Frame 自己的模块依赖。你可能注意到我选了 SpringBoot 2.7.18 这个版本,原因待会在避坑章节展开。最重要的是,业务代码里不需要加任何核心依赖到自己的工程——Sun Frame 会把需要的 SpringBoot 场景依赖通过模块传递过来。
启动类的写法和标准 SpringBoot 完全一样:
@SpringBootApplication public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }是的,没有任何继承框架基类的要求,这体现了低侵入的设计原则。
3.2 配置与第一组接口
在 application.yml 里,除了 SpringBoot 常规的数据源、端口配置,Sun Frame 的组件配置通过各自的前缀开关生效。一个典型配置长这样:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/sun_demo?useUnicode=true&characterEncoding=utf8 username: root password: 123456 redis: host: localhost port: 6379 sun: jwt: enabled: true secret: sun-frame-jwt-secret-change-me expire-minutes: 120 white-list: /api/auth/login, /api/auth/register minio: enabled: true endpoint: http://localhost:9000 access-key: minioadmin secret-key: minioadmin bucket: demo-bucket写一个最简单的实体类和 Mapper 接口。结合 MyBatis-Plus,实体类就一个 @TableName 注解表明对应的表,Mapper 接口继承 BaseMapper 之后基础的增删改查就都有了,完全不用写 SQL:
@TableName("tb_user") public class User { @TableId(type = IdType.AUTO) private Long id; private String username; private String email; }public interface UserMapper extends BaseMapper<User> { }然后是 Service 和 Controller 层。Controller 层最爽的一点是直接返回 Result 对象,不需要在方法里手动处理响应值:
@RestController @RequestMapping("/api/user") public class UserController { @Resource private UserMapper userMapper; @GetMapping("/{id}") public Result<User> getUser(@PathVariable Long id) { User user = userMapper.selectById(id); if (user == null) { throw new BizException(ErrorCode.NOT_FOUND); } return Result.success(user); } @PostMapping public Result<Boolean> createUser(@RequestBody User user) { return Result.success(userMapper.insert(user) > 0); } }你会发现整个链路里没有 System.out 打印、没有手写异常 try-catch、没有手动构建 Map 响应。接口日志由 sun-frame-web 里的 AOP 切面自动处理了,包括请求路径、方法名、入参、耗时和执行结果。这就是框架层帮你砍掉的那些“隐形业务”。
3.3 后端服务如何与前端工程集成
很多做毕设或者中小项目的人,面临的另一个实际问题是:前端是 Vue 工程,后端是 SpringBoot 工程,部署时想要打包成一个 jar 方便运行。这个需求其实不算复杂,只是首次操作容易踩路径坑。
思路很简单:前端项目先执行 npm run build 生成 dist 目录,然后把 dist 里面的文件复制到 SpringBoot 的src/main/resources/static目录下,最后 maven package 打成 fat jar。启动 jar 后,直接访问http://localhost:8080,SpringBoot 会将请求映射到 static 目录中的 index.html,前端路由由 Vue Router 在浏览器端处理。要注意的是,前端访问后端接口时应使用相对路径/api,不要写成http://localhost:8080这种绝对地址,否则部署到服务器换端口后又要改代码。
复制文件这一步我一般用前端构建的拷贝插件,在 Vue 的 vite.config.js 或 vue.config.js 里配置打包后自动拷贝到 SpringBoot 的 static 目录。这样整个发布流程只需要两步:前端 build,然后 maven package。Sun Frame 本身不干预这个过程,因为它的 web 模块不会对静态资源映射做特殊限制,所以这种单 jar 部署模式可以直接用。
3.4 为框架扩展一个自定义 Starter
框架作者视角的操作体验同样重要。假设你现在想给 Sun Frame 新增一个短信发送能力的 starter,步骤是非常标准的四步。
第一步,在 sun-frame 父工程下新建sun-frame-sms-spring-boot-starter模块,引入 common 模块依赖。第二步,创建配置属性类 SmsProperties,用 @ConfigurationProperties 绑定以 sun.sms 开头的配置项。第三步,创建自动配置类 SmsAutoConfiguration,在里面根据配置创建 SmsClient Bean,并在类上使用 @ConditionalOnProperty 控制是否启用。第四步,在 resources 下新建META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件,把自动配置类全限定名写入。然后回到根目录执行 maven install,再到业务项目里引入依赖,配置 sun.sms.enabled=true,服务就具备短信发送能力了。
这个“四步走”的过程,其实就是把 SpringBoot 的 SPI 机制摸透之后形成的肌肉记忆。等做过头两个 starter 之后,后面任何一个新能力接入基本都是复制粘贴改改类名,耗时不超过半小时。
4. 使用与开发过程中踩过的坑
4.1 自动配置不生效的排查套路
框架开发和使用中最常见的现象是:依赖引入了,配置也写了,但功能没生效。比如 JWT 拦截器没有拦截任何路径,比如 MinIO Client 没有注入成功。这类问题的排查套路,我总结了一套比较稳定的流程。
第一步,先看自动装配报告。在启动命令里加上--debug参数,启动日志会输出一份完整的自动装配报告:Positive matches 是已生效的自动配置,Negative matches 是被判定不生效的配置,其中会明确给出不生效的原因是条件不满足还是类不存在。这份报告是定位问题最好的地图。
java -jar demo.jar --debug第二步,对照条件注解检查:@ConditionalOnClass 判断的类是否真的在 classpath 中、@ConditionalOnProperty 判断的配置项是否写对了前缀和值。很多人在配置里写 sun.minio.url,但前缀定义的是 sun.minio.endpoint,配置对不上,条件装配直接放弃执行。
第三步,确认自动配置类的注册方式。SpringBoot 2.7 之前用 META-INF/spring.factories,2.7 之后支持新的 imports 文件,SpringBoot 3.x 则只能走 imports 文件。如果你的项目是 2.7 却只放 spring.factories,结果可能还是能跑,但如果升级到 3.x,自动配置会静默失效。文件路径和文件名错一个字母,整个能力完全失效,而且不会有任何显式报错。这个坑我建议每个做 starter 的人都提前熟悉。
4.2 SpringBoot 版本选择与升级冲突
前面提到我在 Sun Frame 父工程里选的是 SpringBoot 2.7.18,为什么不是最新的版本?这要从实际兼容性说起。热搜词里有一条“springboot版本太高”,这个现象在真实项目里确实存在。SpringBoot 3.x 将基线提升到 JDK 17,同时包名从 javax 改成了 jakarta。如果你的目标环境是 JDK 8,比如老服务器、不少高校实验环境,那就只能使用 2.x。如果本地环境已经是 JDK 17+,我反而建议直接用 3.x,毕竟新版本在性能优化和模块化上更有优势。
另一个和版本强相关的是代理机制。SpringBoot 2.x 默认会优先使用 CGLIB 代理,SDK 目标类没有实现接口时也能正常代理;SpringBoot 3.x 同样保持这个行为。这个点在实际开发中的影响是:如果你用 @Transactional 调同类内部方法,代理不生效,事务会失效。这不是框架的问题,是 Spring AOP 代理机制的老知识了,但每个排查到这里的同学都容易先在配置上翻半天。
我的建议是:第一,项目用什么 JDK 版本,直接决定你选 SpringBoot 2 还是 3;第二,如果要做自定义 starter 的开源发布,尽量兼容两个大版本,Sun Frame 的 common 与 web 模块在代码层面避免使用 jakarta 与 javax 强绑定的 API,需要里用条件编译或者分版本维护时,要注意隔离。
4.3 Redis 与 MinIO 集成时的典型问题
Redis 序列化问题我在前面提过。实际使用 Sun Frame 的 redis starter 时,有个额外问题也很常见:用 Jackson 做 value 序列化之后,存入 Redis 的值会带 @class 字段标记真实类名。业务代码反序列化时如果类的包名或者结构发生了变化,比如从实体里加了个字段,旧缓存直接反序列化失败。这是 Jackson 序列化方案的通病,并不是框架缺陷。解决方案有两种:要么在 RedisUtils 里封装 string 类型的读写场景,业务层自己负责对象的序列化和反序列化;要么缓存 key 里带版本号,发版后自动失效全部缓存。我更倾向第二种,操作成本最低。
MinIO 的坑主要集中在访问地址和 Bucket 策略。一个很经典的场景:服务器上 MinIO 的 endpoint 配的是http://127.0.0.1:9000,开发环境本地看没问题,部署到生产后前端拿到的是127.0.0.1的地址,自然访问不通。正确姿势是配置和公网可达地址一致的 endpoint,或者让 MinIO 走反向代理,对外统一暴露一个域名。另外,通过预签名 URL 访问私有 Bucket 对象时,要确认 bucket 策略与生成 URL 的客户端配置匹配,否则会出现可以下载但无法在线预览的现象。
4.4 常见问题速查表
| 症状 | 原因 | 解决方案 |
|---|---|---|
| 新加的自动配置完全无日志 | AutoConfiguration.imports 文件路径/名称错误 | 检查 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 是否存在且内容正确 |
| 启动报 NoSuchMethodError / ClassNotFoundException | SpringBoot/依赖版本冲突 | mvn dependency:tree 查依赖树,排除重复或者版本不一致的依赖 |
| Redis 的 key 显示为 \xAC\xED 乱码 | 默认 JDK 序列化器 | 使用 sun-frame-redis-starter 自带序列化配置,或手动指定 StringRedisSerializer |
| 接口返回 401 但白名单路径也拦截了 | 白名单配置路径与实际请求路径不一致 | 检查 sun.jwt.white-list 路径是否以 / 开头且不含上下文路径 |
| MyBatis-Plus 分页不生效 | 分页插件未注册或拦截器顺序被覆盖 | 确认只引入了 sun-frame-mybats-starter,未手动注册重复拦截器 |
| 上传文件到 MinIO 后无法访问 | Bucket 访问策略或 endpoint 不通 | bucket 设置为 public 或使用预签名 URL,endpoint 用公网可达地址 |
这些坑没有一个是高深的原理问题,全是工程实践里的细碎东西。但恰恰是这些细碎的东西,决定了框架好不好用、项目能不能快速跑通。Sun Frame 把这些常见问题通过组件封装提前规避,剩下的就交给使用者的正确配置了。
5. 后续方向与一点个人心得
Sun Frame 目前的版本更像是一个基于我自己项目经验的“精选集”,很多能力是从真实业务里长出来的。后续如果有时间,我计划往几个方向扩展:增加基于注解的幂等控制组件、内置 OpenAPI 文档配置、支持多租户数据隔离的 mybatis 扩展、再补一个基于虚拟线程的异步任务模块。不过这些能不能落地,得看项目使用反馈和我的业余时间,开源项目的节奏本来就应该稳着走。
根据我自己这两年的实践体会,做这种个人开源框架,最大的收获不是代码本身,而是把 SpringBoot 自动配置、模块化设计、版本兼容这些知识彻底吃透了。你可以把 Sun Frame 当成一个现成的脚手架来用,也可以当成一个拆解 SpringBoot 原理的案例来学。如果你想开始自己的第一个开源项目,我强烈建议也从一个这样小而美的 starter 开始——不要想着一次做完美,能解决自己一类实际问题,就值得被分享出去。