Army 参数绑定与字面量绑定深度解析
Army 参数绑定与字面量绑定深度解析(源码级)
Army GitHub:https://github.com/PillArmy/army
本文基于 Army 当前源码(
army-core/army-jdbc/army-mysql/army-postgre,master 分支)撰写,所有结论均给出源码文件与行号,可逐条核对。对比部分参考上游源码:MyBatis 3 与 Hibernate ORM 6。
目录
- 0. 一句话总览
- 1. 参数绑定(Parameter Binding)
- 2. 字面量绑定(Literal Binding)
- 3. 参数的隐式绑定与显式绑定
- 4. 参数绑定的负责组件(完整链路)
- 5. 字面量绑定的负责组件(完整链路)
- 6. 两种字面量渲染:literal 与 const 的区别
- 7. SQL 注入分析:参数与字面量分别安全吗?
- 8. MappingType 在绑定中的作用
- 9. namedParam / namedLiteral:批量场景的命名值
- 10. 重要约束:namedLiteral 只能用于 INSERT
- 11. rowParam 与 rowLiteral:IN 列表的两种形态
- 12. 与 MyBatis / Hibernate 的对比
- 13. 总结
0. 一句话总览
Army 把"值进入 SQL"的方式严格分成两条互斥的管道:
| 管道 | API | SQL 形态 | 值何时确定 | 谁负责最终落地 |
|---|---|---|---|---|
| 参数绑定 | SQLs.param() / parameter() / namedParam() |
JDBC 占位符 ? |
SQL 渲染期只登记,执行期由 PreparedStatement#setXxx 绑定 |
方言 Executor(MySQLExecutor / PostgreExecutor) |
| 字面量绑定 | SQLs.literal() / constValue() / namedLiteral() |
值直接渲染进 SQL 文本 | SQL 渲染期由方言 LiteralHandler 安全转义后内联 | 方言 LiteralHandler(MySQLLiteralHandler / PostgreLiteralHandler) |
两条管道都经过 MappingType 的类型校验与转换;参数走 JDBC 协议天然免疫注入,字面量走"类型校验 + 强制转义"同样免疫注入。Army 不存在 MyBatis ${} 那种无保护的裸拼接通道。
1. 参数绑定(Parameter Binding)
1.1 什么是参数绑定
参数绑定即标准 JDBC 的 PreparedStatement 占位符机制:SQL 文本里只有 ?,真实值通过协议在 SQL 之外发送给数据库:
-- SQL 文本(Army 渲染产物)
SELECT id, name FROM users WHERE age > ? AND status = ?
-- 参数列表(不进入 SQL 文本)
[18, "ACTIVE"]
1.2 三个核心 API
| API | 类型来源 | 值 | 典型场景 |
|---|---|---|---|
SQLs.parameter(Object value) |
按值的 Java 类型自动推断 | 当场给定 | 单条语句、简单值 |
SQLs.param(TypeInfer type, Object value) |
显式指定(MappingType / FieldMeta / TypedField) | 当场给定,可为 null | CTE 引用字段、null 值、精确控类型 |
SQLs.namedParam(TypeInfer type, String name) |
显式指定 | 按名字延迟取值 | 批量 UPDATE/DELETE、VALUES 批量插入 |
另有
namedNullableParam(允许 null 的命名参数)、encodingParam/encodingNamedParam(专供codec加密字段)等变体。
1.3 参数表达式的内部结构
SQLs.parameter(value) 的实现(ArmyParamExpression.java L43-L53):
/// @see SQLs#parameter(Object)
static ParamExpression from(final @Nullable Object value) {
if (value == null) {
throw ContextStack.clearStackAndNullPointer();
}
final MappingType type;
type = _MappingFactory.getDefaultIfMatch(value.getClass()); // 按 Class 查默认 MappingType
if (type == null) {
throw CriteriaUtils.clearStackAndNonDefaultType(value); // 不认识的类型直接拒绝
}
return single(type, value);
}
显式类型版本(同文件 L62-70):
static ArmyParamExpression single(final @Nullable TypeInfer infer, final @Nullable Object value) {
final TypeMeta type;
if (infer == null) {
throw ContextStack.clearStackAndNullPointer();
} else if ((type = infer.typeMeta()) instanceof TableField && ((TableField) type).codec()) {
throw ArmyParamExpression.typeInferReturnCodecField("encodingParam"); // 加密字段必须走 encodingParam
}
return new AnonymousParam(type, value);
}
参数对象本身 AnonymousParam(同文件 L181-236)只持有两样东西:TypeMeta type 与 Object value,它的 toString() 固定是 " ?"——它从不把 value 拼进 SQL。
2. 字面量绑定(Literal Binding)
2.1 什么是字面量绑定
字面量绑定指把值在 SQL 渲染阶段直接内联为 SQL 文本的一部分,例如:
-- MySQL
SELECT id, name FROM users WHERE status = 'ACTIVE' AND age > 18
-- PostgreSQL(带类型前缀)
SELECT id, name FROM users WHERE status = 'ACTIVE'::VARCHAR AND age > 18
2.2 核心 API
| API | 类型来源 | 是否输出类型前缀 |
|---|---|---|
SQLs.literalValue(Object value) |
自动推断 | 是(typeName=true) |
SQLs.literal(TypeInfer type, Object value) |
显式指定 | 是 |
SQLs.constValue(Object value) |
自动推断 | 否(typeName=false) |
SQLs.constant(TypeInfer type, Object value) |
显式指定 | 否 |
SQLs.namedLiteral(TypeInfer type, String name) |
显式指定,按名延迟取值 | 是,仅批量 VALUES INSERT |
SQLs.namedConst(TypeInfer type, String name) |
同上 | 否 |
字面量表达式的构造(ArmyLiteralExpression.java L44-L54)与参数几乎对称,唯一差别是最终 appendSql 的去向:
// AnonymousLiteral(同文件 L200-208)
@Override
public void appendSqlWithoutType(StringBuilder sqlBuilder, _SqlContext context) {
context.appendLiteral(this.type, this.value, false);
}
@Override
public final void appendSql(final StringBuilder sqlBuilder, final _SqlContext context) {
context.appendLiteral(this.type, this.value, this.typeName); // 走字面量管道
}
对比参数表达式(ArmyParamExpression.java L175-L178):
@Override
public final void appendSql(StringBuilder sqlBuilder, _SqlContext context) {
context.appendParam(this); // 走参数管道
}
两条管道在表达式层就分道扬镳,这是整个绑定体系最重要的分界点。
3. 参数的隐式绑定与显式绑定
3.1 显式绑定
开发者亲手写出值表达式,称为显式绑定:
// 显式参数
.where(User_.age.greater(SQLs.parameter(18)))
.where(User_.name.equal(SQLs.param(StringType.INSTANCE, "Alice")))
.where(User_.id.in(SQLs.rowParam(IntegerType.INSTANCE, List.of(1, 2, 3))))
// 显式字面量
.where(User_.status.equal(SQLs.literal(User_.status, Status.NORMAL)))
.set(User_.version, SQLs.LITERAL_1) // 预定义字面量常量
3.2 隐式绑定(最常用、最简洁)
Army 允许把裸 Java 值直接传给表达式方法,框架自动包装:
// 裸值 —— 隐式绑定
.where(User_.age.greater(18))
.and(User_.name.equal("Alice"))
.and(User_.status.equal(Status.NORMAL))
.set(User_.name, "Bob")
调用链为:
field.equal(rawValue) // OperationExpression L55
→ Expressions.biPredicate(left, EQUAL, right) // Expressions L326
→ Expressions.wrapRight(left, right) // Expressions L80
→ Expressions.wrapValue((TypedField) left, right) // 从左操作数字段拿 TypeInfer
核心实现(Expressions.java L80-L94):
static Expression wrapRight(final Expression left, final @Nullable Object right) {
final Expression rightExp;
if (right == SQLs.ABSENT) {
throw ContextStack.clearStackAndNullPointer("right operand couldn't be absent.");
} else if (right instanceof Expression) {
rightExp = (Expression) right; // 已经是表达式,原样使用
} else if (left instanceof TypedField) {
rightExp = wrapValue((TypedField) left, right); // ★ 从左字段推断类型
} else if (right == null) {
throw ContextStack.clearStackAndNullPointer("right operand must non-null");
} else {
rightExp = wrapNonNull(right); // 无字段上下文时按值自身类型
}
return rightExp;
}
wrapValue 根据全局 LiteralMode 决定裸值被包装成参数还是字面量(同文件 L148-181):
static ArmyExpression wrapValue(final TypeInfer infer, final @Nullable Object value) {
final Expression valueExp;
switch (LITERAL_MODE) {
case DEFAULT: // 默认:全部走参数 ?
valueExp = SQLs.param(infer, value);
break;
case LITERAL: // 全部内联为带类型前缀字面量
valueExp = SQLs.literal(infer, value);
break;
case CONST: // 全部内联为不带前缀常量
valueExp = SQLs.constant(infer, value);
break;
case PREFERENCE: { // 智能:数字等无注入风险类型内联,其余走参数
final MappingType type = ...;
if (type instanceof _ArmyNoInjectionType) {
valueExp = SQLs.literal(infer, value);
} else {
valueExp = SQLs.param(infer, value);
}
}
break;
...
}
return (ArmyExpression) valueExp;
}
LiteralMode 枚举(LiteralMode.java L21-L31)共四档,通过配置项 expression.literal.mode 加载(Expressions 静态块 L53-64):
| 模式 | 裸值行为 | 适用场景 |
|---|---|---|
DEFAULT |
一律 ? 参数 |
默认,最安全、可复用预编译计划 |
PREFERENCE |
数字/布尔等 _ArmyNoInjectionType 内联,字符串等走 ? |
兼顾计划缓存与 SQL 简洁 |
LITERAL |
全部内联(带类型前缀) | 框架自管字段(如批量插入行号) |
CONST |
全部内联(不带前缀) | 生成纯常量 SQL |
3.3 隐式绑定的类型从哪来
关键在于 wrapRight 优先用左操作数字段的 TypeInfer,而不是值自身的类型。因此:
User_.age.greater((short) 18)
// 左字段 age 是 IntegerType → 18 按 IntegerType 绑定,Short 经 beforeBind 转为 int
// 而不是按 ShortType 绑定
没有字段上下文时(如 SQLs.literalValue(18)),退化为按值的 Class 查 _MappingFactory.getDefaultIfMatch(_MappingFactory.java L69-L94):Integer→IntegerType、String→StringType、枚举→CodeEnumType/LabelEnumType/NameEnumType、@DefinedType POJO→CompositeType 等。
4. 参数绑定的负责组件(完整链路)
参数绑定横跨 SQL 渲染期 与 JDBC 执行期 两个阶段,由四个组件接力:
SQLs.param(...) [表达式层]
└─ ArmyParamExpression.AnonymousParam 持有 (TypeMeta, value)
│ appendSql()
▼
StatementContext.appendParam(sqlParam) [渲染层:写 '?',登记参数]
│ 产出:sqlText="... ? ..." paramGroup=[SQLParam,...]
▼
SimpleStmt.paramGroup() → JdbcExecutor [执行层]
│
├─ type.map(serverMeta) MappingType → 方言 DataType
├─ type.beforeBind(dataType,env,v) Java 值转换/校验
▼
MySQLExecutor / PostgreExecutor.bind(...) [方言层:PreparedStatement#setXxx]
4.1 渲染层:写 ? 并登记参数
StatementContext.java L188-L226:
@Override
public final void appendParam(final SQLParam sqlParam) {
final ArrayList<SQLParam> paramList = this.paramAccepter.paramList;
if (sqlParam instanceof SingleParam) {
this.sqlBuilder.append(SPACE_PLACEHOLDER); // 写一个 '?'
paramList.add(sqlParam); // 参数登记到 paramGroup
} else if (sqlParam instanceof MultiParam) {
appendMultiParamPlaceholder(...); // IN 列表:写 '(?, ?, ?)'
paramList.add(sqlParam);
} else if (sqlParam instanceof NamedParam.NamedSingle) {
this.sqlBuilder.append(SPACE_PLACEHOLDER);
if (this.paramAccepter.nameValueFunc == null) {
paramList.add(sqlParam); // 批量:延迟,保留名字
...
} else {
paramList.add(SingleParam.build(sqlParam.typeMeta(),
readNamedValue((NamedParam) sqlParam))); // 当场按名取值
}
}
...
}
注意:SQL 文本与参数值从此物理分离——StringBuilder 里只有 ?,值在 paramList 里。
4.2 执行层:遍历参数、转换类型、绑定到 PreparedStatement
JdbcExecutor.bindParameters() L1323-L1399(节选):
private void bindParameters(final PreparedStatement statement, final List<SQLParam> paramGroup)
throws SQLException {
...
for (int i = 0; paramIndex = 1, ...; i < paramSize; i++) {
sqlParam = paramGroup.get(i);
typeMeta = sqlParam.typeMeta();
type = (typeMeta instanceof MappingType) ? (MappingType) typeMeta : typeMeta.mappingType();
dataType = type.map(serverMeta); // ① 映射成方言 DataType
...
value = ...; // ② 取 SingleParam / MultiParam 的值
if (value == null) {
statement.setNull(paramIndex++, Types.NULL); // ③ null
continue;
}
value = type.beforeBind(dataType, mappingEnv, value); // ④ Java→Java 转换 + 校验
...
bind(statement, paramIndex++, type, dataType, value); // ⑤ 方言落地:setXxx
}
}
4.3 方言层:真正的 setXxx
bind 是抽象方法(JdbcExecutor.java L399-L401),各方言实现:
- MySQL(MySQLExecutor.bind() L156-L211):按
MySQLTypeswitch,强类型setInt/setLong/setShort,默认分支bindArmyType处理字符串、时间等:
case INT: {
if (!(value instanceof Integer)) {
throw beforeBindMethodError(type, dataType, value); // 类型不符直接抛异常
}
stmt.setInt(indexBasedOne, (Integer) value);
}
- PostgreSQL(PostgreExecutor.bind() L186-L214):数组、复合类型、范围等高级类型统一构造
PGobject走setObject(PG 文本协议),标量走各自 setter:
if (dataType.isArray()) {
stmt.setObject(indexBasedOne, createPgObject(type, dataType, value));
}
...
case UUID:
stmt.setObject(indexBasedOne, value);
Army JDBC 层没有使用
Connection.createArrayOf/createStruct,PG 数组/复合类型全部序列化为 PG 文本表示经PGobject发送——这也是 Army 能统一处理 pgvector、range、composite 的原因。
5. 字面量绑定的负责组件(完整链路)
SQLs.literal(...) / constValue(...) [表达式层]
└─ ArmyLiteralExpression.AnonymousLiteral 持有 (TypeMeta, value, typeName)
│ appendSql()
▼
StatementContext.appendLiteral(type,value,typeName) [渲染层]
│ 标记 hasLiteral,调用 parser.safeLiteral
▼
ArmyParser.safeLiteral() [核心层 L745]
▼
ArmyLiteralHandler.safeLiteral() [核心层 L61:map + beforeBind]
▼
MySQLLiteralHandler.bindLiteral() [方言层:按类型渲染]
/ PostgreLiteralHandler.bindLiteral()
│
├─ 数字/布尔:instanceof 校验后直接 append
├─ 字符串:MySQLLiterals.mysqlEscapes / _PostgreLiterals.quoteEscape 转义
└─ 二进制:0x.. / \x.. 十六进制编码
5.1 渲染入口
StatementContext.java L228-L235:
@Override
public final void appendLiteral(final TypeMeta typeMeta, final @Nullable Object value, boolean typeName) {
if (!this.paramAccepter.hasLiteral) {
this.paramAccepter.setHasLiteral();
}
this.parser.safeLiteral(typeMeta, value, typeName,
this.sqlBuilder.append(_Constant.SPACE));
}
5.2 核心处理器:先映射、再转换、最后交给方言
ArmyLiteralHandler.safeLiteral() L61-L83:
public final void safeLiteral(TypeMeta typeMeta, @Nullable Object value, boolean typeName,
StringBuilder sqlBuilder, @Nullable DataType container) {
final MappingType type = (typeMeta instanceof MappingType)
? (MappingType) typeMeta : typeMeta.mappingType();
final DataType dataType = type.map(this.serverMeta); // ① 方言类型映射
if (value != null) {
value = type.beforeBind(dataType, this.mappingEnv, value); // ② 与参数绑定同一个 beforeBind
}
if (value instanceof Temporal && typeMeta instanceof FieldMeta && this.truncatedTimeType) {
value = _TimeUtils.truncatedIfNeed(((FieldMeta<?>) typeMeta).scale(), (Temporal) value);
}
bindLiteral(typeMeta, dataType, value, typeName, sqlBuilder, container); // ③ 方言渲染(抽象)
}
注意 ②:字面量与参数共用同一个 MappingType.beforeBind 转换逻辑,两条管道在"值转换"上语义完全一致,差别只在最终落地方式(setXxx vs 文本转义内联)。
5.3 方言渲染(以 MySQL 为例)
MySQLLiteralHandler.bindLiteral() L41-L133(节选):
case INT:
case MEDIUMINT: {
if (!(value instanceof Integer)) {
throw ExecutorSupport.beforeBindMethodError(typeMeta.mappingType(), dataType, value);
}
sqlBuilder.append(value); // 数字:校验类型后直接追加,无引号
}
...
case CHAR:
case VARCHAR:
case TEXT:
case JSON: {
if (!(value instanceof String)) {
throw ExecutorSupport.beforeBindMethodError(...);
}
MySQLLiterals.mysqlEscapes(this.literalEscapeMode, (String) value, sqlBuilder); // 字符串:强制转义
}
case BLOB: {
sqlBuilder.append("0x")
.append(_Literals.hexEscapes((byte[]) value)); // 二进制:十六进制
}
PostgreSQL 侧字符串统一走 PostgreLiteralHandler.stringEscape() L349-L374,按 literalEscapeMode 分派给 _PostgreLiterals.quoteEscape / backslashEscape / unicodeEscape。
6. 两种字面量渲染:literal 与 const 的区别
literal* 系列与 const* 系列的唯一构造差异是 typeName 布尔值(见 ArmyLiteralExpression.java L56-L62),它控制是否输出 SQL 类型前缀:
| 值 | literal(typeName=true) |
const(typeName=false) |
|---|---|---|
整数 100 |
PG:100::INTEGER;MySQL:100 |
100(各方言一致) |
BigDecimal("0.00") |
PG:0.00::DECIMAL |
0.00 |
LocalDate.of(2024,1,1) |
DATE '2024-01-01' |
'2024-01-01' |
LocalDateTime |
TIMESTAMP '2024-01-01 12:00:00' |
'2024-01-01T12:00:00' |
字符串 "hi" |
PG:'hi'::VARCHAR;MySQL:'hi' |
'hi' |
PostgreSQL 对字符串类类型的实际渲染(PostgreLiteralHandler.java L287-L297):
if (typeName) {
sqlBuilder.append(dataType.typeName()).append(_Constant.SPACE);
// 采用 dataType 'string' 语法而非 'string'::dataType,源码注释解释:
// XMLEXISTS 在该形式下才能正常工作
}
stringEscape((String) value, sqlBuilder, container);
如何选择
const(默认推荐给纯静态常量):SQL 更短、跨方言一致,适合状态码、固定数字等。literal(需要类型消歧时):防止数据库隐式类型转换,例如日期字面量加DATE/TIMESTAMP前缀、PG 字符串加类型前缀。
两者安全性完全相同(转义路径一致),差别仅在类型前缀。预定义常量也成对存在:SQLs.LITERAL_1 ↔ SQLs.CONST_1、SQLs.LITERAL_EMPTY_STRING ↔ SQLs.CONST_EMPTY_STRING。
7. SQL 注入分析:参数与字面量分别安全吗?
结论先行:两种绑定方式都不会产生 SQL 注入。 但防护机理不同。
7.1 参数绑定:协议级免疫
参数的值从不进入 SQL 文本。渲染期 StatementContext.appendParam 只写 ?;执行期值通过 PreparedStatement.setInt/setLong/setString/setObject 经 JDBC 协议单独传输。数据库把 SQL 文本当"程序"、把参数当"数据"解析,值在语法上永远不可能被解释为 SQL 关键字。这与 MyBatis #{}、Hibernate HQL 位置/命名参数、原生 JDBC 是同一条防线。
7.2 字面量绑定:三层代码级防护
字面量确实进入 SQL 文本,但不是裸拼接,而是经过三层防护:
第一层:按 Java Class 推断类型,字符串无法伪装成数字
ArmyLiteralExpression.from() L44-L54(参数侧的 ArmyParamExpression.from() 同理)都通过 _MappingFactory.getDefaultIfMatch(value.getClass()) 决定 MappingType。"' OR 1=1 --" 的 Class 是 String,只能走字符串渲染分支,不可能进入数字分支。
第二层:数字类型的 instanceof 硬校验 + 无注入类型标记
所有数字/布尔类型继承自 _ArmyNoInjectionType L19-L31:
public abstract class _ArmyNoInjectionType extends _ArmyBuildInCoreType {
_ArmyNoInjectionType() {
assertNonString(this); // 该体系明确禁止 String 类型混入
}
private static void assertNonString(_ArmyNoInjectionType type) {
if (type instanceof SqlString) {
throw new IllegalStateException("sub class error.");
}
}
}
方言渲染数字字面量时再次 instanceof 校验(MySQLLiteralHandler L56-L61):不是 Integer 直接抛异常,绝不追加到 SQL。
第三层:字符串强制转义、二进制十六进制编码
- PostgreSQL(_PostgreLiterals.quoteEscape() L34-L52):逐字符扫描,单引号一律翻倍(标准 SQL 转义,
'→'');还提供反斜杠转义与 Unicode 转义两档。 - MySQL(MySQLLiterals.mysqlEscapes() L28-L118):单引号翻倍;
\、NUL、\n、\r、\t、\032等在反斜杠转义模式下加\;关键兜底——当无法确认会话sql_mode(NO_BACKSLASH_ESCAPES)是否允许反斜杠转义、而字符串又含这些危险字符时,直接放弃引号形式,改写为十六进制:
if (!backSlashEscape && charAfterBachSlash != _Constant.NUL_CHAR) {
sqlBuilder.setLength(startIndex);
sqlBuilder.append("_utf8mb4 0x");
sqlBuilder.append(HexUtils.hexEscapesText(true, literal.getBytes(StandardCharsets.UTF_8)));
}
- 二进制:MySQL
0x...,PG BYTEA'\x...',不经字符串路径。
7.3 攻击载荷演练
String evil = "'; DROP TABLE users; --";
// ① 参数:SQL 文本永远是 ... = ?,载荷作为 String 数据发送 → 安全
User_.name.equal(SQLs.param(StringType.INSTANCE, evil))
// ② 字面量:单引号翻倍,整体是一个普通字符串常量 → 安全
SQLs.literalValue(evil)
// MySQL: '\''; DROP TABLE users; --'
// ③ 试图伪装成数字:类型推断为 StringType,且数字分支有 instanceof 校验 → 不可能
// SQLs.literalValue(evil) 永远不会走到 sqlBuilder.append(value) 的 INT 分支
// ④ 二进制:十六进制编码 → 安全
SQLs.literalValue(evil.getBytes(StandardCharsets.UTF_8))
// 0x273b2044524f50...
7.4 Army 为什么"天然没有 ${} 陷阱"
Army 是编译期 Criteria API,标识符(表名、列名)由生成的元数据类(User_、TableMeta)提供,值只能通过 param/literal 两条受管通道进入;框架根本不提供"把一段用户文本原样拼进 SQL"的公开 API。这与 MyBatis 保留 ${} 作为逃生舱有本质区别(详见第 12 节)。
7.5 工程建议
字面量虽然安全,但用户动态输入仍优先用 param:
?参数可复用数据库预编译计划,高并发下性能更稳;- 避免同一逻辑 SQL 因内联值不同而污染 SQL 审计日志/慢查询统计;
- 字面量适合:框架内部常量、固定状态码、DDL/函数参数中不允许占位符的位置、批量插入行号(
BATCH_NO_LITERAL)。
8. MappingType 在绑定中的作用
MappingType 是 Army 类型系统的中枢,定义见 MappingType.java L34-L74。它是一个 sealed 接口,在绑定全链路中承担五个职责:
8.1 声明 Java 类型
Class<?> javaType();
例如 IntegerType.javaType() 返回 Integer.class(IntegerType.java L60-L63)。
8.2 方言类型映射(Java 类型 → SQL DataType)
DataType map(ServerMeta meta) throws UnsupportedDialectException;
同一个 IntegerType 在三种方言下分别映射为 MySQLType.INT / PgType.INTEGER / SQLiteType.INTEGER(同文件 L149-166)。参数绑定与字面量绑定都先调 map,后续一切分派都以方言 DataType 为准。这让上层 Criteria API 完全与方言解耦。
8.3 写入前转换/校验:beforeBind(参数与字面量共用)
Object beforeBind(DataType dataType, MappingEnv env, Object source) throws CriteriaException;
IntegerType.beforeBind(IntegerType.java L73-L75 → toInt L99-147)能把 Byte/Short/Long/BigDecimal/BigInteger/String/Boolean 安全转换为 int,并做溢出检查(intValueExact()、范围判断),转换失败抛业务异常而非把错误数据送进数据库:
} else if (nonNull instanceof Long) {
final long v = (Long) nonNull;
if (v < min || v > max) {
throw errorHandler.apply(type, dataType, nonNull, null); // 溢出拒绝
}
value = (int) v;
} else if (nonNull instanceof String) {
value = Integer.parseInt((String) nonNull); // 字符串数字也可转,但非法格式抛异常
} else if (nonNull instanceof Boolean) {
value = ((Boolean) nonNull) ? 1 : 0;
}
StringType.beforeBind 则负责把数字、枚举、时间类型统一 toString(StringType.java L92-L95)。复杂类型(Json、数组、range、composite、vector)的 beforeBind 还承担序列化职责(如数组转 PG "{1,2,3}" 文本、composite 转 "(...)" 文本)。
8.4 读取后反向转换:afterGet
Object afterGet(DataType dataType, MappingEnv env, Object source) throws DataAccessException;
与 beforeBind 对称,用于 ResultSet 出参映射(如 PG TIMESTAMPTZ → OffsetDateTime、JSON 文本 → POJO)。JdbcExecutor.get(...) 方言方法读取后统一调它。
8.5 类型族标记 + 数组派生
- 一批 marker 接口(同文件 L87-414)对类型分类:
SqlInteger、SqlString、SqlText、SqlJson/SqlJsonb、SqlArray、SqlVector、SqlRange/SqlMultiRange、SqlComposite、SqlDomain等。表达式系统据此决定允许哪些操作符(如向量距离算子只在SqlVector上开放)、LiteralMode.PREFERENCE据此决定内联还是占位(_ArmyNoInjectionType即非字符串的内置类型基类)。 arrayTypeOfThis()派生数组 MappingType,支撑int[]、String[]、float[][]等字段的自动映射。compatibleFor(dataType, targetType)用于结果集列类型与目标 Java 类型之间的兼容匹配。
8.6 一张图理解 MappingType 的位置
字段元数据 FieldMeta / 裸值 Class
│ 推断
▼
MappingType(类型语义 + 转换规则 + 类型族标记)
│ │ │
map() │ │ beforeBind() │ arrayTypeOfThis()
▼ ▼ ▼
方言 DataType 规范化 Java 值 数组/复合/向量派生
│ │
┌─────────────┴────────────┴──────────────┐
▼ ▼
参数:Executor.bind → setXxx 字面量:LiteralHandler → 安全转义内联
9. namedParam / namedLiteral:批量场景的命名值
9.1 为什么需要"命名"
普通 param(type, value) 在构造语句时值就固定了。但批量操作(一条语句模板 + N 行数据)在构建模板时还没有具体值,只有"值将来从行数据的哪个字段取"的约定。命名值因此只保存一个名字(字符串 key),值在渲染每一行时通过 Function<String,Object> nameValueFunc 从当前行数据中解析。
9.2 namedParam:批量参数
SQLs.batchSingleUpdate()
.update(User_.T)
.set(User_.name, SQLs.namedParam(User_.name, "name"))
.where(User_.id.equal(SQLs.namedParam(User_.id, "id")))
.asBatchUpdate();
// SQL 模板:UPDATE users SET name = ? WHERE id = ?
// 每行数据按 "name"/"id" 取值,JDBC addBatch 逐组绑定
实现见 ArmyParamExpression.named() L78-L88(产出 ImmutableNamedNonNullParam,toString() 为 " ?:name")。渲染期逻辑在 StatementContext.appendParam L196-L220:
- 批量模板阶段(
nameValueFunc == null):只写?并登记命名参数; - 多语句批量阶段(有
nameValueFunc):当场按名取值,转成普通SingleParam。
namedParam 允许的值约束:namedParam 要求非 null;值可能为 null 的场景用 namedNullableParam。
9.3 namedLiteral:批量内联值
SQLs.singleInsert()
.insertInto(Stock_.T)
.values()
.parens(s -> s.space(Stock_.code, SQLs.namedLiteral(StringType.INSTANCE, "code"))
.comma(Stock_.name, SQLs.namedLiteral(StringType.INSTANCE, "name")))
.asInsert();
// 每行渲染:INSERT INTO stock(code,name) VALUES ('AAPL'::VARCHAR,'Apple Inc.'::VARCHAR), ...
值在渲染期通过 nameValueFunc.apply(name) 取出(StatementContext L238-L304),随后走与普通字面量完全相同的 safeLiteral 管道,安全性一致;非空版本(NamedLiteral 继承 NonNullValue)取到 null 会抛 "named literal(...) must non-null"。
特殊常量 SQLs.BATCH_NO_LITERAL / BATCH_NO_CONST(SQLs.java L288 附近,名为 $ARMY_BATCH_NO$)用于在多行 VALUES 中内联行号(1,2,3...),由 batchIndexFunc 提供。
9.4 namedParam vs namedLiteral
| 维度 | namedParam | namedLiteral |
|---|---|---|
| SQL 形态 | ? 占位 |
值直接内联(带/不带类型前缀) |
| 解析时机 | 执行期按行绑定 | 渲染期按行内联 |
| 适用语句 | 批量 INSERT / UPDATE / DELETE 均可 | 仅 VALUES INSERT(独立批量 UPDATE/DELETE 抛异常) |
| 安全性 | JDBC 参数 | 类型校验 + 转义,同样安全 |
| 典型用途 | 通用批量数据 | 需要内联的场景(行号、配合多值 INSERT) |
10. 重要约束:namedLiteral 只能用于 INSERT
这是 Army 的硬性设计约束,不是建议。
10.1 源码中的拒绝点
独立批量语句的上下文 BatchSpecStatementContext.readCurrentRowNamedValue() L100-L118:
@Nullable
@Override
final Object readCurrentRowNamedValue(final String name) {
final List<?> paramList = this.paramList;
final ObjectAccessor accessor = this.accessor;
if (paramList == null || accessor == null) {
if (this instanceof _SimpleQueryContext) {
String m = "simple query don't support named literal";
return new CriteriaException(m);
} else {
throw _Exceptions.independentDmlDontSupportNamedValue(); // ★
}
}
...
return accessor.get(paramList.get(this.paramIndex), name);
}
异常文案(_Exceptions.java L843-L846):
public static CriteriaException independentDmlDontSupportNamedValue() {
String m = "Only the batch update(delete) in multi-statement context support named parameter(literal).";
return new CriteriaException(m);
}
官方文档(docs/index.html "namedLiteral()" 一节)同样明确:
namedLiteralis designed exclusively for VALUES INSERT statements. Using it in a batch UPDATE or batch DELETE statement throwsCriteriaExceptionat runtime … If you need named values in batch UPDATE/DELETE, useSQLs.namedParam()instead.
10.2 为什么这么设计
根本原因是两种批量的执行模型不同:
- 批量 UPDATE/DELETE:一条固定 SQL 模板(
UPDATE t SET ... WHERE id = ?)+ JDBCaddBatch()多次绑定。模板只渲染一次,渲染期没有"当前行",命名字面量无处取值;值必须走?在执行期逐行setXxx。 - 批量 VALUES INSERT:Army 逐行渲染(或一次渲染多行 VALUES),渲染每一行时都有明确的"当前行数据上下文",命名字面量可以按行解析并内联。
- 多语句(multi-statement)场景例外:当批量 UPDATE/DELETE 处于多语句上下文中(每条语句独立完整渲染)时,
accessor存在,命名值可以解析——这正是异常文案 "in multi-statement context" 的含义。普通独立批量 UPDATE/DELETE 则直接拒绝。
10.3 记忆口诀
批量 UPDATE/DELETE 要按名传值 → namedParam(?)
批量 VALUES INSERT 要按名内联 → namedLiteral(值)
11. rowParam 与 rowLiteral:IN 列表的两种形态
用于 IN / NOT IN 右侧的集合值,同样分参数与字面量两条管道。
11.1 rowParam:展开为多个占位符
.where(User_.id.in(SQLs.rowParam(IntegerType.INSTANCE, List.of(1, 2, 3))))
// SQL: WHERE id IN (?, ?, ?)
// 参数列表登记为一个 MultiParam(3 个值)
实现:ArmyRowParamExpression.multi() L51-L63(非空集合校验)。占位符渲染 StatementContext.appendMultiParamPlaceholder() L440-L453:
private static void appendMultiParamPlaceholder(StringBuilder sqlBuilder,
SqlValueParam.MultiParamValue sqlParam) {
final int paramSize = sqlParam.columnSize();
sqlBuilder.append(_Constant.SPACE_LEFT_PAREN);
for (int i = 0; i < paramSize; i++) {
if (i > 0) sqlBuilder.append(_Constant.SPACE_COMMA);
sqlBuilder.append(SPACE_PLACEHOLDER); // 每个元素一个 '?'
}
sqlBuilder.append(_Constant.SPACE_RIGHT_PAREN);
}
执行期 bindParameters 对 MultiParam.valueList() 逐个 beforeBind + bind(JdbcExecutor L1353-L1395)。
批量版本 namedRowParam(type, name, size):渲染 size 个 ?,值按名从行数据取集合。
11.2 rowLiteral:展开为多个安全内联值
.where(User_.id.in(SQLs.rowLiteral(IntegerType.INSTANCE, List.of(1, 2, 3))))
// SQL: WHERE id IN (1, 2, 3)
实现:ArmyRowLiteralExpression.AnonymousRowLiteral.appendSql() L140-L156——逐元素调用 context.appendLiteral(type, value, typeName),即每个元素都走完整的字面量安全管道:
sqlBuilder.append(_Constant.SPACE_LEFT_PAREN);
for (int i = 0; i < valueSize; i++) {
if (i > 0) sqlBuilder.append(_Constant.SPACE_COMMA);
context.appendLiteral(type, valueList.get(i), this.typeName);
}
sqlBuilder.append(_Constant.SPACE_RIGHT_PAREN);
11.3 两者对比与选择
| 维度 | rowParam | rowLiteral |
|---|---|---|
| SQL | IN (?, ?, ?) |
IN (1, 2, 3) / IN ('a','b') |
| 值数量 | 运行期可变,集合大小即占位符数 | 渲染期固定 |
| 计划缓存 | 不同长度生成不同 SQL(占位符数不同),同长度可复用 | 每个值组合都是不同 SQL 文本 |
| 安全性 | 参数级 | 逐元素类型校验 + 转义 |
| 适用 | 用户输入的动态集合(首选) | 固定小集合、静态字典 |
两者都强制集合非空(values.isEmpty() 抛异常),从 API 层面杜绝 IN () 语法错误。
12. 与 MyBatis / Hibernate 的对比
12.1 三者绑定模型总览
| 维度 | Army | MyBatis 3 | Hibernate ORM 6 |
|---|---|---|---|
| 值通道 | param(?)/ literal(安全内联)双通道 |
#{}(?)/ ${}(裸拼接) |
JdbcParameter(?)/ JdbcLiteralFormatter(安全内联) |
| 类型来源 | 编译期字段元数据 + MappingType |
运行期参数对象反射 + TypeHandler |
编译期实体模型 JavaType + JdbcType |
| 取值方式 | 值在 Criteria 链中直接持有 / 命名 key | MetaObject + OGNL 从 JavaBean/Map 反射取值 |
AST 中绑定 JdbcParameter + 参数索引 |
| 值转换 | MappingType.beforeBind(含溢出校验) |
TypeHandler.setParameter |
JavaType.unwrap + BasicBinder.doBind |
| 字面量字符串转义 | 方言级(引号翻倍/反斜杠/十六进制兜底) | 无内建安全字面量通道(${} 不转义) |
Dialect.appendLiteral → 单引号翻倍 |
| 批量命名值 | namedParam/namedLiteral 内建 |
@Param + 动态 SQL <foreach> |
Query.setParameterList / 集合参数 |
| 方言差异处理 | map(ServerMeta) + 方言 Executor/Handler |
databaseId / 手写方言 SQL |
Dialect 体系 + 函数/类型描述符 |
| SQL 构造方式 | Java 类型安全 Criteria DSL | XML 动态 SQL / 注解 SQL | HQL/JPQL/Criteria(SQM AST) |
12.2 与 MyBatis 对比
MyBatis 的参数管道(与 Army param 对应):
#{}由 RawSqlSource 中的new GenericTokenParser("#{", "}", tokenHandler)解析为?并登记ParameterMapping;- 执行期 DefaultParameterHandler.setParameters() 通过
MetaObject.getValue(propertyName)用反射从参数对象取值,再由TypeHandler.setParameter(ps, i+1, value, jdbcType)绑定。
这条管道同样安全,但有两个 Army 不存在的问题:
${}裸拼接通道:TextSqlNode 用new GenericTokenParser("${", "}", handler)做原样文本替换,无任何转义,是 MyBatis SQL 注入的主要来源(动态表名/列名/排序字段不得不使用时,必须开发者自行白名单校验)。Army 没有等价 API——标识符来自类型安全的元数据,值只有受管的两条通道。- 参数解析发生在运行期:MyBatis 的值散落在 JavaBean/Map/
@Param中,靠MetaObject反射 + OGNL 表达式在执行期逐个提取(DefaultParameterHandler中大量取值分支),属性名拼错要到运行时才暴露;Army 的值在编译期由 Java 类型链持有,字段名错误直接编译失败。
Army 相对 MyBatis 的劣势:
- MyBatis 的
${}、#{}文本模板极其灵活,任意复杂动态 SQL(任意片段拼接、临时加提示字)表达成本低;Army 的 DSL 有结构约束,极少数"完全动态、无法预先建模"的 SQL 写起来不如模板自由。 - MyBatis 学习曲线平缓,XML 即 SQL,DBA 可直接参与维护;Army 需要理解 MappingType/LiteralMode 等概念。
- MyBatis 生态与既有 Mapper 资产庞大,迁移成本低。
12.3 与 Hibernate 对比
Hibernate 的参数管道:HQL/JPQL 参数翻译为 SQM AST 中的 JdbcParameter,执行时由 BasicBinder.bind() 分派到 doBind(如整型 st.setInt),null 走 st.setNull(index, jdbcTypeCode)。与 Army param 同为 JDBC 级安全。
Hibernate 的字面量管道:由 JdbcLiteralFormatter 渲染,字符串最终走 Dialect.appendLiteral() → appendSingleQuoteEscapedString(单引号翻倍),二进制走 X'...'。Hibernate 与 Army 一样提供安全字面量通道,不存在 MyBatis ${} 式裸拼接。
两者类型系统的思路也高度相似:Hibernate 的 JavaType + JdbcType(双向描述符)≈ Army 的 MappingType(beforeBind/afterGet + map);Hibernate Dialect ≈ Army 的方言 Executor/LiteralHandler。
差异/各自优势:
| 点 | Army | Hibernate |
|---|---|---|
| 核心抽象 | 轻量 SQL DSL + MappingType,无会话态实体、无脏检查 | 全功能 ORM:实体生命周期、一级缓存、脏检查、延迟加载、级联 |
| SQL 可控性 | 生成的 SQL 与 DSL 一一对应,显式 select/join/分页 | HQL 经 SQM 翻译,复杂场景生成 SQL 不完全直观,需开 SQL 日志调优 |
| 字面量控制粒度 | LiteralMode 四档全局策略 + 逐值 literal/const/param 精细选择 |
主要由 HQL 书写形态与字面量处理策略决定,逐值控制不如 Army 直接 |
| 高级类型 | PG array/range/composite/vector 均有内建 MappingType | 基础 JDBC 类型强;PG 数组/复合类型需额外配置或 @JdbcTypeCode,pgvector 需第三方 |
| 重量级 | 模块小、启动快、无增强(无字节码织入) | 功能全但体系重,启动与映射模型复杂度高 |
| 适合 | 要精确控制 SQL、又要类型安全的团队 | 需要完整对象模型与持久化上下文的团队 |
12.4 注入安全性横向结论
- Army:param 协议级安全;literal 类型校验 + 方言转义 + 十六进制兜底安全;无裸拼接 API。
- Hibernate:参数协议级安全;字面量经
Dialect.appendLiteral转义安全;同样不鼓励裸拼接(原生 SQL 字符串拼接仍是开发者可控的风险点,但框架的受管 API 安全)。 - MyBatis:
#{}安全;${}不转义,是三者中唯一框架级保留的注入入口,必须靠开发者纪律(白名单)兜底。
13. 总结
- 两条物理隔离的通道:
param家族产出?,值由方言 Executor 在执行期setXxx;literal/const家族在渲染期由方言 LiteralHandler 安全内联。表达式层appendParamvsappendLiteral是分界点。 - 隐式绑定是显式绑定的语法糖:裸值经
Expressions.wrapRight → wrapValue,按左字段TypeInfer与全局LiteralMode(DEFAULT/PREFERENCE/LITERAL/CONST)自动选择通道。 - MappingType 是五合一中枢:Java 类型声明、方言映射(
map)、写入转换(beforeBind)、读取转换(afterGet)、类型族标记/数组派生;参数与字面量共用同一套转换语义。 - 注入免疫:参数靠 JDBC 协议,字面量靠"Class 推断 + instanceof 硬校验 +
_ArmyNoInjectionType标记 + 字符串转义 + 二进制十六进制兜底";Army 不提供裸拼接 API。 - 批量语义:
namedParam通吃批量 INSERT/UPDATE/DELETE;namedLiteral仅限 VALUES INSERT(独立批量 UPDATE/DELETE 渲染期没有行上下文,直接抛CriteriaException)。 - 集合值:
rowParam展平为IN (?, ?, ?),rowLiteral逐元素安全内联为IN (1, 2, 3),均强制非空。 - 横向定位:比 MyBatis 少了
${}这个框架级注入面、参数在编译期即类型确定;与 Hibernate 同属"受管安全字面量"阵营,但更轻、SQL 可控性更强,不承担完整 ORM 的实体生命周期职责。
Army GitHub:https://github.com/PillArmy/army
浙公网安备 33010602011771号