☰
Java代码片段存储库:Spring Boot+MyBatis检索与标签
2026/10/23 6:28:21 网站建设 项目流程

简介:Codehub 是一份面向 Java 开发者与编程学习者的代码片段管理仓库,核心用途是集中保存、分类检索日常积累的示例代码,并借助目录结构展示如何按主题组织个人代码资产。压缩包共 21 个文件,以 19 个 Java 源文件为主,另含 1 个 Markdown 说明文档和 1 个 C++ 文件,整体约 14KB,体量轻便,适合快速浏览与本地运行。内容围绕设计模式展开,涵盖策略、简单工厂、抽象工厂、命令、迭代器、适配器、观察者、单例、建造者、模板方法、组合、装饰与桥接等实现,同时附带 LeetCode 题解及 Effective Java、Effective C++ 相关笔记,README 则给出仓库使用说明。已有 186 人学习或下载。读者可从中获取可直接参考的 Java 代码范例、设计模式落地思路、题目求解片段与代码组织方式,用于复习巩固、面试准备或搭建自己的代码片段库。

1. 上周找一段旧代码找了四十分钟

事情本身很小:线上某个接口偶发重复提交,我记得半年前写过一段基于数据库唯一索引的防重逻辑,想直接抄过来。结果翻遍 IDE 的 scratch 文件、笔记软件、几个老项目的 util 包,最后在一个早就忘了名字的临时目录里找到,而且和三个错误版本的实现混在一起,分不清哪个是能用的。codehub 就是解决这个场景的:一个用 Java 写的代码片段存储库,把零散的小段代码集中存起来,按语言、标签、关键词检索,点开就是带高亮的代码,复制走直接用。它适合手里攒了一堆“以后肯定用得上”的片段、又不想为每段几十行代码单开一个 Git 仓库的人。

2. 工程骨架与启动:从解压到第一个 200

拿到一个 Java 项目,我会先花五分钟干三件事:看pom.xml里引了什么、看目录里有没有application.yml、看有没有schema.sql。这三样基本决定了它能不能在十分钟内跑起来。codehub 属于典型的单体 Spring Boot 工程,没有拆微服务,没有引入注册中心,前端是静态页 + 原生 JS,后端只暴露一组 REST 接口,数据库一张主表加一张关联表。这种结构看着朴素,但对“管理自己的代码片段”这个需求来说刚好——你不需要为了存 500 条片段去维护一套分布式基础设施。

2.1 技术栈选型:为什么是 Spring Boot + MyBatis

从关键词 Java 出发,这类工具型项目最常见的组合就是 Spring Boot 打底、MyBatis 做持久层。原因不玄学:片段的检索条件高度动态——关键词可能为空,语言可能不选,标签可能选三个也可能一个不选,还要分页和排序。用 JPA 拼Specification也能做,但拼到标签的EXISTS子查询时就开始别扭;MyBatis 直接在 XML 里写动态 SQL,条件怎么变,SQL 就长什么样,排查时把日志里的 SQL 复制到客户端一跑,问题立刻现形。

我一般会把技术栈归纳成下面这张表,方便判断改造时需要动哪一层:

层次选型在项目里承担什么
运行环境JDK 17语言特性、record、文本块(存多行代码片段很关键)
框架Spring Boot内嵌容器、自动装配、统一异常处理
持久层MyBatis + XML 映射动态检索、标签关联查询
数据库MySQL(开发期可切 H2)片段正文、标签、关联关系
前端Thymeleaf 静态页 + highlight.js列表、编辑、代码高亮
构建Maven Wrapper不依赖本机 Maven 版本

提示:仓库里如果锁的是 Spring Boot 2.7,javax.*的包名不要随手改成jakarta.*,改一半会出现“编译过、启动报类找不到”的经典翻车。

2.2 目录结构:一眼看出改哪里

工程目录按“接口层—业务层—持久层”三段切,外加resources下的 SQL 和模板。下面是我按实际拆包习惯整理的目录清单,字段名和包名可以按自己的规范调整,但分层不要打乱:

