1. 从一次线上事故说起:表达式选型搞错了有多痛
先讲一个我早年踩过的坑。当时在某公司做一个报表权限系统,需求很简单:用户登录后,根据其角色动态渲染菜单和按钮。前端传过来一个表达式字符串,后端解析后判断“这个用户能不能点这个按钮”。我当时图省事,直接用 EL 表达式去解析前端传来的字符串,结果在测试环境一切正常,上线第二天就出事了——某个用户居然看到了不该看的导出按钮。
排查了一整天才定位到根因:EL 表达式在解析时踏踏实实地“取值”,而那段字符串里藏了一个方法调用。EL 根本不执行方法,直接返回 null,权限判断走了默认放行分支。如果当时用 OGNL,这个漏洞根本不会存在——因为 OGNL 会老老实实把那个方法调用执行出来,然后在权限层把它拦住。
这事让我彻底明白:OGNL 和 EL 根本不是“哪个更好用”的关系,而是“哪个场景该用哪个”的关系。很多 Java 开发者对这两者的认知停留在“一个能取值,一个也能取值”,但真正落到项目里,选错表达式引擎的代价往往是线上事故级别的。
这篇文章我不打算从教科书定义讲起,而是直接按“区别、原理、用法、踩坑”四个维度拆开揉碎,把我这些年实际项目里的经验、教训、排查思路全部倒出来。不管你是刚接触 JavaWeb 的新人,还是已经写过几年业务代码的老手,这篇文章都能帮你把表达式这个基础但关键的地基打牢。
2. OGNL 和 EL 的底层定位:同样叫表达式,其实是两种生物
2.1 EL 的出身:为 JSP 页面而生,追求的是“安全地读”
EL(Expression Language)最早是 JSP 2.0 规范引入的,核心目标只有一个:让页面里不要出现 Java 代码。在它出现之前,JSP 页面里写<%= request.getAttribute("user").getName() %>这种脚本片段是常态,页面又臭又长还难维护。EL 出现后,页面里只需要写${user.name}就够了。
这个出身决定了 EL 的基因:它是为“读取展示数据”设计的,天然偏向保守。
- 默认只支持属性访问、算术运算、逻辑运算、集合访问,不支持方法调用(除非配置 MethodExpression)。
- 遇到 null 值默认返回空字符串或 null,不会抛异常,也不会触发副作用。
- 设计哲学是“容错优先”,页面渲染不能因为某个数据缺失就整个 500。
用生活化类比:EL 像个“只读不写的图书馆管理员”,你问他要什么书,他给你找,找不到就告诉你“没有”,但他绝对不会把书拆开抄一段给你,更不会帮你改书里的内容。
2.2 OGNL 的出身:从 Struts 2 走红,天生就是“万能工具人”
OGNL(Object-Graph Navigation Language)全称是对象图导航语言,它的历史比 EL 更早,最初是一个独立的开源项目,后来被 Struts 2 选中作为默认表达式引擎,从此在 JavaWeb 圈子里有了姓名。
OGNL 的设计目标恰恰和 EL 相反:它要成为“访问 Java 对象图的瑞士军刀”。什么都能干:
- 支持方法调用:
user.getName()、user.doSomething() - 支持静态方法/属性访问
- 支持构造对象:
new java.util.Date() - 支持投影和选择:
users.{name}、users.{? age > 18} - 支持赋值操作:
user.name = "xxx" - 支持多表达式执行,用逗号分隔
- 甚至能做类型转换、 lambda 表达式
还是那个类比:OGNL 像个“万能工匠”,不仅能帮你找书,还能帮你抄书、改书、写书、甚至把书拆了重新装订。
2.3 两者的核心差异对照表
我把日常开发和面试里最常涉及的差异点整理成了一张表,建议你收藏:
| 对比维度 | EL | OGNL |
|---|---|---|
| 全称 | Expression Language | Object-Graph Navigation Language |
| 起源 | JSP 规范 | 独立开源项目,Struts 2 采用 |
| 核心设计目标 | 页面数据展示,安全读取 | 对象图任意操作,强能力 |
| 方法调用 | 默认不支持(需特殊配置) | 原生支持 |
| 静态成员访问 | 不支持 | 支持(@java.lang.Math@PI) |
| 赋值操作 | 不支持 | 支持 |
| 集合投影/选择 | 不支持 | 支持(OGNL 特色) |
| 构造对象 | 不支持 | 支持(new 表达式) |
| null 处理策略 | 容错,返回空 | 抛异常或按逻辑处理 |
| 典型应用场景 | JSP、Thymeleaf 部分场景、JSF | Struts 2、MyBatis 动态 SQL |
| 性能开销 | 轻量 | 相对较重 |
| 安全性 | 相对安全(能力弱) | 危险(能力越强越危险) |
这张表背后有一条核心结论:EL 的能力边界是它的安全边界,而 OGNL 的能力边界同时也是它的安全风险边界。你选的表达式引擎越强大,越要小心它被恶意利用。
2.4 为什么 MyBatis 选了 OGNL 而不选 EL
一个很有意思的问题:MyBatis 的动态 SQL(<if test="...">)为什么选 OGNL,而不是选更轻量的 EL?
我当初也困惑过,后来看了 MyBatis 源码里的ExpressionEvaluator类才算想明白。MyBatis 需要的不只是“判断某个属性是否为 null”,还要支持user.name != null and user.age > 18这种组合判断,甚至有时候要调用实体类上的方法。EL 的保守策略在这里根本不够用——比如默认不支持方法调用,这在写动态 SQL 时就是个硬伤。
而 OGNL 的完整表达式能力,让 MyBatis 可以优雅地支持复杂多变的动态查询条件。代价就是 MyBatis 启动时解析这些表达式有一定的性能开销和安全隐患,但相对于它换来的灵活性,这笔买卖值。
3. 核心用法拆解:EL 和 OGNL 的语法与实战示例
3.1 EL 的基本用法:三分钟上手,但有隐藏边界
EL 的基础语法非常简单,核心就是${}。我直接按场景列:
属性访问
${user.name} ${user.address.city} ${list[0]} ${map["key"]}运算
${count + 1} ${count > 10 && flag} ${empty list}隐式对象(这是 EL 独有的,JSP 场景很常用)
${param.username} ${sessionScope.user} ${applicationScope.config}EL 的empty运算符是个好东西,能同时判断 null、空字符串、空集合:
${empty userList ? "暂无数据" : "有数据"}EL 的隐藏限制,是时候说清楚了
很多人写 EL 写着写着就想“顺手调个方法”,比如${user.getFullName()},然后发现页面报错或者直接不生效。原因在于 EL 默认不允许方法调用。当然,EL 2.2 之后如果你使用的是支持该规范的容器(比如 Tomcat 8+),并且通过MethodExpression的方式,是可以调用无参方法的。
但这里有个很隐蔽的坑:EL 的方法调用不支持传参。你想写${user.getAddress("home")},门都没有。这是 EL 的硬限制,设计如此,不是 bug。
所以我在实际项目里的原则是:EL 只做读取和展示,凡是涉及逻辑处理、方法调用的,一律在后台(Controller/Service)算好,页面只拿结果。这样既绕开了 EL 的限制,也让页面保持纯净。
3.2 OGNL 的基本用法:功能强大,但要用在正确的地方
OGNL 的标准用法,我做 Java 开发这些年,主要接触三个场景:Struts 2 标签、MyBatis 动态 SQL、以及自己写代码调用 OGNL 表达式。
场景一:Struts 2 标签
<!-- 访问值栈中的属性 --> <s:property value="user.name"/> <!-- 调用方法 --> <s:property value="user.getFullName()"/> <!-- 静态访问 --> <s:property value="@java.lang.Math@PI"/> <!-- 集合投影 --> <s:property value="users.{name}"/>场景二:MyBatis 动态 SQL(这是我日常打交道最多的)
<select id="findUsers" resultType="User"> SELECT * FROM user WHERE 1 = 1 <if test="name != null and name != ''"> AND name LIKE CONCAT('%', #{name}, '%') </if> <if test="age != null and age > 0"> AND age > #{age} </if> <if test="deptIdList != null and deptIdList.size() > 0"> AND dept_id IN <foreach collection="deptIdList" item="deptId" open="(" separator="," close=")"> #{deptId} </foreach> </if> </select>注意这里<if test="...">里面的表达式,本质就是 OGNL 在对传入的参数对象进行判断。deptIdList.size()能直接调方法,这就是 OGNL 给 MyBatis 带来的便利。
场景三:直接用 Java 代码调用 OGNL
如果你在项目中引入了 ognl 依赖,可以直接这样写:
import ognl.Ognl; import ognl.OgnlContext; // 创建一个简单的对象 Map<String, Object> user = new HashMap<>(); user.put("name", "张三"); user.put("age", 25); // 构建表达式,并执行 Object expression = Ognl.parseExpression("name + '的年龄是' + age"); OgnlContext context = new OgnlContext(); Object result = Ognl.getValue(expression, context, user); System.out.println(result); // 输出:张三的年龄是25 // 支持方法调用 Object expression2 = Ognl.parseExpression("name.length()"); Object result2 = Ognl.getValue(expression2, context, user); System.out.println(result2); // 输出:2这个 API 看起来很简洁,但用的时候有几个细节要注意:
Ognl.parseExpression返回的是编译后的表达式对象,最好缓存起来复用,不要每次执行都重新解析,性能差距很大。OgnlContext可以塞入 Root 对象和自定义变量,可以通过#变量名在表达式中引用。- 默认的 OgnlContext 没有开启成员访问权限控制,恶意表达式可以调用任意方法、访问任意类,所以如果表达式来源不可信,务必先看本文第 5 节的安全措施。
3.3 一个容易混淆的语法点:${}和%{}和#{}
很多新手会把 Struts 2 的%{}、OGNL 的#、MyBatis 的#{}搞混,我当初也花了不少时间。这里用一张表彻底理清:
| 语法 | 所属框架 | 作用 |
|---|---|---|
${expression} | JSP EL / Spring | 立即求值,输出结果 |
%{ognlExpr} | Struts 2 标签 | 强制把字符串当 OGNL 表达式解析 |
#variable | OGNL | 访问非 Root 对象的变量,如#session.user |
#{} | MyBatis | 预编译占位符,生成?参数 |
${} | MyBatis | 字符串拼接直接替换,有 SQL 注入风险 |
一个经典场景:在 Struts 2 的<s:property>标签中,value 属性默认会当作 OGNL 表达式解析,所以直接写value="user.name"就行。但如果你在一个普通 HTML 属性里想动态拼接,就需要%{}强制转换,比如:
<input type="text" value="%{user.name}"/>而 MyBatis 里#{}和${}的区别更是老生常谈的重点:#{}是预编译参数,安全;${}是字符串替换,危险。能用#{}的地方绝不用${},除非你明确知道你在做什么(比如动态表名、动态排序字段这种无法用占位符的场景)。
4. 实操复盘:在真实项目中如何选型和落地
4.1 选型决策框架:三个问题帮你避开 90% 的坑
我在接手一个新项目、或者要给现有系统引入表达式能力时,会先问自己三个问题:
问题一:表达式来自哪里?
- 来自开发者自己写的代码/配置文件 → OGNL 可选,只要管好代码审查。
- 来自用户输入/前端传递 → 一律 EL 优先,或者对 OGNL 做严格白名单校验。
- 来自数据库动态配置 → 警惕!这属于半可信来源,必须做沙箱隔离。
问题二:需要什么能力?
- 只是取值展示、简单判断 → EL 足够,没必要引入 OGNL 的复杂度。
- 需要方法调用、集合操作、动态判断 → OGNL 合适(比如 MyBatis 场景)。
- 需要执行复杂业务逻辑 → 都不该用表达式,写成 Java 方法。
问题三:性能敏感吗?
- 高频调用路径(比如每秒上千次的接口)→ EL 更稳,OGNL 需要缓存表达式避免解析开销。
- 低频配置场景(比如启动时加载规则)→ OGNL 没问题。
这三个问题问完,基本就能定下来。我的经验是:绝大数 web 项目里,EL 是默认选项,OGNL 只在特定框架(Struts 2、MyBatis)里被动出现,很少需要主动引进来做业务。主动引入 OGNL 的场景,一般集中在规则引擎、权限系统、动态配置中心这类偏底层的模块。
4.2 案例一:用 EL 做页面数据展示的完整代码
一个典型的用户列表页面,用 EL 把用户信息和角色状态展示到 JSP 上:
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> ... <table> <thead> <tr> <th>姓名</th> <th>年龄</th> <th>所属部门</th> <th>状态</th> </tr> </thead> <tbody> <c:forEach items="${userList}" var="user"> <tr> <td>${user.name}</td> <td>${user.age}</td> <td>${user.dept.name}</td> <td> <c:choose> <c:when test="${user.status == 1}"> <span class="active">正常</span> </c:when> <c:otherwise> <span class="disabled">禁用</span> </c:otherwise> </c:choose> </td> </tr> </c:forEach> </tbody> </table>这里有个我在生产环境踩过的细节点:${user.dept.name}在 user.dept 为 null 时,EL 会静默返回空字符串,页面不报错,看起来一切正常。但如果你在页面上判断dept.name == "研发部",就会得到 false,而且没有任何日志。这种“静默失败”在排查问题时非常隐蔽,建议在后台组装 VO 时就把关联对象处理好,不要过度依赖页面兜底。
4.3 案例二:用 OGNL 写一个灵活的规则判断工具
假设你有一个需求:运营人员在后台配置优惠券的使用规则,规则以字符串形式存储,例如"user.level >= 3 && order.amount >= 100 && !user.isBlacklisted()"。这时候 EL 干不了(不支持方法调用),纯 Java if-else 又太死板,OGNL 就成了最顺手的工具。
我封装过的工具类大致长这样:
import ognl.Ognl; import ognl.OgnlContext; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; public class OgnlRuleEngine { // 表达式缓存:避免重复解析的开销 private static final Map<String, Object> EXPR_CACHE = new ConcurrentHashMap<>(); public static boolean evaluate(String rule, Object rootObject) { try { // 1. 尝试从缓存取解析后的表达式 Object expr = EXPR_CACHE.computeIfAbsent(rule, key -> { try { return Ognl.parseExpression(key); } catch (Exception e) { throw new RuntimeException("OGNL 表达式解析失败: " + key); } }); // 2. 创建上下文,放入自定义变量 OgnlContext context = new OgnlContext(); context.put("now", System.currentTimeMillis()); // 3. 执行表达式,结果为 Boolean Object result = Ognl.getValue(expr, context, rootObject); return result instanceof Boolean && (Boolean) result; } catch (Exception e) { // 规则配置错误时,建议记录日志并返回 false,保证不阻断主流程 return false; } } }用的时候,你可以把用户对象作为 root 传入:
User user = userService.getById(123); Order order = orderService.getLatestByUserId(123); // 把 user 作为 root,order 作为变量,规则里可以通过 #order 访问 Map<String, Object> root = new HashMap<>(); root.put("user", user); OgnlContext context = new OgnlContext(); context.put("order", order); String rule = "user.level >= 3 && #order.amount >= 100 && !user.isBlacklisted()"; boolean pass = OgnlRuleEngine.evaluate(rule, root);几个我在封装过程中的心得:
- 规则表达式尽量由项目组技术侧审核后再上线,不要让运营直接写,不然很容易写出能让系统崩溃的表达式(比如死循环、内存溢出)。
- 缓存表达式一定要做,我自己压测过,不缓存 vs 缓存,在高频调用下有几十倍的性能差距。
- 表达式执行要加超时控制。OGNL 本身没有超时机制,如果表达式里有大量对象遍历,可能卡住线程。我在实战中是用线程池 + Future.get(timeout) 来兜底的。
4.4 案例三:MyBatis 动态 SQL 里 OGNL 的隐藏陷阱
MyBatis 的<if test="...">是大家最熟悉的 OGNL 使用场景,但这块有几个坑我见过无数人踩。
坑一:整数比较的自动类型转换
<if test="status == 1">这个1在 OGNL 里默认是 Integer,而status如果数据库返回的是 Long 类型,比较结果就是 false。排查方法:在 OGNL 表达式中把字面量写成status == 1L,或者把实体类属性类型改成 Integer。
坑二:字符串比较必须用引号不能省
<if test='name == "张三"'>注意这里外层用单引号,内层用双引号,因为 XML 属性本身要用双引号。如果你在 XML 里写test="name == '张三'",在 OGNL 里也能识别,但很多人混用的时候容易把引号写错,导致 SQL 一直走不到这个分支。
坑三:list.size() > 0的判空顺序问题
<if test="deptIdList != null and deptIdList.size() > 0">如果把前后两个条件反过来写,先调用deptIdList.size()再判断 null,那 null 集合会直接抛 NPE,导致 SQL 报错。OGNL 的and是短路还是非短路?答案是OGNL 的 and 不保证短路,这一点和 Java 的&&不一样。所以 MyBatis 里判空条件必须放在最前面,这个顺序问题在代码审查时一定要盯紧。
坑四:<符号要转义
在 XML 中,<if test="age < 18">会被 XML 解析器当成标签开始,直接报错。必须写成<:
<if test="age < 18">这个坑看似低级,但在我见过的大多数 MyBatis 报错帖里,有一半是这个问题。
4.5 工具选型解析:什么时候引入第三方表达式引擎
除了 EL 和 OGNL,Java 生态里还有几个表达式引擎,我简单对比一下,方便你做技术选型时有全局观:
| 表达式引擎 | 核心特点 | 适用场景 |
|---|---|---|
| EL | 轻量、安全、只读优先 | JSP 展示、简单模板 |
| OGNL | 功能全面、能调方法、有赋值能力 | Struts 2、MyBatis |
| SpEL | Spring 家族,功能全面 | Spring 注解、缓存 key、安全规则 |
| MVEL | 高性能、支持复杂逻辑 | 规则引擎、业务编排 |
| Aviator | 轻量高性能、Java 语法子集 | 公式计算、规则判断 |
如果你的项目里已经在用 Spring,且需要一个能力适中、安全可控的表达式引擎,SpEL 是 OGNL 之外很好的备选。它的语法更贴近 Java,对静态方法的访问支持也更规范。但如果你就是想轻量地做动态判断,不想引入 Spring,Aviator 也是一个不错的选择。
我个人的偏好是:项目里已有 Spring 就用 SpEL,项目是纯 MyBatis 体系就用 OGNL,完全自定义场景才评估 MVEL/Aviator。不要因为“OGNL 功能强”就在所有地方硬上,功能强不一定是优点,安全管控成本会随着能力增强而指数级上升。
5. 安全红线:OGNL 威力越大,越要小心被反噬
5.1 为什么 OGNL 能成为攻击武器
这个问题我必须放在最后压轴讲,因为太重要了。OGNL 的最大卖点——能调用任意方法、能访问任意类、能构造任意对象——同时就是它最大的安全软肋。
最典型的风险场景:如果你的系统把用户输入的字符串直接用 OGNL 解析执行,攻击者可以构造出任意代码执行效果。曾经发生过多起知名开源框架因为 OGNL 表达式注入被攻破的安全事件,核心原理都是同一个:不可信输入 + OGNL 强大能力 = 攻击面。
举个例子,一段看似人畜无害的输入:
#context["x"] = new java.lang.ProcessBuilder({"calc"}).start()如果这条表达式被 OGNL 执行,结果是什么?直接启动一个系统进程。在服务器上,攻击者可以用类似手法执行任意命令、读取任意文件、甚至反弹 shell。这不是危言耸听,而是真实发生过无数次的攻击路径。
5.2 三个必须遵守的安全底线
根据我在安全意识培训和实践中的总结,只要你的系统里出现 OGNL,这三条底线必须守住:
底线一:绝不用 OGNL 解析不可信输入
这是最核心的一条。用户输入、请求参数、外部接口传来的字符串,一律禁止直接传给 OGNL.parseExpression。如果业务上确实需要“动态规则”能力,必须走“后台配置 + 白名单校验 + 权限审批”的流程,不能让用户自助提交规则代码。
底线二:配置安全上下文,限制成员访问
OGNL 提供了一些安全相关的开关,比如:
OgnlContext context = new OgnlContext(); // 限制类解析,禁止访问 Object 以外的基础类 context.setMemberAccess(new DefaultMemberAccess(false)); // 或者使用更严格的自定义 MemberAccess不过说实话,这些开关我并不推荐完全依赖,因为 OGNL 的沙箱机制并不完善,绕过方法层出不穷。安全主要靠第一道防线:输入源头治理。
底线三:表达式白名单 + 运行时沙箱
如果实在无法避免使用 OGNL 解析配置文件中的规则,至少要做到:
- 对表达式做静态语法检查、关键词黑名单(过滤
new、ProcessBuilder、Runtime、reflect、class等危险关键词)。 - 在独立线程池中执行,设置超时时间,防止表达式死循环。
- 对表达式的执行结果做类型限制,只输出基本类型和指定 VO。
5.3 EL 是不是就一定安全
也不能这么绝对。EL 虽然能力弱,但历史上也出过基于 EL 的注入风险,比如构造特殊表达式访问内部数据结构。但相比 OGNL,EL 的攻击面小得多,能力边界就是它的天然盾牌。这也是为什么在不太可控的场景里,我强烈建议先用 EL 兜底。
一句话总结安全策略:表达式引擎的能力,必须严格遵循“最小够用原则”。能用 EL 解决的问题绝不上 OGNL,万不得已上 OGNL,必须把输入可信任这关把得死死的。
6. 个人实操经验总结:表达式选型的那些事
写到这里,我再分享几点这些年反复验证过的经验。
第一,在 JavaWeb 常规业务开发中,EL 的使用频率远高于 OGNL,但面试和源码阅读中 OGNL 的重要性更高。如果你在看 Struts 2 或 MyBatis 源码,看不懂 OGNL 就只能看懂个皮毛。所以学习优先级上,我建议先精通 EL 的日常用法,再深入 OGNL 的原理,两条腿走路。
第二,遇到“页面数据没显示”这类问题,先怀疑表达式本身,再怀疑后端数据。我遇到过太多次了,后端数据明明没问题,页面就是空的,最后发现是 EL 表达式大小写写错了,或者属性名和 getter 对不上。EL 解析属性user.name会先找getName(),如果你的方法叫getUName(),EL 是识别不了的,页面就静默输出空值。这种问题比 OGNL 报错更头疼,因为完全没有报错信息。
第三,写 MyBatis 动态 SQL 时,尽量避免写过于复杂的 OGNL 表达式。复杂的条件判断逻辑尽量在 Service 层提前处理成简单的布尔值或 List 参数传入,这样既让 SQL 可读性高,又避免 OGNL 在运行时出现类型转换等隐性问题。我在代码评审时有一条铁律:<if test="...">里只允许出现“属性判空 + 简单比较”,超过三个条件的组合判断必须挪到 Java 里算好再传。
第四,如果团队里有新手,一定要把 EL 和 OGNL 的差异讲透,尤其是 OGNL 的 and 不短路、类型自动转换、XML 转义这几个细节。这些点看起来小,但几乎每个新手都会踩一遍,而且踩的时候往往毫无头绪。
最后送大家一句我常对组里同学说的话:表达式语言是 Java 开发里最不起眼却最容易捅娄子的部分之一,选型和防注入的意识,比记住语法重要十倍。希望这篇文章能让你在之后的开发里,少走几步我曾走过的弯路。