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 轻松实现了标签功能,但简单粗暴的代价很显著:底层的一对多实现会导致数据库中大量重复数据。

要节省空间,需要创建额外的关联表连接 StudentTag 实体。好在 Spring JPA 会帮我们处理大部分工作。下面我们重构 StudentTag 实体来演示。

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
}

注意三个关键点:

  1. 使用自动生成的 ID 便于管理表关联
  2. @ManyToMany 注解声明类间关系
  3. @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 代码


原始标题:An Advanced Tagging Implementation with JPA