JFinal 深度评测:轻量级 Java Web 框架的实战与选型思考
发布时间:2026/10/16 3:54:07 锦皓数字建站

1. 为什么我又把 JFinal 翻出来认真评测了一遍做后端开发十来年Java 生态里的 Web 框架我基本都摸过一遍。Spring 全家桶不用多说项目里用得最多Vert.x、Javalin、Spark Java 这些轻量级的也试过甚至有一阵子还折腾过纯 Servlet 加手写路由。但 JFinal 这个框架说实话我之前的印象一直停留在“国内某位前辈做的小巧框架”这个层面知道它快、知道它简单但从没认真在真实项目里跑过一轮完整流程。这次之所以决定认真评测起因是帮一个朋友看他的小团队项目。他们做的是一个内部管理系统日活不高但迭代频率很快团队里只有一个后端。他跟我吐槽说 Spring Boot 启动慢、配置多、改一个接口要动好几个文件我就随口提了一句“你可以看看 JFinal”。结果他真去看了两天后跟我说“这东西有点意思”。我当时心里就有点不服气——一个框架能在两天内让一个习惯了 Spring 的人觉得“有意思”要么是他太菜要么是这框架确实有东西。于是我决定自己动手从零搭一个完整的项目把 JFinal 的核心链路全部走一遍路由、控制器、ORM、模板、拦截器、插件、打包部署。这篇文章就是这次评测的完整记录不是官方文档的复述也不是“Hello World”级别的演示而是一个真实后端开发者在使用过程中踩到的坑、发现的亮点、以及最终形成的判断。如果你是一个正在选型的小团队后端或者你手头有那种“不需要微服务、不需要复杂生态、就想快速把业务逻辑写出来”的项目那这篇评测应该对你有参考价值。如果你是大厂里深度绑定 Spring 体系的开发者也可以看看另一种思路——一个把“少写代码”作为核心哲学的框架到底能做到什么程度。2. JFinal 的核心设计思路拆解2.1 零配置与约定优于配置的取舍JFinal 最核心的设计哲学就四个字少写代码。这不是一句口号而是贯穿整个框架的设计准则。我先把它的几个关键设计决策拆开讲你就能理解为什么它用起来会让人觉得“快”。第一个决策是零 XML 配置。JFinal 从设计之初就完全抛弃了 XML所有配置都通过 Java 代码完成。你可能会说 Spring Boot 也做到了零 XML但区别在于Spring Boot 的零 XML 是建立在大量注解和自动配置类之上的而 JFinal 的配置集中在一个继承自JFinalConfig的类里通常不超过 50 行。路由、插件、拦截器、数据库连接全部在这一个文件里搞定。第二个决策是路由即配置。在 JFinal 里你不需要在控制器上写RequestMapping而是在配置类里用routes.add(/user, UserController.class)这样的方式集中注册。这个设计看起来像是“倒退”因为 Spring 早就用注解把路由分散到各个控制器了。但实际用下来集中式路由有一个被低估的好处你可以在一个文件里看到整个应用的所有入口。对于一个中小型项目来说这比在几十个控制器里翻注解要高效得多。第三个决策是ActiveRecord 模式的 ORM。JFinal 的 ORM 不是 JPA 那种完全面向对象的映射而是更接近 ActiveRecord 模式——模型类直接继承Model然后通过User.dao.findById(1)这样的方式操作数据库。没有 Repository 接口没有 EntityManager没有复杂的关联映射配置。这种设计在复杂业务场景下确实不如 JPA 灵活但在 CRUD 为主的业务系统里开发效率极高。这里要说明一点JFinal 的 ORM 并不是要替代 JPA 或 MyBatis它解决的是“80% 的简单查询和写入”这个场景。如果你有极其复杂的多表关联查询JFinal 也支持手写 SQL 和Db工具类不会把你锁死。2.2 与主流框架的对比为什么它敢说自己快JFinal 官方一直强调“开发速度快、运行速度快”我一开始觉得这是营销话术但实际测下来这两个“快”都有具体支撑。开发速度快体现在几个方面。第一代码量确实少。我同样的一个用户管理模块用 Spring Boot MyBatis 写了大概 8 个文件Controller、Service、ServiceImpl、Mapper、Mapper XML、Entity、DTO、Config用 JFinal 只写了 3 个文件Controller、Model、Config。第二没有分层强迫症。JFinal 不强制你写 Service 层Controller 里可以直接调 Model 做数据库操作。对于简单业务来说这省掉了大量“为了分层而分层”的模板代码。第三热加载体验好。JFinal 的JFinalFilter配合开发模式下的devMode配置改完 Java 代码后不需要重启整个应用刷新页面就能看到效果这个体验比 Spring Boot DevTools 还要顺滑。运行速度快则主要得益于它的架构设计。JFinal 没有使用反射来做路由映射而是在启动时就把路由表构建成一个ActionMapping的哈希表请求进来后直接查表定位到Action然后执行。整个请求处理链路非常短没有复杂的过滤器链和 AOP 代理嵌套。我用 JMeter 做了一个简单的压测对比同样的一个返回 JSON 的接口JFinal 在 100 并发下的平均响应时间是 3ms 左右Spring Boot 是 8ms 左右。当然这个数据受很多因素影响但趋势是明显的——JFinal 的请求处理开销确实更小。不过这里要客观说一句运行速度的差异在绝大多数业务场景下并不关键。你的瓶颈大概率在数据库查询、网络 IO 或者业务逻辑本身而不是框架的请求分发。所以“快”这个优势更多是体现在开发效率和部署体验上而不是压测数字上。2.3 适合什么场景不适合什么场景用了这一轮之后我对 JFinal 的适用边界有了比较清晰的认识。适合的场景中小型 Web 应用、内部管理系统、API 网关层、快速原型开发、个人项目、教学演示。这些场景的共同特点是业务逻辑相对直接不需要复杂的依赖注入和 AOP 切面团队规模小迭代速度快。不太适合的场景大型企业级应用需要复杂的模块化和依赖管理、需要深度集成 Spring 生态的项目比如 Spring Cloud、Spring Security 的复杂配置、团队已经深度绑定 Spring 技术栈且没有切换动力的情况。这不是说 JFinal 做不了大项目而是说在大项目里Spring 生态的成熟度和工具链优势会更明显。选型这件事从来不是“哪个框架更好”而是“哪个框架更适合当前这个项目”。3. 从零搭建一个 JFinal 项目的完整实操3.1 环境准备与项目骨架搭建我这次用的是 Maven 来管理依赖JDK 版本是 17JFinal 版本是 5.1.0。虽然 JFinal 对 JDK 版本要求不高JDK 8 就能跑但我建议至少用 JDK 11 以上因为很多现代工具链已经不再支持 JDK 8 了。先看 Maven 依赖核心就两个dependency groupIdcom.jfinal/groupId artifactIdjfinal/artifactId version5.1.0/version /dependency dependency groupIdcom.jfinal/groupId artifactIdjfinal-undertow/artifactId version3.5/version /dependency第一个是 JFinal 核心包第二个是 Undertow 服务器插件。JFinal 默认不绑定 Servlet 容器你可以选择 Tomcat、Jetty 或者 Undertow。我选 Undertow 是因为它启动快、内存占用小而且 JFinal 官方对它的支持最好。项目结构我建议这样组织src/main/java/ config/ AppConfig.java controller/ UserController.java model/ User.java interceptor/ AuthInterceptor.java src/main/webapp/ WEB-INF/ view/ user/ list.html这个结构比 Spring Boot 的“约定结构”要自由得多你可以按自己的习惯调整。但有一点要注意JFinal 的模板文件默认放在src/main/webapp/WEB-INF/view下如果你用 Maven 的标准目录结构需要确保这个路径被正确打包。3.2 核心配置类的编写与参数解读配置类是 JFinal 的入口所有东西都在这里组装。我先把完整的配置代码贴出来然后逐段解释。public class AppConfig extends JFinalConfig { Override public void configConstant(Constants me) { me.setDevMode(true); me.setEncoding(UTF-8); me.setJsonFactory(new FastJsonFactory()); } Override public void configRoute(Routes me) { me.add(/user, UserController.class); me.add(/api/blog, BlogController.class, /api); } Override public void configPlugin(Plugins me) { DruidPlugin druidPlugin new DruidPlugin( jdbc:mysql://localhost:3306/demo, root, password); me.add(druidPlugin); ActiveRecordPlugin arp new ActiveRecordPlugin(druidPlugin); arp.addMapping(user, User.class); arp.setShowSql(true); me.add(arp); } Override public void configInterceptor(Interceptors me) { me.add(new AuthInterceptor()); } Override public void configHandler(Handlers me) { // 可以添加全局处理器 } }configConstant里我设置了三个东西。setDevMode(true)开启开发模式这个模式下 JFinal 会输出详细的请求日志并且支持热加载。setEncoding(UTF-8)设置默认编码避免中文乱码。setJsonFactory(new FastJsonFactory())是替换默认的 JSON 实现JFinal 默认用的是 Jackson我换成 FastJson 是因为它在序列化复杂对象时性能更好而且对循环引用的处理更友好。configRoute是路由注册。me.add(/user, UserController.class)表示所有/user开头的请求都交给UserController处理。第二个参数/api是视图路径的前缀意思是这个控制器返回的视图会去/api目录下找。这个设计很实用可以让不同模块的视图文件分开存放。configPlugin里配置了数据库连接池和 ActiveRecord。我用的是 Druid 连接池这是国内项目里很常见的选择监控功能强。arp.addMapping(user, User.class)把数据库的user表和User模型类关联起来。setShowSql(true)会在控制台打印执行的 SQL开发阶段很有用上线时记得关掉。configInterceptor注册了全局拦截器。JFinal 的拦截器是方法级别的可以精确控制到某个 Controller 的某个方法。这个后面会详细讲。3.3 控制器与路由的实战写法JFinal 的 Controller 写法跟 Spring MVC 差别很大我先把代码贴出来public class UserController extends Controller { public void index() { renderText(Hello JFinal); } public void list() { ListUser users User.dao.find(select * from user); setAttr(users, users); render(user/list.html); } public void detail() { int id getInt(id, 0); User user User.dao.findById(id); if (user null) { renderJson(Ret.fail(msg, 用户不存在)); return; } renderJson(Ret.ok(data, user)); } public void save() { User user getModel(User.class, user); user.save(); renderJson(Ret.ok()); } }几个关键点。第一Controller 里的方法名直接对应 URL 路径。比如list()方法对应的访问路径是/user/list不需要任何注解。第二renderText、renderJson、render是渲染方法分别对应返回纯文本、JSON 和模板视图。第三getInt、getModel是参数获取方法JFinal 会自动把请求参数绑定到模型对象上。这里有一个我踩过的坑getModel的参数名必须和前端表单的字段名前缀一致。比如getModel(User.class, user)要求前端传的参数是user.name、user.age这种格式。如果前端直接传name、age需要用getModel(User.class, )或者手动getPara逐个获取。这个设计是为了支持一个表单提交多个模型的情况但在简单场景下反而有点绕。3.4 ORM 与数据库操作的细节JFinal 的 ORM 是我这次评测的重点之一。先看模型类的定义public class User extends ModelUser { public static final User dao new User().dao(); }就这两行。没有 getter/setter没有字段声明没有注解。所有字段都是动态的通过user.get(name)或者user.getStr(name)获取。这种设计的好处是极简坏处是失去了编译期检查字段名写错了要到运行时才发现。查询操作我常用的几种写法// 根据主键查询 User user User.dao.findById(1); // 条件查询 ListUser users User.dao.find(select * from user where age ?, 18); // 分页查询 PageUser page User.dao.paginate(1, 10, select *, from user where status ?, 1); // 保存 User user new User(); user.set(name, 张三).set(age, 25); user.save(); // 更新 user.set(age, 26).update(); // 删除 User.dao.deleteById(1);paginate方法是 JFinal 的一个亮点它自动处理了分页查询的 count 语句和 limit 语句你只需要传页码、每页条数和查询条件就行。这个在写后台列表页的时候非常省事。注意JFinal 的find方法返回的是ListModel如果你需要返回自定义的 DTO可以用Db.find配合BeanKit或者手动转换。另外paginate的 count 语句是自动生成的如果查询条件很复杂建议手动写 count 语句避免性能问题。3.5 模板引擎与前端渲染JFinal 自带一个叫 Enjoy 的模板引擎语法跟 Velocity 和 FreeMarker 类似但更简洁。我一开始想用 Thymeleaf但发现 JFinal 对 Enjoy 的集成最顺滑就干脆用 Enjoy 了。一个典型的列表页模板#for(user : users) tr td#(user.id)/td td#(user.name)/td td#(user.age)/td /tr #end#for是循环指令#(表达式)是输出指令。Enjoy 的语法设计原则是“尽量少写字符”所以没有${}这种冗长的写法。条件判断用#if、#elseif、#else跟 Java 的语法很接近。Enjoy 还支持模板布局和模板函数可以做一些简单的复用。但说实话如果你习惯了前后端分离的开发模式JFinal 的模板引擎可能用得不多。我这次评测的项目是传统的服务端渲染所以模板用得比较多。如果是前后端分离的项目Controller 里直接renderJson就行模板引擎基本不碰。4. 拦截器、插件与扩展机制4.1 拦截器的实现与执行顺序JFinal 的拦截器机制是我觉得设计得比较优雅的一块。它不像 Spring 的 HandlerInterceptor 那样有 preHandle、postHandle、afterCompletion 三个方法而是只有一个intercept方法通过inv.invoke()来控制是否继续执行。public class AuthInterceptor implements Interceptor { Override public void intercept(Invocation inv) { Controller controller inv.getController(); String token controller.getHeader(Authorization); if (token null || !validateToken(token)) { controller.renderJson(Ret.fail(msg, 未登录)); return; } inv.invoke(); } }inv.invoke()之前的代码相当于 preHandle之后的代码相当于 postHandle。如果不调用inv.invoke()请求就被拦截了不会进入 Controller 方法。拦截器的注册有三个级别全局拦截器在configInterceptor里注册、Controller 级别在 Controller 类上加Before注解、方法级别在方法上加Before。执行顺序是全局 - Controller - 方法形成一个拦截器链。实操心得拦截器里不要做太重的操作比如查数据库。我一开始在 AuthInterceptor 里每次都查用户表验证 token后来发现 QPS 一高数据库压力很大。后来改成用本地缓存存 token 和用户信息的映射性能好了很多。拦截器适合做轻量的前置检查重操作应该放到 Service 层或者用缓存解决。4.2 常用插件的选型与配置JFinal 的插件生态不算丰富但常用的都有。我这次用了三个插件Druid 连接池、ActiveRecordORM、EhCache缓存。Druid 的配置前面已经展示了这里补充几个关键参数DruidPlugin druidPlugin new DruidPlugin(url, user, password); druidPlugin.setInitialSize(5); druidPlugin.setMinIdle(5); druidPlugin.setMaxActive(20); druidPlugin.setMaxWait(60000); druidPlugin.setValidationQuery(select 1);initialSize是初始连接数minIdle是最小空闲连接数maxActive是最大连接数。这三个参数的设置要根据实际并发量来定。我的经验是maxActive不要超过数据库的最大连接数除以应用实例数否则会出现连接等待。maxWait是获取连接的超时时间单位毫秒设成 60000 表示最多等 60 秒。EhCache 插件的配置也很简单EhCachePlugin ehCachePlugin new EhCachePlugin(); me.add(ehCachePlugin);然后在需要缓存的方法上加Cacheable注解就行。但要注意JFinal 的缓存注解是方法级别的而且默认的 key 生成策略比较简单复杂场景下建议手动用CacheKit操作缓存。4.3 自定义插件的开发思路JFinal 的插件机制基于IPlugin接口只有start()和stop()两个方法。如果你有一些需要在应用启动时初始化的资源比如定时任务、消息队列消费者、自定义线程池都可以封装成插件。public class SchedulerPlugin implements IPlugin { private ScheduledExecutorService scheduler; Override public boolean start() { scheduler Executors.newScheduledThreadPool(2); scheduler.scheduleAtFixedRate(() - { // 执行定时任务 }, 0, 1, TimeUnit.HOURS); return true; } Override public boolean stop() { if (scheduler ! null) { scheduler.shutdown(); } return true; } }这个插件在应用启动时创建一个定时任务线程池每小时执行一次任务。start()返回 true 表示启动成功返回 false 会导致应用启动失败。stop()在应用关闭时调用用来释放资源。注意插件的start()方法是在应用启动时同步调用的如果里面有耗时操作比如建立网络连接会拖慢启动速度。建议把耗时操作放到异步线程里执行或者用懒加载的方式。5. 实际开发中遇到的问题与排查记录5.1 常见异常与解决思路这一轮评测下来我遇到了几个比较典型的问题整理成表格方便查阅问题现象可能原因解决思路启动时报ClassNotFoundException: com.jfinal.core.JFinalFilter缺少 jfinal 依赖或依赖未正确打包检查 Maven 依赖确认jfinal包在 classpath 中访问接口返回 404路由未注册或路径不匹配检查configRoute中的路径和 Controller 方法名数据库查询返回空结果表名映射错误或字段名不匹配检查addMapping的表名和数据库实际表名是否一致模板渲染报错Template not found视图路径配置错误确认模板文件在WEB-INF/view下且路径大小写正确热加载不生效devMode未开启或 IDE 未编译确认setDevMode(true)并在 IDE 中开启自动编译其中我踩得最久的一个坑是表名映射。JFinal 默认会把模型类的类名转成下划线格式作为表名比如UserProfile会映射到user_profile。但如果你的表名不符合这个规则就必须在addMapping里显式指定。我一开始没注意查了半天才发现是表名不对。5.2 性能调优的实操经验JFinal 本身的性能开销很小所以调优的重点不在框架本身而在数据库和缓存。第一个优化点是连接池参数。我一开始用默认配置压测时发现 QPS 上不去后来把maxActive从 10 调到 20QPS 提升了将近一倍。但也不是越大越好连接数太多会导致数据库端的上下文切换开销增加。我的经验值是maxActive设置为CPU 核数 * 2 磁盘数左右比较合适。第二个优化点是SQL 日志。开发阶段setShowSql(true)很方便但上线后一定要关掉否则每条 SQL 都打印日志IO 开销很大。我是在配置类里根据环境变量来判断是否开启arp.setShowSql(dev.equals(System.getProperty(env)));第三个优化点是分页查询的 count 语句。JFinal 的paginate方法会自动生成 count 语句但它的生成逻辑是简单地把select *替换成select count(*)。如果原查询有order by或者group by生成的 count 语句可能不正确或者性能很差。这种情况下建议手动传 count 语句String select select * from user where status ?; String count select count(*) from user where status ?; PageUser page User.dao.paginate(1, 10, select, count, 1);5.3 那些官方文档没写的坑有几个问题我在官方文档里没找到明确说明是靠自己试出来的。第一个坑getModel和getBean的区别。getModel返回的是Model对象字段是动态的getBean返回的是普通的 Java Bean需要有无参构造器和 setter 方法。如果你用的是普通的 POJO 而不是继承Model就必须用getBean。我一开始混用了导致字段绑定不上排查了很久。第二个坑事务的写法。JFinal 的事务不是通过注解实现的而是通过Db.tx()或者Tx拦截器。我一开始想找Transactional注解发现根本没有。正确的写法是Db.tx(() - { user.set(balance, 100).update(); order.set(status, 1).update(); return true; });Db.tx()接受一个Atomic接口的实现返回 true 表示提交返回 false 或者抛异常表示回滚。这个设计比注解更灵活但也更容易写错——如果你忘了返回 true事务就不会提交。第三个坑静态方法的线程安全。JFinal 的User.dao是一个静态字段但它是线程安全的因为dao()方法返回的是一个无状态的代理对象。不过如果你在 Model 里加了自定义的实例字段就要注意线程安全问题。我的建议是Model 类里只放静态的 dao 字段不要加任何实例字段。6. 打包部署与生产环境注意事项6.1 打包方式的选型JFinal 支持两种打包方式打 WAR 包部署到外部 Servlet 容器或者打成可执行 JAR 包内嵌 Undertow 启动。我两种都试了最终推荐可执行 JAR的方式。WAR 包的好处是可以部署到已有的 Tomcat 里适合那些已经有运维体系的公司。但缺点是启动慢、配置繁琐而且需要额外管理 Tomcat 的版本和配置。可执行 JAR 的好处是启动快、部署简单一个java -jar命令就搞定。配合 Maven 的maven-assembly-plugin或者maven-shade-plugin可以把所有依赖打成一个 JAR 包。我用的配置是plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-shade-plugin/artifactId version3.4.1/version executions execution phasepackage/phase goalsgoalshade/goal/goals configuration transformers transformer implementationorg.apache.maven.plugins.shade.resource.ManifestResourceTransformer mainClasscom.demo.Main/mainClass /transformer /transformers /configuration /execution /executions /plugin启动类很简单public class Main { public static void main(String[] args) { UndertowServer.start(AppConfig.class, 8080, true); } }UndertowServer.start的三个参数分别是配置类、端口号、是否开启热加载。生产环境把第三个参数设为 false。6.2 生产环境的配置调整开发环境和生产环境的配置差异主要有几点。第一devMode必须关掉否则会有安全和性能问题。第二setShowSql必须关掉避免日志爆炸。第三连接池参数要根据实际负载调整生产环境建议maxActive设大一些maxWait设小一些快速失败比长时间等待更好。第四日志级别要调整JFinal 默认用的是 SLF4J可以通过 logback 或 log4j2 的配置文件控制。还有一个容易被忽略的点静态资源的处理。JFinal 默认会处理webapp目录下的静态资源但在生产环境建议用 Nginx 来托管静态资源JFinal 只负责动态请求。这样既能减轻应用服务器的压力又能利用 Nginx 的缓存和压缩能力。6.3 监控与日志的接入JFinal 本身没有内置监控功能但可以通过插件接入。我用的是 Druid 自带的监控页面配置一个 Servlet 就行me.add(new ServletKit(/druid/*, new StatViewServlet()));然后在浏览器访问/druid/index.html就能看到连接池的实时状态、SQL 执行统计等信息。这个在排查性能问题时非常有用。日志方面JFinal 用的是 SLF4J 门面你可以在 classpath 里放 logback.xml 或 log4j2.xml 来配置。我建议至少配置两个 appender一个输出到控制台开发用一个输出到文件生产用。文件日志要配置滚动策略避免磁盘被写满。实操心得生产环境的日志级别建议设为 INFO不要用 DEBUG。JFinal 在 DEBUG 级别下会输出大量请求细节虽然对排查问题有帮助但 IO 开销很大。如果确实需要排查某个接口的问题可以临时把那个包的日志级别调到 DEBUG排查完再调回来。7. 我对 JFinal 的最终判断7.1 它解决了什么问题没解决什么问题JFinal 解决的核心问题是让 Java Web 开发回归简单。它没有试图成为一个“全能框架”而是专注于把最常用的功能做到极致简洁。路由、ORM、模板、拦截器这四个东西覆盖了 Web 开发 80% 的场景JFinal 把这 80% 做得非常顺手。它没有解决的问题是复杂企业级应用的架构支撑。如果你需要分布式事务、服务治理、复杂的依赖注入和 AOP 切面JFinal 给不了你。这不是它的设计目标也不应该用它来评判它的好坏。7.2 什么团队适合用它根据我这次的评测体验我认为以下类型的团队适合用 JFinal小型团队1 到 3 个后端项目周期短迭代快不需要复杂的架构分层。内部系统后台管理、运营工具、数据看板这些系统业务逻辑直接对框架的生态要求不高。个人项目想快速把想法变成可运行的产品不想在框架配置上花太多时间。教学场景JFinal 的代码量少结构清晰适合用来讲解 Web 开发的核心概念。反过来如果你的团队已经在 Spring 生态里深耕多年或者项目需要复杂的微服务架构那 JFinal 可能不是最佳选择。选型这件事适合的才是最好的。7.3 如果重新选一次我还会用它吗会。但我会更明确地划定使用边界。对于我手头那些“不需要微服务、不需要复杂生态、就想快速把业务逻辑写出来”的项目JFinal 是一个非常好的选择。它的开发效率确实高代码量确实少启动确实快。这些优势在中小型项目里是实打实的。但我也清楚地知道如果项目规模变大、团队人数增加、业务复杂度上升我可能会考虑迁移到 Spring Boot。这不是因为 JFinal 不好而是因为 Spring 生态在大型项目里的工具链和社区支持更成熟。技术选型从来不是一锤子买卖而是随着项目阶段不断调整的过程。最后分享一个我在这次评测里养成的小习惯每次尝试一个新框架我都会用一个真实的小项目去跑完整流程而不是只看文档或者跑 Demo。因为文档里不会告诉你getModel和getBean的区别不会告诉你事务要手动返回 true不会告诉你生产环境要关掉 SQL 日志。这些细节只有真正动手做过才会知道。JFinal 这次给我的感觉是它不完美但在它擅长的场景里它做得足够好。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。