标签 DSL 下的文章


MyBatis Dynamic SQL 是一种类型安全的 Java 领域特定语言(DSL),用于通过编程方式构建 SQL 查询,而非编写 SQL 字符串或基于 XML 的动态查询。它在运行时使用流畅的 Java 构建器生成 SQL,同时仍通过标准的 MyBatis 映射器执行。与手动拼接字符串或复杂的 XML 逻辑相比,这使得查询构建更安全、更易于重构,并且更不容易出错。

由于查询是用 Java 编写的,列名和表引用通过强类型的元数据类在编译时进行验证,这提供了更好的 IDE 支持并减少了运行时 SQL 错误。本文将解释 MyBatis Dynamic SQL,并展示如何在 Java 应用程序中使用它。

1. 使用 MyBatis Dynamic SQL 可以做什么?

MyBatis Dynamic SQL 支持大多数常见的 SQL 操作,包括 SELECTINSERTUPDATEDELETE,以及连接、子查询、分页、排序、条件过滤和批量操作。它允许我们逐步构建查询,仅在某些参数存在时添加条件,这使其成为搜索界面和过滤 API 的理想选择。

它直接与 MyBatis 映射器接口集成,意味着我们仍然可以受益于结果映射、事务处理和连接管理。由于它生成标准的 SQL,因此适用于 MyBatis 支持的任何数据库,没有供应商锁定的问题。

1.1 MyBatis Dynamic SQL 的工作原理

Dynamic SQL 基于两个组件:表元数据类和 DSL 构建器。元数据类用 Java 描述表和列。DSL 构建器使用这些类以流畅、类型安全的方式组装 SQL 语句。

DSL 不直接执行 SQL。相反,它生成语句提供者对象,例如 SelectStatementProviderInsertStatementProvider。这些对象被传递给使用 @SelectProvider@InsertProvider 及类似注解标注的映射器方法,然后 MyBatis 使用其正常的执行引擎来执行这些语句。

2. 项目设置与依赖

Maven 依赖

<dependency>
    <groupId>org.mybatis</groupId>
    <artifactId>mybatis</artifactId>
    <version>3.5.15</version>
</dependency>

<dependency>
    <groupId>org.mybatis.dynamic-sql</groupId>
    <artifactId>mybatis-dynamic-sql</artifactId>
    <version>1.5.2</version>
</dependency>

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <version>2.4.240</version>
    <scope>runtime</scope>
</dependency>

这些依赖项包括 MyBatis 本身、Dynamic SQL DSL 以及一个用于测试的嵌入式数据库。

注意
数据库驱动可以替换为 MySQL、PostgreSQL 或任何其他支持的数据库。MyBatis Dynamic SQL 不依赖于数据库类型,仅依赖于标准 SQL 生成。

数据库模式

schema.sql

CREATE TABLE users (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(50) NOT NULL,
    email VARCHAR(100),
    age INT
);

INSERT INTO users(username, email, age) VALUES
('thomas', 'thomas@jcg.com', 30),
('benjamin', 'benjamin@jcg.com', 22),
('charles', 'charles@jcg.com', 17);

此脚本创建一个简单的表并插入测试数据。内存中的 H2 数据库将在启动时执行此脚本,因此无需外部依赖即可测试查询。

领域模型

public class User {

    private Long id;
    private String username;
    private String email;
    private Integer age;

    // Getter 和 Setter 方法...
}

这个 POJO 代表数据库中的一行。MyBatis 会自动使用匹配的字段名将列映射到字段,因此本例不需要额外的结果映射。

3. 用于 Dynamic SQL 的表元数据

下面的类以类型安全的方式定义数据库表及其列,允许在构建查询时被 Dynamic SQL DSL 引用。它充当 Java 代码与实际数据库结构之间的桥梁,实现了列名和类型的编译时验证。

public final class UserDynamicSqlSupport {

    public static final User user = new User();

    public static final SqlColumn<Long> id = user.id;
    public static final SqlColumn<String> username = user.username;
    public static final SqlColumn<String> email = user.email;
    public static final SqlColumn<Integer> age = user.age;

    public static final class User extends SqlTable {

