简介:这是一份围绕Jenkins平台落地Java项目持续集成与持续部署的小型实践包,面向具备Java基础、希望掌握流水线脚本编写与自动化构建流程的开发者。压缩包共10个文件,约222KB,包含Java源码、测试依赖包、构建配置、Jenkinsfile以及说明文档,体量轻便,适合本地导入后逐步调试。它把Jenkinsfile的阶段编排同Ant构建任务结合起来,配合矩形计算相关类与单元测试,展示代码拉取、编译、测试、打包到产物输出的基本链路,并涉及构建命名、版本控制触发和阶段划分等常见做法;读者可借此理解持续集成、持续部署与自动化构建的协作方式。已有304人学习,可用作流水线入门练手、课堂演示,或为既有项目改造持续集成流程提供参考骨架,也便于对照说明文档快速定位关键文件。
1. 管道不是链式调用:Java_Pipeline 资源先解决“阶段失控”这件事
很多团队把 Java 管道写成 Service 里的一串 if/else,前面校验、中间补数据、后面落库,接口一改就牵一发动全身。Java_Pipeline 这份资源把阶段拆分、上下文传递、异常短路和异步执行做成可运行的工程骨架,适合正在做数据清洗、订单流转、内容处理链路的开发者。它不教你背设计模式,而是给出一套能跑通、能替换业务 Stage 的落地结构;新手可以照着跑,熟手重点看线程模型和失败边界。
2. 把样例跑起来:Java 管道工程的启动路径与最小可运行闭环
拿到这类 Java 管道工程,先别急着改业务 Stage。我一般先确认三件事:入口类从哪里读数据、Stage 按什么顺序装配、失败时进程是抛异常还是只记日志。资源包如果带样例数据,原样跑一遍最省时间;跑不通再回头看依赖和 JDK 版本,比一上来重写业务代码靠谱得多。下面按“看目录、跑样例、改一个 Stage、补冒烟用例”的顺序走。
2.1 先认清目录:入口、Stage、上下文和样例数据
资源包解压后,不要直接全局搜索main,先把浅层目录列出来。常见结构是bootstrap放入口,stage放阶段实现,context放请求级数据,data放样例输入。目录名不一定完全一样,但判断标准一致:入口类负责参数解析和装配,Stage 只负责单步处理,上下文不跨请求复用。
| 位置 | 先看什么 | 判断标准 |
|---|---|---|
| 启动入口 | main方法 | 是否支持输入路径、输出路径、配置文件 |
| Stage 实现 | implements Stage | 一个类是否只做一件事 |
| 上下文 | PipelineContext | 是否有类型约束,是否按请求创建 |
| 样例数据 | sample、data | 编码、字段分隔符、空行处理 |
| 输出目录 | out、result | 是否覆盖写,失败时是否留半成品 |
unzip Java_Pipeline.zip -d Java_Pipeline cd Java_Pipeline find . -maxdepth 3 -type f | sort | sed -n '1,40p'这段命令只做三件事:解压、进入目录、列出前三层文件。-maxdepth 3是为了避开依赖目录里成百上千的文件,sed -n '1,40p'只展示前 40 行,避免终端被刷屏。如果资源包已经是目录形式,跳过unzip即可;如果目录层级更深,把3改成4再看一轮。
mvn -q clean packageclean清掉旧的target,package会编译、跑测试并打包。-q只保留错误和必要输出,不是静默失败;如果命令没输出但退出码非 0,先看target/surefire-reports或终端里的编译错误。资源包如果使用别的构建方式,换成对应的清理和打包命令即可,核心是先得到可执行的类或 jar。
2.2 最小可运行闭环:读入、处理、写出
下面这段入口代码是我会先对齐的骨架。它不依赖复杂框架,重点是把输入、管道、输出三件事分开。资源包里的类名可能不同,但方法职责基本一致:builder装配 Stage,run接收一批输入,PipelineResult汇总成功、跳过和失败数量。
import java.nio.charset.StandardCharsets; import java.nio.file.*; import java.util.List; public final class PipelineBootstrap { public static void main(String[] args) throws Exception { Path input = Paths.get(args[0]); Path output = Paths.get(args[1]); Pipeline<String, String> pipeline = Pipeline.<String>builder("demo") .stage(new TrimStage()) .stage(new UpperCaseStage()) .stage(new FileSinkStage(output)) .build(); List<String> lines = Files.readAllLines(input, StandardCharsets.UTF_8); PipelineResult result = pipeline.run(lines); System.out.printf("ok=%d, skipped=%d, failed=%d%n", result.okCount(), result.skipCount(), result.failCount()); } }args[0]是输入文件,args[1]是输出文件,两个参数都不建议写死在代码里。Files.readAllLines适合样例和小批量文件,读大文件要换成流式读取,否则内存会先扛不住。PipelineResult的三个计数是排错入口:成功数对不上,先看跳过数和失败数,不要只盯着输出文件。
import java.util.Locale; public final class TrimStage implements Stage<String, String> { @Override public String name() { return "trim"; } @Override public String process(String in, PipelineContext ctx) { if (in == null) { return null; } String out = in.trim(); if (out.isEmpty()) { ctx.put("trim.skip", "blank-line"); return null; } return out; } } public final class UpperCaseStage implements Stage<String, String> { @Override public String name() { return "upper-case"; } @Override public String process(String in, PipelineContext ctx) { return in.toUpperCase(Locale.ROOT); } }TrimStage把空行转成null,很多管道实现会把null当作“当前这条不再往下走”,所以它本质上是一个短路信号。ctx.put("trim.skip", "blank-line")是给排错留的证据,不然只看到少了几条数据,很难判断是空行、解析失败还是写出失败。UpperCaseStage用Locale.ROOT,避免某些语言环境下大小写转换结果不一致。
JAR=$(find target -maxdepth 1 -name "*.jar" ! -name "*sources*" | head -n 1) java -jar "$JAR" ./data/sample.txt ./out/result.txtJAR变量从target里取第一个非 sources 的 jar,避免手打带版本号的文件名。java -jar依赖 jar 的Main-Class;如果资源包没有配置入口清单,改用java -cp target/classes:target/dependency/* 完整类名 输入 输出。输出目录要先存在,或者让FileSinkStage自己创建父目录,否则最后一步会报NoSuchFileException。
2.3 改一个 Stage 后补冒烟用例
跑通样例不等于管道可靠。最少补一个不依赖外部服务的冒烟用例:两条正常数据、一条空行,检查成功数和跳过数。这样后面改TrimStage、改短路规则、改线程模型时,至少知道基础行为有没有被破坏。
import java.util.Arrays; public final class PipelineSmokeTest { public static void main(String[] args) { Pipeline<String, String> p = Pipeline.<String>builder("smoke") .stage(new TrimStage()) .stage(new UpperCaseStage()) .build(); PipelineResult r = p.run(Arrays.asList(" a ", "", "b")); if (r.okCount() != 2 || r.skipCount() != 1) { throw new AssertionError("unexpected result: " + r); } System.out.println("smoke ok"); } }输入里" a "和"b"应该成功,""应该被跳过,所以okCount=2、skipCount=1。这个用例故意不接文件系统和数据库,只验证 Stage 装配和计数,失败时定位范围小。生产前再把文件写出、重试、超时各自补成独立用例,不要把所有问题塞进一个大集成测试。
3. Stage 契约怎么定:输入输出、上下文和配置参数的三层边界
管道能不能长期维护,关键不在 Stage 数量,而在契约有没有定死。Stage 的输入输出类型、上下文里放什么、配置从哪里来,这三层只要有一层含糊,后面就会出现“某个阶段偷偷改了对象”“上下文键冲突”“参数散落在构造器里”这类问题。我的做法是先把接口收窄,再写业务实现;接口宽一点看似灵活,实际会把排查成本转给所有人。
3.1 Stage 接口只保留四个约定
一个可落地的 Stage 接口不需要太多方法。名称用于日志和指标,处理方法是主逻辑,跳过判断用于前置条件,错误钩子用于补充上下文。把“是否继续”和“业务分支”分开,Stage 内部就不会到处写if (ctx.get("mode") == ...)。
public interface Stage<I, O> { String name(); O process(I input, PipelineContext ctx) throws StageException; default boolean shouldSkip(PipelineContext ctx) { return false; } default void onError(I input, Exception ex, PipelineContext ctx) { throw new StageException(name(), ex); } }name()不要返回类名简写,直接返回稳定的阶段名,比如validate-amount、write-sink,日志检索和指标聚合都靠它。process的入参和出参用泛型,别图省事全用Object;一旦全用Object,编译期检查没了,类型转换异常会推迟到运行期。shouldSkip只放前置条件,比如开关关闭、灰度未命中,不要把业务规则塞进去。onError默认包装成StageException,需要补充订单号、文件名时再覆写。
public final class ValidateAmountStage implements Stage<Order, Order> { private final long maxAmount; public ValidateAmountStage(long maxAmount) { this.maxAmount = maxAmount; } @Override public String name() { return "validate-amount"; } @Override public Order process(Order order, PipelineContext ctx) { if (order.getAmount() > maxAmount) { throw new StageException(name(), "amount too large: " + order.getAmount()); } ctx.put("validate.maxAmount", maxAmount); return order; } }maxAmount通过构造器传入,不在方法里读全局配置,这样单测可以直接 new 一个边界值。异常信息里带金额,但不建议把完整用户信息打进去;日志里保留可检索的业务键就够了。ctx.put记录的是本次请求实际使用的阈值,排查“为什么这条被拦”时比翻配置文件快。
3.2 上下文别当万能 Map:键、类型和作用域都要管
上下文最容易失控。今天放userId,明天放user,后天有人放一个Map<String,Object>,最后没人知道哪个键是必填、哪个键会被覆盖。我的习惯是给上下文加类型读取和命名空间,键名用阶段名.字段名或领域.字段名,并且只放请求级数据。
import java.util.HashMap; import java.util.Map; public final class PipelineContext { private final String traceId; private final Map<String, Object> values = new HashMap<>(); public PipelineContext(String traceId) { this.traceId = traceId; } public String traceId() { return traceId; } public PipelineContext put(String key, Object value) { if (value == null) { throw new IllegalArgumentException("null value: " + key); } values.put(key, value); return this; } public <T> T get(String key, Class<T> type) { Object v = values.get(key); if (v == null) { return null; } if (!type.isInstance(v)) { throw new IllegalStateException( "key=" + key + " expected=" + type.getName() + " actual=" + v.getClass().getName()); } return type.cast(v); } }traceId做成不可变字段,整条链路都用它串日志。put拒绝null,避免“键存在但值是空”这种二义状态。get带Class<T>,类型不匹配时立刻抛错,不要等到业务代码强转时才发现。这个实现适合单线程管道;如果 Stage 会切线程,就要提供snapshot()复制一份只读视图,不能让两个线程同时改同一个HashMap。
| 键名 | 类型 | 写入位置 | 读取位置 |
|---|---|---|---|
traceId | String | 入口 | 所有 Stage、日志 |
source.file | String | 入口 | 解析、写出 |
validate.maxAmount | Long | 校验 Stage | 审计、排错 |
skip.reason | String | 任意短路点 | 结果汇总 |
键名一旦定了就写进团队约定,不要今天userId、明天user_id。上下文不是缓存,不要往里放大对象;需要跨阶段传递的大数据放领域对象里,上下文只放定位信息和轻量参数。
3.3 配置参数从文件进来,默认值和边界检查一起做
Stage 参数不要散落在new的地方。最小做法是用properties文件加显式默认值,启动时解析一次,参数非法就 fail-fast。这样换环境只改配置,不改代码;排查时也能把实际生效值打进日志。
pipeline.name=order-clean stage.validate.maxAmount=100000 stage.retry.maxAttempts=2 stage.timeout.ms=800 stage.sink.batchSize=200import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Paths; import java.util.Properties; public final class PipelineConfig { private final Properties props = new Properties(); public PipelineConfig(String path) throws Exception { try (InputStream in = Files.newInputStream(Paths.get(path))) { props.load(in); } } public long maxAmount() { long v = Long.parseLong(props.getProperty("stage.validate.maxAmount", "100000")); if (v <= 0) { throw new IllegalArgumentException("maxAmount must be positive"); } return v; } public int batchSize() { int v = Integer.parseInt(props.getProperty("stage.sink.batchSize", "200")); if (v < 1 || v > 5000) { throw new IllegalArgumentException("batchSize out of range: " + v); } return v; } }getProperty的第二个参数是默认值,适合样例和本地启动;生产环境不要依赖默认值,配置文件缺失时直接报错更安全。maxAmount和batchSize都做了范围检查,避免负数和超大批次把下游打挂。解析后的值建议在启动日志里打印一次,但不要打印密钥类配置;这份资源里如果没有配置类,可以按这个思路补一个,不要把解析逻辑散到每个 Stage。
4. 同步转异步:Java 管道的线程池、超时与背压参数
管道一开始用同步跑没问题,真正容易翻车的是“为了快,把循环改成并行流”。线程数、队列长度、超时和重试四个参数没有一起定,最后表现就是任务提交成功、结果少几条、日志还看不出哪条失败。我的顺序是先量同步延迟,再决定要不要异步;要异步就自己管线程池,不把命运交给默认工厂。
4.1 先判断同步够不够:看阻塞点,不看代码行数
如果每个 Stage 都是内存计算,单条处理在毫秒级,同步管道通常够用,硬加线程只会增加上下文复制和顺序问题。真正需要异步的信号是下游 I/O 占比高、单条延迟明显、批量任务可以分片,并且业务允许乱序或能按主键保序。顺序强依赖的链路,比如“先扣库存再写流水”,不要为了吞吐把两个 Stage 并行。
| 信号 | 判断 | 动作 |
|---|---|---|
| Stage 大多是本地计算 | 同步优先 | 先优化算法和批量写 |
| 下游接口延迟高 | 可异步 | 限制并发数,设超时 |
| 结果必须严格有序 | 不要乱序并行 | 分片内串行,分片间并行 |
| 失败要逐条重试 | 需要任务边界 | 每个任务带业务主键 |
我一般先跑 1000 条样例,记录总耗时和 P99 单条耗时,再看线程堆栈里有多少时间卡在 I/O。没有这个数,调线程池参数就是猜。异步不是免费加速,它把等待从当前线程挪到另一个线程,队列满的时候照样会反压回来。
4.2 线程池参数落地:核心数、最大数、队列和拒绝策略
不要用无界队列的快捷工厂。无界队列会把内存撑爆,而且任务堆积时没有任何信号。下面这个线程池配置适合批处理型管道:核心线程常驻,最大线程翻倍,队列有界,队列满时让提交任务的线程自己跑,形成反压。
import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; public final class PipelinePool { public static ThreadPoolExecutor create() { int cpu = Runtime.getRuntime().availableProcessors(); int core = Math.max(2, cpu); int max = Math.max(core, cpu * 2); BlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(200); ThreadFactory factory = new ThreadFactory() { private final AtomicInteger seq = new AtomicInteger(); @Override public Thread newThread(Runnable r) { Thread t = new Thread(r, "pipeline-worker-" + seq.incrementAndGet()); t.setDaemon(false); return t; } }; return new ThreadPoolExecutor( core, max, 60L, TimeUnit.SECONDS, queue, factory, new ThreadPoolExecutor.CallerRunsPolicy() ); } }core和max只是起点,CPU 密集型和 I/O 密集型不能套同一个公式。ArrayBlockingQueue(200)让队列有明确上限,队列满时CallerRunsPolicy把任务退回提交线程执行,速度会降下来但不会静默丢任务。setDaemon(false)避免主线程退出后工作线程被直接收掉。线程名带序号,线程 dump 里能一眼看出是管道线程,不是业务线程。
如果是在线接口链路,CallerRunsPolicy可能把请求线程拖住,这时更适合AbortPolicy加快速失败,把拒绝次数打到指标里。选哪种拒绝策略取决于调用方能不能接受变慢,不能一概而论。
4.3 超时和重试:把失败锁在单个 Stage
异步任务最怕“提交后没人等”。每个 Stage 调用都要有超时,Future.get(timeout)是最小实现。超时后cancel(true)只是发出中断信号,Stage 内部如果在读文件或等网络,要自己检查中断状态并尽快退出。
import java.util.concurrent.*; public final class StageRunner { private final ExecutorService pool; public StageRunner(ExecutorService pool) { this.pool = pool; } public <T> T run(Stage<T, T> stage, T input, PipelineContext ctx, long timeoutMs) { Future<T> future = pool.submit(() -> stage.process(input, ctx)); try { return future.get(timeoutMs, TimeUnit.MILLISECONDS); } catch (TimeoutException e) { future.cancel(true); throw new StageException(stage.name(), "timeout " + timeoutMs + "ms"); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new StageException(stage.name(), "interrupted"); } catch (ExecutionException e) { throw new StageException(stage.name(), e.getCause()); } } }timeoutMs要按 Stage 分别配置,不要整条管道一个值。重试只加在幂等 Stage 上,写库、发消息、扣库存这类操作要么带幂等键,要么不要自动重试。ExecutionException里包的是真实原因,别只打印外层异常;日志里至少带上stage.name()和traceId,不然线程池一忙就查不到是哪条数据。
4.4 背压:队列满不是加机器就完事
异步管道的吞吐由最慢的 Stage 决定,不是线程越多越快。用Semaphore控制同时在途的任务数,比无限提交更稳。下面的写法保留Future列表顺序,最终结果可以按提交顺序收集,不会因为先完成先返回而乱序。
import java.util.*; import java.util.concurrent.*; public final class OrderedBatchRunner { public List<Result> run(List<Item> items, ExecutorService pool) throws Exception { Semaphore permits = new Semaphore(32); List<Future<Result>> futures = new ArrayList<>(); for (Item item : items) { permits.acquire(); Future<Result> f = pool.submit(() -> { try { return handle(item); } finally { permits.release(); } }); futures.add(f); } List<Result> results = new ArrayList<>(); for (Future<Result> f : futures) { results.add(f.get()); } return results; } }Semaphore(32)表示最多 32 条在途,提交线程会在acquire处被挡住,形成背压。finally里释放许可,Stage 抛异常也不会把许可吞掉。futures按提交顺序保存,最后按顺序get,结果顺序和输入一致;代价是慢任务会挡住后面的结果收集,如果业务不要求顺序,可以改成完成一个收集一个。这个参数不要拍脑袋,按下游连接池大小和单条内存占用反推。
5. 避坑排查:Java 管道里四类静默失败与修复动作
管道最难受的不是报错,而是“看起来跑完了”。输出少几条、任务提交成功但没结果、同一条数据写两次、上下文串到另一条请求,这些问题在日志里往往只留下一个不起眼的计数。下面四类是我在 Java 管道项目里见得最多的,按“现象、原因、解决”拆开,能直接对着查。
5.1 阶段返回 null 后整条链路静默停住
现象:输入文件里有空行,跑完后输出文件少了对应行,控制台没有异常,失败数也是 0。原因:管道把null当成短路信号,Stage 返回null后后续 Stage 不再执行,但结果汇总只记了成功或跳过,没记原因。解决:所有返回null的地方都写skip.reason,结果类把跳过原因聚合出来;需要区分“空数据”和“业务拒绝”时,用显式的StageResult,不要全靠null。
public final class SkipAwareStage implements Stage<String, String> { @Override public String name() { return "skip-aware"; } @Override public String process(String in, PipelineContext ctx) { if (in == null || in.trim().isEmpty()) { ctx.put("skip.reason", "blank-input"); return null; } return in.trim(); } }skip.reason是排查入口,不是装饰。结果汇总时按原因分组,才能看出是空行太多、解析失败还是规则拦截。如果团队里有人把null当正常值继续传,接口契约要写清楚:null只表示短路,不表示业务空对象。
5.2 线程池任务提交成功,结果却少了几条
现象:批量提交 1000 条,日志显示提交完成,最终只收到 980 条,没有明显异常。原因:用了execute提交任务,Stage 内部异常被线程池吞掉;或者拒绝策略是丢弃型,队列满后任务直接消失。解决:用submit拿Future,统一get收集异常;拒绝策略改成AbortPolicy或CallerRunsPolicy,并把拒绝次数打成指标。
import java.util.*; import java.util.concurrent.*; public final class SafeSubmit { public void submitAll(List<String> lines, ExecutorService pool) throws Exception { List<Future<?>> futures = new ArrayList<>(); for (String line : lines) { futures.add(pool.submit(() -> { // 这里调用管道,异常会包在 Future 里 runOne(line); })); } for (Future<?> f : futures) { try { f.get(); } catch (ExecutionException e) { // 记录失败条数和原因,不要只打一行堆栈 System.err.println("stage failed: " + e.getCause()); } } } private void runOne(String line) { // 省略实际管道调用 } }submit不会把异常直接抛到提交线程,必须get才拿得到。ExecutionException.getCause()才是业务异常。拒绝次数要单独计数,队列满和 Stage 失败是两种问题,不要混在一个失败数里。
5.3 重试之后同一条数据被写了两次
现象:下游表里出现重复主键或重复流水,日志显示某个 Stage 超时后重试过一次。原因:重试加在非幂等写操作上,第一次其实已经写成功,只是响应超时。解决:写操作带业务幂等键,数据库用唯一索引或去重表兜底;重试只放在可重复执行的 Stage 上。
create table if not exists pipeline_dedup ( item_id varchar(64) primary key, stage_name varchar(64) not null, updated_at timestamp not null );public void writeOnce(String itemId, String stageName, Runnable writer) { if (!dedup.tryMark(itemId, stageName)) { return; } writer.run(); }item_id + stage_name
本文还有配套的精品资源,点击获取