codehub/ ├── pom.xml ├── mvnw / mvnw.cmd └── src/main/ ├── java/com/demo/codehub/ │ ├── CodehubApplication.java # 启动类 │ ├── controller/ # SnippetController、TagController │ ├── service/ # SnippetService、TagService │ ├── mapper/ # SnippetMapper、TagMapper 接口 │ ├── entity/ # Snippet、Tag、SnippetTag │ ├── dto/ # SnippetForm、SnippetQuery、SnippetVO │ └── common/ # 统一返回、全局异常、分页对象 └── resources/ ├── application.yml ├── application-dev.yml # H2 开发环境 ├── schema.sql # 建表脚本 ├── data.sql # 初始化示例片段 ├── mapper/SnippetMapper.xml # 动态 SQL 都在这 └── templates/ # 列表页、编辑页

这个结构的好处是:加一个功能,改动路径是确定的。比如“按语言统计片段数量”,改SnippetMapper加一个GROUP BY language的查询,SnippetService里包一层,SnippetController加一个GET /api/stats/language,前端只负责画。不用在六个模块之间来回跳。

2.3 启动步骤:先跑 H2,再接 MySQL

第一次跑,千万不要直接连本机 MySQL。先确认代码本身没问题,再接真实库,这是我踩过太多次的顺序问题——库连不上和代码有 bug,报错长得完全不一样,混在一起排查会浪费一晚上。

第一步,进目录,用 Wrapper 起开发环境:

# 进入解压后的工程根目录 cd codehub # dev profile 用内存版 H2,启动时自动执行 schema.sql 和 data.sql # Windows 下把 ./mvnw 换成 mvnw.cmd ./mvnw spring-boot:run -Dspring-boot.run.profiles=dev

命令里-Dspring-boot.run.profiles=dev是关键参数,它决定加载application-dev.yml而不是默认配置。启动日志里出现Tomcat started on port(s): 8080之后,浏览器打开http://localhost:8080就能看到列表页,data.sql里初始化了几条示例片段,用来验证渲染链路是通的。

第二步,切 MySQL 之前先建库建账号,再改配置:

# application.yml 关键片段 server: port: 8080 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/codehub?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: codehub password: change-me sql: init: mode: always # 首次启动执行 schema.sql;表已存在时建议改成 never mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true # create_time 自动映射到 createTime

map-underscore-to-camel-case这一行漏了,表现是所有实体的createdAt、updatedAt全是 null,页面时间列一片空白——不是 SQL 查错了,是映射没开。sql.init.mode在第一次建表时用always,第二次启动前记得改成never,否则CREATE TABLE重复执行会直接让应用起不来。

3. 核心链路拆解:一条片段从提交到落库

这一章拆的是最主干的一条路:用户在编辑页粘贴一段代码、填标题、选语言、打标签,点保存,到列表页能搜到。中间经过 Controller 参数校验、Service 事务、Mapper 写入、标签关联同步四步。把这四步吃透,后面加字段、加导出、加版本历史都是在这条链上挂东西。

3.1 表结构与实体:两张表 + 一张关联表

片段和标签是多对多,所以是经典的三张表。t_snippet存正文,t_tag存标签名,t_snippet_tag存关联。建表脚本我一般直接放在schema.sql里,跟代码一起走版本管理:

-- t_snippet:片段主表 CREATE TABLE IF NOT EXISTS t_snippet ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(120) NOT NULL, language VARCHAR(32) NOT NULL DEFAULT 'text', content MEDIUMTEXT NOT NULL, -- 片段正文,可能上千行 description VARCHAR(500) DEFAULT NULL, star TINYINT NOT NULL DEFAULT 0,-- 0 普通 1 收藏 deleted TINYINT NOT NULL DEFAULT 0,-- 逻辑删除,不物理删 created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, UNIQUE KEY uk_lang_title (language, title) -- 同语言下标题唯一,防重复粘贴 ); -- t_tag:标签表,name 唯一,避免 "redis" 和 "Redis" 变成两个 CREATE TABLE IF NOT EXISTS t_tag ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(40) NOT NULL, UNIQUE KEY uk_name (name) ); -- t_snippet_tag:关联表,主键用联合主键,天然防重复关联 CREATE TABLE IF NOT EXISTS t_snippet_tag ( snippet_id BIGINT NOT NULL, tag_id BIGINT NOT NULL, PRIMARY KEY (snippet_id, tag_id) );