        public final SqlColumn<Long> id = column("id", JDBCType.BIGINT);
        public final SqlColumn<String> username = column("username", JDBCType.VARCHAR);
        public final SqlColumn<String> email = column("email", JDBCType.VARCHAR);
        public final SqlColumn<Integer> age = column("age", JDBCType.INTEGER);

        public User() {
            super("users");
        }
    }
}

这个类为 users 表定义了类型安全的元数据,使 MyBatis Dynamic SQL 可以在不使用原始 SQL 字符串的情况下构建查询。DSL 使用这些 Java 对象而非按名称引用列,从而提高了安全性和 IDE 支持。

内部类 User 继承 SqlTable,这将其标记为可用于 from(user) 和连接等子句的数据表。构造函数调用 super("users") 来告知 MyBatis 要在 SQL 语句(如 FROM users)中呈现的确切表名。

每个列都使用 SqlTable 中的 column() 方法定义,该方法注册列名及其 JDBC 类型。这会产生强类型的 SqlColumn<T> 对象,确保比较和条件在编译时使用正确的 Java 类型。

外部类公开了对表及其列的静态引用,以便于静态导入,使得查询读起来很自然,例如:select(id, username).from(user),同时保持完全的类型安全和重构友好。

映射器接口

@Mapper
public interface UserMapper {

    @SelectProvider(type = SqlProviderAdapter.class, method = "select")
    List<User> selectMany(SelectStatementProvider selectStatement);
}

@Mapper 注解告诉 MyBatis 此接口应注册为映射器并在运行时进行代理。MyBatis 会自动生成实现,因此不需要具体的类。

selectMany 方法接受一个 SelectStatementProvider,它封装了完全呈现的 SQL 语句及其参数。MyBatis 执行该语句并将每个结果行映射到 User 对象,将它们作为 List<User> 返回。

@SelectProvider 注解指定 SQL 将由 MyBatis Dynamic SQL 的一部分 SqlProviderAdapter 动态提供。实际的 SQL 是在运行时从使用 DSL 构建的 SelectStatementProvider 生成的,而不是在注解或 XML 中编写 SQL。

4. 构建动态查询

在这里,我们使用流畅的 Dynamic SQL DSL 构建 SQL 语句,而不是编写原始 SQL 字符串。

public static void main(String[] args) throws Exception {

    MyBatisUtil.runSchema();

    try (SqlSession session = MyBatisUtil.getSession()) {

        UserMapper mapper = session.getMapper(UserMapper.class);

        SelectStatementProvider select
                = select(id, username, email, age)
                        .from(user)
                        .where(age, isGreaterThan(18))
                        .and(username, isLike("%tho%"))
                        .orderBy(username)
                        .build()
                        .render(RenderingStrategies.MYBATIS3);

        List<User> users = mapper.selectMany(select);

        users.forEach(u
                -> System.out.println(u.getUsername() + " - " + u.getAge()));
    }
}

此代码使用流畅的 Dynamic SQL DSL 动态构建一个 SELECT 查询,并将其渲染为与 MyBatis 兼容的语句提供者。通过以编程方式添加条件,它能够以类型安全且可维护的方式创建复杂的过滤器。在本例中,查询选择 age 大于 18 岁且 username 包含 "tho" 的用户,然后按用户名字母顺序对结果进行排序。

MyBatis 工具类

public class MyBatisUtil {

    private static SqlSessionFactory factory;

