1. 概述
标签是一种设计模式,能帮我们对数据进行高级过滤和排序。本文是 JPA 简单标签实现 的续篇,将直接从上一篇结束的地方开始,深入探讨标签的高级应用场景。
2. 认可标签
最知名的高级标签实现当属 认可标签(Endorsed Tag),在 LinkedIn 等平台随处可见。其核心是将标签设计为「名称+数值」的组合,数值代表该标签被投票或"认可"的次数。
下面是这种标签的实现示例:
@Embeddable
public class SkillTag {
private String name;
private int value;
// 构造方法、getter、setter
}
使用方式很简单,直接在数据对象中添加 List<SkillTag>
:
@ElementCollection
private List<SkillTag> skillTags = new ArrayList<>();
⚠️ 上一篇提到过,
@ElementCollection
会自动创建一对多映射。这种场景非常适合该关系——因为每个标签都关联了实体特有的数据,无法通过多对多机制节省存储空间。
由于标签已嵌入原始实体,我们可以像查询其他属性一样操作它。下面这个示例查询找出所有某技能认可数超过阈值的学生:
@Query(
"SELECT s FROM Student s JOIN s.skillTags t WHERE t.name = LOWER(:tagName) AND t.value > :tagValue")
List<Student> retrieveByNameFilterByMinimumSkillTag(
@Param("tagName") String tagName, @Param("tagValue") int tagValue);
实际使用示例:
Student student = new Student(1, "Will");
SkillTag skill1 = new SkillTag("java", 5);
student.setSkillTags(Arrays.asList(skill1));
studentRepository.save(student);
Student student2 = new Student(2, "Joe");
SkillTag skill2 = new SkillTag("java", 1);
student2.setSkillTags(Arrays.asList(skill2));
studentRepository.save(student2);
List<Student> students =
studentRepository.retrieveByNameFilterByMinimumSkillTag("java", 3);
assertEquals("size incorrect", 1, students.size());
现在我们既能按标签存在性查询,也能按认可数量筛选,还能组合其他查询参数构建复杂查询。
3. 位置标签
另一种常见实现是 位置标签(Location Tag),主要用于两种场景:
- 标记地理物理位置
- 标记媒体(如照片/视频)中的位置
这两种场景的实现模型几乎完全相同。下面是照片标记的示例:
@Embeddable
public class LocationTag {
private String name;
private int xPos;
private int yPos;
// 构造方法、getter、setter
}
⚠️ 位置标签最大的坑在于:纯数据库实现地理位置过滤极其困难!如果需要按地理边界搜索,更优解是使用 Elasticsearch 等内置地理支持的搜索引擎。
因此我们重点讨论按标签名过滤的方案。查询逻辑和上一篇的简单标签实现很像:
@Query("SELECT s FROM Student s JOIN s.locationTags t WHERE t.name = LOWER(:tag)")
List<Student> retrieveByLocationTag(@Param("tag") String tag);
使用示例也大同小异:
Student student = new Student(0, "Steve");
student.setLocationTags(Arrays.asList(new LocationTag("here", 0, 0));
studentRepository.save(student);
Student student2 = studentRepository.retrieveByLocationTag("here").get(0);
assertEquals("name incorrect", "Steve", student2.getName());
如果实在不能用 Elasticsearch,又必须做地理边界搜索,建议用简单几何形状(如圆形/矩形)来简化查询条件。判断点是否在区域内就留给读者当练习吧。
4. 键值标签
有时我们需要存储更复杂的标签——比如给实体打上少量固定键(key),但值(value)可以千变万化。举个栗子:给学生打上 department
标签,值可以是 Computer Science
。每个学生都有 department
键,但值各不相同。
实现和认可标签类似:
@Embeddable
public class KVTag {
private String key;
private String value;
// 构造方法、getter、setter
}
添加到模型的方式:
@ElementCollection
private List<KVTag> kvTags = new ArrayList<>();
现在在仓库里添加新查询:
@Query("SELECT s FROM Student s JOIN s.kvTags t WHERE t.key = LOWER(:key)")
List<Student> retrieveByKeyTag(@Param("key") String key);
我们还能快速扩展出按值查询、或键值联合查询的功能,大幅提升搜索灵活性。测试验证如下:
@Test
public void givenStudentWithKVTags_whenSave_thenGetByTagOk(){
Student student = new Student(0, "John");
student.setKVTags(Arrays.asList(new KVTag("department", "computer science")));
studentRepository.save(student);
Student student2 = new Student(1, "James");
student2.setKVTags(Arrays.asList(new KVTag("department", "humanities")));
studentRepository.save(student2);
List<Student> students = studentRepository.retrieveByKeyTag("department");
assertEquals("size incorrect", 2, students.size());
}
按这个模式,我们甚至能设计更复杂的嵌套对象来标记数据。虽然多数场景用本文的高级实现已足够,但需要时可以无限扩展复杂度。
5. 重构标签实现
最后我们来探讨标签的终极优化方案。目前我们用 @ElementCollection
轻松实现了标签功能,但简单粗暴的代价很显著:底层的一对多实现会导致数据库中大量重复数据。
要节省空间,需要创建额外的关联表连接 Student
和 Tag
实体。好在 Spring JPA 会帮我们处理大部分工作。下面我们重构 Student
和 Tag
实体来演示。
5.1. 定义实体
先重建模型,从 ManyStudent
开始:
@Entity
public class ManyStudent {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private int id;
private String name;
@ManyToMany(cascade = CascadeType.ALL)
@JoinTable(name = "manystudent_manytags",
joinColumns = @JoinColumn(name = "manystudent_id",
referencedColumnName = "id"),
inverseJoinColumns = @JoinColumn(name = "manytag_id",
referencedColumnName = "id"))
private Set<ManyTag> manyTags = new HashSet<>();
// 构造方法、getter、setter
}
注意三个关键点:
- 使用自动生成的 ID 便于管理表关联
@ManyToMany
注解声明类间关系@JoinTable
配置实际的关联表
接着是新标签模型 ManyTag
:
@Entity
public class ManyTag {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private int id;
private String name;
@ManyToMany(mappedBy = "manyTags")
private Set<ManyStudent> students = new HashSet<>();
// 构造方法、getter、setter
}
由于关联表已在学生模型中配置,这里只需用 mappedBy
属性指向之前的关联表即可。
5.2. 定义仓库
需要为每个实体创建仓库,让 Spring Data 处理繁重工作:
public interface ManyTagRepository extends JpaRepository<ManyTag, Long> {
}
当前不需要按标签搜索,仓库类保持空即可。学生仓库稍复杂一点:
public interface ManyStudentRepository extends JpaRepository<ManyStudent, Long> {
List<ManyStudent> findByManyTags_Name(String name);
}
依然让 Spring Data 自动生成查询方法。
5.3. 测试验证
最后看测试效果:
@Test
public void givenStudentWithManyTags_whenSave_theyGetByTagOk() {
ManyTag tag = new ManyTag("full time");
manyTagRepository.save(tag);
ManyStudent student = new ManyStudent("John");
student.setManyTags(Collections.singleton(tag));
manyStudentRepository.save(student);
List<ManyStudent> students = manyStudentRepository
.findByManyTags_Name("full time");
assertEquals("size incorrect", 1, students.size());
}
✅ 将标签存储到独立可搜索表带来的灵活性,远超代码中增加的少量复杂度。同时还能通过合并重复标签减少系统存储总量。
❌ 但多对多关系不适合需要存储实体特定状态信息的场景(如认可标签的数值)。
6. 总结
本文承接 上一篇基础实现,重点介绍了:
- 多种高级标签模型的设计思路
- 用多对多映射重构标签系统的方案
想看完整运行示例,请查看 GitHub 代码。