三个设计点值得说一下。content用MEDIUMTEXT不用TEXT,因为TEXT上限 64KB,一段带注释的长配置类很容易超;deleted走逻辑删除,因为删片段这件事在工具类项目里手滑概率极高,物理删了没有后悔药;uk_lang_title这个唯一索引是最后一道防线,前端防抖没拦住、用户连点两次保存时,数据库会直接拒掉第二条。

对应实体:

// entity/Snippet.java public class Snippet { private Long id; private String title; private String language; private String content; private String description; private Integer star; private Integer deleted; private LocalDateTime createdAt; private LocalDateTime updatedAt; // 非表字段,列表页展示用;不参与 insert/update private List<Tag> tags = new ArrayList<>(); }

tags这个字段不在t_snippet里,靠后面的关联查询回填。实体里放非表字段在 MyBatis 下没问题,只要insert语句里显式列出列名,别用INSERT INTO t_snippet VALUES (...)这种全列写法。

3.2 Service:事务边界和标签同步

最常见的新手写法是在 Controller 里先插片段、再插标签,两步各自成功各自失败。片段插进去了,标签插失败,页面报错但数据已经脏了。正确做法是把整段操作收进一个事务方法:

// service/SnippetService.java @Service @RequiredArgsConstructor public class SnippetService { private final SnippetMapper snippetMapper; private final TagMapper tagMapper; @Transactional(rollbackFor = Exception.class) public Long create(SnippetForm form) { Snippet entity = new Snippet(); entity.setTitle(form.getTitle().trim()); entity.setLanguage(StringUtils.hasText(form.getLanguage()) ? form.getLanguage() : "text"); entity.setContent(form.getContent()); entity.setDescription(form.getDescription()); entity.setStar(0); entity.setDeleted(0); entity.setCreatedAt(LocalDateTime.now()); entity.setUpdatedAt(LocalDateTime.now()); // 1. 主表落库,useGeneratedKeys 回填自增 id snippetMapper.insert(entity); // 2. 标签名去重 + 转小写,避免 "Redis" / "redis" 分裂 List<String> names = form.getTags() == null ? List.of() : form.getTags().stream() .filter(StringUtils::hasText) .map(t -> t.trim().toLowerCase()) .distinct() .limit(10) // 上限,防止有人贴 50 个标签 .toList(); for (String name : names) { // 3. 存在就取,不存在就建,返回主键 Long tagId = tagMapper.findIdByName(name); if (tagId == null) { Tag tag = new Tag(); tag.setName(name); tagMapper.insert(tag); tagId = tag.getId(); } tagMapper.insertRelation(entity.getId(), tagId); } return entity.getId(); } }

@Transactional(rollbackFor = Exception.class)里的rollbackFor不能省。Spring 默认只在RuntimeException和Error时回滚,如果中间抛的是受检异常(比如某些 IO 操作包装后的异常),事务会正常提交,脏数据就这么留下来的。

limit(10)是刻意的业务约束。标签一旦没有上限,就会出现有人把整段代码的每个类名都当标签打进去,检索页的标签云直接爆炸。十个够用了。

3.3 查询与分页:动态 SQL 怎么写才不漏条件

列表页是使用频率最高的入口,检索条件是组合式的:关键词、语言、标签、收藏状态、分页参数,任意组合。三个if块拼 SQL 之前,先明确一件事:WHERE之后的条件,列表查询和总数查询必须完全一致,否则分页会出现“共 37 条,翻到第 4 页是空的”。