    static {
        try {
            Reader reader = Resources.getResourceAsReader("mybatis-config.xml");
            factory = new SqlSessionFactoryBuilder().build(reader);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    public static SqlSession getSession() {
        return factory.openSession(true);
    }

    public static void runSchema() throws IOException, SQLException {
        try (SqlSession session = getSession()) {
            Connection conn = session.getConnection();
            Statement stmt = conn.createStatement();

            try (InputStream is = Resources.getResourceAsStream("schema.sql")) {
                String sql = new String(is.readAllBytes(), StandardCharsets.UTF_8);
                stmt.execute(sql);
            }
        }
    }
}

此工具类加载 MyBatis 配置,构建 SqlSessionFactory,并提供对数据库会话的访问。它还通过执行 SQL 脚本(schema.sql)手动初始化数据库模式。

5. 使用 Dynamic SQL 进行插入、更新和删除

MyBatis 中的 Dynamic SQL 允许我们使用流畅的 DSL 以编程方式构造 INSERT、UPDATE 和 DELETE 语句。在此,我们演示如何执行这些常见的数据操作。

// INSERT
User newUser = new User();
newUser.setUsername("andrew");
newUser.setEmail("andrew@jcg.com");
newUser.setAge(28);

InsertStatementProvider<User> insert
        = insert(newUser)
                .into(user)
                .map(username).toProperty("username")
                .map(email).toProperty("email")
                .map(age).toProperty("age")
                .build()
                .render(RenderingStrategies.MYBATIS3);

int inserted = mapper.insert(insert);
System.out.println("Rows inserted: " + inserted);

// UPDATE
UpdateStatementProvider update
        = update(user)
                .set(age).equalTo(35)
                .where(username, isEqualTo("thomas"))
                .build()
                .render(RenderingStrategies.MYBATIS3);

int updated = mapper.update(update);
System.out.println("Rows updated: " + updated);

// DELETE
DeleteStatementProvider delete
        = deleteFrom(user)
                .where(age, isLessThan(18))
                .build()
                .render(RenderingStrategies.MYBATIS3);

int deleted = mapper.delete(delete);
System.out.println("Rows deleted: " + deleted);

相同的 DSL 风格也用于写操作。语句以流畅的方式构建、渲染,然后由映射器提供者方法执行。

  • INSERT:创建一个新的 User 对象并填充值。使用 Dynamic SQL DSL,我们将其字段映射到表列并生成 InsertStatementProvider。映射器执行插入操作,返回受影响的行数。
  • UPDATE:DSL 构建一个更新语句,将用户名为 "thomas" 的用户的年龄设置为 35。这确保只修改目标行,映射器执行更新。
  • DELETE:删除语句移除所有年龄小于 18 岁的用户。在 DSL 中使用条件保证了类型安全并避免了字符串拼接。

更新后的映射器接口

为了支持这些操作,映射器接口必须包含用于 INSERT、UPDATE 和 DELETE 的方法,使用 MyBatis Dynamic SQL 提供者。

// INSERT
@InsertProvider(type = SqlProviderAdapter.class, method = "insert")
int insert(InsertStatementProvider<User> insertStatement);

// UPDATE
@UpdateProvider(type = SqlProviderAdapter.class, method = "update")
int update(UpdateStatementProvider updateStatement);

// DELETE
@DeleteProvider(type = SqlProviderAdapter.class, method = "delete")
int delete(DeleteStatementProvider deleteStatement);

映射器中的每个方法处理一个特定的 DML 操作(插入、更新或删除),并接受一个封装了生成的 SQL 及其参数的 InsertStatementProviderUpdateStatementProviderDeleteStatementProvider。这种方法允许所有写操作都在 Java 中以编程方式表达,而无需手动组合 SQL 字符串,同时仍能利用 MyBatis 高效地执行语句和映射结果。

6. 结论

在本文中,我们探讨了如何在 Java 应用程序中使用 MyBatis Dynamic SQL 来创建类型安全、可维护且可编程的 SQL 查询。通过将 SQL 构建与执行分离,MyBatis Dynamic SQL 简化了复杂查询逻辑的处理,降低了错误风险,并提高了代码可读性。这种方法非常适合查询需要动态变化或经常修改的应用程序。

7. 下载源代码

本文讨论了 MyBatis Dynamic SQL 及其在 Java 中的使用方法。

下载

您可以通过此处下载此示例的完整源代码:java mybatis dynamic sql


【注】本文译自:Getting Started with MyBatis Dynamic SQL

摘要

随着 Anthropic 开源 skills 仓库,"Code Interpreter"(代码解释器)模式成为 Agent 开发的热门方向。许多开发者试图采取激进路线:赋予 LLM 联网和 Python 执行权限,让其现场编写代码来解决一切问题。但在构建企业级“智能文档分析 Agent”的实践中,我们发现这种“全托管”模式在稳定性、安全性和可控性上存在巨大隐患。本文将分享我们如何摒弃激进路线,采用 Java (确定性 ETL) + DSL 封装式 Skills + 实时渲染 的混合架构,在保留 LLM 灵活性的同时,确保系统的工业级稳定性。

一、 背景:当文档分析遇到“复杂生成”

在我们的“文档处理 Agent”项目中,基础的问答功能(RAG)已经解决得很好。但随着用户需求升级,我们面临了新的挑战:

用户场景

“这是 2024 和 2025 年的两份经营数据报表,请对比 DAU 和营收的同比增长率,并生成一个 Excel 表格给我。另外,把总结报告导出为 PDF。”

这类需求包含两个特征:

  1. 逻辑计算:需要精确算术(LLM 弱项)。

  2. 文件 IO:需要生成物理文件(LLM 无法直接做到)。

引入 Skills(让 LLM 调用 Python 代码)似乎是唯一解。但在具体落地时,我们走了一段弯路。

二、 弯路:激进的“纯 Skills”路线

起初,我们参考了开源社区做法,采用了 完全的 Code Interpreter 模式。我们将 requestspandasreportlab 等库的权限全部开放给 LLM,并在 Prompt 中告诉它:“你是一个 Python 专家,请自己写代码解决所有问题。”

这种“裸奔”模式在生产环境中遭遇了三次暴击:

  1. 输入端不可控:LLM 对非结构化数据(如无后缀 URL、加密 PDF)的处理极其脆弱,经常陷入报错死循环。

  2. 输出端崩坏:让 LLM 从零绘制 PDF/Word 是灾难。经常出现中文乱码、表格对不齐、使用了过期的库 API 等问题。

  3. 安全黑洞:数据流完全在沙箱内闭环,Java 主程序失去了对内容的控制权,无法拦截敏感词或违规数据。

三、 变革:Java 主控 + DSL Skills 的混合架构

为了解决上述问题,我们重构了架构。核心思想是:收回 LLM 的“底层操作权”,只保留其“逻辑调度权”。

我们制定了新的架构分工:Java 负责确定性的数据流转与安检,LLM 负责意图理解与代码组装,Python 沙箱 负责在受控环境下执行具体计算。

3.1 架构设计概览

我们将系统重新划分为四个逻辑层级:

  • ETL 层 (Java):负责下载、MIME 识别、OCR、敏感词检测。这是“确定性管道”。

  • Brain 层 (LLM):负责阅读纯文本,进行逻辑推理,并生成调用代码。

  • Skills 层 (Python Sandbox):提供高度封装的 SDK(DSL),而非裸库。

  • Delivery 层 (Java):负责将 Markdown/HTML 实时渲染为 PDF/Word。

3.2 输入侧:回归 Java 流水线 (ETL)

我们不再让 LLM 去下载和解析文件。所有输入文件,先经过 Java 的 DocPipeline。利用 Apache Tika 进行精准解析,并立即进行敏感词检测文本截断。这一步保证了喂给 LLM 的数据是干净、安全、标准化的纯文本

3.3 中间层:DSL 封装模式 (The Wrapper Pattern)

这是我们对 Skills 实践最大的改进。我们禁止 LLM 直接写 import pandas 进行底层操作,而是预置了一套高度封装的 DSL。

Python 端封装 (excel_tool.py):

import pandas as pdimport osdef create_excel(data_list, filename="report.xlsx", output_dir="/workspace"):    try:        df = pd.DataFrame(data_list)        save_path = os.path.join(output_dir, filename)        # 【封装价值体现】自动处理格式、列宽、引擎兼容性,屏蔽 LLM 的幻觉风险        with pd.ExcelWriter(save_path, engine='openpyxl') as writer:            df.to_excel(writer, index=False, sheet_name='Sheet1')                        # 自动调整列宽 (LLM 很难写对的工程细节)            worksheet = writer.sheets['Sheet1']            for idx, col in enumerate(df.columns):                max_len = max(df[col].astype(str).map(len).max(), len(str(col))) + 2                worksheet.column_dimensions[chr(65 + idx)].width = min(max_len, 50)                    return save_path    except Exception as e:        return f"Error: {str(e)}"
复制代码

Skill 说明书 (SKILL.md):

我们在 Prompt 中通过“接口契约”强行约束 LLM 的行为,明确了何时该写代码,何时该纯输出文本。

# File Generation Skill (Standardized)你拥有生成专业格式文件(Excel, Word, PDF)的能力。沙箱中已预装了封装好的 `excel_tool` 库。**核心决策树**:1. 如果是 **统计数据/表格** -> 必须生成 **Excel** -> **写 Python 代码**。2. 如果是 **分析报告/文档** -> 必须生成 **Word/PDF** -> **禁止写代码**,走渲染路径。---### 场景 1:生成 Excel (.xlsx)**规则**:禁止使用 `pandas` 底层 API,必须调用封装函数。**数据结构**:必须是【字典列表】,每个字典代表一行。**Python 调用示例**:```pythonimport excel_tool# 1. 准备数据 (从文档中提取)data = [    {'年份': '2024', 'DAU': 1000, '营收': '500万'},    {'年份': '2025', 'DAU': 1500, '营收': '800万'}]# 2. 调用封装函数 (自动处理样式、列宽)excel_tool.create_excel(data, filename='analysis.xlsx')```---### 场景 2:生成 Word / PDF (.docx / .pdf)**规则**:**严禁编写 Python 代码**(如 `reportlab``python-docx`)。**执行动作**:1. 请直接输出内容丰富、排版精美的 **Markdown** 文本。2. 在 Markdown 的**最后一行**,务必添加对应的动作标签,系统会自动将其渲染为文件。**输出示例**:# 2024 年度经营分析报告## 一、 数据概览本季度营收同比增长 20%...| 指标 | Q1 | Q2 || :--- | :--- | :--- || DAU | 100w | 120w |...(此处省略 2000 字内容) ...<<<ACTION:CONVERT|pdf>>>
复制代码

3.4 输出侧:渲染与交付的分离

对于不同类型的文件,我们采取了截然不同的交付策略:

  1. Excel(强结构化):走 Skills 路线。LLM 组装数据 -> 调用 excel_tool -> 沙箱生成物理文件。

  2. Word/PDF(富文本):走 渲染路线严禁 LLM 写代码生成。

  3. LLM 只输出高质量的 Markdown 并在末尾打上 <<>> 标签。

  4. Java 后端拦截该标签,利用 OpenHTMLtoPDFPandoc 将 Markdown 实时转换 为精美的 PDF/Word。

四、 硬核代码实现 (Spring AI)

以下是我们在 Spring AI 体系下实现这套混合架构的关键逻辑。

4.1 动态技能注入 (SkillManager)

我们实现了一个 SkillManager,支持按需加载技能。为了提升性能,我们设计了 Session 级的“防抖机制”,确保同一个会话中只需上传一次 Python 脚本,避免重复 IO。

@Servicepublic class SkillManager{    // 缓存技能脚本: 技能名 -> { 文件路径 -> 内容 }    private final Map<String, Map<String, String>> skillScripts = new ConcurrentHashMap<>();    // 防止重复注入的防抖 Set    private final Set<String> injectedSessions = ConcurrentHashMap.newKeySet();    /**     * 核心逻辑:根据需要的技能列表,动态注入脚本到沙箱     */    public void injectToSandbox(String sessionId, List<String> neededSkills) {        // 1. 防抖检查:如果该 Session 已注入,直接跳过,避免重复 IO        if (injectedSessions.contains(sessionId)) return;        // 2. 注入 Python 包结构 (__init__.py)        sandboxService.uploadFile(sessionId, "/workspace/skills/__init__.py", "");        // 3. 批量上传该技能所需的 DSL 脚本        for (String skillName : neededSkills) {            Map<String, String> scripts = skillScripts.get(skillName);            if (scripts != null) {                scripts.forEach((path, content) ->                     sandboxService.uploadFile(sessionId, path, content)                );            }        }        injectedSessions.add(sessionId);    }        // ... 省略加载 Resource 的代码 ...}
复制代码

4.2 业务调度与意图分流 (Handler)

串联 Java ETL、LLM 推理和最终的交付分流。

@Servicepublic class DocumentAnalysisRequestHandler{    public Flowable<Response> processStreamingRequest(Request req) {        // 1. 【Java ETL】确定性解析与安检        // 无论 URL 还是文件,先转为纯文本,并做敏感词过滤        List<ParseResult> parsedDocs = etlPipeline.process(req.getUrls());                // 2. 【技能注入】        List<String> neededSkills = List.of("file_generation");        skillManager.injectToSandbox(req.getSessionId(), neededSkills);        // 3. 【LLM 执行】Context Stuffing        String prompt = buildPrompt(parsedDocs, skillManager.getPrompts(neededSkills));                // 调用 LLM,挂载 ToolContext 以实现多租户隔离        Flowable<AgentOutput> agentFlow = chatClient.prompt()                .system(prompt)                .user(req.getUserInstruction())                .toolContext(Map.of("projectId", req.getSessionId()))                 .stream()                .content();        // 4. 【结果分流】        return agentFlow                .toList() // 收集完整回复                .flatMap(this::handlePostGenerationAction);    }    /**     * 核心分流逻辑:决定是返回沙箱文件(Excel) 还是 调用Java渲染(PDF)     */    private Single<AgentOutput> handlePostGenerationAction(List<String> rawChunks) {        String text = String.join("", rawChunks);        // 分支 A:检测到 Python 生成了 Excel (Skills 产物)        // 格式:[FILE_GENERATED: /workspace/report.xlsx]        if (FILE_GENERATED_PATTERN.matcher(text).find()) {            String path = extractPath(text);            return Single.just(new AgentOutput(path, OutputType.FILE));        }        // 分支 B:检测到转换指令 (渲染产物)        // 格式:<<<ACTION:CONVERT|pdf>>>        if (text.contains("<<<ACTION:CONVERT|pdf>>>")) {            // Java 侧实时渲染:Markdown -> PDF            // 优势:完美控制字体和样式,避免 Python 生成乱码            String pdfPath = docConverterService.convertAndSave(text, "pdf");            return Single.just(new AgentOutput(pdfPath, OutputType.FILE));        }        // 分支 C:普通文本        return Single.just(new AgentOutput(text, OutputType.TEXT));    }}
复制代码

4.3 拦截与交付 (SandboxTools)

在 Tool 执行层做最后一道防线:输出内容的二次安检

@Componentpublic class SandboxTools{    @Tool(name = "execute_command", description = "在沙箱中执行 Shell 命令")    public String executeCommand(ExecuteCommandRequest req, ToolContext context) {        String projectId = (String) context.getContext().get("projectId");                try {            // 1. 执行 Python 脚本            Map<String, Object> result = sandboxMcpService.executeCommand(projectId, req.command());            String stdout = (String) result.get("stdout");            // 2. 【关键】输出侧安检            // 防止 LLM 通过代码计算出违规内容,绕过输入侧检查            if (banwordService.hasBanWords(stdout)) {                log.warn("Banword detected in sandbox output!");                throw new BanwordException("敏感内容阻断");            }            // 3. 超长截断 (防止 LLM 上下文爆炸)            if (stdout.length() > MAX_TEXT_LENGTH) {                return stdout.substring(0, MAX_TEXT_LENGTH) + "\n[SYSTEM: TRUNCATED]";            }            return stdout;        } catch (Exception e) {            return "Execution Error: " + e.getMessage();        }    }}
复制代码

五、 总结

Skills 技术让 LLM 拥有了“手”,但这双手必须戴上“手套”。

通过这次架构演进,我们得出的核心经验是:

  1. 不要高估 LLM 的 Coding 能力:它是一个优秀的逻辑推理引擎,但在工程细节(排版、库依赖、环境配置)上非常糟糕。DSL 封装是必须的。

  2. 不要丢掉 Java 的确定性:解析、下载、格式转换、安全检查,这些传统代码擅长的领域,不要交给概率性的 LLM 去做。

  3. 架构分层

  4. Input: Java (Standardization & Security)

  5. Thinking: LLM (Reasoning)

  6. Action: Python (Calculation via DSL)

  7. Output: Java (Rendering & Delivery)

这种混合架构,既保留了 Agent 处理复杂动态需求的能力(如自定义计算涨跌幅),又守住了企业级应用对稳定性与合规性的底线。