JFinal相关

12 minute read

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 是否生效。

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 必须为 false
  • setShowSql 关闭(避免日志刷 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= vs json= 对比。

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 模板引擎文档 模板语法