JFinal相关
JFinal 极速 Web 框架 完整系统学习大纲
本文面向测试工程师,按”概念 → 快速上手 → 配置 → Controller → ORM → 模板 → 拦截/校验 → 部署 → 测试实战”的顺序组织,由浅入深。 你已有 SpringBoot 篇,JFinal 是国产的另一条技术路线:更轻、更快、零 XML。国内不少中小型项目、后台管理系统、外包项目仍在用它。测试同学了解它,是为了看懂项目结构、知道接口在哪、怎么造数据、怎么定位问题。
一、JFinal 概述
1.1 什么是 JFinal?
- 定义:JFinal 是基于 Java 语言的极速 WEB + ORM 框架,国产开源(作者詹波)。
- 核心设计目标:开发迅速、代码量少、学习简单、功能强大、轻量级、易扩展、Restful。
- 设计哲学:COC 原则(Convention Over Configuration,约定优于配置)——零配置、无 XML,所有配置用 Java 代码写。
- 体积:核心 jar 极小(几百 KB),依赖极少。
1.2 核心特性
| 特性 | 说明 |
|---|---|
| MVC 架构 | Controller + Model + 模板引擎,职责清晰 |
| 零配置 | 无 web.xml 大量配置、无 spring.xml、无 struts.xml |
| ActiveRecord | 极简 ORM,Model + Db+Record 两种模式 |
| Enjoy 模板引擎 | 自带(5.x 默认),性能好,语法简洁 |
| AOP 支持 | Interceptor 拦截器,无侵入 |
| 插件体系 | Druid、Redis、EhCache、Quartz 等开箱即用 |
| 内嵌容器 | 支持 Undertow / Jetty / Tomcat,可打成 fatjar |
| 自动热加载 | devMode 下改代码立即生效 |
1.3 JFinal vs Spring Boot
| 维度 | JFinal | Spring Boot |
|---|---|---|
| 配置方式 | Java 代码配置,零 XML | 注解 + yml/properties |
| 依赖体积 | 极小 | 庞大 |
| ORM | ActiveRecord(自带) | MyBatis / JPA(需引入) |
| 模板引擎 | Enjoy(自带) | Thymeleaf / Freemarker |
| 启动速度 | 秒级 | 数秒 |
| 学习曲线 | 平缓,一天上手 | 较陡,概念多(IoC/AOP/自动配置) |
| 生态 | 小,以国内社区为主 | 巨大,全球标准 |
| 微服务 | 需自行整合(可配 Dubbo) | Spring Cloud 全家桶 |
| 适用场景 | 中小型项目、后台管理、快速交付 | 企业级、中大型、微服务 |
| 招聘/面试 | 少数公司要求 | 主流必备 |
💡 测试视角:两者对外都是 HTTP 接口 + 数据库,测试方法和工具完全一样。差异只在”去哪找接口定义”“怎么造数据”“日志在哪”。
1.4 适用场景与局限
适合:中小型 Web 应用、内部后台管理系统、快速原型、外包交付项目、已有遗留 JFinal 项目的维护。
局限:生态和社区较小、微服务治理能力弱、复杂企业级场景支持不足、招人相对难。
1.5 整体架构
请求 → JFinalFilter(或 Undertow)
↓
Handler 全局处理器(最外层,可接管请求)
↓
ActionMapping 路由分发(根据 URL 找 Controller.action)
↓
Interceptor AOP 拦截器(权限、日志、事务…)
↓
Controller.action 业务处理
↓ ┌─────────────┐
│ ActiveRecord │ → 数据库(Model / Db+Record)
└─────────────┘
↓
render(...) 渲染(JSON / HTML / 文本 / 文件 / 重定向)
二、快速上手
2.1 Maven 依赖
<dependencies>
<!-- JFinal 核心(版本号以 Maven 中央仓库为准) -->
<dependency>
<groupId>com.jfinal</groupId>
<artifactId>jfinal</artifactId>
<version>5.1.2</version>
</dependency>
<!-- 内嵌 Undertow(推荐,替代早期的 Jetty) -->
<dependency>
<groupId>com.jfinal</groupId>
<artifactId>jfinal-undertow</artifactId>
<version>3.2</version>
</dependency>
<!-- 数据库驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
</dependencies>
2.2 三种运行方式
| 方式 | 说明 | 适用 |
|---|---|---|
| 内嵌 Undertow | main 方法启动,打成 fatjar |
✅ 推荐,java -jar 即可 |
| 内嵌 Jetty | 早期常用(jetty-server 依赖) | 老项目 |
| 外置 Tomcat | 打 war 包,web.xml 配 JFinalFilter |
传统部署 |
2.3 第一个应用(完整可跑)
① 配置类
package com.demo;
import com.jfinal.config.*;
import com.jfinal.server.undertow.UndertowServer;
public class AppConfig extends JFinalConfig {
// 1. 常量配置
public void configConstant(Constants me) {
me.setDevMode(true); // 开发模式:热加载 + 详细报错
me.setBaseUploadPath("/upload"); // 文件上传根目录
me.setMaxPostSize(10 * 1024 * 1024); // 上传大小限制 10M
}
// 2. 路由配置
public void configRoute(Routes me) {
me.add("/hello", HelloController.class);
me.add("/blog", BlogController.class, "/view/blog"); // 第三个参数是视图路径
}
// 3. 插件配置
public void configPlugin(Plugins me) {
// 数据库插件(Druid 连接池)
DruidPlugin dp = new DruidPlugin(
"jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=utf8",
"root", "123456");
me.add(dp);
// ActiveRecord 插件(ORM)
ActiveRecordPlugin arp = new ActiveRecordPlugin(dp);
me.add(arp);
arp.addMapping("blog", "id", Blog.class); // 表名 → Model 类
}
// 4. 全局拦截器
public void configInterceptor(Interceptors me) {
// me.addGlobalActionInterceptor(new LogInterceptor());
}
// 5. 全局处理器
public void configHandler(Handlers me) {
// me.add(new UrlSkipHandler());
}
// 启动入口
public static void main(String[] args) {
UndertowServer.create(AppConfig.class, 8080).start();
}
}
② Controller
package com.demo;
import com.jfinal.core.Controller;
public class HelloController extends Controller {
// 访问 /hello 默认调用 index
public void index() {
renderText("Hello JFinal!");
}
// 访问 /hello/json
public void json() {
setAttr("code", 200);
setAttr("msg", "success");
renderJson(); // 把所有 setAttr 的值转成 JSON 输出
}
}
# 运行 main 方法,或
mvn clean package && java -jar target/demo.jar
# 验证
curl http://localhost:8080/hello # Hello JFinal!
curl http://localhost:8080/hello/json # {"code":200,"msg":"success"}
2.4 典型项目结构
src/main/java/
├── com/demo/config/
│ ├── AppConfig.java 主配置(继承 JFinalConfig)
│ ├── FrontRoutes.java 前台路由
│ └── AdminRoutes.java 后台路由
├── com/demo/controller/
│ ├── BlogController.java
│ └── UserController.java
├── com/demo/model/
│ └── Blog.java Model(ActiveRecord)
├── com/demo/service/
│ └── BlogService.java 业务逻辑(可选)
├── com/demo/interceptor/
│ └── LoginInterceptor.java
└── com/demo/validator/
└── BlogValidator.java
src/main/webapp/
├── view/blog/list.html Enjoy 模板
└── WEB-INF/
💡 测试同学看项目的顺序:先找
configRoute(所有接口 URL 都在这)→ 再看对应Controller的每个 public 方法(每个方法就是一个接口)→ 再看Model(表结构)。这比 SpringBoot 找@RequestMapping更集中。
三、核心配置:JFinalConfig
3.1 五个配置方法
| 方法 | 作用 | 常用程度 |
|---|---|---|
configConstant |
常量、开发模式、上传路径、编码 | ★★★ |
configRoute |
路由映射(URL → Controller) | ★★★ |
configPlugin |
数据源、ORM、缓存、定时任务 | ★★★ |
configInterceptor |
全局拦截器 | ★★ |
configHandler |
全局处理器(最外层) | ★ |
3.2 configConstant 常用项
public void configConstant(Constants me) {
me.setDevMode(true); // 开发模式(生产必须 false!)
me.setEncoding("UTF-8"); // 编码
me.setBaseViewPath("/WEB-INF/view"); // 视图根目录
me.setBaseUploadPath("/upload"); // 上传根目录
me.setMaxPostSize(10 * 1024 * 1024); // 最大 POST 大小
me.setJsonFactory(new FastJsonFactory()); // JSON 实现(默认 JFinal 自带)
me.setError404View("/404.html"); // 404 页面
me.setError500View("/500.html");
}
⚠️ 测试必查项:
devMode在生产环境必须为false,否则会暴露详细异常堆栈(信息泄露),且性能差。上线前检查这一项。
3.3 configRoute 路由
public void configRoute(Routes me) {
// 基本:/blog → BlogController(默认调用 index 方法)
me.add("/blog", BlogController.class);
// 指定视图路径:模板在 /view/blog/ 下
me.add("/blog", BlogController.class, "/view/blog");
// 分组路由(推荐用于区分前后台)
me.add(new FrontRoutes());
me.add(new AdminRoutes());
}
public class AdminRoutes extends Routes {
public void config() {
setBaseViewPath("/view/admin");
add("/admin/user", UserController.class);
add("/admin/order", OrderController.class);
}
}
路由规则:
/blog → BlogController.index()
/blog/list → BlogController.list()
/blog/save → BlogController.save()
即:controllerKey = /blog,方法名 = URL 的第二段
3.4 configPlugin 插件
public void configPlugin(Plugins me) {
// 1. 数据源(Druid)
DruidPlugin dp = new DruidPlugin(jdbcUrl, user, password);
dp.setInitialSize(5).setMaxActive(50); // 连接池参数
me.add(dp);
// 2. ORM
ActiveRecordPlugin arp = new ActiveRecordPlugin(dp);
arp.setShowSql(true); // 打印 SQL(开发环境)
me.add(arp);
arp.addMapping("blog", "id", Blog.class); // 表名、主键、Model
arp.addMapping("user", "user_id", User.class); // 自定义主键名
// 3. 缓存
me.add(new EhCachePlugin());
me.add(new RedisPlugin("main", "localhost", 6379));
// 4. 定时任务
me.add(new Cron4jPlugin());
}
四、Controller(Web 层)
4.1 基本结构
public class BlogController extends Controller {
// 每个 public 无参方法 = 一个可访问的 action
public void index() {
renderText("index");
}
public void list() {
List<Blog> list = Blog.dao.find("select * from blog order by id desc");
setAttr("blogList", list);
render("list.html"); // 渲染模板(相对 viewPath)
}
public void detail() {
int id = getParaToInt("id", 0);
Blog blog = Blog.dao.findById(id);
if (blog == null) {
renderError(404);
return;
}
setAttr("blog", blog);
renderJson();
}
}
4.2 获取请求参数(重点,测试时要知道传什么)
// 1. 基本参数(?id=1)
String id = getPara("id");
int idInt = getParaToInt("id", 0); // 带默认值
long idLong = getParaToLong("id");
Boolean flag = getParaToBoolean("flag");
Date date = getParaToDate("date");
// 2. URL 路径参数(/blog/detail/3,需在路由配置)
// me.add("/blog", BlogController.class); 后用 getPara(0)、getPara(1) 取
String p0 = getPara(0);
// 3. 映射到 Model(字段名与表字段一致,表单提交常用)
Blog blog = getModel(Blog.class);
Blog blog2 = getModel(Blog.class, "prefix"); // 带前缀
// 4. 映射到普通 JavaBean(POJO,支持下划线转驼峰)
User user = getBean(User.class);
// 5. 获取原始请求体(JSON 接口常用 ★)
String raw = getRawData();
// 配合 JSON 解析
JSONObject jo = JSON.parseObject(getRawData());
// 6. 文件上传
UploadFile file = getFile("fileName", "/upload/blog");
// 7. Header / Attribute
String token = getHeader("Authorization");
getRequest().getMethod();
💡 接口测试要点:JFinal 的
getPara同时支持 GET 的 query 参数和 POST 表单;getModel/getBean依赖表单字段名;纯 JSON 接口要用getRawData()。测之前先确认 Controller 用的是哪种,否则传了参数取不到。
4.3 渲染方法(renderXxx)
| 方法 | 作用 | 接口测试常见 |
|---|---|---|
renderJson() |
输出 setAttr 的全部属性为 JSON | ★★★ |
renderJson(Object) |
输出指定对象为 JSON | ★★★ |
renderJson(String key, Object value) |
输出指定键值对 | ★★ |
renderText(String) |
输出纯文本 | ★★ |
renderHtml(String) |
输出 HTML | ★ |
render(String view) |
渲染模板(Enjoy) | ★★ |
renderTemplate(String) |
渲染 Enjoy 模板 | ★★ |
renderNull() |
不输出(只返回 200) | ★ |
renderError(int) |
返回错误码(404/500) | ★ |
renderFile(File) |
文件下载 | ★ |
redirect(String) |
重定向 | ★★ |
forwardAction(String) |
内部转发到另一个 action | ★ |
public void api() {
Map<String, Object> ret = new HashMap<>();
ret.put("code", 200);
ret.put("data", Blog.dao.findAll());
renderJson(ret); // 直接输出 Map
}
4.4 文件上传与下载
// 上传
public void upload() {
UploadFile uf = getFile("file", "/upload/img"); // 表单字段名、子目录
String fileName = uf.getFileName();
renderJson("url", "/upload/img/" + fileName);
}
// 下载
public void download() {
renderFile(new File("/upload/img/a.png"));
}
⚠️ 测试点:上传接口要测文件类型(传 .jsp/.exe 是否被拦)、文件大小(超限是否有友好提示)、文件名(是否重命名防路径穿越)、
setMaxPostSize是否生效。
4.5 Session 与 Cookie
setSessionAttr("user", user);
Object user = getSessionAttr("user");
removeSessionAttr("user");
getSession().invalidate();
setCookie("token", "xxx", 3600);
String token = getCookie("token");
五、ActiveRecord(ORM 层,重点)
JFinal 的 ORM 叫 ActiveRecord,最大的特点是不需要 XML、不需要注解、不需要配字段映射。
5.1 Model 模式
// 定义:继承 Model,加一个 dao 静态对象
public class Blog extends Model<Blog> {
public static final Blog dao = new Blog().dao();
}
// 使用
Blog blog = Blog.dao.findById(1); // 按主键查
blog.set("title", "新标题");
blog.update(); // 更新
blog.delete(); // 删除
new Blog()
.set("title", "标题")
.set("content", "内容")
.save(); // 新增
List<Blog> list = Blog.dao.find("select * from blog where id > ?", 10);
Blog one = Blog.dao.findFirst("select * from blog where title = ?", "t");
long count = Blog.dao.findCount();
Model 常用方法:
| 方法 | 作用 |
|---|---|
save() |
新增 |
update() |
更新 |
delete() / deleteById(id) |
删除 |
findById(id) |
按主键查 |
find(sql, paras) |
查询列表 |
findFirst(sql, paras) |
查第一条 |
paginate(page, size, select, from) |
分页 |
getStr/getInt/getLong/getDate(col) |
取字段值 |
set(col, value) |
设字段值 |
toJson() |
转 JSON |
keep(...) / remove(...) |
保留/移除字段 |
5.2 Db + Record 模式(无 Model 时用,更灵活)
// 不需要定义 Model 类,直接操作表
Record r = Db.findById("blog", 1);
r.set("title", "新").update(); // 需指定主键
List<Record> list = Db.find("select * from blog where id > ?", 5);
Record one = Db.findFirst("select * from user where name=?", "张三");
Db.save("blog", new Record().set("title", "t"));
Db.update("update blog set title=? where id=?", "t", 1);
Db.deleteById("blog", 1);
Db.delete("delete from blog where id < ?", 10);
Model vs Db+Record:
| Model | Db + Record | |
|---|---|---|
| 需要定义类 | ✅ 需要 | ❌ 不需要 |
| 适用场景 | 有明确业务实体的表 | 临时查询、动态表、统计 |
| 关联 | 支持 | 更灵活 |
5.3 分页
public void list() {
int page = getParaToInt("page", 1);
int size = getParaToInt("size", 10);
Page<Blog> p = Blog.dao.paginate(page, size,
"select *", "from blog where status = ? order by id desc", 1);
setAttr("blogPage", p);
renderJson();
}
Page 对象属性:
getList() 当前页数据
getPageNumber() 当前页码
getPageSize() 每页条数
getTotalPage() 总页数
getTotalRow() 总记录数
isFirstPage() / isLastPage()
5.4 事务
// 方式一:Db.tx(推荐)
boolean ok = Db.tx(() -> {
Db.update("update account set balance = balance - ? where id=?", 100, 1);
Db.update("update account set balance = balance + ? where id=?", 100, 2);
return true; // 返回 false 或抛异常则回滚
});
// 方式二:@Before(Tx.class) 注解在 Controller 方法上
@Before(Tx.class)
public void transfer() {
...
}
💡 测试点:事务是高频 Bug 区。测试方法:故意让第二步失败(如转出成功但转入账号不存在),看第一步是否回滚,余额是否守恒。并发转账更要测(见 5.5)。
5.5 与 MyBatis 对比(测试对比理解)
| 维度 | JFinal ActiveRecord | MyBatis |
|---|---|---|
| 配置 | 零配置,addMapping 一行 | XML 或注解写 SQL 映射 |
| SQL 写法 | Java 字符串拼接 | XML 里写,可复用 |
| 复杂查询 | 写原生 SQL | 动态 SQL 标签更强大 |
| 学习成本 | 低 | 中 |
| 字段映射 | 自动(按表名/字段名) | 需配置 resultMap |
⚠️ 安全测试点:ActiveRecord 的
find(sql, paras)用?占位符是预编译(安全);但如果开发写成字符串拼接"select * from blog where id=" + id,就有 SQL 注入。测试时可直接审代码搜+拼接的 SQL。
六、Enjoy 模板引擎
JFinal 自带的模板引擎,5.x 起为默认,性能好、语法简洁。
6.1 基本语法
<!-- 输出变量 -->
#(blog.title)
#(blog.title ?? '默认标题') <!-- 空值处理 -->
<!-- 设置变量 -->
#set(x = 123)
#set(list = [1, 2, 3])
<!-- 条件 -->
#if(cond)
...
#else if(cond2)
...
#else
...
#end
<!-- 循环 -->
#for(blog : blogList)
<p>#(blog.title)</p>
#end
<!-- 带下标 -->
#for(i : [1..10])
#(i)
#end
<!-- 引入其他模板 -->
#include("header.html")
<!-- 模板函数(布局) -->
#define layout()
<html><body>
#@content()
</body></html>
#end
#call layout()
#define content()
<h1>页面内容</h1>
#end
#end
6.2 常用指令速查
| 指令 | 作用 |
|---|---|
#(expr) |
输出表达式 |
#set(k = v) |
赋值 |
#if / #else / #end |
条件 |
#for(x : list) |
循环 |
#include("x.html") |
引入模板 |
#define / #@ |
定义与调用模板函数 |
#[[ ... ]]# |
原样输出(不解析) |
#-- 注释 --# |
注释 |
6.3 Controller 传值
setAttr("blog", blog);
setAttr("list", list);
render("detail.html"); // 模板里用 #(blog.title)
💡 测试视角:页面显示异常时,先区分是数据问题还是模板问题——接口返回 JSON 正确但页面错,就是模板或前端问题。
七、Interceptor 与 Validator
7.1 Interceptor(AOP 拦截器)
public class LoginInterceptor implements Interceptor {
public void intercept(Invocation inv) {
Controller c = inv.getController();
if (c.getSessionAttr("user") == null) {
c.redirect("/login");
return; // 不放行
}
inv.invoke(); // 放行,继续执行
}
}
// 使用方式一:注解在方法上
@Before(LoginInterceptor.class)
public void save() { ... }
// 使用方式二:注解在类上(作用于所有方法)
@Before(LoginInterceptor.class)
public class AdminController extends Controller { ... }
// 使用方式三:多个拦截器(按顺序执行)
@Before({LoginInterceptor.class, PermissionInterceptor.class})
public void delete() { ... }
// 使用方式四:全局配置
me.addGlobalActionInterceptor(new LogInterceptor());
典型用途:登录校验、权限控制、日志记录、事务(Tx.class)、防重复提交、接口耗时统计。
7.2 执行顺序(重要)
请求
├─ Handler (最外层,可完全接管)
├─ GlobalActionInterceptor(全局)
├─ Controller 级 @Before
├─ Method 级 @Before
├─ Validator (校验,也是一种拦截器)
└─ Controller.action (业务方法)
响应
💡 测试要点:权限类问题(越权)通常出在拦截器的覆盖范围——某个方法忘了加
@Before(LoginInterceptor.class),或全局拦截器排除了某些路径。测试时可以逐个接口不带 token 请求一遍,找出漏网之鱼。
7.3 Validator(参数校验)
public class BlogValidator extends Validator {
protected void validate(Controller c) {
// 必填校验
validateRequiredString("title", "titleMsg", "标题不能为空");
// 长度校验
validateString("title", 1, 100, "titleMsg", "标题长度 1-100");
// 数字范围
validateLong("userId", 1, 999999, "userMsg", "用户ID不合法");
// 正则(手机号)
validateRegex("phone", "^1\\d{10}$", "phoneMsg", "手机号格式错误");
// 邮箱
validateEmail("email", "emailMsg", "邮箱格式错误");
// 相等(密码确认)
validateEqualField("password", "confirmPwd", "pwdMsg", "两次密码不一致");
// 自定义
validateUrl("site", "urlMsg", "URL 不合法");
}
protected void handleError(Controller c) {
c.keepPara("title", "content"); // 回显已填内容
c.render("add.html");
}
}
@Before(BlogValidator.class)
public void save() {
...
}
💡 测试点:Validator 的校验只在用了它的 action 上生效。直接调接口绕过页面(比如直接 POST 到 save)时,如果 save 加了 Validator 就会校验;但如果另一个 action 也写库却没加,就能绕过。这是逻辑漏洞的常见来源——每个写操作接口都要单独验证参数校验。
八、插件体系
| 插件 | 作用 | 备注 |
|---|---|---|
| DruidPlugin | 数据源/连接池(阿里 Druid) | 自带监控页面 |
| ActiveRecordPlugin | ORM | 核心 |
| EhCachePlugin | 本地缓存 | |
| RedisPlugin | Redis 缓存 | Redis.use("main") |
| QuartzPlugin / Cron4jPlugin | 定时任务 | |
| ShiroPlugin | 权限框架 |
// Redis 使用示例
me.add(new RedisPlugin("main", "localhost", 6379, 30, "password"));
// 业务代码
Cache cache = Redis.use("main");
cache.set("key", "value");
String v = cache.get("key");
cache.del("key");
// Cron4j 定时任务
Cron4jPlugin cp = new Cron4jPlugin();
cp.addTask("*/5 * * * *", new MyTask());
me.add(cp);
九、部署与运维
9.1 打包
<!-- 打成 fatjar(含依赖),用于 java -jar 启动 -->
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<version>3.6.0</version>
<configuration>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
<archive>
<manifest>
<mainClass>com.demo.AppConfig</mainClass>
</manifest>
</archive>
</configuration>
<executions>
<execution>
<id>make-assembly</id>
<phase>package</phase>
<goals><goal>single</goal></goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
mvn clean package
java -jar target/demo-jar-with-dependencies.jar
外置 Tomcat(war 包) 需在 web.xml 配置过滤器:
<filter>
<filter-name>jfinal</filter-name>
<filter-class>com.jfinal.core.JFinalFilter</filter-class>
<init-param>
<param-name>configClass</param-name>
<param-value>com.demo.AppConfig</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>jfinal</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
9.2 生产环境检查清单(测试要参与)
devMode必须为 falsesetShowSql关闭(避免日志刷 SQL)- 数据库连接池参数合理(maxActive)
- 上传目录权限与大小限制
- 自定义 404/500 页面(不暴露堆栈)
- Druid 监控页面不能对外网开放(
/druid/index.html会泄露 SQL 与配置信息) - 日志级别与切割
- 后台管理路径是否需要 IP 白名单
十、测试视角:JFinal 项目怎么测(重点)
10.1 测试分层策略
| 层次 | 测试方式 | 工具 |
|---|---|---|
| Controller / 接口层 | HTTP 接口测试(主要) | Postman / requests / JMeter |
| Service / Model 层 | JUnit 单元测试 | JUnit + ActiveRecord 独立启动 |
| 页面层 | UI 自动化 | Selenium / Playwright |
| 定时任务 | 手工触发 + 检查数据库 | 管理后台 / 直接调方法 |
JFinal 项目的特点:Controller 依赖 Servlet 容器,单测成本高,所以接口测试是最主要、最高效的手段。Model 层可以脱离 Web 单独测试。
10.2 接口测试(主力方式)
第一步:梳理接口清单 —— 从 configRoute + Controller 的所有 public 方法导出:
/blog (index) → GET /blog
/blog/list (list) → GET /blog/list
/blog/save (save) → POST /blog/save
/blog/detail (detail) → GET /blog/detail?id=1
# test_blog_api.py —— 用 requests 测 JFinal 接口
import requests
BASE = "http://localhost:8080"
def test_hello():
r = requests.get(f"{BASE}/hello")
assert r.status_code == 200
assert "Hello JFinal" in r.text
def test_blog_list():
r = requests.get(f"{BASE}/blog/list", params={"page": 1, "size": 10})
assert r.status_code == 200
data = r.json()
assert "blogList" in data or "blogPage" in data
def test_blog_save_form():
"""JFinal 的 getModel 依赖表单提交"""
r = requests.post(f"{BASE}/blog/save",
data={"title": "测试标题", "content": "内容"}) # form 表单
assert r.status_code == 200
def test_blog_save_json():
"""若 Controller 用 getRawData() 接收 JSON"""
r = requests.post(f"{BASE}/blog/save",
json={"title": "测试标题", "content": "内容"})
assert r.status_code == 200
def test_detail_not_found():
r = requests.get(f"{BASE}/blog/detail", params={"id": 999999})
assert r.status_code in (404, 200) # 看业务约定:应返回明确的错误信息
💡 最常见的坑:用
json=传参但 Controller 用的是getPara/getModel→ 取不到值,参数全为空。先确认 Controller 用哪种方式接收,再决定用data=(表单)还是json=(请求体)。详见 requests 篇的data=vsjson=对比。
10.3 Model / Service 层单测(脱离 Web)
ActiveRecord 可以独立启动,不需要 Web 容器:
// BaseModelTest.java
import com.jfinal.plugin.activerecord.*;
import com.jfinal.plugin.druid.DruidPlugin;
import org.junit.*;
public class BaseModelTest {
@BeforeClass
public static void initAR() {
DruidPlugin dp = new DruidPlugin(
"jdbc:mysql://localhost:3306/demo_test", "root", "123456");
ActiveRecordPlugin arp = new ActiveRecordPlugin(dp);
arp.addMapping("blog", "id", Blog.class);
dp.start();
arp.start();
}
@AfterClass
public static void stopAR() {
// 关闭插件(按实际 API)
}
}
public class BlogTest extends BaseModelTest {
@Test
public void testCrud() {
Blog b = new Blog().set("title", "单测标题").set("content", "内容");
Assert.assertTrue(b.save());
Blog saved = Blog.dao.findById(b.get("id"));
Assert.assertEquals("单测标题", saved.getStr("title"));
saved.set("title", "改后").update();
Assert.assertEquals("改后", Blog.dao.findById(b.get("id")).getStr("title"));
Assert.assertTrue(saved.delete());
Assert.assertNull(Blog.dao.findById(b.get("id")));
}
@Test
public void testTransactionRollback() {
// 验证事务:第二条失败应回滚第一条
boolean ok = Db.tx(() -> {
Db.update("update account set balance = balance - 100 where id = 1");
Db.update("update account set balance = balance + 100 where id = 999"); // 不存在
return true;
});
// 断言余额守恒
}
}
💡 测试数据:单测要连独立的测试库(
demo_test),并在@Before里造数据、@After里清理,避免污染。
10.4 JFinal 项目专项测试点
| 类型 | 测试点 |
|---|---|
| 参数 | 必填、类型、长度、边界、特殊字符、空值、getParaToInt 的默认值行为 |
| 权限 | 每个接口不带 token 访问;低权限访问高权限接口(越权) |
| SQL 注入 | 搜索框、排序字段、ID 参数;检查代码里 SQL 是否有字符串拼接 |
| 上传 | 文件类型、大小、文件名、路径穿越、重命名 |
| 事务 | 多步操作失败是否回滚;并发下数据是否守恒 |
| 分页 | 页码越界(0、-1、超大)、每页条数、空列表 |
| 缓存 | Redis 缓存与数据库一致性;缓存失效后的表现 |
| 定时任务 | 触发时间、重复执行、失败重试、并发执行 |
| devMode | 生产环境是否关闭(信息泄露) |
| Druid 监控 | /druid 是否对外暴露 |
| 编码 | 中文参数、中文文件名、emoji |
| 并发 | 秒杀/库存/余额的并发扣减(加锁是否到位) |
10.5 并发测试示例
# 并发扣减库存/余额测试
import threading
import requests
URL = "http://localhost:8080/order/create"
results = []
def create():
r = requests.post(URL, data={"productId": 1, "qty": 1}, timeout=10)
results.append(r.status_code)
# 库存只有 1 件
threads = [threading.Thread(target=create) for _ in range(20)]
[t.start() for t in threads]
[t.join() for t in threads]
ok = sum(1 for c in results if c == 200)
print(f"成功下单数: {ok}")
assert ok <= 1, f"超卖!库存 1 件却成功 {ok} 笔"
10.6 性能压测
用 JMeter 或 Locust 压 JFinal 接口时,重点观察:
- Druid 连接池:
maxActive是否够用(压测时大量超时可能是连接池打满) - SQL 慢查询:开启
setShowSql(true)或查 Druid 监控的慢 SQL - devMode:必须关闭,否则性能差异巨大
- Undertow 线程数:默认配置是否够
详见 JMeter 篇。
10.7 排查问题时的常见位置
| 现象 | 排查位置 |
|---|---|
| 404 | URL 拼错 / configRoute 没有该路由 / 方法名不存在 |
| 参数取不到 | getPara vs getRawData;表单 vs JSON(见 10.2) |
| 500 且暴露堆栈 | devMode=true,看堆栈定位行 |
| 页面空白 | 模板路径错(viewPath / baseViewPath) |
| 数据库操作没生效 | 表映射 addMapping 是否配;主键名是否一致 |
| 拦截器不生效 | @Before 是否加;全局拦截器是否注册 |
| 上传失败 | setMaxPostSize 超限;上传目录无写权限 |
| 中文乱码 | 编码配置;数据库连接串 characterEncoding=utf8 |
十一、常见问题速查
| 问题 | 原因 | 解决 |
|---|---|---|
ActiveRecord 报错表不存在 |
未 addMapping |
在 configPlugin 中注册表与 Model |
Model 的 dao 为 null |
没写 public static final X dao = new X().dao(); |
补上 |
| 页面找不到模板 | viewPath 配置与实际路径不符 |
检查 render() 相对路径 |
| 404 | 路由未注册或方法名错 | 检查 configRoute |
| 参数全是 null | 用了 json= 但代码用 getPara |
改 data= 表单提交,或代码改用 getRawData() |
| 启动报端口占用 | 8080 被占 | UndertowServer.create(AppConfig.class, 8081) |
| 生产暴露堆栈 | devMode=true |
改 false |
| 上传超限 | maxPostSize 太小 |
调大 setMaxPostSize |
| 事务不回滚 | 未用 Db.tx 或 @Before(Tx.class);异常被吞 |
检查事务边界 |
| 慢查询 | 无索引 / SQL 拼接不当 | 看 Druid 监控的慢 SQL |
十二、学习资源
| 资源 | 说明 |
|---|---|
| JFinal 官网 | 官方文档、下载、更新日志 |
| JFinal 官方文档 | 完整手册,中文,上手必读 |
| JFinal 开源项目 | 源码与 issue |
| jfinal-undertow | 内嵌 Undertow 部署 |
| JFinal Weixin | 微信开发 SDK |
| JFinal Club | 官方社区/问答 |
| ActiveRecord 文档 | ORM 章节 |
| Enjoy 模板引擎文档 | 模板语法 |