
简介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.7javax.*的包名不要随手改成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.profilesdev命令里-Dspring-boot.run.profilesdev是关键参数它决定加载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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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 自动映射到 createTimemap-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 ListTag 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 分裂 ListString 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 idqueryCondition where s.deleted 0 if testq ! null and q ! AND (s.title LIKE CONCAT(%, #{q}, %) OR s.description LIKE CONCAT(%, #{q}, %) OR s.content LIKE CONCAT(%, #{q}, %)) /if if testlanguage ! null and language ! AND s.language #{language} /if if teststar ! null AND s.star #{star} /if if testtagIds ! 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 collectiontagIds itemtid open( separator, close) #{tid} /foreach ) /if /where /sql !-- 列表带分页 -- select idsearch resultMapSnippetMap SELECT s.* FROM t_snippet s include refidqueryCondition/ ORDER BY s.star DESC, s.updated_at DESC LIMIT #{offset}, #{size} /select !-- 总数复用同一段条件保证口径一致 -- select idcountByQuery resultTypelong SELECT COUNT(1) FROM t_snippet s include refidqueryCondition/ /selectsql片段 include是这套写法的核心。条件只写一遍列表和计数共用改条件时不会漏改。ORDER BY s.star DESC, s.updated_at DESC让收藏的排前面、新的排前面符合“我最近在用的东西应该最先看到”的直觉。参数说明offset (page - 1) * sizesize要在 Service 层做兜底传 0 或负数时 MySQL 会直接语法报错传 5000 时会拖垮页面我一般钳制在[1, 50]。3.4 Controller统一返回和异常兜底接口层只做三件事接参、校验、返回。业务判断全部下沉到 ServiceController 里出现if (xxx null) return ...超过两行就该挪走了。// controller/SnippetController.java RestController RequestMapping(/api/snippets) RequiredArgsConstructor public class SnippetController { private final SnippetService snippetService; GetMapping public PageResultSnippetVO list(SnippetQuery query) { // query 里的 page/size 由 Service 统一钳制 return snippetService.search(query); } PostMapping ResponseStatus(HttpStatus.CREATED) public MapString, 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和descriptioncontent单独走一个“全文搜索”按钮用二次确认的方式提示“这次会在正文里搜稍慢”。这个取舍来自实际使用大多数时候人记得的是片段标题或者它干什么用的description而不是正文里某一行。把慢查询从主路径上挪开比优化 SQL 有效得多。如果确实要正文搜索MySQL 的FULLTEXT索引在 MyISAM 时代好用InnoDB 支持之后也能用但中文分词是硬伤——默认分词器按空格切一整句中文会被当成一个词。常见做法是接一个中文分词插件或者干脆把正文抽出来塞进独立的搜索服务。对个人片段库这个量级我一般建议先不折腾把LIKE加个LIMIT兜住延迟就够。4.2 标签交互从标签云到多选筛选标签这块前端要做三件事展示标签云、点击筛选、编辑时多选。后端对应一个很简单的接口// controller/TagController.java GetMapping(/cloud) public ListTagStatVO cloud() { // 只返回被使用过的标签附带引用次数按次数倒序 return tagMapper.selectTagStats(); }对应的 SQLselect idselectTagStats resultTypecom.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 /selectJOIN 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. 先按语言设置 classhighlight.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-springhighlight.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 idqueryCondition两边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 固定带上serverTimezoneAsia/ShanghaicharacterEncodingutf8SQL 里分页统一用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字段类型写错几百条片段正文被截断虽然最后从备份捞回来了但那两个小时的心跳我不想再来一次。希望帮到你。本文还有配套的精品资源点击获取