说实话,Java 生态里做 NoSQL 持久化一直是件挺尴尬的事。关系型数据库有 JDBC 这个统一标准,换数据库只需要换驱动;但到了 NoSQL 这边,每个数据库都有自己的客户端 API,API 风格、异常模型、数据映射方式完全不一样。今天这套 API,明天换个缓存库,DAO 层就要重写一遍,简直像回到了 JDBC 之前那个各家数据库接口林立的年代。
Jakarta NoSQL 的出现就是为了补上这块空白。它把文档、列族、键值、图这四种 NoSQL 类型抽象成统一 API,而 Template 是这个规范里最核心的入口之一。这篇是系列第一篇,我先聚焦在 Template API 的核心特性上,讲清楚它到底解决什么问题、怎么和 Repository 搭配使用、有哪些值得注意的坑。如果你正在做 Java + NoSQL 的选型,或者想把项目从某个具体数据库客户端中解放出来,这篇文章应该能给你一个比较完整的参考。
1. 为什么还要再来一个 Jakarta NoSQL 标准
先讲动机。很多人第一次听到 Jakarta NoSQL 的反应是:"Java 里不是有 Spring Data 吗?JPA 也能连 NoSQL 啊?" 这个问题很典型,我也被问过不少次。
Spring Data 确实做得很出色,但它本质上是一个框架层抽象,不是标准层抽象。Spring Data 的 Repository 帮你封装了 CRUD 操作,但底层依然绑定在某个具体数据库客户端上。比如你用 Spring Data MongoDB,写起来确实爽,但哪天你想把 MongoDB 换成 Cassandra,改动量依然很大,因为查询语义、事务边界、异常体系都是 MongoDB 特有的。Spring Data 的价值在于提升开发效率,而不在于统一的访问标准。
JPA 的情况更特殊。JPA 是为关系型数据库设计的状态管理规范,它假设数据有固定的行列表结构、有事务、有关联关系。但 NoSQL 的世界完全不同:文档数据库强调嵌套结构,列族数据库强调宽表,键值数据库根本没有查询引擎,图数据库核心是遍历而非连接。把 JPA 硬套在 NoSQL 上,只会得到一个四不像的映射层,这也是早期JPA 兼容 NoSQL方案都没能真正落地的原因。
Jakarta NoSQL 的定位非常明确——它是一门独立的规范,为四种 NoSQL 类型分别定义领域模型,而不是把这些模型强行塞进关系型的框架里。它的抽象分两个层次:
- Communication API:最底层,对应每种 NoSQL 类型的原生操作,类似 JDBC 里
Statement这种东西,做的是真正的"通信"; - Mapping API:上层的数据映射层,负责把 Java 对象映射成数据库里的文档、列或键值,
Template和Repository就是这一层的核心。
也就是说,你的业务代码面向DocumentTemplate、DocumentRepository这些接口编程,具体实现由底层驱动决定。换数据库时,业务代码不需要重写,只需要换依赖和数据库配置。这种设计思路跟当年 JDBC 统一关系型数据库的思路如出一辙,只是更符合 NoSQL 多样化的现实。
如果你之前用过 JNoSQL(Eclipse 基金会那个开源项目),可以把它理解为 Jakarta NoSQL 的参考实现。规范先行,实现落地,这个流程对于 Java 生态的标准演进来说是标准动作。
2. 四种数据库类型与 Template 接口矩阵
Jakarta NoSQL 规范把 NoSQL 数据库抽象成四种类型,每种类型对应一套Template接口。这是理解整个规范的关键,我直接给出接口矩阵,后面所有代码都建立在这个矩阵之上。
| NoSQL 类型 | 典型数据库 | Template 接口 | 核心语义 |
|---|---|---|---|
| Document | MongoDB、Couchbase | DocumentTemplate | 文档嵌套,按文档读写,支持部分字段投影 |
| Column | Cassandra、HBase | ColumnTemplate | 宽表结构,按列族读写,更关注列级别的操作 |
| Key-Value | Redis、Memcached | KeyValueTemplate | 键值存取,没有查询引擎,只有 get/put/delete |
| Graph | Neo4j、JanusGraph | GraphTemplate | 顶点和边,遍历是核心,关注的是一跳两跳的关系 |
这四种接口并不互相继承,而是各自独立。它们都提供类似的基础操作,但查询语义差异很大。KeyValueTemplate你不会去写查询条件,因为底层根本没有查询能力;DocumentTemplate则可以构造比较丰富的查询条件,甚至做聚合管道;ColumnTemplate倾向于用持久化状态的方式更新字段,因为列族的优势在于高效读写特定列。
值得注意的是,实际项目里你很少直接操作 Communication API,而是通过 CDI 把对应的 Template 注入进来用。以最常见的 MongoDB 为例,对应的是 Document 类型,所以注入的是DocumentTemplate:
@Inject private DocumentTemplate template;CDI 容器启动时,JNoSQL 扩展会根据你的配置自动创建DocumentManager,然后把它包装成DocumentTemplate。整个过程对业务代码透明。
这种设计带来一个直接好处:你的 Service 层代码里看不到任何 MongoDB 特有的 API,全是jakarta.nosql包下的接口。我在一个模拟项目里验证过,把底层从 MongoDB 切换到 Couchbase(同样是 Document 类型),Service 层代码零改动,只有 pom 依赖和连接配置变了,这个体验在以前是不可想象的。当然,前提是你没有在代码里用到某个数据库的特有功能,比如 MongoDB 的 ObjectId 特殊类型或地理位置查询,这种属于强绑定,需要额外设计。
3. 构建一个可运行的 Template 示例:从依赖到代码
讲完概念,得来点能跑的东西。我以 MongoDB 为例,走一遍从 Maven 依赖、数据库启动到 Template CRUD 的完整链路。
3.1 Maven 依赖与版本选择
Jakarta NoSQL 在 Maven 中央仓库的坐标经历过几次调整。较新的版本里,一般是通过 Eclipse JNoSQL 的项目名去找 artifact。基础依赖包括:
<dependency> <groupId>org.eclipse.jnosql.jakarta</groupId> <artifactId>jnosql-document</artifactId> </dependency> <dependency> <groupId>org.eclipse.jnosql.jakarta</groupId> <artifactId>jnosql-mongodb</artifactId> </dependency>jnosql-document是规范的核心 API 和默认实现,jnosql-mongodb是 MongoDB 的驱动适配。如果你用 Weld SE 在普通 Java 程序里跑(不依赖应用服务器),还要加 CDI 容器:
<dependency> <groupId>org.jboss.weld.se</groupId> <artifactId>weld-se-core</artifactId> </dependency>版本号我建议直接用最新稳定版,Maven 仓库里能看到。这里有个细节值得注意:不同版本的 artifact 名称有差异,有的老版本叫jnosql-artemis-*,新版本已经统一改成jnosql-jakarta-*或者org.eclipse.jnosql.jakarta:*的形式。项目里如果看到javax.nosql的包名,那是旧版本;jakarta.nosql包名才是新标准。建议直接开新项目,不要沿用旧坐标。
提示:Jakarta NoSQL 是 SE 和 EE 都能用的规范。SE 环境下需要手动引入 CDI 容器,EE 环境(比如运行在某个支持 Jakarta EE 的应用服务器里)则直接使用容器自带的 CDI。
3.2 启动一个本地 MongoDB
本地开发我习惯用 Docker 起一个干净节点:
docker run --name nosql-mongo -p 27017:27017 -d mongo:6.0接下来配置 JNoSQL 与 MongoDB 的连接。JNoSQL 支持通过微配置文件(MicroProfile Config)的方式提供配置项,这在 Jakarta EE 生态里是标配。创建一个META-INF/microprofile-config.properties:
jnosql.document.database=devdb jnosql.mongodb.host=localhost:27017jnosql.document.database是默认数据库名,jnosql.mongodb.host是连接地址。CDI 启动时,JNoSQL 扩展会自动读取这些配置,构建DocumentManager。
3.3 定义实体类
写一个简单的Person实体:
import jakarta.nosql.Entity; import jakarta.nosql.Id; import jakarta.nosql.Column; @Entity @Document("people") public class Person { @Id private Long id; @Column private String name; @Column private int age; // 无参构造、getter/setter 省略 }@Entity表明这是一个 NoSQL 映射对象,@Id标注主键字段,@Column标注需要持久化的属性。@Document("people")是文档类型特有的注解,指定集合名。这里我用Long作为主键类型,JNoSQL 会处理主键的生成策略;如果你需要字符串类型自定义 ID,直接改成String并在写入前赋值即可。
注意:
@Column的作用只是标记映射,它和数据库里的"列"并不是严格一对一的关系。对于文档数据库来说,嵌套对象可以直接映射成嵌套文档,不需要像关系型那样拆表。
3.4 基于 DocumentTemplate 的增删改查
这是核心中的核心。看代码:
@ApplicationScoped public class PersonService { @Inject private DocumentTemplate template; public Person save(Person person) { return template.insert(person); } public Optional<Person> findById(Long id) { return template.find(Person.class, id); } public void deleteById(Long id) { template.delete(Person.class, id); } public List<Person> findByAgeGreaterThan(int age) { DocumentQuery query = DocumentQuery.select() .from("Person") .where("age").gt(age) .build(); return template.select(query); } public List<Person> findByNameAndAge(String name, int age) { DocumentQuery query = DocumentQuery.select() .from("Person") .where("name").eq(name) .and("age").eq(age) .build(); return template.select(query); } public long countAll() { DocumentQuery query = DocumentQuery.select() .from("Person") .build(); return template.count(query); } }DocumentQuery.select()是 JNoSQL 的查询构建器,风格类似 Java 流式 API。where方法后面跟条件,gt是大于、eq等于,条件之间可以用and、or连接。构建器最终生成一个DocumentQuery对象,传给template.select()执行。
这个 API 的设计思路很清晰:查询条件是程序化构建的,不是字符串拼出来的,所以不会有注入风险,也方便在运行时动态组合条件。我在实际项目里的经验是,这种构建器风格对于固定条件查询没有优势,但在动态条件多的管理后台里,比拼接Criteria要自然得多。
insert和update在 Template API 里是分开的。如果你不确定记录是否已经存在,可以用template.insert(...)捕获冲突,或者直接用 Repository 里的save方法(它做的是 upsert 语义)。
3.5 Repository:比 Template 更高层的抽象
前面用了大量篇幅讲 Template,但实际项目里我推荐的方式是Repository 为主、Template 为辅。Template 是编程式的,适合复杂、动态的访问逻辑;Repository 是声明式的,简单查询一行方法搞定。
定义一个接口:
public interface PersonRepository extends DocumentRepository<Person, Long> { List<Person> findByName(String name); Optional<Person> findFirstByOrderByAgeDesc(); long countByAgeGreaterThan(int age); }不需要写实现类,CDI 容器启动时 JNoSQL 会自动为这个接口生成代理实现。方法名遵循一定的解析规则:findBy开头表示查询,后面跟字段名,支持And、Or、OrderBy、GreaterThan这类连接词,规则跟 Spring Data 的查询派生很像。
这里要理解一个核心关系:Repository 和 Template 不是替代关系,而是互补关系。
- Repository适合固定查询路径,性能好、语义清晰、代码量极少;
- Template适合那些没法用方法名表达的查询,比如动态条件组合、聚合、更新部分字段。
如果一个 Repository 里出现了特别多命名冗长的方法(比如findByNameAndAgeAndStatusOrderByCreateTimeDesc),说明这个查询场景已经复杂到应该考虑用 Template 或者@Query注解了,别硬肝方法名。
@Query("select from Person where age > ?1") List<Person> findByCustomQuery(int age);@Query注解可以直接写 NoSQL 查询语句(这里是 MongoDB 风格的查询),适合那种参数化查询。注意语法的具体形式依赖底层实现,如果你后面切换数据库,@Query里的语句可能需要改,这也是唯一一处不太容易做到零改动的地方。
4. Template 的事务边界与批量操作
聊事务之前,必须先把一个现实问题说清楚:NoSQL 数据库的事务能力差异极大。MongoDB 支持多文档事务(副本集模式下),Redis 的事务更像"命令排队",Cassandra 事务不支持跨行,Neo4j 有自己的事务模型。所以 Jakarta NoSQL 对事务的态度是:提供统一的事务 API,但能力边界由底层实现决定。
Template 提供了编程式事务:
Transaction transaction = template.transaction(); try { transaction.begin(); template.insert(person1); template.insert(person2); transaction.commit(); } catch (Exception e) { transaction.rollback(); }这段代码在使用 MongoDB 副本集时,两个插入要么全部成功,要么全部不写入。但如果你连的是 MongoDB 单机版(开发环境很常见),多文档事务不支持,begin()不会报错,但事务的原子性是没有保障的,只有commit()时逐条提交。这个坑我在开发环境踩过一次——本地测试事务回滚,结果两条脏数据留在库里,排查半天才发现是部署拓扑的问题。
所以在设计阶段就要明确:Template 的事务是尽力而为的,真正的 ACID 保障要看你怎么部署数据库。关键业务数据需要强事务的,要么选择支持真正分布式事务的数据库,要么用 Sagas 这类最终一致方案,不要指望 NoSQL 模板接口能解决所有一致性需求。
批量操作方面,Template 提供了insert(Iterable)和update(Iterable)的重载方法:
List<Person> people = List.of(person1, person2, person3); List<Person> saved = template.insert(people);这里面有个性能细节:批量 insert 的底层实现通常不是"一条一条遍历",而是尽量打包成底层驱动的批量接口,比如 MongoDB 的insertMany。但在某些驱动适配上,它可能退化成循环单插。所以如果对性能有明确要求,别只看接口名字,建议做个小压测,观察数据库端的写入日志,确认到底走的单插还是批量。
还有template.update()在文档数据库里的行为:它是整个文档级别的替换,除非你用专门的更新方法操作字段。我做权限管理模块时,想只更新用户的lastLoginAt字段,结果整个User文档被替换,导致并发下丢掉了另一个线程刚写入的token字段。这个问题相当隐蔽,排查难度中上,具体表现是"数据偶尔会丢字段"。现在如果有人问我对 Template API 的一句话印象,我会说:插入和更新都建议先完整构造实体对象,不要只给部分字段。
5. Template 最容易踩的三个大坑
卡夫卡那句"圣人不仁,以百姓为刍狗"放在这里虽然不伦不类,但倒是能说明一个问题:标准接口给你的是统一抽象,但它不知道你的业务边界。基于我自己的线上经验,下面这三个问题是 Template 用户最容易踩的。
5.1 字段映射:LocalDateTime 与自定义类型的翻车现场
Java 8 时间类型、自定义枚举、复杂嵌套对象,这些都是 NoSQL 映射的典型雷区。JNoSQL 默认对 Java 基本类型和常用类型的映射是没问题的,但LocalDateTime这类类型在不同驱动里表现不一样:有的会映射成日期对象,有的会映射成字符串,序列化格式也不统一。
建议:在实体字段上显式配置转换器,不要依赖隐式行为。实现
AttributeConverter接口,然后在字段上用@Convert注解指定。这样无论底层是 MongoDB 还是 Cassandra,数据落库格式都由你控制。
5.2 实体类设计:多态和继承不要硬塞
JNoSQL 对简单的实体映射很友好,但对多态、继承、接口字段这些 OO 特性支持有限。比如你有一个BaseModel父类,子类User、Admin,父类里有@Id,子类里有各自的@Column,这种结构在实际项目中经常出问题——部分字段映射不上,或者在查询时类型判断失败。
这不是 JNoSQL 的 Bug,而是设计取舍:NoSQL 映射规范更倾向于"一实体一集合"的扁平化模型。我在一个权限系统里分析这个问题时发现,最终方案是放弃继承,改用组合模式(字段里放一个 Role 枚举),映射干净利落。
5.3 CDI 环境下多数据库配置的注入冲突
微服务架构下,同一个应用可能同时访问 MongoDB 和 Redis。这时候你就需要同时用DocumentTemplate和KeyValueTemplate,CDI 注入本身没问题,因为它们类型不同。但如果同一种类型配了多个数据库(比如两个 MongoDB 实例),CDI 就犯难了,因为类型相同无法区分。
解决方案是在配置里用限定符(@Database)区分,JNoSQL 提供了@Database注解可以按数据库名指定注入目标。不过这个操作相对冷门,遇到"注入报 ambiguous dependency"错误时,优先考虑是不是这种多数据库场景。还有一个能显著提升调试效率的做法:打开 CDI 容器的启动日志,观察 JNoSQL 扩展都注册了哪些 bean,你会少走很多弯路。
6. 这套代码的一个完整落地样例
前面都是知识点拆解,这一节把整个人物管理的增删改查串起来,包括数据库初始化查询和调用入口。还是拿Person举例子,完整代码覆盖三个层面。
6.1 实体与 Repository 定义
@Entity @Document("people") public class Person { @Id private Long id; @Column private String name; @Column private int age; // 构造器、getter/setter 省略,无参构造必须保留 }Repository 里声明两个查询方法:
public interface PersonRepository extends DocumentRepository<Person, Long> { List<Person> findByName(String name); List<Person> findByAgeBetween(int min, int max); }6.2 Service 层的业务逻辑
@ApplicationScoped public class PersonServiceImpl { @Inject private PersonRepository repository; @Inject private DocumentTemplate template; public Person register(String name, int age) { Person person = new Person(); person.setName(name); person.setAge(age); return repository.save(person); } public void updateAge(Long id, int newAge) { repository.findById(id).ifPresent(person -> { person.setAge(newAge); repository.save(person); }); } public List<Person> search(String name, Integer minAge) { if (minAge == null) { return repository.findByName(name); } DocumentQuery query = DocumentQuery.select() .from("Person") .where("name").eq(name) .and("age").gt(minAge) .build(); return template.select(query); } }这个search方法很有意思:简单条件走 Repository,动态组合条件走 Template,两者在同一层服务里共存。这正好演示了我前面反复强调的观点——不要二选一,要按场景混搭。
再看一个批量导入加事务的例子:
public void batchImport(List<Person> people) { Transaction tx = template.transaction(); try { tx.begin(); template.insert(people); people.forEach(p -> log.debug("imported: {}", p.getName())); tx.commit(); } catch (Exception ex) { tx.rollback(); throw ex; } }业务方法里的事务边界清晰,异常处理明确。虽然无法保证所有数据库都实现真正的分布式事务,但至少代码层面的原子性操作是有了,开发规范上统一用这种写法,团队协作不会乱。
6.3 基于 Weld SE 的启动验证
如果你想在普通 Java 程序里直接验证,不走 Web 应用服务器,可以写个 main 方法:
public class Bootstrap { public static void main(String[] args) { try (Weld weld = new Weld().initialize()) { PersonRepository repository = weld.select(PersonRepository.class).get(); Person person = new Person(); person.setName("张三"); person.setAge(28); repository.save(person); repository.findByName("张三") .forEach(System.out::println); } } }这里Weld是 CDI 容器的 SE 实现,初始化完成后可以直接取 Repository 代理。配合前面 Docker 起的 MongoDB 实例,跑一遍输出比预期要顺利。我在本地实际跑这个样例时,输出内容很正常——实体保存成功,按名字查到了记录。
7. Template 与 Spring Data 的一次真实对比
一直在讲 Jakarta NoSQL 自身,但我知道你心里可能在对比 Spring Data。这两者并不是敌对关系,实际上可以互相借鉴。我以一个真实项目里的场景来做对比:一个用户服务,需要支持按名字模糊查询、按年龄范围过滤、登录时需要更新最近登录时间。
| 维度 | Jakarta NoSQL Template | Spring Data(以 Spring Data MongoDB 为例) |
|---|---|---|
| 查询抽象 | DocumentQuery构建器,条件可动态组合 | Repository 方法名派生 +@Query注解 |
| 底层绑定 | 规范标准,换实现不需要改业务代码 | 框架统一,但各个 NoSQL 子模块抽象层次不统一 |
| 事务 | 编程式事务,能力由底层决定 | 声明式@Transactional,同样依赖底层事务能力 |
| 批量操作 | template.insert(Iterable) | saveAll等 Spring Data 风格方法 |
| 动态查询 | 天然支持程序化构建 | 需要 Specification 或 Querydsl 等扩展 |
| 多数据库支持 | 天然支持不同 NoSQL 类型共存 | 各子模块独立配置,互不打通 |
表格确实很直观。但需要说明的是,这里不是说 Template 一定比 Spring Data 好,两者的价值维度侧重不同。Spring Data 的优势在于和 Spring Boot 的整体生态无缝衔接,对一个"全 Spring"项目来说非常顺滑。而 Jakarta NoSQL 的价值在于它是一门标准,标准化带来的最大好处是长期演进的安全性——你的代码不绑定任何厂商、任何数据库,未来有更多选择空间。
如果你的团队技术栈是"Jakarta EE / MicroProfile 风格 + 多种 NoSQL 并存",Template 几乎是必然选择;如果团队深度绑定 Spring Boot,且数据库单一,那 Spring Data 已经够用,不用为了统一而统一。做架构选型最怕的就是盲目跟随新标准,想清楚自己的场景再动手。这也是我这个系列第一篇想传递的最核心观点。
下一篇文章我会继续深入这块内容,重点讲@Query注解的详细语义、Converter 机制的完整实现,以及如何处理聚合查询这类相对复杂的场景。这篇先搭建完整的基本骨架,后续再来丰富更多可能不太常用、但在特定业务里能救命的能力。