简介:面向Java初学者与CI/CD实践者的Jenkins流水线示例工程,围绕Java项目的持续集成与持续部署展开,适合希望理解自动化构建、测试与发布流程的开发者参考。压缩包共10个文件,约222KB,包含3个Java源码、2个JAR依赖、2个XML配置、1个Jenkinsfile及说明文档等,分别对应示例代码、单元测试依赖、构建配置与流水线脚本,体量轻便,便于本地导入和对照学习。目前已有304人学习下载。工程以矩形计算示例串联编译、测试与打包环节,借助Jenkinsfile描述阶段与步骤,并配合Ant构建配置展示传统构建方式,读者可从中梳理从代码拉取、单元测试、制品生成到部署的完整链路,理解stage-step结构、版本控制触发与构建自动化的组织方式,也可作为搭建小型Java流水线的起步模板。
1. 从一段八百行的订单方法说起:Java管道项目到底在解决什么
某团队有个订单创建方法,最早两百行,校验、算价、锁库存、写订单、发通知全塞在一起。半年后加到八百行,每加一个营销规则就要动核心方法,回归测试跑一遍四十分钟,谁改谁头大。后来他们把每个步骤拆成独立处理节点,用一条 Java 管道按顺序串起来,新增规则只加一个节点,核心调度代码不动。Java_Pipeline 这个项目就是把管道式处理做成可复用的小框架:Stage 接口定义处理契约,Pipeline 负责调度,Context 贯穿全链路,Builder 做链式组装。它适合业务流程长、步骤增减频繁、需要看清每步输入输出的场景,比如订单履约、审批流、数据清洗;不适合只有两三个固定步骤、追求极致性能的简单逻辑,那种直接写方法更省事。
2. 项目定位与技术选型:管道模式不是万能药,先看边界
2.1 管道、责任链、装饰器到底差在哪
很多人把管道和责任链混着叫。责任链强调“谁先处理、处理完要不要往下传”,节点可以动态决定下一个节点是谁;管道强调“固定顺序、逐级加工”,上一个阶段的输出就是下一个阶段的输入,顺序在组装时就定死。装饰器是在同一个入口上层层包功能,偏静态。Java_Pipeline 的定位更接近管道:阶段顺序由 Builder 一次性确定,运行期不随意改链路,换来的是可预测的执行路径和更简单的异常处理。如果你的业务需要运行期动态跳转,责任链更合适;如果只是想在方法调用前后加日志,装饰器或 AOP 更省代码。选型前先问自己:步骤顺序是不是基本固定?如果是,管道能把“顺序”这件事从代码里抽出来,变成可配置、可测试的结构。
2.2 技术栈与依赖选择
项目走轻量路线,不引 Spring、不引响应式框架,纯 Java 8+ 就能跑。构建用 Maven,测试用 JUnit 5,日志门面用 SLF4J,异步扩展可选 CompletableFuture。这样选的原因很实在:管道框架本身不该绑定容器,业务方在 Spring 项目里能引,在普通 Java SE 里也能引;依赖越少,升级 Java 版本时越不容易被第三方卡住。pom 里核心依赖大概长这样:
<dependencies> <!-- 日志门面,业务方自己决定绑 logback 还是 log4j2 --> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-api</artifactId> <version>1.7.36</version> </dependency> <!-- 测试用 --> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>5.9.2</version> <scope>test</scope> </dependency> </dependencies>编译插件里把 source 和 target 设成 1.8,保证老项目能直接用。如果你的团队已经全面 Java 17,把版本调上去即可,代码里没有用反射黑魔法,升级成本低。这里有个血泪经验:别在框架里偷偷引 Guava 或 Fastjson 这类工具库,业务方一旦版本冲突,排查起来非常费劲。工具方法自己写几个静态类就够,管道框架用不到多少工具。
2.3 哪些业务场景值得上管道
判断标准可以粗暴一点:步骤超过五个、步骤之间有明确先后依赖、其中至少两个步骤未来可能增减或替换,就值得考虑。订单履约是典型:验库存、算优惠、拆单、锁库存、生成支付单、发消息,每一步都可能因为业务规则调整而变。数据清洗也是:读取、解析、字段映射、校验、落库,清洗规则三天两头改。反过来,一个只有“查缓存、查库、返回”的接口,硬套管道只会多一层抽象,调试时还要多点几次跳转。我一般会跟团队说:管道是用来管“变化顺序”的,不是用来管“代码行数”的;如果步骤顺序永远不变,拆成私有方法比拆成 Stage 更直接。另外,管道对事务边界不友好,一个 Stage 一个数据库操作时,跨 Stage 事务要靠外层包,这一点在订单场景要提前想清楚。
3. 目录结构与核心抽象:Stage、Pipeline、Context 三件套怎么分工
3.1 工程目录一览
项目结构不复杂,按职责分包:
java-pipeline/ ├── pom.xml └── src ├── main/java/com/example/pipeline │ ├── Pipeline.java # 调度器,按顺序执行阶段 │ ├── PipelineBuilder.java # 链式组装,构建 Pipeline 实例 │ ├── Stage.java # 阶段接口,定义处理契约 │ ├── AbstractStage.java # 提供默认跳过逻辑和命名 │ ├── PipelineContext.java # 上下文,存放共享数据和中间结果 │ ├── exception │ │ └── PipelineException.java # 统一异常包装 │ └── stage │ ├── ValidateStage.java # 示例:参数校验 │ ├── TransformStage.java # 示例:数据转换 │ └── PersistStage.java # 示例:持久化 └── test/java/com/example/pipeline └── PipelineTest.java # 链路顺序、短路、异常测试这个结构的好处是核心抽象和业务示例分开:前五个类属于框架,stage 包下的是演示用的具体阶段。二次开发时,业务方在自己的工程里实现 Stage 接口即可,不用改框架代码。测试类里覆盖了正常链路、条件跳过、异常中断三种情况,跑 mvn test 就能验证。注意 exception 包没有做成大而全的异常体系,只有一个 PipelineException,避免业务方被迫继承一堆用不上的异常类。
3.2 Stage 接口:每个处理节点的最小契约
Stage 是整个框架的扩展点,设计得越窄越好:
public interface Stage<I, O> { /** 阶段名称,用于日志和监控 */ String name(); /** 核心处理方法,输入 I,输出 O,上下文透传 */ O process(I input, PipelineContext context) throws PipelineException; /** 是否跳过当前阶段,默认不跳过 */ default boolean shouldSkip(PipelineContext context) { return false; } }泛型 I 和 O 让相邻阶段在编译期就能对上类型:ValidateStage 输入 OrderRequest、输出 ValidatedOrder,TransformStage 输入 ValidatedOrder、输出 PricedOrder,串链时类型不匹配直接编译不过。process 方法抛受检的 PipelineException,强制实现方处理异常,而不是悄悄吞掉。shouldSkip 给了一个运行期跳过的钩子,比如灰度期间某些阶段不执行,可以在上下文里放一个开关,由 shouldSkip 读取。name() 看似多余,但排查问题时日志里没有阶段名,根本不知道卡在哪一步,这个字段后面会反复用到。
3.3 PipelineContext:贯穿全链路的上下文
上下文解决的是“阶段之间怎么传数据”的问题。如果每个阶段都靠入参出参传递,链一长,参数列表会爆炸;用一个 Map 式的上下文,阶段按需读写:
public class PipelineContext { // 用 ConcurrentHashMap 防止异步扩展时的并发写 private final Map<String, Object> attributes = new ConcurrentHashMap<>(); // 记录执行轨迹,便于排查 private final List<String> trace = new ArrayList<>(); public void put(String key, Object value) { attributes.put(key, value); } public <T> T get(String key, Class<T> type) { Object value = attributes.get(key); return value == null ? null : type.cast(value); } public void addTrace(String stageName) { trace.add(stageName); } public List<String> getTrace() { return Collections.unmodifiableList(trace); } }attributes 用 ConcurrentHashMap,是为了后面加异步阶段时不会因为并发写抛异常;get 方法带 Class 参数,调用方显式声明类型,取值时如果类型不对会立刻抛 ClassCastException,比在业务代码里强转后隔几层才报错好排查。trace 记录经过的阶段名,出问题时打印出来就能看到链路走到哪、在哪跳过了。注意上下文只放“跨阶段需要共享的数据”,单个阶段内部的临时变量不要往里塞,否则上下文会变成垃圾场,后面接手的人根本不知道哪个 key 是谁写的。
3.4 PipelineBuilder:把阶段串成链
Builder 负责组装顺序和类型衔接:
public class PipelineBuilder<I, O> { private final List<Stage<?, ?>> stages = new ArrayList<>(); public <T> PipelineBuilder<I, T> then(Stage<O, T> stage) { this.stages.add(stage); // 用泛型方法返回新的 Builder,保证链式调用时类型逐级推进 return new PipelineBuilder<>(stages); } public Pipeline<I, O> build() { return new Pipeline<>(stages); } }then 方法每次接收一个 Stage<O, T>,把当前输出类型 O 和下一个阶段的输入类型绑在一起,编译期就能挡住“把 String 塞给需要 Order 的阶段”这种错误。内部直接复用同一个 stages 列表,返回新 Builder 只是为了让泛型类型推进,运行期没有额外对象开销。build() 返回不可变的 Pipeline,构造后阶段列表不能再改,避免运行期有人往链里插阶段导致顺序错乱。这种写法比在 Pipeline 构造函数里传 List 更安全,也更好读。实际使用时链式调用大概是这样:
Pipeline<OrderRequest, OrderResult> pipeline = new PipelineBuilder<OrderRequest, OrderRequest>() .then(new ValidateStage()) .then(new TransformStage()) .then(new PersistStage()) .build();一眼能看出顺序,新增阶段就在合适位置插一个 then,不用改调度代码。
4. 核心链路拆解:一个请求进入 Pipeline 后的完整执行过程
4.1 execute 方法的调度逻辑
Pipeline 的核心就是一个循环:
public class Pipeline<I, O> { private final List<Stage<?, ?>> stages; private final PipelineContext context; public Pipeline(List<Stage<?, ?>> stages) { this.stages = Collections.unmodifiableList(new ArrayList<>(stages)); this.context = new PipelineContext(); } @SuppressWarnings("unchecked") public O execute(I input) throws PipelineException { Object current = input; for (Stage<?, ?> rawStage : stages) { Stage<Object, Object> stage = (Stage<Object, Object>) rawStage; String stageName = stage.name(); // 跳过判断放在执行前,避免无谓的处理 if (stage.shouldSkip(context)) { context.addTrace(stageName + ":skipped"); continue; } try { long start = System.currentTimeMillis(); current = stage.process(current, context); long cost = System.currentTimeMillis() - start; context.addTrace(stageName + ":" + cost + "ms"); } catch (Exception e) { // 统一包装,保留阶段名和原始异常 throw new PipelineException("stage [" + stageName + "] failed", e); } } return (O) current; } }循环里做了四件事:判断跳过、记录开始时间、调用 process、记录轨迹。skip 判断放在 process 之前,被跳过的阶段不产生任何副作用,这点在灰度场景很关键。异常统一包成 PipelineException,消息里带阶段名,排查时一眼知道是哪个节点炸的。这里用了 unchecked 强转,是因为 Java 泛型在运行期擦除,链式组装时已经保证类型正确,运行期强转只是形式。@SuppressWarnings 压在方法上,避免编译告警铺满屏幕。current 变量在循环里不断被下一个阶段的输出覆盖,最终返回最后一个阶段的输出。
4.2 上下文传递与短路控制
短路有两种实现方式:一种是 shouldSkip 返回 true,阶段被跳过但链路继续;另一种是阶段内部抛异常,链路中断。前者适合“条件不满足就不执行”,后者适合“数据非法必须停”。示例里 ValidateStage 校验失败时直接抛异常:
public class ValidateStage implements Stage<OrderRequest, OrderRequest> { @Override public String name() { return "validate"; } @Override public OrderRequest process(OrderRequest input, PipelineContext context) throws PipelineException { if (input.getUserId() == null || input.getAmount() == null) { throw new PipelineException("order request missing required field"); } // 校验结果放进上下文,后续阶段可以直接用 context.put("validated", true); return input; } @Override public boolean shouldSkip(PipelineContext context) { // 如果上游已经标记跳过校验,这里就不执行 return Boolean.TRUE.equals(context.get("skipValidate", Boolean.class)); } }校验失败抛异常,Pipeline 捕获后包装再抛出,调用方拿到的是带阶段名的异常,不用猜。shouldSkip 读上下文里的 skipValidate 开关,实现“某些入口跳过校验”的需求,不用改 Pipeline 代码。注意上下文里的 key 最好集中定义成常量,散落在各个 Stage 里写字符串,后期改名会漏。短路控制不要做得太花,两种方式够用了,再加“跳转到指定阶段”这种能力,链路就变成小型工作流引擎,复杂度会失控。
4.3 异常封装与结果收集
PipelineException 的定义很简单:
public class PipelineException extends Exception { public PipelineException(String message) { super(message); } public PipelineException(String message, Throwable cause) { super(message, cause); } }继承受检异常,强制调用方处理。有人喜欢用 RuntimeException 省去 throws,但管道里每个阶段都可能失败,受检异常能让调用方在写代码时就意识到“这条链会断”,而不是运行到一半才崩。构造器保留 cause,原始异常堆栈不丢,日志里打出来能看到根因。结果收集靠上下文和最终返回值两条线:最终返回值是最后一个阶段的输出,适合“链式加工”的场景;上下文里可以放中间结果,比如校验阶段放的 validated 标记,后续阶段按需读取。两条线不要混用,最终结果走返回值,过程数据走上下文,边界清晰,测试也好写。
5. 避坑与常见问题:管道跑通前容易踩的五个坑
5.1 泛型擦除导致类型转换失败
现象:链式组装时编译通过,运行到某个阶段抛 ClassCastException,提示 String cannot be cast to Order。原因:Stage 接口用了泛型,但 Pipeline 内部用 Object 承接 current,运行期泛型被擦除,如果 Builder 的 then 调用顺序和实际类型对不上,编译期检查被绕过。解决:组装时严格按输入输出类型写 then,不要用原始类型 Stage;在 Pipeline.execute 里对 current 做一次显式类型检查,失败时把阶段名和期望类型一起打出来。我一般会在测试里加一条“类型不匹配链路”的用例,确保异常信息够定位。
5.2 上下文里塞了可变共享对象
现象:两个阶段读同一个 List,前者改了内容,后者拿到的数据和预期不一致,排查半天发现是同一个引用。原因:PipelineContext 存的是对象引用,put 进去的 ArrayList、HashMap 在阶段里被原地修改,所有持有该引用的阶段都会看到变化。解决:上下文只放不可变对象或明确归属的值对象;需要集合时放 Collections.unmodifiableList 包装后的副本;如果确实要共享可变状态,在 key 命名上带阶段前缀,并在文档里写清楚谁写谁读。更彻底的做法是给上下文加类型化的命名空间,但会增加复杂度,小项目用命名约定就够。
5.3 阶段顺序写错导致空指针
现象:PersistStage 拿不到 TransformStage 产出的字段,抛 NPE。原因:Builder 里 then 的顺序和业务依赖不一致,或者有人插队加了一个新阶段,把原来的顺序打乱。解决:在 PipelineBuilder.build() 里做一次静态检查,要求每个阶段声明它依赖的上游阶段名,构建时校验依赖是否在前方;更轻量的做法是把链路的组装集中在一个配置类里,不允许散落在业务代码中 new PipelineBuilder。顺序问题本质是配置问题,把配置收拢到一处,review 时一眼能看出来。
5.4 异常被捕获后没有中断链路
现象:某个阶段处理失败,但日志只打了一行 warn,链路继续往下跑,最后落库的是脏数据。原因:阶段实现里 catch 住异常后没有重新抛出,或者吞掉后返回了原对象,Pipeline 以为处理成功。解决:约定阶段内要么不 catch,让 Pipeline 统一包装;要么 catch 后转成 PipelineException 抛出,绝不在阶段里吞异常。代码 review 时重点看 catch 块,凡是 catch 了不打日志也不抛的,一律打回。管道的好处是异常传播路径清晰,前提是每个阶段都遵守约定。
5.5 上下文持有大对象导致内存回收不掉
现象:批量跑几千条数据,内存涨上去下不来,GC 日志显示老年代有大量对象。原因:Pipeline 实例被复用,PipelineContext 跟着生命周期走,里面 put 的原始报文、大 List 一直不清理。解决:每次 execute 前重置上下文,或者把 Pipeline 设计成每次执行新建上下文;确实要复用的,在 execute 结束后调用 context.clear() 清空 attributes 和 trace。批量场景下我一般直接每次 new Pipeline,对象创建成本远低于内存泄漏的排查成本。上下文不是缓存,别把它当临时存储用。
6. 二次开发:加异步、加监控、做动态编排的三个改造点
6.1 用 CompletableFuture 把无依赖阶段并行化
串行管道最直接的问题是耗时累加。如果两个阶段之间没有数据依赖,比如“发短信通知”和“写操作日志”,完全可以并行。改造思路是在 Stage 接口上加一个 isAsync() 默认方法,Pipeline 遇到异步阶段时提交到线程池,用 CompletableFuture 承接结果,下一个阶段通过 join 取回。注意并行只适用于无依赖阶段,有依赖的还是要串行;线程池要由业务方传入,框架不自己 new,否则容易把容器线程打满。改造后测试要覆盖“并行阶段异常时主链路能收到”这种情况,不然异步异常会被吞掉。
6.2 给每个阶段加耗时埋点
第 4 章的 execute 里已经记了耗时到 trace,但只存在内存里。生产环境要接监控,可以在 Pipeline 里留一个 StageListener 接口,阶段执行前后回调,业务方实现后把耗时打到日志或指标系统。别在框架里直接引 Micrometer,保持零依赖;监听器抛异常要 catch 住,监控代码不能影响主链路。我一般会加一个慢阶段阈值,超过阈值的阶段单独打 warn 日志,排查性能问题时比全量日志好用。
6.3 用 ServiceLoader 做阶段插件化
如果阶段实现分散在多个模块,可以用 Java 自带的 ServiceLoader 按名称加载:每个模块在 META-INF/services 下声明 Stage 实现,PipelineBuilder 提供 byName(String) 方法从加载结果里取。好处是新增阶段不用改组装代码,配置里写名字就行;代价是启动时要扫描,且类加载失败的错误信息不如直接 new 清晰。小团队用配置类集中组装更直观,阶段超过二十个、由不同团队维护时再上插件化。改造完记得补一条集成测试,确保 ServiceLoader 在打包成 jar 后还能找到实现,别在 IDE 里跑得好好的,上线就加载不到。
最后说个自己的教训:早些年我给一个审批流加管道,图省事把上下文做成静态单例,测试环境单线程跑没问题,上线后并发一上来,不同请求的数据串得一塌糊涂,查了两天才定位到静态上下文。从那以后我每次引入管道,都强制要求上下文跟着一次执行走,execute 结束就丢弃,测试里必加一条并发用例。管道把顺序管住了,但生命周期得自己盯紧。希望帮到你。
本文还有配套的精品资源,点击获取