Jakarta NoSQL这个名字,很多做Java后端的朋友可能看着眼熟,但真正在项目里把它用起来的人不算多。它是一个规范,一个想统一NoSQL数据库访问方式的Jakarta EE标准,而Template就是这套规范里最核心的API形态。你可以把它理解为NoSQL版的JdbcTemplate,一个帮你屏蔽底层数据库差异、用来执行数据操作的门面接口。这篇文章我想从"为什么会有这东西"讲起,把DocumentTemplate、ColumnTemplate、KeyValueTemplate这三兄弟拆开讲透,再给出一份能直接抄的接入案例。适合正在做Java微服务、被MongoDB和Cassandra不同客户端API搞到头大的同学,也适合那些项目里想"保留换库余地"却又怕抽象过度的团队参考。
1. 先搞清楚:Jakarta NoSQL到底在解决什么问题
1.1 NoSQL数据库的碎片化,是比想象中更痛的痛点
很多人对NoSQL的第一印象是"灵活、高性能、随便搞"。真到写代码的时候,你会发现另一件事:每一种NoSQL数据库的客户端API长得完全不一样。MongoDB有Document对象和DocumentCodec,Cassandra有PreparedStatement和BoundStatement,Redis有Jedis的string操作,HBase有Put和Result。换一种数据库,意味着换一套API、换一套对象模型、换一套异常体系,甚至换一种思维模式。
我以前在一个项目里同时用MongoDB和Redis做不同业务的数据存储,光维护两套数据访问代码就够呛。后来项目调研要引入Cassandra,我一看那套CQL和Row的用法,头都大了。这不是某一个数据库厂商的问题,而是NoSQL生态天然就缺乏一个"像JDBC那样把关系型数据库统一起来"的标准层。JDBC当年做的事,NoSQL这边一直没人做。
Java社区不是没尝试过抽象。Spring Data把数据访问Repository抽象做得很好,但它本质上是"每个数据库一个module,各自实现一套语义",底层API的差异被藏在了各自的Template实现里。Spring Data MongoDB和Spring Data Cassandra看似都是Repository接口,可你真的去读它们的实现源码,完全是两条路线。也就是说,抽象层是有了,但标准层还是缺的。
Jakarta NoSQL就是冲着这个缺口去的。它是Jakarta EE平台上针对NoSQL数据源的一套标准化规范,目的很直接:在JDBC同款的层面上,为文档型、键值型、列式这几种主流NoSQL数据库定义一个统一的数据模型和操作接口。Template接口正是这个规范落到代码里的第一道门面。
1.2 Template的设计哲学:从JdbcTemplate身上抄作业
如果你用过Spring的JdbcTemplate,第一次看到Jakarta NoSQL的Template接口会感觉很亲切。JdbcTemplate的思路是把"获取连接、执行SQL、处理ResultSet、关闭资源"这一大串样板代码收进一个模板方法里,业务代码只需要传入SQL和参数。Jakarta NoSQL的Template也是这个路数:把"创建与数据库的连接、切换集合或表、序列化实体、把结果映射回Java对象"这些重复劳动全部封装起来,让业务代码只关心实体本身。
但Jakarta NoSQL的Template有更高一层追求。JdbcTemplate面向的底层是同一个标准——JDBC和SQL,所以Template只需要处理一种数据库方言下的样板代码。Jakarta NoSQL面对的却是文档、键值、列式三种完全不同的存储模型。文档库有"集合"和"文档"的概念,Cassandra有"表"和"行"的概念,Redis只有"键和值"。所以Template接口并不打算一把梭全天下,它在顶层定义一套通用的CRUD语义(insert、update、delete、find、count),再往下按存储模型拆出三个子接口,分别照顾各自的特殊操作。
这个设计的巧妙之处在于:通用部分真的能做到"换库不换代码",特殊部分又能让每种数据库充分发挥自己的特性。你写业务代码的时候,面对的是一个足够抽象但又没有抽象到失去表达力的接口。
1.3 和JPA、Spring Data的定位区别
可能有人会问:JPA不也干这个事吗?JPA确实统一了关系型数据库的ORM,但它管不了NoSQL。Hibernate虽然有Hibernate OGM之类的尝试,最终也没成气候。原因很简单:关系模型、文档模型、键值模型,三者的数据建模方式根本不在一个维度上,硬套一套实体关系和JPQL,只会憋出一堆别扭的映射。
Spring Data则是另一个方向,它更像一个"便利层",建立在各大数据库的客户端API之上,每个数据库的Repository实现其实都是独立的。Jakarta NoSQL选择站在标准层,它不依赖Spring,不绑定任何容器,只要你的项目里有一个CDI容器(比如Weld、OpenWebBeans,或者完整版的Jakarta EE服务器),就能跑起来。这一点让它有机会成为NoSQL访问的事实标准,也让它能天然融入微服务和云原生环境。
2. Template家族拆解:认识三兄弟
2.1 DocumentTemplate:文档型数据库的操作入口
DocumentTemplate对应的是文档型数据库,典型代表是MongoDB、Couchbase。文档型数据库的基本单元是"文档",可以简单理解成一条JSON记录,文档的"集合"类比关系型数据库的"表"。DocumentTemplate在Template通用CRUD的基础上,增加了一些面向文档模型的能力,比如可以直接拿DocumentEntity做细粒度的插入和更新,支持嵌套的复杂查询条件。
实际使用中,DocumentTemplate最常用的场景是:
- 保存一个带复杂嵌套结构的业务对象(比如订单包含多条商品明细)
- 按普通字段而非主键做条件查询(文档库对查询条件天然友好)
- 动态更新文档中的部分字段
在代码层面,你得到DocumentTemplate的方式很简单:
@Inject private DocumentTemplate template;然后就可以直接操作实体了。实体用注解映射,长得跟JPA实体挺像的:
@Entity public class Person { @Id private String id; @Column private String name; @Column private int age; }插入一条数据就是一个insert调用的事,底层会自动完成对象到文档的序列化、集合的定位和写入。
2.2 ColumnTemplate:宽表模型的访问模式
ColumnTemplate对应的是宽列(列式)数据库,Cassandra、ScyllaDB、HBase都算这一类。这类数据库的表可以非常大,行数动辄上亿,但每一行的列可以动态变化,而且写入吞吐极高。它的数据模型和文档最大的区别在于:查询模式是强规划好的,一般依赖主键的一、二、三级索引来做高效定位。
ColumnTemplate在设计上保留了Template的通用CRUD语义,但它更强调"按主键操作"。你很难像操作MongoDB那样随手写一个任意条件的查询就去全表扫描——在Cassandra里全表扫描是灾难。所以用ColumnTemplate时,实体设计从一开始就要围绕主键和查询模式来规划。
比如这个实体:
@Entity public class TemperatureReading { @Id private String city; @Id private LocalDateTime recordedAt; @Column private double value; }两个@Id字段对应Cassandra里的复合主键,City + 时间戳这种设计就是典型的时间序列数据建模,查询时通过这两个字段定位数据非常快。
2.3 KeyValueTemplate:最轻量的键值访问
KeyValueTemplate对应的是键值型数据库,Redis、Hazelcast、Infinispan这类。它是三兄弟里最简单的那一个,原因很朴素:键值型数据库的操作本来就只有三件事——存、取、删。
有意思的是,KeyValueTemplate并没有机械地复用insert语义,而是提供了独立的put方法。在键值型数据库里,put是幂等的,同一个键反复写就是覆盖,不存在"插入"和"更新"的本质区别,所以接口直接统一成put。读取用get,删除用remove,一目了然。
@Inject private KeyValueTemplate kvTemplate; // 存 kvTemplate.put(session); // 取 Optional<Session> rawSession = kvTemplate.get(sessionId, Session.class); // 删 kvTemplate.remove(sessionId);这里有一个很贴心的设计:KeyValueTemplate操作实体时,主键会作为键,其余字段会被序列化成值。所以你可以把一个Java对象直接塞进去,再原样取出来,根本不需要关心底层Key和Value分别是什么类型。
2.4 三个Template的共性方法视图
把三个接口放在一起看,能清楚感受到共性和差异:
| 操作 | DocumentTemplate | ColumnTemplate | KeyValueTemplate |
|---|---|---|---|
| 保存 | insert(entity) | insert(entity) | put(entity) |
| 更新 | update(entity) | update(entity) | 无需区分(put覆盖) |
| 按ID删除 | delete(Class, id) | delete(Class, id) | remove(key) |
| 按ID查询 | find(Class, id) | find(Class, id) | get(key, Class) |
| 条件查询 | select().where(...) | select().where(...) | 不支持 |
| 统计 | count(Class) | count(Class) | 不支持 |
条件查询那一栏特别值得注意。文档和列式数据库的Template都有select().where()这套流式查询API,因为这两类数据库都支持结构化条件过滤。键值数据库就是纯靠Key,所以KeyValueTemplate压根不提供条件查询能力。这种"按存储模型裁剪API"的做法,比强行把所有方法塞进一个接口里要实在得多。
3. 从零开始:5分钟跑通一个MongoDB接入
3.1 依赖引入与配置
以MongoDB为例,在pom.xml里需要引入两个部分:核心API和具体数据库的驱动实现。这个分层也是Jakarta NoSQL的核心设计之一——业务代码依赖的是核心API,数据库驱动只是运行时的一个实现。
<dependency> <groupId>jakarta.nosql</groupId> <artifactId>jakarta.nosql-api</artifactId> <version>1.0.0</version> </dependency> <dependency> <groupId>org.eclipse.jnosql.databases</groupId> <artifactId>mongodb</artifactId> <version>1.0.0</version> </dependency> <dependency> <groupId>org.eclipse.jnosql</groupId> <artifactId>jnosql-cdi</artifactId> <version>1.0.0</version> </dependency>提示:不同版本的驱动类名可能有细微差别,比如MongoDB的配置类从早期的MongoDBConfiguration调整成了MongoDocumentConfiguration。这不影响整体思路,看官方文档时留意一下版本就好。
接下来提供一个CDI生产者,把DocumentCollectionManager交给容器管理。这一步相当于告诉容器:MongoDB连接哪个地址、用哪个数据库。之后你在任何地方@Inject DocumentTemplate,容器都能自动把连接注入进来。
@ApplicationScoped public class MongoProducer { @Produces public DocumentCollectionManager manager() { return new MongoDocumentConfiguration() .host("localhost") .port(27017) .database("myapp") .get(); } }有了这个Producer,业务代码里只需要一句:
@Inject private DocumentTemplate template;3.2 实体映射:从注解开始的ORM
实体映射是这套规范里最接近JPA体验的部分。@Entity标注一个类,@Id标注主键,@Column标注需要持久化的字段。如果字段不想持久化,什么都不标就行。
@Entity public class Person { @Id private String id; @Column private String name; @Column private int age; @Column private List<String> tags; }这里有个实用细节:@Id字段类型建议用String。原因不复杂:Docuemnt型数据库(比如MongoDB)的"_id"天然就是字符串友好的,用String可以让你自己不指定ID的情况下、由驱动生成UUID字符串;如果非要用Long,就得考虑自增ID的生成策略,在分布式环境下反而麻烦。键值型数据库的Key同理,String类型是最稳妥的选择。
一个踩过的坑是:实体类必须提供无参构造器。这个跟JPA的约定一模一样。原因也简单——规范需要能在不调用有参构造器的情况下创建对象实例,再从数据库字段往回填属性。如果你写了一个只有带参构造器的类,启动时不会报错,但第一次查询数据回来做映射的时候,各种实例化异常就会冒出来,而且报错信息还挺绕。
3.3 用Template完成第一组CRUD
实体和依赖都准备好之后,CRUD代码是真的简洁。插入:
Person person = new Person(); person.setId(UUID.randomUUID().toString()); person.setName("张三"); person.setAge(28); person.setTags(List.asList("java", "nosql")); Person saved = template.insert(person); System.out.println(saved.getId());主键查询:
Optional<Person> maybePerson = template.find(Person.class, "xxx-xxx-xxx"); maybePerson.ifPresent(p -> System.out.println(p.getName()));更新:
Person person = maybePerson.get(); person.setAge(29); template.update(person);删除:
template.delete(Person.class, "xxx-xxx-xxx");整个过程中不需要close连接,不需要处理Document对象,不需要关心集合名怎么取。规范默认会把类名的复数形式当集合名,Person对应persons,Order对应orders。如果你需要自定义集合名,在@Entity注解里可以指定,比如@Entity("people")。
3.4 条件查询:流式API的正确姿势
真正复杂的业务往往不是按主键查,而是"查所有大于18岁的人"这种条件过滤。这时用select()构造查询:
try (var people = template.select() .from(Person.class) .where("age").gt(18) .result()) { people.forEach(p -> System.out.println(p.getName())); }多条件组合也支持,且写法非常接近自然语言:
try (var people = template.select() .from(Person.class) .where("age").gt(18) .and("name").eq("张三") .result()) { // ... }注意我上面用了try-with-resources。这不是我习惯好,而是踩过坑后养成的习惯——result()返回的是一个Stream,底层连着数据库游标,不关闭的话,在长时间运行的应用里很容易把连接池占满。这个体验和Java 8的Stream完全是两码事,用的时候一定记得关流。
如果查询结果预期只有一条,可以用singleResult(),它返回Optional,比从Stream里摸第一条数据爽快得多:
Optional<Person> person = template.select() .from(Person.class) .where("email").eq("zhangsan@example.com") .singleResult();4. Repository模式与Template的搭配使用
4.1 用Repository接口进一步收敛数据访问
Template已经足够简洁了,但很多团队还想要更"声明式"的体验:只定义接口,不写实现,容器自动生成数据访问代码。Jakarta NoSQL的Repository接口就是干这个的。
@Repository public interface PersonRepository extends Repository<Person, String> { List<Person> findByName(String name); Optional<Person> findByEmail(String email); List<Person> findByAgeGreaterThan(int age); }这里完全不需要你写任何实现类。CDI容器会在启动时扫描这个接口,根据方法名解析查询意图,生成一个代理实现注入进来。用的时候:
@Inject private PersonRepository persons; List<Person> result = persons.findByName("李四");爽不爽?数据访问层变成了一堆"看起来像接口定义、实际上是一段完整查询"的声明式代码。这个模式跟Spring Data Repository非常像,用起来没什么学习成本。
4.2 查询方法命名规则:约定优于配置
Repository方法名的解析规则是不是和Spring Data一模一样?大致类似,但有它自己的细节。findBy、getBy、existsBy这些前缀是支持的,后面跟实体字段名,再通过And、Or连接条件,通过GreaterThan、LessThan、Between、Like这类关键词修饰条件。例如:
List<Person> findByAgeBetween(int start, int end); List<Person> findByAgeGreaterThanAndNameEquals(int age, String name); boolean existsByEmail(String email); long countByAgeGreaterThan(int age);注意返回值类型也很灵活,可以是List、Set、Stream,也可以是Optional(前提是你确信查询结果最多一条)。
这些方法没有SQL需要写,也没有查询对象要拼,方法名就是查询语句本身。对业务开发来说,可读性极高,review代码的时候扫一眼方法名就知道在查什么。
4.3 Template与Repository的使用边界
Repository这么好用,是不是就不需要Template了?我的实际体验是:两者各管一层,配合起来才是最舒服的状态。
Repository适合业务语义明确、条件固定的数据访问场景,比如"按用户邮箱查用户""按城市和日期查温度记录",这种查询写进接口里,语义清晰且复用方便。但业务里总有一些动态性很强的查询——条件在运行时拼接,可能加一个条件,也可能删一个条件。这种需求用Repository接口很难表达,总不能把所有排列组合都声明成方法。这时候直接上Template的select()流式API,现场搭条件,反而更灵活。
我的习惯是:固定查询放Repository,动态查询用Template。同一个业务类里两个都可以注入,不冲突。而且Template本身也支持分页和跳过,拿它处理后台管理那种"条件多、变化快"的列表查询很顺手。
5. 常见问题与避坑实录
5.1 实体类必须有无参构造器
前面提过一次,这里再深入说一句。JNoSQL的实体映射底层用的是CDI的实例创建机制,它需要默认构造器来创建一个空壳对象,然后把数据库里取回来的字段一个个填进去。如果你写了多个构造器,确保其中有一个无参的,不然运行时大概率会在你意想不到的地方炸一个NoSuchMethodException。
5.2 insert和update,别混着用
insert和update在Jakarta NoSQL里有明确的语义区分:insert是"新数据"的操作,如果主键已经存在,不同驱动的行为不一样,有的会抛异常,有的会覆盖;update是"老数据"的操作,如果主键在数据库里不存在,可能直接抛异常,也可能静默处理。最稳妥的做法是:新增一律insert,修改一律先find再update,不要拿update去试图"自动建数据"。这个约定在MongoDB上尤其重要,因为它的原生upsert习惯移植过来很容易踩坑。
5.3 Stream用完必须关闭
findAll、select().result()这类返回Stream的方法,本质上是一个打开了的游标。你不关闭它,轻则连接池泄漏,重则整个数据源被拖垮。正确做法是用try-with-resources包起来,或者直接调用collect接入一个List:
List<Person> persons = template.findAll(Person.class) .collect(Collectors.toList());这样Stream的生命周期就交代谢干净了,不用自己操心关闭。如果是Repository接口里的方法,返回值通常已经是Iterable或List,不存在这个问题,这也是Repository在简单查询上更省心的一个隐性福利。
5.4 关于事务和一致性,别抱不切实际的期望
很多从关系型数据库转过来的同学会下意识问"Jakarta NoSQL的Template支持@Transactional吗?"答案是:它是NoSQL的规范,NoSQL数据库本身大多不具备传统ACID事务能力,所以规范不承诺事务。MongoDB有单文档事务,Cassandra有条件写的原子性,但这些都属于各自数据库的特性范畴,不会通过统一的Template接口暴露。
如果你确实需要强一致性,正确做法是去用数据库自己的事务能力,或者干脆别把这类数据放在NoSQL里。Template存在的意义是简化数据访问,不是抹平数据库能力差异。
5.5 换库的成本没那么高,但也没那么低
Template能带来"换库接近无感"的体验,前提是你只用了三个Template接口的通用能力。如果你用到了某个数据库独有的特性(比如MongoDB的聚合管道、Cassandra的轻量事务),那部分代码一定得单独处理,不可能靠Template抹平。
我的建议是:在项目里把Template当成"默认路线"。遇到非用不可的特殊能力时,再允许业务代码直接调用底层API,并把这个特殊使用的边界用注释标注清楚。这样既享受了标准化的红利,又没有被标准化绑死。
这几年我经手过好几个数据访问层重构的项目,越来越觉得,NoSQL世界太需要一个"大家都认可的统一接口"来收敛维护成本了。Jakarta NoSQL和Template未必是最终答案,但它提供了一个值得尝试的方向。尤其是新项目或者正在做技术选型的团队,与其让每个开发者去学习不同数据库的原生客户端,不如从第一天起就站在Template这一层看问题。即便将来真的要换存储引擎,业务代码不会跟着伤筋动骨,这本身就是一笔划算的账。