在 Micronaut Data JDBC 中建立关联模型并验证连接查询
原作者:Sergio del Amo。本文合并翻译整理官方 One-to-Many with Micronaut Data JDBC 与 Many-to-Many with Micronaut Data JDBC and MySQL,覆盖 Java/Gradle 版本的建表、实体、投影、仓库、测试、Test Resources 与原生测试。来源核对日期为 2026-10-05。以下代码只做静态审查,没有运行 Gradle、数据库、Docker 或 GraalVM;测试里的断言是原文设计的预期行为。
关联映射与关联查询是两回事:声明联系人有多个电话,并不意味着每次查询联系人都会把电话一起取回。理解 Micronaut Data JDBC 的关键,是让表结构、实体关系、连接方式和返回投影彼此对应,再用断言说明自己期望加载什么。

版本与项目准备
两篇网页的前提都写着 JDK 21 或更高,并要求正确配置 JAVA_HOME。本次于 2026-10-05 下载两份官方源码 ZIP,静态读取后确认:gradle.properties 均为 micronautVersion=5.2.0,build.gradle 的 Java sourceCompatibility 与 targetCompatibility 均为 25。因此,“满足正文的 21+”不能直接推出“能编译当前下载源码”;下载项目应使用与其构建目标一致的 JDK,并以随包构建文件为准。源码包与提取出的构建文件随稿保存,本次未执行构建。
你需要编辑器或 IDE;可以跟着步骤从头建立应用,也可以下载官方 一对多源码包和 多对多源码包对照。两个例子是独立项目,不应直接把不同数据库方言和配置混装进同一默认数据源。现有项目可以通过 Micronaut Launch 的 Diff 功能查看各 feature 引入的依赖和配置变化。
Micronaut CLI 默认使用 Java;Java/Kotlin 默认测试框架为 JUnit,Groovy 默认为 Spock。若不指定 build,默认是使用 Kotlin DSL 的 Gradle。下面显式保留原文的参数;CLI 创建出的项目位于 micronautguide,默认包为 example.micronaut。创建项目和依赖解析会写入文件并可能联网,本文没有执行它们。
一对多:一个联系人有多个电话
示例的 contact 表有 id、first_name、last_name,phone 表有 id、phone、contact_id。示例用联系人 Sergio del Amo 和两个保留虚构号码 +1-202-555-0100、+44 20 7946 0001说明:两条 phone 记录的 contact_id 都指向同一联系人。美国号码取自 NANPA 保留作虚构用途的 555-0100 至 555-0199 段,英国号码取自 Ofcom 伦敦剧情保留号段;它们是教程占位值,不应拨打。
mn create-app example.micronaut.micronautguide \
--features=data-jdbc,liquibase,h2,graalvm \
--build=gradle \
--lang=java \
--test=junit
通过 Micronaut Launch 创建时,选择 Micronaut Application,并加入同样四个 feature。H2 数据源放在 src/main/resources/application.properties:
datasources.default.dialect=H2
datasources.default.driver-class-name=org.h2.Driver
datasources.default.url=jdbc\:h2\:mem\:devDb;LOCK_TIMEOUT\=10000;DB_CLOSE_ON_EXIT\=FALSE
datasources.default.username=sa
datasources.default.password=
这是内存数据库演示,sa 加空密码不是远程数据库的生产凭据建议。进程与连接生命周期会影响内存数据库的保留范围,不能把它当作持久化方案。
让 Liquibase 建立真正的外键
引入 implementation("io.micronaut.liquibase:micronaut-liquibase"),并添加:
liquibase.datasources.default.change-log=classpath\:db/liquibase-changelog.xml
根变更日志 src/main/resources/db/liquibase-changelog.xml 通过相对路径包含 schema 文件:
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd">
<include file="changelog/01-schema.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
src/main/resources/db/changelog/01-schema.xml 建表并添加外键。下面保留原文的列约束与 rollback 顺序,整理了排版:
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd">
<changeSet id="01" author="username">
<createTable tableName="contact">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints nullable="false" unique="true"
primaryKey="true" primaryKeyName="pk_contact"/>
</column>
<column name="first_name" type="VARCHAR(255)">
<constraints nullable="true"/>
</column>
<column name="last_name" type="VARCHAR(255)">
<constraints nullable="true"/>
</column>
</createTable>
<createTable tableName="phone">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints nullable="false" unique="true"
primaryKey="true" primaryKeyName="pk_phone"/>
</column>
<column name="phone" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<column name="contact_id" type="BIGINT">
<constraints nullable="false"/>
</column>
</createTable>
<addForeignKeyConstraint
baseTableName="phone" baseColumnNames="contact_id"
constraintName="fk_phone_contact"
referencedTableName="contact" referencedColumnNames="id"/>
<rollback>
<dropTable tableName="phone"/>
<dropTable tableName="contact"/>
</rollback>
</changeSet>
</databaseChangeLog>
非空 contact_id 与外键约束要求每条电话记录指向存在的联系人;回滚先删电话再删联系人,以尊重依赖关系。Liquibase 迁移和回滚会实际修改数据库,dropTable 会删除数据,应只对预期的开发数据库执行。
用 record 映射实体
两个实体分别保存到独立的 Java 文件。下列代码都属于 package example.micronaut;需导入 io.micronaut.core.annotation 的 Nullable/NonNull,以及 io.micronaut.data.annotation 的 Id、GeneratedValue、MappedEntity、Relation。ContactEntity 还需 java.util.List。为集中呈现关系,代码块省略这些重复 imports,类型和字段保持原文语义。
@MappedEntity("contact")
public record ContactEntity(
@Id @GeneratedValue @Nullable Long id,
@Nullable String firstName,
@Nullable String lastName,
@Nullable
@Relation(value = Relation.Kind.ONE_TO_MANY, mappedBy = "contact")
List<PhoneEntity> phones
) { }
@MappedEntity("phone")
public record PhoneEntity(
@Id @GeneratedValue @Nullable Long id,
@NonNull String phone,
@Nullable
@Relation(value = Relation.Kind.MANY_TO_ONE)
ContactEntity contact
) { }
@MappedEntity 指向数据库表;@Id 标记标识,@GeneratedValue 表示由数据库生成、不包含在插入值中。record 构造参数不可变,新建对象时生成型 id 尚不存在,所以声明为可空并传入 null。mappedBy = "contact" 指向 PhoneEntity 的属性名,而不是 SQL 列名 contact_id。
PhoneEntity 的 contact 在 Java 映射中标了 Nullable,不会让数据库里非空的 contact_id 自动变成可空。插入电话时仍应提供已存在联系人的引用;加载时关联是否完整,则由查询决定。
为不同页面返回不同投影
完整投影包含电话集合,预览投影只含联系人字段。两者使用 @Introspected 在编译期生成 BeanIntrospection 元数据,供不依赖运行时反射的属性访问等用途使用。导入 Introspected、NonNull、Nullable;完整投影另导入 java.util.Set。
@Introspected
public record ContactComplete(
@NonNull Long id,
@Nullable String firstName,
@Nullable String lastName,
@Nullable Set<String> phones
) { }
@Introspected
public record ContactPreview(
@NonNull Long id,
@Nullable String firstName,
@Nullable String lastName
) { }
仓库方法决定什么时候连接电话表
仓库需导入 JdbcRepository、Dialect、CrudRepository,联系人仓库另导入 Join、Query 和 Optional。CrudRepository 提供编译期生成的增删改查操作,方法名也可以表达删除条件:
@JdbcRepository(dialect = Dialect.H2)
public interface PhoneRepository
extends CrudRepository<PhoneEntity, Long> {
void deleteByContact(@NonNull ContactEntity contact);
}
@JdbcRepository(dialect = Dialect.H2)
public interface ContactRepository
extends CrudRepository<ContactEntity, Long> {
@Join(value = "phones", type = Join.Type.LEFT_FETCH)
Optional<ContactEntity> getById(@NonNull Long id);
@Query("select id, first_name, last_name from contact where id = :id")
Optional<ContactPreview> findPreviewById(@NonNull Long id);
@Query("""
select c.id, c.first_name, c.last_name, LISTAGG(p.phone, ',') WITHIN GROUP (ORDER BY p.phone) as phones
from contact c
left outer join phone p on c.id = p.contact_id
where c.id = :id
group by c.id
""")
Optional<ContactComplete> findCompleteById(@NonNull Long id);
}
getById 明确用 LEFT_FETCH 把电话关联一同取回,左连接让没有电话的联系人仍能出现。findPreviewById 完全不连接电话表。findCompleteById 用显式 SQL 左连接,并把电话聚合成名为 phones 的结果列。原文源码使用 group_concat;此处采用 H2 聚合函数文档列出的 LISTAGG(p.phone, ',') WITHIN GROUP (ORDER BY p.phone)。Micronaut Platform 5.2.0 管理依赖表列出的 H2 版本为 2.5.250,而源码中的 H2 JDBC URL 没有启用 MySQL 兼容模式,因此出版代码使用 H2 明确列出的聚合语法。此为基于版本与文档的静态修订,未运行 SQL 或测试;更换数据库版本或兼容模式时仍须重新核对。
:id 是绑定参数,不能为了扩展查询而改成把外部字符串直接拼入 SQL。聚合语法、长度限制、空值与到 Java 集合的转换都与方言及驱动行为有关;不能将这个 H2 示例视为跨数据库标准 SQL。这里对电话值排序,以免依赖数据库未指定的返回顺序。
用测试区分“没加载”与“没有电话”
原测试使用 @MicronautTest(startApplication = false, transactional = false),注入两个仓库。这里不需要启动嵌入式服务器,transactional = false 关闭测试方法默认的事务回滚行为,因此代码末尾必须显式清理。原文旁注曾把属性简写成 transaction,实际代码中的名称是 transactional。
测试的关键顺序如下;这是原断言流程的紧凑改写,姓氏由原测试里的 "Sergio" 修正为表格示例的 "del Amo",同时显式使用保存返回的新 record。它是说明性片段,置于已经注入仓库的 JUnit 测试方法中,需导入 JUnit assertions 与 Java 集合:
String firstName = "Sergio";
String lastName = "del Amo";
long contactCount = contactRepository.count();
long phoneCount = phoneRepository.count();
ContactEntity e = contactRepository.save(
new ContactEntity(null, firstName, lastName, null));
assertEquals(contactCount + 1, contactRepository.count());
assertEquals(
new ContactPreview(e.id(), firstName, lastName),
contactRepository.findPreviewById(e.id()).orElseThrow());
assertEquals(
new ContactEntity(e.id(), firstName, lastName, Collections.emptyList()),
contactRepository.getById(e.id()).orElseThrow());
assertEquals(
new ContactComplete(e.id(), firstName, lastName, null),
contactRepository.findCompleteById(e.id()).orElseThrow());
ContactEntity ref = new ContactEntity(e.id(), null, null, null);
PhoneEntity us = phoneRepository.save(
new PhoneEntity(null, "+1-202-555-0100", ref));
PhoneEntity uk = phoneRepository.save(
new PhoneEntity(null, "+44 20 7946 0001", ref));
assertEquals(phoneCount + 2, phoneRepository.count());
assertEquals(
new ContactPreview(e.id(), firstName, lastName),
contactRepository.findPreviewById(e.id()).orElseThrow());
assertEquals(
new ContactEntity(e.id(), firstName, lastName, Collections.emptyList()),
contactRepository.findById(e.id()).orElseThrow());
ContactEntity joined = contactRepository.getById(e.id()).orElseThrow();
ContactEntity nested = new ContactEntity(
e.id(), firstName, lastName, Collections.emptyList());
assertEquals(
new ContactEntity(e.id(), firstName, lastName, List.of(
new PhoneEntity(us.id(), us.phone(), nested),
new PhoneEntity(uk.id(), uk.phone(), nested))),
joined);
assertEquals(
new ContactComplete(e.id(), firstName, lastName,
Set.of(us.phone(), uk.phone())),
contactRepository.findCompleteById(e.id()).orElseThrow());
phoneRepository.deleteByContact(ref);
contactRepository.deleteById(e.id());
assertEquals(phoneCount, phoneRepository.count());
assertEquals(contactCount, contactRepository.count());
添加电话之前,连接实体中的 phones 为一个空列表,聚合投影里的 phones 为 null;添加之后,普通继承的 findById 在本例预期里仍没有加载电话,而带 Join 的 getById 返回两个 PhoneEntity,完整投影返回电话字符串集合。不能把普通查询的空关联当成“数据库里没有电话”的证明。
清理时先删 phone,再删 contact。对于关闭事务回滚的测试,如果断言中途失败,末尾清理不会自动执行;工程化测试还应考虑 finally、清理钩子或隔离数据库。本文没有静默替换原文测试模型,只指出这一限制。
多对多:用关联实体表达用户与角色
第二篇把关系换成用户与角色:一个用户能有多个角色,同一角色也能属于多个用户。底层不是在 users 表里存一个逗号列表,而是用 user_role 连接 users 与 role。关联表的复合主键 (user_id, role_id) 阻止同一组合重复出现。
mn create-app example.micronaut.micronautguide \
--features=data-jdbc,liquibase,mysql,validation,graalvm \
--build=gradle \
--lang=java \
--test=junit
通过 Launch 创建时,同样选择 Micronaut Application,并加入这些 feature。原文明确列出 Data JDBC、Hikari、MySQL 驱动和 Liquibase 依赖;若 feature 已生成这些条目,不必重复添加:
annotationProcessor("io.micronaut.data:micronaut-data-processor")
implementation("io.micronaut.data:micronaut-data-jdbc")
implementation("io.micronaut.sql:micronaut-jdbc-hikari")
runtimeOnly("com.mysql:mysql-connector-j")
implementation("io.micronaut.liquibase:micronaut-liquibase")
datasources.default.schema-generate=NONE
datasources.default.driver-class-name=com.mysql.cj.jdbc.Driver
datasources.default.db-type=mysql
datasources.default.dialect=MYSQL
liquibase.datasources.default.change-log=classpath\:db/liquibase-changelog.xml
schema-generate=NONE 让 schema 由迁移负责;db-type 帮助 Test Resources 识别要准备 MySQL。原文没有在这里硬编码 URL、用户和密码,因为测试可由 Test Resources 启动临时数据库并提供配置。这并不是生产数据源可以完全不配置连接凭据的意思。
建立关联表、复合主键与级联删除
根 Liquibase 文件和前一例相同,包含 changelog/01-schema.xml。多对多项目的 schema 内容如下:
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd">
<changeSet id="01" author="username">
<createTable tableName="users">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" primaryKeyName="pk_user" nullable="false"/>
</column>
<column name="username" type="VARCHAR(255)">
<constraints nullable="false" unique="true" uniqueConstraintName="uk_user_username"/>
</column>
</createTable>
<createTable tableName="role">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" primaryKeyName="pk_role" nullable="false"/>
</column>
<column name="authority" type="VARCHAR(255)">
<constraints nullable="false" unique="true" uniqueConstraintName="uk_role_authority"/>
</column>
</createTable>
<createTable tableName="user_role">
<column name="user_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="role_id" type="BIGINT"><constraints nullable="false"/></column>
</createTable>
<addPrimaryKey tableName="user_role" constraintName="pk_user_role"
columnNames="user_id, role_id"/>
<addForeignKeyConstraint baseTableName="user_role" baseColumnNames="user_id"
constraintName="fk_user_role_user" referencedTableName="users"
referencedColumnNames="id" onDelete="CASCADE"/>
<addForeignKeyConstraint baseTableName="user_role" baseColumnNames="role_id"
constraintName="fk_user_role_role" referencedTableName="role"
referencedColumnNames="id" onDelete="CASCADE"/>
</changeSet>
</databaseChangeLog>
删除用户或角色时,数据库的 CASCADE 会删除相关 user_role 行。这种级联删除的是关联记录,不是顺着用户关系把所有角色一起删掉。它仍是真实的数据删除行为;在真实权限系统里,更改关联就是改变授权,应由业务层的授权检查和审计保护,单有实体映射并不能形成完整访问控制。
用户、角色和复合关联键
UserEntity 与 Role 使用生成型 Long id,用户名与角色名使用 jakarta.validation.constraints.NotBlank:
@MappedEntity("users")
public record UserEntity(
@Nullable @Id @GeneratedValue Long id,
@NotBlank String username
) { }
@MappedEntity
public record Role(
@Nullable @Id @GeneratedValue Long id,
@NotBlank String authority
) { }
这里的 NotBlank 适用于字符串内容约束。是否在某个保存路径自动触发 Bean Validation,还取决于调用边界与验证集成;不能仅看字段注解就宣称所有入口都完成校验。
UserRole 把复合标识包装为 @EmbeddedId;UserRoleId 标为 @Embeddable,内含两个多对一关系。每个 public 类仍应位于自己的文件中:
@MappedEntity
public class UserRole {
@EmbeddedId
private final UserRoleId id;
public UserRole(UserRoleId id) {
this.id = id;
}
public UserRole(UserEntity user, Role role) {
this(new UserRoleId(user, role));
}
public UserRoleId getId() {
return id;
}
}
@Embeddable
public class UserRoleId {
@Relation(value = Relation.Kind.MANY_TO_ONE)
private final UserEntity user;
@Relation(value = Relation.Kind.MANY_TO_ONE)
private final Role role;
public UserRoleId(UserEntity user, Role role) {
this.user = user;
this.role = role;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
UserRoleId other = (UserRoleId) o;
return role.id().equals(other.getRole().id())
&& user.id().equals(other.getUser().id());
}
@Override
public int hashCode() {
return java.util.Objects.hash(role.id(), user.id());
}
public UserEntity getUser() { return user; }
public Role getRole() { return role; }
}
上述 equals 与 hashCode 按两个数据库 id 比较关联身份,而非比较整条实体的其他字段。这也带来明确前提:user、role 及其 id 需要已经有效;把尚未保存、id 为 null 的对象拿来构造并比较关联键,可能触发空指针错误。原测试先保存用户与角色,再建立关联,正符合这个顺序。
投影中的 Long 不能使用 NotBlank
原文 User 投影把 @NotBlank 同时标在 Long id 和 String username 上。前者与约束支持的字符序列类型不匹配,真实触发校验时可能报没有适用验证器。下面是明确修正的版本:id 改用 jakarta.validation.constraints.NotNull,用户名保留 NotBlank;@NonNull 仍用于 Micronaut 的空值元数据:
@Introspected
public record User(
@NonNull @jakarta.validation.constraints.NotNull Long id,
@NonNull @NotBlank String username,
@Nullable java.util.List<String> authorities
) { }
这是一处类型约束修正,不是声称已验证整个授权模型。原始写法仍保留在来源快照中,便于审查差异。
连接查询把多行角色聚合进 User
@JdbcRepository(dialect = Dialect.MYSQL)
public interface UserJdbcRepository
extends CrudRepository<UserEntity, Long> {
UserEntity save(String username);
@Query("""
SELECT
u.id,
u.username,
GROUP_CONCAT(
DISTINCT r.authority ORDER BY r.authority SEPARATOR ','
) AS authorities
FROM users AS u
LEFT JOIN user_role AS ur ON ur.user_id = u.id
LEFT JOIN role AS r ON r.id = ur.role_id
WHERE u.username = :username
GROUP BY u.id, u.username;
""")
java.util.Optional<User> findByUsername(String username);
}
@JdbcRepository(dialect = Dialect.MYSQL)
public interface RoleJdbcRepository
extends CrudRepository<Role, Long> {
Role save(String authority);
java.util.Optional<Role> findByAuthority(String authority);
void deleteByAuthority(String authority);
}
@JdbcRepository(dialect = Dialect.MYSQL)
public interface UserRoleJdbcRepository
extends CrudRepository<UserRole, UserRoleId> {
}
左连接保证尚无角色的用户也能被查询到。DISTINCT 去重、ORDER BY 让角色名按字符串排序,SEPARATOR 定义拼接分隔符;这就是测试期待 ROLE_ADMIN 在 ROLE_USER 前面的原因。用户名通过 :username 绑定,而非字符串拼接。
这段 SQL 明确属于 MySQL 方言。聚合结果的长度上限可能截断长列表,角色名若包含逗号也会使“字符串再转集合”的语义变复杂。对真正决定权限的结果,应限制角色名格式并核对聚合上限,或改用逐行映射等清晰的返回方式;不能把教程的小样本投影直接当作任意规模权限目录的保证。
测试多对多的建立、共享和删除
原文测试使用 @MicronautTest(startApplication = false),在测试方法参数中注入三个仓库。它先创建两个角色,再创建没有角色的 Sergio,检查投影 authorities 为 null;加入两个关联后检查两个角色;接着创建 Tim,并让他共享 ROLE_USER。下面保留完整的关键断言与删除顺序,常量直接放在类内:
@MicronautTest(startApplication = false)
class ManyToManyTest {
private static final String ROLE_USER = "ROLE_USER";
private static final String ROLE_ADMIN = "ROLE_ADMIN";
private static final String U_SERGIO = "sergio";
private static final String U_TIM = "tim";
@org.junit.jupiter.api.Test
void testManyToManyPersistence(
RoleJdbcRepository roleRepo,
UserJdbcRepository userRepo,
UserRoleJdbcRepository userRoleRepo) {
Role roleUser = roleRepo.save(ROLE_USER);
Role roleAdmin = roleRepo.save(ROLE_ADMIN);
assertFalse(userRepo.findByUsername(U_SERGIO).isPresent());
UserEntity sergio = userRepo.save(U_SERGIO);
assertUser(userRepo.findByUsername(U_SERGIO).orElse(null),
U_SERGIO, null);
userRoleRepo.save(new UserRole(sergio, roleUser));
userRoleRepo.save(new UserRole(sergio, roleAdmin));
assertUser(userRepo.findByUsername(U_SERGIO).orElse(null),
U_SERGIO, List.of(ROLE_ADMIN, ROLE_USER));
UserEntity tim = userRepo.save(U_TIM);
userRoleRepo.save(new UserRole(tim, roleUser));
assertUser(userRepo.findByUsername(U_TIM).orElse(null),
U_TIM, List.of(ROLE_USER));
userRoleRepo.delete(new UserRole(tim, roleUser));
userRoleRepo.delete(new UserRole(sergio, roleUser));
userRoleRepo.delete(new UserRole(sergio, roleAdmin));
userRepo.delete(sergio);
userRepo.delete(tim);
roleRepo.deleteByAuthority(ROLE_ADMIN);
roleRepo.deleteByAuthority(ROLE_USER);
}
void assertUser(User user, String expectedUsername,
List<String> expectedAuthorities) {
assertNotNull(user);
assertNotNull(user.id());
assertEquals(expectedUsername, user.username());
assertEquals(expectedAuthorities, user.authorities());
}
}
这段需导入 MicronautTest、java.util.List 和相应 JUnit 静态断言。它验证同一个角色能分配给多个用户,也展示可以只删除 user_role 来撤销关系。虽然 schema 有 ON DELETE CASCADE,原测试仍显式先删关联、再删实体,意图较清楚。它没有覆盖并发分配、重复关联失败、角色名含分隔符或超长聚合等情况,本文不将这些未覆盖行为写成已通过测试。
运行普通测试与原生测试的含义
原文运行 JVM 测试的命令是:
./gradlew test
随后查看 build/reports/tests/test/index.html。Windows 的常规调用应使用对应的 gradlew.bat test。在一对多项目中,测试使用 H2;在多对多项目中,Micronaut Test Resources 与 Testcontainers 集成,会启动一次性 MySQL 容器并提供连接信息,因此需要可用的 Docker 兼容环境,也可能拉取镜像。该命令会编译、执行测试、创建临时服务和写入测试数据库,不是静态检查命令。
Test Resources 的设计目标包括:尽量零配置地启动资源并把应用指向它们;保持测试资源依赖与应用、测试 classpath 的隔离;兼容 GraalVM 原生程序和原生测试;由 Gradle/Maven 插件处理依赖复杂性;允许扩展自定义资源;不强制所有资源都由 Testcontainers 实现。这些是工具的设计目标,具体环境仍要满足对应资源的运行条件。
io.micronaut.application Gradle 插件会集成 GraalVM Native Image 的构建插件,使 JUnit Platform 测试可以编译为原生代码执行。原文命令为:
./gradlew nativeTest
原文同样提示查看测试报告目录,并说明可以用 JUnit 的 @DisabledInNativeImage 跳过不适合原生镜像的测试。实际报告位置应以所用插件的输出为准。原生测试需要对应 GraalVM 工具链,JVM 测试通过不能自动替代原生验证。本文未运行两条命令,也未声称任一断言通过。
把映射、查询和校验各自核对
这两个例子形成了一条完整的练习路径:先让 Liquibase 约束真实数据结构,再用实体表达关联,用仓库方法明确加载边界,最后用投影和测试描述调用者实际收到的形状。默认查询中的未加载集合、左连接下的空关联、聚合返回的 null,并不是可以互换的概念。
继续深入可以阅读 Micronaut Data 指南与其他官方教程。迁移数据库或升级框架时,重新核对方言、record 映射、类型转换、约束类型和测试工具链;本文已经指出的 JDK 前提差异、Long 上的 NotBlank、聚合分隔符与清理顺序,都是比“能写出实体类”更值得验证的边界。
许可与署名:Sergio del Amo,Micronaut Guides。两份源码包中的 Java 代码版权行分别标为 Copyright 2017–2026 original authors(一对多)与 Copyright 2017–2025 / 2017–2026 original authors(多对多);代码按 Apache License 2.0 提供,完整文本也随稿附带。两篇指南的文字与媒体按 Creative Commons Attribution 4.0 提供。本译文合并两篇指南,重新组织中文说明,省略重复 imports 展示,修正投影约束、聚合函数与示例数据,并加入静态审查说明。新增图署名为编者,不表示原作者认可改写或测试结果。
Apache License 2.0
The translated code excerpts in this article are provided under Apache License 2.0. The complete license text follows.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.











暂无评论内容