S06-X16 · 集成库的落地点:PDFBox、JSqlParser、Elastic-Job 与 Ip2region
引入一个第三方库,真正花时间的不是"调通 API",而是判断它能不能被你的边界容纳:线程安全不安全、异常面有多大、升级会不会带进传递依赖、数据文件由谁维护。四个库恰好覆盖四类不同的集成形态——文档解析、SQL 改写、分布式调度、离线数据查询。把它们放在一起看,选型判据比单个库的用法更值钱。
1. 先立判据:一个库要过五道关
| 判据 | 观察方式 | 不合格的信号 |
|---|---|---|
| 维护活跃度 | 提交频率、issue 响应、发布节奏 | 两年无 release,issue 只剩机器人 |
| 线程安全 | 文档是否声明;源码里有没有共享可变状态 | "文档没写"就等于不安全 |
| 依赖体积 | mvn dependency:tree -Dincludes=groupId |
一个工具库拖进整个日志/序列化栈 |
| 异常面 | 抛哪些受检异常,是否吞异常 | 大量 catch (Exception e) {} |
| 数据/资源归属 | 是否需要外部数据文件、字典、模型 | 文件升级路径不明,靠人工替换 |
$ mvn dependency:tree -Dincludes=org.apache.pdfbox,com.github.jsqlparser \
-DoutputFile=/tmp/tree.txt && cat /tmp/tree.txt
看的是传递依赖:文档解析库常带字体与图像库,SQL 解析库很轻,调度框架会带注册中心客户端——对启动时间与类加载的影响差一个量级(典型量级)。
2. PDFBox:文本抽取、坐标与那些"抽不出来"的页
PDFBox 的价值是在不整份解密的前提下按结构读取:页面树、内容流、字体资源、注释。常见用法是抽文本,工程上要处理的是"抽不出来怎么办"。
try (PDDocument doc = PDDocument.load(new File("/data/in/report.pdf"))) {
PDFTextStripper stripper = new PDFTextStripper();
stripper.setSortByPosition(true); // 按坐标排序,避免多栏错行
stripper.setStartPage(1);
stripper.setEndPage(3);
String text = stripper.getText(doc);
// 版面坐标:拿到每段文字在页面上的位置
PDFTextStripperByArea area = new PDFTextStripperByArea();
area.addRegion("header", new Rectangle(0, 0, 595, 90));
area.extractRegions(doc.getPage(0));
}
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 抽出的文字全是乱码 | 字体是子集化嵌入,没有 ToUnicode 映射 | 接受限制,或走 OCR |
| 文字顺序错乱 | 内容是绝对定位绘制,无阅读顺序 | setSortByPosition(true),仍乱则按区域抽 |
| 完全抽不到文本 | 扫描件,页面只有一张图 | 走 OCR 链路,PDFBox 只负责渲染 |
| 页面旋转 90° | 存在 /Rotate 属性 |
按 getRotation() 校正坐标 |
| 中文缺字 | 字体子集不含该字形 | 渲染图像后 OCR,或换来源 |
线程安全要写死:PDDocument 不是线程安全的,一个实例只能单线程访问,PDFTextStripper 也不该跨线程复用。并发抽取应每任务一个 PDDocument,用信号量控制同时打开数——内存占用与页数、图片数正相关,典型量级几十 MB 到数百 MB。
3. JSqlParser:在 SQL 进数据库之前改写它
JSqlParser 把 SQL 解析成 AST,能遍历、能改、能还原成文本。落地点三个:分页改写、字段脱敏、强制索引与租户条件注入。
String sql = "select id, name, phone from t_user where status = 1";
Statement stmt = CCJSqlParserUtil.parse(sql);
Select select = (Select) stmt;
// 1) 包一层分页:select * from (原 SQL) t limit ? offset ?
PlainSelect body = (PlainSelect) select.getSelectBody();
PlainSelect page = new PlainSelect();
page.addSelectItems(new AllColumns());
page.setFromItem(new ParenthesedSelect().withSelect(select).withAlias(new Alias("t")));
page.setLimit(new Limit().withRowCount(new LongValue(20)));
// 2) 追加租户条件
Expression tenant = new EqualsTo(
new Column("tenant_id"), new LongValue(1024));
body.setWhere(new AndExpression(body.getWhere(), tenant));
System.out.println(page); // 还原为可执行 SQL
| 改写目标 | 要动的 AST 节点 | 容易翻车的点 |
|---|---|---|
| 分页 | Limit / Offset、包一层子查询 |
原 SQL 带 order by 时,外层排序要保留 |
| 脱敏 | SelectItem 换 Function(如 mask()) |
select * 难以定位列,必须先展开 |
| 强制索引 | IndexItem / 厂商扩展语法 |
各家方言不同,解析器不一定认 |
| 租户隔离 | Where 上 AndExpression |
原 where 为 null 时直接赋值,别拼 and |
| 白名单校验 | 遍历 Table、Column |
子查询与 union 里的表要递归走 |
工程约束:解析器认的是语法,不是语义。它不会告诉你这条 SQL 在目标库上跑不跑得通,也不会替你处理绑定参数与执行计划。所以改写层只做"结构变换",权限与合法性判断另设一层。另外,同一段 SQL 反复解析是有成本的,典型做法是按 SQL 文本做缓存(注意缓存键要含方言与参数占位符形态)。
// 缓存键必须区分方言与绑定形态
String key = dialect + "|" + sqlText + "|" + (hasPlaceholder ? "?" : "literal");
4. Elastic-Job:分片、弹性与"任务跑在哪台机器上"
Elastic-Job 的模型是三件套:注册中心(ZooKeeper 等)负责协调,作业配置描述分片,执行器按分片索引跑业务。它和"定时触发一次全量"的思路差别在于:任务被切成 N 片,每片只处理数据的一个子集。
elasticjob:
regCenter:
serverLists: zk-1:2181,zk-2:2181
namespace: job-prod
jobs:
orderArchive:
elasticJobClass: com.example.job.OrderArchiveJob
cron: "0 0 3 * * ?"
shardingTotalCount: 8
shardingItemParameters: "0=A,1=B,2=C,3=D,4=E,5=F,6=G,7=H"
failover: true
misfire: true
| 配置 | 作用 | 常见误配 |
|---|---|---|
shardingTotalCount |
分片总数,也是最大并行度上限 | 设成 100 但只有 4 个实例,分片空转 |
shardingItemParameters |
把分片号映射成业务参数 | 分片数与参数个数不一致,部分分片无参数 |
failover |
实例宕机后由其他实例接管分片 | 任务非幂等时,接管会重复处理 |
misfire |
错过调度点是否补跑 | 补跑堆积,形成雪崩 |
cron |
触发时间 | 用 * * * * * ? 每小时误写成每分钟 |
分片的核心是分片键要与数据分布对齐。按 id % shardingTotalCount 分片,改分片数会全量重分布;按时间窗口分片(每片处理一段日期),扩容只是缩短每片的时间段,更安全。
public class OrderArchiveJob implements SimpleJob {
@Override
public void execute(ShardingContext ctx) {
int item = ctx.getShardingItem();
String param = ctx.getShardingParameter(); // 来自 shardingItemParameters
archiveOneSlice(item, ctx.getShardingTotalCount());
}
}
5. Ip2region:离线 IP 归属的取舍
Ip2region 把 IP 段索引与地域数据做成二进制文件(xdb),查询走 btree/binary 算法,本地内存映射,不依赖外部服务。
try (Searcher searcher = Searcher.newWithFileOnly("/data/ip2region.xdb")) {
String region = searcher.search("203.0.113.7");
// 返回"国家|区域|省份|城市|ISP"的拼接串
}
| 模式 | 内存占用 | 查询延迟 | 适用 |
|---|---|---|---|
| 文件模式 | 几乎为零 | 每次查都读文件 | 低频查询、内存紧张的边缘节点 |
| 向量索引缓存 | 只缓存索引(MB 级) | 中 | 通用场景,性价比最高 |
| 全量缓存 | 整个 xdb 进内存 | 最低 | 高频查询,但要评估容器内存配额 |
两条边界要先说清:一是归属数据会过期,运营商调整 IP 段后需要换 xdb 文件,版本要进发布流程;二是它不是风控手段。机房 IP、代理池、移动网络出口都会让"归属地"失去意义,用它做地域拦截只能当弱信号。IPv6 支持情况按所使用版本的文档核对,别默认可用。
6. 上游生态:版本、活跃度与 GSoC 这类旁证
选库时要回答"这东西还能用几年"。除了看 release,还可以看上游是否参与 GSoC(Google Summer of Code)这类学生项目——有导师、有提案,说明维护者有持续投入的意愿。这只是弱旁证,替代不了发布节奏与 issue 响应。
| 检查项 | 命令/入口 | 用途 |
|---|---|---|
| 是否有新版本 | 仓库 releases / Maven Central 元数据 | 判断是否已停更 |
| 传递依赖 | dependency:tree |
判断升级代价 |
| 许可证 | 仓库 LICENSE / pom.xml |
影响分发方式 |
| 安全公告 | 依赖扫描(如 OWASP Dependency-Check 类工具) | 判断是否需紧急升级 |
| 上游参与度 | 贡献者数、GSoC 项目列表 | 弱旁证,仅作参考 |
$ mvn versions:display-dependency-updates -DoutputFile=/tmp/updates.txt
$ grep -E "pdfbox|jsqlparser|elasticjob|ip2region" /tmp/updates.txt
7. 排错:五个"调通了但线上不对"的场景
| 现象 | 原因 | 处理 |
|---|---|---|
| 抽取偶尔 OOM | 并发打开大量 PDDocument |
加信号量限并发,及时 close() |
| 解析某些 SQL 抛异常 | 方言语法超出解析器支持范围 | 降级为字符串拼接兜底,并打点记录样本 |
| 分片任务部分不执行 | shardingItemParameters 与分片数不一致 |
补齐参数或改分片数 |
| 调度错过时间点不补 | misfire 关闭 |
按业务容忍度打开,并限制补跑窗口 |
| IP 归属明显错误 | xdb 文件版本旧 | 换数据文件,纳入发布流程 |
8. 边界:这四个库不该承担的事
| 场景 | 为什么不该用它 | 该用什么 |
|---|---|---|
| 复杂版面的扫描件 | PDFBox 只做结构与文本,不做识别 | OCR 链路 + 版面分析 |
| 跨库 SQL 改写 | 方言差异太大,解析器覆盖不全 | 各库原生能力,或 ORM 层拦截 |
| 秒级触发的高频调度 | 调度框架的协调开销高于任务本身 | 进程内调度器 + 队列 |
| 精准风控与反欺诈 | IP 归属只是弱信号 | 设备指纹、行为序列、规则引擎 |
| 需要强一致的任务分片 | 分片只是并行手段,不保证一致性 | 幂等 + 状态表 + 补偿 |
一句话:库负责把结构读出来,业务负责决定结构怎么用。
10. 自测
- 选库时
dependency:tree回答的是哪一个问题? - PDFBox 抽不出文本但页面明显有内容,最可能是哪种 PDF?
- 用 JSqlParser 改写
select *做脱敏,为什么必须先展开列? - Elastic-Job 的
shardingTotalCount远大于实例数会发生什么? - Ip2region 的全量缓存模式换来的是什么、付出的是什么?
- IP 归属地做地域拦截,为什么只能算弱信号?