<!-- resources/mapper/SnippetMapper.xml --> <sql id="queryCondition"> <where> s.deleted = 0 <if test="q != null and q != ''"> AND (s.title LIKE CONCAT('%', #{q}, '%') OR s.description LIKE CONCAT('%', #{q}, '%') OR s.content LIKE CONCAT('%', #{q}, '%')) </if> <if test="language != null and language != ''"> AND s.language = #{language} </if> <if test="star != null"> AND s.star = #{star} </if> <if test="tagIds != null and tagIds.size() > 0"> AND EXISTS ( SELECT 1 FROM t_snippet_tag st WHERE st.snippet_id = s.id AND st.tag_id IN <foreach collection="tagIds" item="tid" open="(" separator="," close=")"> #{tid} </foreach> ) </if> </where> </sql> <!-- 列表:带分页 --> <select id="search" resultMap="SnippetMap"> SELECT s.* FROM t_snippet s <include refid="queryCondition"/> ORDER BY s.star DESC, s.updated_at DESC LIMIT #{offset}, #{size} </select> <!-- 总数:复用同一段条件,保证口径一致 --> <select id="countByQuery" resultType="long"> SELECT COUNT(1) FROM t_snippet s <include refid="queryCondition"/> </select>

<sql>片段 +<include>是这套写法的核心。条件只写一遍,列表和计数共用,改条件时不会漏改。ORDER BY s.star DESC, s.updated_at DESC让收藏的排前面、新的排前面,符合“我最近在用的东西应该最先看到”的直觉。

参数说明:offset = (page - 1) * size,size要在 Service 层做兜底,传 0 或负数时 MySQL 会直接语法报错,传 5000 时会拖垮页面,我一般钳制在[1, 50]。

3.4 Controller:统一返回和异常兜底

接口层只做三件事:接参、校验、返回。业务判断全部下沉到 Service,Controller 里出现if (xxx == null) return ...超过两行,就该挪走了。

// controller/SnippetController.java @RestController @RequestMapping("/api/snippets") @RequiredArgsConstructor public class SnippetController { private final SnippetService snippetService; @GetMapping public PageResult<SnippetVO> list(SnippetQuery query) { // query 里的 page/size 由 Service 统一钳制 return snippetService.search(query); } @PostMapping @ResponseStatus(HttpStatus.CREATED) public Map<String, Long> create(@RequestBody @Valid SnippetForm form) { return Map.of("id", snippetService.create(form)); } @GetMapping("/{id}") public SnippetVO detail(@PathVariable Long id) { return snippetService.detail(id); } }

@Valid触发SnippetForm上的校验注解,比如@NotBlank(message = "标题不能为空")、@Size(max = 120)。校验失败抛MethodArgumentNotValidException,交给@RestControllerAdvice统一转成{code, message},前端只认一种错误结构,不用为每个接口写一套提示逻辑。

@ResponseStatus(HttpStatus.CREATED)让新增接口返回 201 而不是 200,前端fetch里if (res.status === 201)就能判断成功,比在 body 里塞success: true干净。

4. 检索与标签:让 500 条片段五秒内找到

存进去不是目的,找得到才是。片段库超过一百条之后,纯粹的“按更新时间倒序翻页”就废了,人会开始怀疑自己到底存没存过。这一章讲检索体验怎么搭:关键词匹配的范围、标签的交互、结果里的高亮,以及中文内容怎么处理才不会让搜索变成摆设。

4.1 关键词匹配:三个字段的取舍

s.title、s.description、s.content三个字段参与LIKE。为什么不全表扫一遍就完事?因为content是MEDIUMTEXT,全表LIKE '%x%'在几千条数据时还能忍,上万条就开始明显卡顿。我的做法是:列表页只查title和description,content单独走一个“全文搜索”按钮,用二次确认的方式提示“这次会在正文里搜,稍慢”。

这个取舍来自实际使用:大多数时候人记得的是片段标题或者它干什么用的(description),而不是正文里某一行。把慢查询从主路径上挪开,比优化 SQL 有效得多。

如果确实要正文搜索,MySQL 的FULLTEXT索引在 MyISAM 时代好用,InnoDB 支持之后也能用,但中文分词是硬伤——默认分词器按空格切,一整句中文会被当成一个词。常见做法是接一个中文分词插件,或者干脆把正文抽出来塞进独立的搜索服务。对个人片段库这个量级,我一般建议先不折腾,把LIKE加个LIMIT兜住延迟就够。

4.2 标签交互:从标签云到多选筛选

标签这块前端要做三件事:展示标签云、点击筛选、编辑时多选。后端对应一个很简单的接口:

// controller/TagController.java @GetMapping("/cloud") public List<TagStatVO> cloud() { // 只返回被使用过的标签,附带引用次数,按次数倒序 return tagMapper.selectTagStats(); }

对应的 SQL:

<select id="selectTagStats" resultType="com.demo.codehub.dto.TagStatVO"> SELECT t.id, t.name, COUNT(st.snippet_id) AS refCount FROM t_tag t JOIN t_snippet_tag st ON st.tag_id = t.id JOIN t_snippet s ON s.id = st.snippet_id AND s.deleted = 0 GROUP BY t.id, t.name HAVING refCount > 0 ORDER BY refCount DESC, t.name ASC LIMIT 60 </select>

JOIN t_snippet ... AND s.deleted = 0这一段不能省。片段逻辑删除之后关联表里的记录还在,如果不联表过滤,标签云里会出现一堆引用次数为 1、点进去却啥也没有的“孤儿标签”。这个问题在开发期不明显,用一个月之后会非常明显。

LIMIT 60也是刻意的。标签超过六十个之后,标签云本身就没有浏览价值了,剩下的应该靠搜索框。与其把页面撑得老长,不如砍掉长尾。

4.3 高亮渲染:前端别把代码当 HTML 塞

详情页要把片段正文渲染成带语法高亮的代码块。这里有个必须说清楚的点:片段内容里包含<、>、&,如果直接innerHTML,轻则显示错乱,重则被注入脚本。正确顺序是先转义、再交给高亮库。

// static/js/detail.js async function renderSnippet(id) { const res = await fetch(`/api/snippets/${id}`); const data = await res.json(); // 1. 先按语言设置 class,highlight.js 靠这个识别语法 const codeEl = document.getElementById('code'); codeEl.className = `language-${data.language || 'text'}`; // 2. textContent 赋值 = 浏览器自动转义,绝不能用 innerHTML codeEl.textContent = data.content; // 3. 再让高亮库处理已经转义过的文本节点 hljs.highlightElement(codeEl); // 4. 语言不在内置列表里时,highlight.js 会报警告,这里兜底成纯文本 if (!hljs.getLanguage(data.language)) { codeEl.className = 'language-plaintext'; hljs.highlightElement(codeEl); } }

textContent那行是整个文件里最重要的一行。改成innerHTML之后,只要有人存了一段带<script>的片段(比如存了一段 HTML 示例),页面就会被执行。工具类项目往往跑在内网或者本机,大家容易放松警惕,但这类漏洞一旦存在,后面接公网或者分享链接时就是现成的坑。

hljs.getLanguage那层判断是为了处理自定义语言标签,比如你写了个language-spring,highlight.js 不认识,会在控制台刷警告,虽然不影响显示,但日志里一堆黄色提示看着闹心。

5. 避坑与排查:五个真实踩过的坑

这一章记录的是我在这个结构上真踩过的五件事。每条按“现象—原因—解决”写,遇到相似症状可以直接对号入座。

5.1 启动报错“Table already exists”

现象:第二次spring-boot:run直接启动失败,日志里是Table 't_snippet' already exists,应用起不来。

原因:spring.sql.init.mode设成了always,每次启动都执行schema.sql。建表语句里虽然写了IF NOT EXISTS,但索引和唯一约束那几行不一定带,或者脚本里混了DROP之外的语句,导致重复执行报错。

解决:开发期用 H2 内存库时保持always(每次都是干净的),切到 MySQL 之后立刻改成never,建表交给schema.sql手工执行一次,或者接 Flyway 这类迁移工具管理版本。不要指望“自动建表”在生产环境一直好用。

5.2 分页总数对不上,最后一页是空的

现象:列表显示“共 128 条”,翻到第 13 页(每页 10 条)什么都没有。

原因:countByQuery和search的条件不一致。最常见的是count那条忘了加s.deleted = 0,或者tagIds的EXISTS子查询只加在了列表查询里。两条 SQL 各写各的,改一处漏一处。

解决:条件抽成<sql id="queryCondition">,两边<include>。改条件时只改一处,从结构上消灭这个问题。写完顺手在测试里断言count和search用的参数对象是同一个。

5.3 标签重复,出现“Redis”和“redis”两个

现象:标签云里同一个概念出现两次,点其中一个只能搜到一半片段。

原因:入库前没做归一化。前端传什么就存什么,用户手动输入时大小写不一致,或者前后带空格。

解决:Service 层统一trim().toLowerCase(),t_tag.name上加唯一索引兜底。已经有脏数据的,写一个一次性修复脚本:按LOWER(TRIM(name))分组,把重复的标签 id 合并到最小 id 上,更新关联表,删掉多余行。这个脚本要在事务里跑,跑之前先备份。

5.4 H2 能跑,MySQL 报语法错误

现象:dev profile 下一切正常,切 MySQL 之后LIMIT #{offset}, #{size}报错,或者日期字段差 8 小时。

原因:两件事。一是 H2 的 MySQL 兼容模式对LIMIT offset, size的支持和真实 MySQL 有细微差别;二是时区,JDBC URL 里没带serverTimezone,驱动按 UTC 解析,页面上所有时间都少八小时。

解决:JDBC URL 固定带上serverTimezone=Asia/Shanghai&characterEncoding=utf8;SQL 里分页统一用LIMIT #{size} OFFSET #{offset}这种标准写法,两边都认。这类方言差异是血泪经验——本地跑通不等于线上跑通,中间差着一整个数据库。

5.5 搜索框输入特殊字符直接 500

现象:搜索100%或者a_b时接口报错,或者返回结果完全不符合预期。

原因:%和_在LIKE里是通配符。用户输入100%时拼出来的条件是LIKE '%100%%',语义完全变了;更糟的是内容里带单引号时,如果某处用了字符串拼接而不是#{}占位符,就是注入。

解决:全部用#{}预编译占位,禁止${}拼进LIKE;对用户输入里的%、_、\做转义,或者用ESCAPE子句声明转义符。这一条是所有带搜索框的项目都要过的关,早处理早安心。

6. 二次开发:给它加一个“片段版本历史”

拿到这种工具类项目,最快产生价值的一步改造是加版本历史。原因很实际:同一段代码你会改很多次,改坏了想回到上一版,现在的表结构只能覆盖,回不去。下面是我在这套结构上加这个功能的路子。

先加一张表,不碰主表:

CREATE TABLE t_snippet_history ( id BIGINT PRIMARY KEY AUTO_INCREMENT, snippet_id BIGINT NOT NULL, content MEDIUMTEXT NOT NULL, version INT NOT NULL, created_at DATETIME NOT NULL, KEY idx_snippet (snippet_id, version) );

然后在SnippetService.update里,写入新内容之前先把旧内容快照进去,两件事放同一个事务:

@Transactional(rollbackFor = Exception.class) public void update(Long id, SnippetForm form) { Snippet old = snippetMapper.findById(id); if (old == null) throw new IllegalArgumentException("片段不存在"); // 内容真的变了才存快照,纯改标题不产生版本 if (!Objects.equals(old.getContent(), form.getContent())) { int next = historyMapper.nextVersion(id); historyMapper.insert(id, old.getContent(), next, LocalDateTime.now()); } snippetMapper.updateContentAndMeta(id, form, LocalDateTime.now()); }

验证方法我一般走三步:先手工调PUT /api/snippets/{id}改两次内容,再查t_snippet_history确认有两条且version递增,最后调一个GET /api/snippets/{id}/history看能不能按版本倒序拉出来。测试里补一个边界用例——内容没变时不应该产生新版本,这个判断漏了,历史表会以每次保存的速度膨胀。

再往下可以做的还有:给t_snippet加expire_at字段做定期清理、加一个POST /api/snippets/import支持从 Markdown 批量导入、把LIKE搜索换成独立索引。改造的通用原则是:新功能尽量加表、加接口,不动现有的t_snippet主键和查询路径,这样改坏了随时能回退。

我现在的习惯是,任何一次改表结构之前,先把schema.sql和一份真实数据导出一起备份,然后在一个空库上完整跑一遍建表加启动。之前有一次图省事直接在开发库上ALTER,字段类型写错,几百条片段正文被截断,虽然最后从备份捞回来了,但那两个小时的心跳我不想再来一次。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询