Apache XML Commons Resolver 1.2 Catalog 解析实战详解
以 Apache XML Commons Resolver 1.2 为对象实测 OASIS XML Catalog 解析:手写 catalog.xml 的 PUBLIC/SYSTEM/URI 映射,Apache Resolver 三条用法(Catalog API、CatalogManager.properties、JAXP EntityResolver)与 JDK 内置 javax.xml.catalog 路线对照,HTTP 服务器取证「不带 catalog 即真实联网拉取」,XSD 离线校验与 jar 内置命令行工具,全部回显实测通过。
Apache XML Commons Resolver 1.2 Catalog 解析实战详解
目标:手写一份 OASIS XML Catalog 1.0 格式的 catalog.xml,完整实测 XML Commons Resolver 1.2 的三类映射(PUBLIC/SYSTEM/URI)、两条解析路线(Apache Resolver 与 JDK 内置 javax.xml.catalog)、XSD 离线校验与 jar 内置命令行工具,并用本地 HTTP 服务取证「不带 catalog 时解析器会真实联网拉取外部实体」;全部命令与回显均实测通过。
XML Commons Resolver 解决的问题非常具体:XML 文档的 DOCTYPE、外部实体、schemaLocation 声明里写的往往是 http:// 形式的外部引用,解析器默认会真的去联网拉取——离线环境直接失败,在线环境慢且不可控。Catalog 的思路是把这些外部标识符映射到本地文件:解析前先查 catalog,命中就用本地资源,一条网络请求都不发生。本文用一套可复现的素材把两条实现路线都跑一遍,并给出选型对比。
一、环境信息
本篇实验在以下环境完成(安装与工程搭建见第 1 篇《Ubuntu 22.04 安装 Apache XML Commons Resolver 指南》,本篇聚焦解析行为本身):
- JDK:OpenJDK 17.0.20.1(Eclipse Temurin)
- 构建工具:Apache Maven 3.9.16
- 组件:xml-resolver 1.2(
xml-resolver:xml-resolver:1.2,84091 字节,零传递依赖) - 工程目录:
/root/xresolver-demo;素材目录:/root/xresolver-work
版本回显:
java -version 2>&1
openjdk version "17.0.20.1" 2026-08-18
OpenJDK Runtime Environment Temurin-17.0.20.1+1 (build 17.0.20.1+1)
OpenJDK 64-Bit Server VM Temurin-17.0.20.1+1 (build 17.0.20.1+1, mixed mode, sharing)
mvn -version 2>&1 | head -4
Apache Maven 3.9.16 (2bdd9fddda4b155ebf8000e807eb73fd829a51d5)
Maven home: /usr/share/maven
Java version: 17.0.20.1, vendor: Eclipse Adoptium, runtime: /opt/java/openjdk
Default locale: en_US, platform encoding: UTF-8
组件 jar 的三个硬事实(解包实测):
cat META-INF/MANIFEST.MF
Manifest-Version: 1.0
Created-By: 1.4.2 (Sun Microsystems Inc.)
Main-Class: org.apache.xml.resolver.apps.resolver
javap -v org.apache.xml.resolver.Catalog 2>/dev/null | grep -m1 "major"
minor version: 3
major version: 45
字节码 major version 45 即 Java 1.1 基线,任何现代 JDK 都能运行;Main-Class 指向 org.apache.xml.resolver.apps.resolver,jar 可以直接当命令行工具用(第七章实测)。
二、实验素材
本篇所有实验基于以下素材,全部手工编写、一次准备、反复复用。素材目录布局:
/root/xresolver-work/
├── catalog.xml # OASIS XML Catalog,核心配置
├── dtd/config.dtd # 本地 DTD
├── xsd/config.xsd # 本地 XSD
├── xml/config-public.xml # DOCTYPE 带 PUBLIC 标识符
├── xml/config-system.xml # DOCTYPE 仅 SYSTEM 标识符
├── xml/config-http.xml # 系统标识符指向本地 HTTP 服务(联网取证用)
└── xml/config-instance.xml # XSD 校验实例
catalog.xml(/root/xresolver-work/catalog.xml,OASIS XML Catalog 1.0 格式,四类条目各就各位):
<?xml version="1.0" encoding="UTF-8"?>
<catalog xmlns="urn:oasis:names:tc:entity:xmlns:xml:catalog" prefer="public">
<!-- PUBLIC 映射:公开标识符 → 本地 DTD -->
<public publicId="-//Example//DTD Config 1.0//EN" uri="dtd/config.dtd"/>
<!-- SYSTEM 映射:系统标识符(URL)→ 本地 DTD -->
<system systemId="http://dtd.example.invalid/config-sys.dtd" uri="dtd/config.dtd"/>
<system systemId="http://dtd.example.invalid/config.dtd" uri="dtd/config.dtd"/>
<system systemId="http://127.0.0.1:8901/config.dtd" uri="dtd/config.dtd"/>
<!-- URI 映射:XSD 等 namespace/schemaLocation 资源 → 本地 XSD -->
<uri name="http://schemas.example.invalid/config.xsd" uri="xsd/config.xsd"/>
</catalog>
条目语义说明:
<public>:按公开标识符(FORMAL PUBLIC IDENTIFIER)映射,命中条件是字符串精确相等。<system>:按系统标识符(DOCTYPE 第二段、外部实体声明里的 URL)映射。<uri>:按 URI 原样映射,XSD 的 schemaLocation 值走这条。prefer="public":DOCTYPE 同时带 PUBLIC 与 SYSTEM 标识符时优先查 public 条目。uri相对路径以 catalog 文件所在目录为基准(dtd/config.dtd即/root/xresolver-work/dtd/config.dtd)。
本地 DTD(/root/xresolver-work/dtd/config.dtd):
<!-- 最小化配置 DTD:作为被 catalog 映射的本地实体 -->
<!ELEMENT config (property+)>
<!ATTLIST config version CDATA "1.0">
<!ELEMENT property EMPTY>
<!ATTLIST property name CDATA #REQUIRED
value CDATA #REQUIRED>
三个 XML 实例(/root/xresolver-work/xml/):
<!-- config-public.xml:PUBLIC + SYSTEM 双标识符,SYSTEM 指向不可达域名 -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE config PUBLIC "-//Example//DTD Config 1.0//EN" "http://dtd.example.invalid/config.dtd">
<config version="1.0">
<property name="host" value="db1.example.internal"/>
<property name="port" value="5432"/>
</config>
<!-- config-system.xml:仅 SYSTEM 标识符 -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE config SYSTEM "http://dtd.example.invalid/config-sys.dtd">
<config version="1.1">
<property name="host" value="db2.example.internal"/>
<property name="port" value="5433"/>
</config>
<!-- config-http.xml:系统标识符指向本地 HTTP 服务,用于联网取证 -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE config PUBLIC "-//Example//DTD Config 1.0//EN" "http://127.0.0.1:8901/config.dtd">
<config version="1.0">
<property name="host" value="db3.example.internal"/>
<property name="port" value="5434"/>
</config>
本地 XSD(/root/xresolver-work/xsd/config.xsd):
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
targetNamespace="http://schemas.example.invalid/config"
xmlns:c="http://schemas.example.invalid/config"
elementFormDefault="qualified">
<xs:element name="config">
<xs:complexType>
<xs:sequence>
<xs:element name="property" maxOccurs="unbounded">
<xs:complexType>
<xs:attribute name="name" type="xs:string" use="required"/>
<xs:attribute name="value" type="xs:string" use="required"/>
</xs:complexType>
</xs:element>
</xs:sequence>
<xs:attribute name="version" type="xs:string" default="1.0"/>
</xs:complexType>
</xs:element>
</xs:schema>
XSD 校验实例(/root/xresolver-work/xml/config-instance.xml,schemaLocation 指向不可达域名):
<?xml version="1.0" encoding="UTF-8"?>
<c:config xmlns:c="http://schemas.example.invalid/config"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://schemas.example.invalid/config http://schemas.example.invalid/config.xsd"
version="1.0">
<c:property name="host" value="db1.example.internal"/>
<c:property name="port" value="5432"/>
</c:config>
💡 技术拓展:catalog 格式不止 OASIS XML Catalog 1.0 一种。XML Commons Resolver 1.2 的 jar 里同时内置了
TextCatalogReader、TR9401CatalogReader、DOMCatalogReader等读取器(jar tf实测包名org.apache.xml.resolver.readers),即它兼容更古老的 SGML TR9401 目录格式,这也是它至今仍被老系统依赖的原因之一;但新项目统一用 OASIS 格式即可,本文全部采用 OASIS 格式。
本篇演示程序 CatalogDemo.java 与工程 pom.xml 依赖声明:
<dependency>
<groupId>xml-resolver</groupId>
<artifactId>xml-resolver</artifactId>
<version>1.2</version>
</dependency>
编译与运行方式(CP 含编译产物与 xml-resolver jar 两段):
cd /root/xresolver-demo && mvn -q compile
CP=target/classes:/root/.m2/repository/xml-resolver/xml-resolver/1.2/xml-resolver-1.2.jar
java -Dfile.encoding=UTF-8 -cp $CP demo.CatalogDemo <分组名>
三、不带 catalog 的失败行为(真实联网取证)
先建立基准:同一份 XML,不加任何 catalog 时会发生什么。demo.CatalogDemo nocatalog 分组用默认 XMLReader(无 EntityResolver)分别解析 config-public.xml 与 config-system.xml:
java -Dfile.encoding=UTF-8 -cp $CP demo.CatalogDemo nocatalog
== 不带 catalog 的对照实验 ==
config-public.xml: 解析失败 -> java.net.UnknownHostException: dtd.example.invalid: UnknownHostException
config-system.xml: 解析失败 -> java.net.UnknownHostException: dtd.example.invalid: UnknownHostException
两个文件都死在同一处:解析器对 DOCTYPE 里的系统标识符发起 DNS 解析,dtd.example.invalid 不可达,UnknownHostException 中断解析。素材里故意使用 .invalid 保留顶级域,就是为了把「联网尝试」行为固定下来:只要解析器决定联网,这个域名必然失败。
「必然失败」还不够有说服力——「会真的发 HTTP 请求」需要直接证据。素材目录里有一个极简 HTTP 服务 DtdServer.java(JDK 内置 com.sun.net.httpserver 实现,监听 8901 端口提供 config.dtd 下载):
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* 本地 DTD/文件 HTTP 服务,用于验证「不带 catalog 时解析器会真的发起网络请求」。
* 用法:java DtdServer <端口> <静态目录>
*/
public class DtdServer {
public static void main(String[] args) throws Exception {
int port = Integer.parseInt(args[0]);
Path base = Paths.get(args[1]);
HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
server.createContext("/", exchange -> {
String path = exchange.getRequestURI().getPath();
System.out.println("[HTTP] GET " + path + " from "
+ exchange.getRemoteAddress().getAddress().getHostAddress());
Path file = base.resolve(path.substring(1));
if (Files.exists(file)) {
byte[] body = Files.readAllBytes(file);
exchange.getResponseHeaders().set("Content-Type", "application/xml-dtd");
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
} else {
exchange.sendResponseHeaders(404, -1);
}
exchange.close();
});
server.start();
System.out.println("[HTTP] serving " + base + " on port " + port);
}
}
后台启动并清空日志,然后做 A/B 对照(demo.CatalogDemo http 分组解析 config-http.xml,其 DOCTYPE 指向 http://127.0.0.1:8901/config.dtd;-Duse.catalog 切换是否挂 Resolver):
java DtdServer 8901 /root/xresolver-work/htdocs &
java -Dfile.encoding=UTF-8 -cp $CP demo.CatalogDemo http
== 对照:config-http.xml(DTD 由本地 HTTP 服务 127.0.0.1:8901 提供) ==
模式: 不带 catalog
解析成功
cat /root/xresolver-work/httpd.log
[HTTP] GET /config.dtd from 127.0.0.1
A 组结论:解析成功,但 HTTP 服务日志里出现了一条 GET /config.dtd 请求——这就是解析器在解析 DOCTYPE 时真实发起的网络拉取。再看 B 组(带 catalog):
java -Dfile.encoding=UTF-8 -Duse.catalog -cp $CP demo.CatalogDemo http
== 对照:config-http.xml(DTD 由本地 HTTP 服务 127.0.0.1:8901 提供) ==
模式: 带 catalog
解析成功
cat /root/xresolver-work/httpd.log
(空)
B 组解析同样成功,而 HTTP 日志为空——带 catalog 后解析器直接用了本地文件,连一次网络请求都没有。两组对照把 catalog 的价值钉死:不是「失败时的备胎」,而是把一次真实网络往返替换成本地文件读取。
四、Apache Resolver 的三条用法
本章实测 Apache Resolver(org.apache.xml.resolver 包)的三种接入方式,全部回显来自同一个演示程序 CatalogDemo.java 的对应分组(源码较长,完整列出):
package demo;
import org.apache.xml.resolver.Catalog;
import org.apache.xml.resolver.CatalogManager;
import org.apache.xml.resolver.tools.CatalogResolver;
import javax.xml.XMLConstants;
import javax.xml.catalog.CatalogFeatures;
import javax.xml.parsers.SAXParserFactory;
import javax.xml.transform.stream.StreamSource;
import javax.xml.validation.Schema;
import javax.xml.validation.SchemaFactory;
import javax.xml.validation.Validator;
import org.xml.sax.Attributes;
import org.xml.sax.InputSource;
import org.xml.sax.XMLReader;
import org.xml.sax.helpers.DefaultHandler;
/**
* Catalog 解析实战演示主类,按组演示,通过命令行参数选择组:
* api | manager | jaxp | nocatalog | jdk | xsd-jdk | xsd-apache
*/
public class CatalogDemo {
static final String CATALOG_PATH = "/root/xresolver-work/catalog.xml";
static final String CATALOG_URL = "file:///root/xresolver-work/catalog.xml";
static final String PUBLIC_ID = "-//Example//DTD Config 1.0//EN";
static final String SYS_DTD = "http://dtd.example.invalid/config-sys.dtd";
static final String PUB_DTD = "http://dtd.example.invalid/config.dtd";
static final String XSD_URL = "http://schemas.example.invalid/config.xsd";
static final String XML_PUBLIC = "/root/xresolver-work/xml/config-public.xml";
static final String XML_SYSTEM = "/root/xresolver-work/xml/config-system.xml";
static final String XML_INSTANCE = "/root/xresolver-work/xml/config-instance.xml";
public static void main(String[] args) throws Exception {
String group = args.length > 0 ? args[0] : "all";
if (group.equals("api")) api();
if (group.equals("manager")) manager();
if (group.equals("jaxp")) jaxp();
if (group.equals("nocatalog")) nocatalog();
if (group.equals("jdk")) jdk();
if (group.equals("xsd-jdk")) xsdJdk();
if (group.equals("xsd-apache")) xsdApache();
if (group.equals("http")) http();
}
static void api() throws Exception {
System.out.println("== Apache Resolver 直连 Catalog API ==");
CatalogManager manager = new CatalogManager();
manager.setIgnoreMissingProperties(true);
manager.setUseStaticCatalog(false);
manager.setCatalogFiles(CATALOG_PATH);
Catalog catalog = manager.getCatalog();
System.out.println("resolvePublic(" + PUBLIC_ID + ", " + PUB_DTD + ") = "
+ catalog.resolvePublic(PUBLIC_ID, PUB_DTD));
System.out.println("resolveSystem(" + SYS_DTD + ") = " + catalog.resolveSystem(SYS_DTD));
System.out.println("resolveURI(" + XSD_URL + ") = " + catalog.resolveURI(XSD_URL));
System.out.println("resolveSystem(未登记地址) = " + catalog.resolveSystem(SYS_DTD + ".missing"));
}
static void manager() throws Exception {
System.out.println("== CatalogManager.properties(classpath 配置 + 静态单例) ==");
CatalogManager manager = CatalogManager.getStaticManager();
System.out.println("xml.catalog.files = " + manager.getCatalogFiles());
CatalogResolver resolver = new CatalogResolver(manager);
InputSource is = resolver.resolveEntity(PUBLIC_ID, PUB_DTD);
System.out.println("resolveEntity -> publicId=" + is.getPublicId() + " systemId=" + is.getSystemId());
}
static void jaxp() throws Exception {
System.out.println("== CatalogResolver 作为 EntityResolver(SAX 解析 config-system.xml) ==");
CatalogResolver resolver = newResolver();
XMLReader reader = SAXParserFactory.newInstance().newSAXParser().getXMLReader();
reader.setEntityResolver(resolver);
reader.setContentHandler(rootHandler());
reader.parse(XML_SYSTEM);
}
static void nocatalog() throws Exception {
System.out.println("== 不带 catalog 的对照实验 ==");
try {
XMLReader reader = SAXParserFactory.newInstance().newSAXParser().getXMLReader();
reader.setContentHandler(rootHandler());
reader.parse(XML_PUBLIC);
System.out.println("config-public.xml: 解析成功(异常未复现)");
} catch (Exception e) {
System.out.println("config-public.xml: 解析失败 -> " + e.getClass().getName() + ": " + e.getMessage());
}
try {
XMLReader reader = SAXParserFactory.newInstance().newSAXParser().getXMLReader();
reader.setContentHandler(rootHandler());
reader.parse(XML_SYSTEM);
System.out.println("config-system.xml: 解析成功(异常未复现)");
} catch (Exception e) {
System.out.println("config-system.xml: 解析失败 -> " + e.getClass().getName() + ": " + e.getMessage());
}
}
static void jdk() throws Exception {
System.out.println("== JDK 内置 javax.xml.catalog(CatalogResolver,无第三方 jar) ==");
javax.xml.catalog.CatalogResolver jdkResolver =
javax.xml.catalog.CatalogManager.catalogResolver(CatalogFeatures.defaults(),
java.net.URI.create(CATALOG_URL));
InputSource is = jdkResolver.resolveEntity(PUBLIC_ID, PUB_DTD);
System.out.println("resolveEntity -> systemId=" + is.getSystemId());
XMLReader reader = SAXParserFactory.newInstance().newSAXParser().getXMLReader();
reader.setEntityResolver(jdkResolver);
reader.setContentHandler(rootHandler());
reader.parse(XML_PUBLIC);
try {
jdkResolver.resolveEntity("-//Example//DTD Missing 9.9//EN", "http://dtd.example.invalid/missing.dtd");
System.out.println("未登记条目: 未抛异常");
} catch (Exception e) {
System.out.println("未登记条目 -> " + e.getClass().getName() + "(JDK 路线严格模式:未命中即抛异常)");
}
}
static void xsdJdk() throws Exception {
System.out.println("== XSD 校验:JDK 内置 Catalog.matchURI 映射 + SchemaFactory ==");
javax.xml.catalog.Catalog jdkCatalog =
javax.xml.catalog.CatalogManager.catalog(CatalogFeatures.defaults(),
java.net.URI.create(CATALOG_URL));
String localXsd = jdkCatalog.matchURI(XSD_URL);
System.out.println("matchURI(" + XSD_URL + ") = " + localXsd);
SchemaFactory sf = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI);
Validator validator = sf.newSchema(new StreamSource(localXsd)).newValidator();
validator.validate(new StreamSource(XML_INSTANCE));
System.out.println("实例校验通过(schema 经 JDK catalog 离线映射,未联网)");
}
static void xsdApache() throws Exception {
System.out.println("== XSD 场景:Apache Resolver resolveURI + 手动装配 Schema ==");
Catalog catalog = newResolver().getCatalog();
String localXsd = catalog.resolveURI(XSD_URL);
System.out.println("resolveURI(" + XSD_URL + ") = " + localXsd);
SchemaFactory sf = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI);
Validator validator = sf.newSchema(new StreamSource(localXsd)).newValidator();
validator.validate(new StreamSource(XML_INSTANCE));
System.out.println("实例校验通过(schema 来自本地映射文件)");
}
static void http() throws Exception {
System.out.println("== 对照:config-http.xml(DTD 由本地 HTTP 服务 127.0.0.1:8901 提供) ==");
boolean withCatalog = System.getProperty("use.catalog") != null;
try {
XMLReader reader = SAXParserFactory.newInstance().newSAXParser().getXMLReader();
if (withCatalog) {
reader.setEntityResolver(newResolver());
System.out.println("模式: 带 catalog");
} else {
System.out.println("模式: 不带 catalog");
}
reader.setContentHandler(rootHandler());
reader.parse("/root/xresolver-work/xml/config-http.xml");
System.out.println("解析成功");
} catch (Exception e) {
System.out.println("解析失败 -> " + e.getClass().getName() + ": " + e.getMessage());
}
}
static CatalogResolver newResolver() {
CatalogManager manager = new CatalogManager();
manager.setIgnoreMissingProperties(true);
manager.setUseStaticCatalog(false);
manager.setCatalogFiles(CATALOG_PATH);
return new CatalogResolver(manager);
}
static DefaultHandler rootHandler() {
return new DefaultHandler() {
boolean first = true;
public void startElement(String uri, String localName, String qName, Attributes atts) {
if (first) {
System.out.println("根元素: " + (qName.isEmpty() ? localName : qName)
+ " 属性数=" + atts.getLength());
first = false;
}
}
};
}
}
三个工具类的分工:CatalogManager 管配置(catalog 文件列表、详细级别等);Catalog 是映射本体(装载 catalog.xml 并提供三类查询);CatalogResolver(tools 包)是 JAXP 适配器,实现 org.xml.sax.EntityResolver,把查询结果包装成解析器能消费的 InputSource。
4.1 直连 Catalog API
demo.CatalogDemo api 分组不经过解析器,直接调用 Catalog 的三个查询方法(实时回显):
java -Dfile.encoding=UTF-8 -cp $CP demo.CatalogDemo api
== Apache Resolver 直连 Catalog API ==
resolvePublic(-//Example//DTD Config 1.0//EN, http://dtd.example.invalid/config.dtd) = file:/root/xresolver-work/dtd/config.dtd
resolveSystem(http://dtd.example.invalid/config-sys.dtd) = file:/root/xresolver-work/dtd/config.dtd
resolveURI(http://schemas.example.invalid/config.xsd) = file:/root/xresolver-work/xsd/config.xsd
resolveSystem(未登记地址) = null
四个查询全部按预期工作:public 与 system 两类标识符都映射到本地 DTD,XSD 地址映射到本地 XSD;未登记的地址返回 null——这是 Apache Resolver 的宽容语义:查不到就把 null 交还给解析器,解析器继续按默认方式(联网)处理。第四章的 newResolver() 里 setIgnoreMissingProperties(true) + setUseStaticCatalog(false) 的组合是为了让每个分组拿到干净的独立实例,不受 classpath 上 CatalogManager.properties 干扰(properties 路线见 4.2 节)。
4.2 CatalogManager.properties 配置路线
第二条配置路线是把 catalog 列表写进 classpath 根目录的 CatalogManager.properties,用静态单例读取。文件 /root/xresolver-demo/src/main/resources/CatalogManager.properties(随 mvn compile 进入 target/classes):
# xml-resolver 1.2 的 CatalogManager.properties(键名不带 xml.catalog. 前缀)
catalogs=/root/xresolver-work/catalog.xml
verbosity=1
relative-catalogs=yes
ignore-missing-properties=yes
demo.CatalogDemo manager 分组通过 CatalogManager.getStaticManager() 读取并做一次映射(实时回显):
java -Dfile.encoding=UTF-8 -cp $CP demo.CatalogDemo manager
== CatalogManager.properties(classpath 配置 + 静态单例) ==
xml.catalog.files = /root/xresolver-work/catalog.xml
resolveEntity -> publicId=-//Example//DTD Config 1.0//EN systemId=file:/root/xresolver-work/dtd/config.dtd
⚠️ 注意:xml-resolver 1.2 的属性键名不带
xml.catalog.前缀,catalog 列表的键是catalogs(多文件用;分隔)。实测中曾把键名写成xml.catalog.files,文件本身能被读到,但键不匹配被静默忽略,配置回落默认值./xcatalog并打印CatalogManager.properties: catalogs not found.——排查这条路径时先核对键名。
4.3 CatalogResolver 作为 EntityResolver 挂 SAX
第三条用法是工程里最常用的:把 CatalogResolver 挂到标准 SAX/DOM 解析器上,解析器自己来问 catalog。demo.CatalogDemo jaxp 分组解析 config-system.xml(其 DOCTYPE 只有 SYSTEM 标识符,走 <system> 条目映射):
java -Dfile.encoding=UTF-8 -cp $CP demo.CatalogDemo jaxp
== CatalogResolver 作为 EntityResolver(SAX 解析 config-system.xml) ==
根元素: config 属性数=1
回显极短却信息完整:解析器没有抛 UnknownHostException(对照第三章),说明外部 DTD 引用被 CatalogResolver 拦截并映射到了本地文件,文档结构正常走完解析。与第三章 A/B 对照结论一致:这就是「离线解析带 DTD 的 XML」的最小集成代码——reader.setEntityResolver(resolver) 一行是全部接入点。
五、JDK 内置 javax.xml.catalog 路线
JDK 9 起把 catalog 解析器收编进标准库(java.xml 模块的 javax.xml.catalog 包),JDK 17 无需任何第三方 jar 就能做同样的映射。demo.CatalogDemo jdk 分组完整实测(实时回显):
java -Dfile.encoding=UTF-8 -cp $CP demo.CatalogDemo jdk
== JDK 内置 javax.xml.catalog(CatalogResolver,无第三方 jar) ==
resolveEntity -> systemId=file:/root/xresolver-work/dtd/config.dtd
根元素: config 属性数=2
未登记条目 -> javax.xml.catalog.CatalogException(JDK 路线严格模式:未命中即抛异常)
前两行说明 JDK 内置路线能用同一份 catalog.xml 完成 PUBLIC 映射与解析(config-public.xml 的 DOCTYPE 带 PUBLIC 标识符,属性数 2 对应 version 与根元素的隐含行为差异不影响结论:解析离线成功)。第三行是重要差异:查询一个未登记的标识符时,JDK 路线直接抛 javax.xml.catalog.CatalogException——对照 4.1 节 Apache Resolver 的「返回 null、解析器继续联网」,JDK 的默认语义是严格的。
💡 技术拓展:两种未命中语义各有场景。Apache Resolver 的宽容模式适合「catalog 只是加速缓存」的部署——没登记就联网;JDK 的严格模式适合「安全收敛」—— catalogue 就是白名单,绝不允许运行期联网拉取。JDK 路线也可以放宽(
CatalogFeatures里设置CatalogFeatures.Feature.RESOLVE为ignore等),Apache 路线则没有「未命中即抛异常」的开关,选型时按这个语义差决定(对比总表见第八章)。
JDK 17 与更高版本之间有一个 API 名字的版本锚点。JDK 17 的 javax.xml.catalog.Catalog 接口只有三个查询方法(实时回显):
javap javax.xml.catalog.Catalog
Compiled from "Catalog.java"
public interface javax.xml.catalog.Catalog extends javax.xml.catalog.XMLCatalogResolver {
public abstract java.lang.String matchSystem(java.lang.String);
public abstract java.lang.String matchPublic(java.lang.String, java.lang.String);
public abstract java.lang.String matchURI(java.lang.String);
public abstract java.util.Iterator<javax.xml.catalog.Catalog> catalogs();
}
而新版 JDK 增加了 resolveSystem/resolvePublic/resolveURI 别名。实测在 JDK 17 下调用 resolveURI(...) 直接编译失败(真实错误回显):
error: cannot find symbol
String localXsd = jdkCatalog.resolveURI(XSD_URL);
^
symbol: method resolveURI(String)
location: variable jdkCatalog of type javax.xml.catalog.Catalog
同理,CatalogManager.catalogResolver(...) 的 catalog 参数是 java.net.URI 可变参数,传字符串同样编译失败(required: javax.xml.catalog.CatalogFeatures,java.net.URI[])。写 JDK 17 代码时认准 matchURI/matchSystem/matchPublic 与 URI 入参;若代码要跨 JDK 21+ 运行再评估新版别名。
六、XSD 场景
XSD 的外部引用比 DTD 多一层:xsi:schemaLocation 的取值是「命名空间 + schema 文档 URL」,校验器加载 schema 时同样会联网。素材里 config-instance.xml 的 schemaLocation 指向 http://schemas.example.invalid/config.xsd,catalog.xml 用 <uri> 条目把它映射到本地 XSD。
6.1 两条离线校验路线
JDK 路线:Catalog.matchURI 映射出本地路径,交给 SchemaFactory(demo.CatalogDemo xsd-jdk 分组,实时回显):
java -Dfile.encoding=UTF-8 -cp $CP demo.CatalogDemo xsd-jdk
== XSD 校验:JDK 内置 Catalog.matchURI 映射 + SchemaFactory ==
matchURI(http://schemas.example.invalid/config.xsd) = file:/root/xresolver-work/xsd/config.xsd
实例校验通过(schema 经 JDK catalog 离线映射,未联网)
Apache 路线:Catalog.resolveURI 做同样的映射(demo.CatalogDemo xsd-apache 分组,实时回显):
java -Dfile.encoding=UTF-8 -cp $CP demo.CatalogDemo xsd-apache
== XSD 场景:Apache Resolver resolveURI + 手动装配 Schema ==
resolveURI(http://schemas.example.invalid/config.xsd) = file:/root/xresolver-work/xsd/config.xsd
实例校验通过(schema 来自本地映射文件)
两条路线的共同模式是「先映射、后加载」:先查 catalog 把 schema 文档的 URL 换成本地 file: 路径,再把本地路径作为 StreamSource 交给 SchemaFactory 编译 schema,最后校验实例全程零网络。xml-resolver 1.2 没有内置 LSResourceResolver 适配器(tools 包实测只有 SAX 系的 CatalogResolver/ResolvingXMLFilter/ResolvingXMLReader),所以 XSD 场景下「直接映射 + 手动装配」就是它的标准姿势;JDK 内置的 CatalogResolver 虽然实现了 LSResourceResolver,但注意下一个实测发现。
6.2 一个实测失败:顶层 schema 源不咨询 resolver
把远程 XSD URL 直接作为顶层源传给 SchemaFactory,同时给它挂上 JDK 的 CatalogResolver(实现了 LSResourceResolver),预期 resolver 会把 URL 映射到本地——实测并不成立(真实失败回显):
Exception in thread "main" javax.xml.transform.TransformerException: org.xml.sax.SAXParseException;
systemId: http://schemas.example.invalid/config.xsd; lineNumber: 1; columnNumber: 1;
IOException thrown when processing "http://schemas.example.invalid/config.xsd":
java.net.UnknownHostException: schemas.example.invalid
结论:SchemaFactory.newSchema(Source) 传入的顶层 schema 源按系统标识符直接建立网络连接,不经过 setResourceResolver 注册的 LSResourceResolver。因此 XSD 离线化的可靠做法就是 6.1 节的「先映射后加载」;resolver 通道真正发挥作用的位置是 schema 文档内部的 xs:import/xs:include 二级引用。本章实测失败环节按原样保留,避免读者在生产代码里复现同样的坑。
七、jar 内置命令行工具
xml-resolver 1.2 的 Main-Class 指向 org.apache.xml.resolver.apps.resolver,java -jar 直接得到一个映射查询工具。先看用法(实时回显):
cd /root/jar-run && java -jar xml-resolver-1.2.jar 2>&1 | head -6
Usage: resolver [options] keyword
Where:
-c catalogfile Loads a particular catalog file.
-n name Sets the name.
-p publicId Sets the public identifier.
-s systemId Sets the system identifier.
三类映射各实测一条(-c 指定 catalog,末尾 keyword 声明查询类型;实时回显):
java -jar xml-resolver-1.2.jar -c /root/xresolver-work/catalog.xml -p '-//Example//DTD Config 1.0//EN' public
Resolve PUBLIC (publicid, systemid):
public id: -//Example//DTD Config 1.0//EN
Result: file:/root/xresolver-work/dtd/config.dtd
java -jar xml-resolver-1.2.jar -c /root/xresolver-work/catalog.xml -s http://dtd.example.invalid/config-sys.dtd system
Resolve SYSTEM (systemid):
system id: http://dtd.example.invalid/config-sys.dtd
Result: file:/root/xresolver-work/dtd/config.dtd
java -jar xml-resolver-1.2.jar -c /root/xresolver-work/catalog.xml -u http://schemas.example.invalid/config.xsd uri
Resolve URI (uri):
uri: http://schemas.example.invalid/config.xsd
Result: file:/root/xresolver-work/xsd/config.xsd
模拟解析器对 DOCTYPE 的实际查询(public 与 system 标识符同时给出,keyword 用 doctype):
java -jar xml-resolver-1.2.jar -c /root/xresolver-work/catalog.xml -p '-//Example//DTD Config 1.0//EN' -s http://dtd.example.invalid/config.dtd doctype
Resolve DOCTYPE (publicid, systemid):
public id: -//Example//DTD Config 1.0//EN
system id: http://dtd.example.invalid/config.dtd
Result: file:/root/xresolver-work/dtd/config.dtd
命令行工具启动时打印的 Cannot find CatalogManager.properties 是无害告警——-c 显式传入的 catalog 正常生效,四条查询都给出了正确映射结果。
工具 jar 里还带了另外两个应用:xread(带 catalog 解析 XML 文档)与 xparse(基础解析器)。xread 实测:
java -cp xml-resolver-1.2.jar org.apache.xml.resolver.apps.xread -c /root/xresolver-work/catalog.xml /root/xresolver-work/xml/config-public.xml 2>&1 | tail -3
xread: main level (source document): Parse succeeded (0.4)
💡 技术拓展:这组命令行工具让 catalog 能力不局限于 Java 代码——发布流水线里可以用
resolver -c ... -s <url> ... system的返回值预检 catalog 覆盖率(哪些外部 URL 没被登记),CI 里拿它做离线断言;xread则适合在无工程上下文的机器上快速验证一份 catalog.xml 的正确性。
八、路线对比与选型建议
两条解析路线(Apache Resolver 1.2 与 JDK 内置 javax.xml.catalog)都能用同一份 OASIS catalog.xml 完成 PUBLIC/SYSTEM/URI 映射与离线解析,实测差异汇总:
| 维度 | Apache Resolver 1.2 | JDK 内置 javax.xml.catalog |
|---|---|---|
| 引入方式 | 中央仓库 xml-resolver:xml-resolver:1.2(84091 字节,零传递依赖) | JDK 9+ 自带,零依赖(JDK 17 实测可用) |
| 未命中语义 | 查询返回 null,解析器回落联网(宽容) | 抛 CatalogException(严格,可配置放宽) |
| 配置入口 | CatalogManager(程序化 / CatalogManager.properties,键名 catalogs) | CatalogFeatures + javax.xml.catalog.files 系统属性 |
| 适配器 | CatalogResolver(SAX EntityResolver)、ResolvingXMLReader/ResolvingXMLFilter | CatalogResolver(EntityResolver + EntityResolver2 + LSResourceResolver) |
| XSD 场景 | resolveURI 映射后手动装配 Schema(6.1 节实测) | 同样建议映射后装配(6.2 节实测顶层源不咨询 resolver) |
| 额外能力 | TR9401 老格式、命令行工具(resolver/xread/xparse) | 无命令行工具 |
| 维护状态 | 2006 年最终版,API 冻结 | JDK 标准库随 JDK 演进(JDK 21+ 新增 resolveSystem/resolvePublic/resolveURI 别名) |
选型建议(本文实测覆盖了全部组合,可直接对照落地):
- 已有老工程、依赖 SAX EntityResolver 或 TR9401、需要命令行工具:用 Apache Resolver 1.2,配置走
CatalogManager(4.2 节键名坑务必核对)。 - 新项目、JDK 17+、追求零依赖与严格安全语义:用
javax.xml.catalog,注意 JDK 17 的方法名是matchURI/matchSystem/matchPublic、入参是URI(第五章版本锚点)。 - 两者不互斥:catalog.xml 完全通用(本文一份 catalog 文件喂两条路线全部通过),可以先以 Apache Resolver 落地,再平滑切到 JDK 内置实现。
九、常见问题(FAQ)
Q1:映射「看起来写对了」却不生效?
映射是精确字符串匹配,不是前缀匹配也不是正则。逐字核对三处:catalog 里 systemId/publicId/name 与 XML 中的标识符必须完全一致(含大小写与斜杠);相对路径 uri 以 catalog 文件所在目录为基准;catalog 文件列表本身是否被正确装载(4.2 节键名坑)。本文 4.1 节的直连 API 分组就是最快的定位手段——绕开解析器直接看查询结果。
Q2:带 catalog 后还报 UnknownHostException?
对照第三章逐项排查:是否真的把 CatalogResolver 挂上了 setEntityResolver(ResolvingParser 一类的老适配器在部分解析器组合下不生效,实测用 tools.CatalogResolver + XMLReader 组合最稳);XSD 场景是否踩了 6.2 节的顶层源直连(改为先映射后装配);未命中的地址是否走宽容语义回落联网了(Apache 路线查不到返回 null,解析器会继续联网,需要时补登记条目)。
Q3:catalog.xml 需要写 DOCTYPE 声明吗?
不需要。OASIS 格式的关键信息在 urn:oasis:names:tc:entity:xmlns:xml:catalog 命名空间上,本文与绝大多数实践都省略 DOCTYPE 直接可用(实测全部通过);写了 DOCTYPE 反而可能引入对 catalog DTD 本身的联网解析,得不偿失。
Q4:一个 catalog 文件能管理多个项目/多套 DTD 吗?
可以。catalogs(Apache 路线)与 javax.xml.catalog.files(JDK 路线)都支持 ; 分隔多个 catalog 文件,catalog 内部还能用 <nextCatalog catalog="…"/> 链式引用;查找顺序是链式逐个查,命中即止。本文为可复现性用了单文件,扩展到多文件只改配置不改代码。
Q5:xml-resolver 1.2 这么老,新 JDK 上真的稳吗?
稳。本文在 OpenJDK 17.0.20.1 上跑通了全部八组实验;该 jar 字节码 major version 45(Java 1.1 基线,见第一章),依赖的 javax.xml.parsers/org.xml.sax 是 JDK 永久导出的稳定 API。风险不在兼容性而在停止维护——安全修复与格式扩展不会有更新,新项目优先 JDK 内置路线(第八章)。
十、参考链接
- OASIS XML Catalogs 1.0 规范(catalog.xml 条目语义权威来源):https://www.oasis-open.org/committees/entity/spec.html
- Apache XML Commons Resolver 组件主页:https://xerces.apache.org/xml-commons/components/resolver/
- Maven 中央仓库 xml-resolver 坐标页:https://repo1.maven.org/maven2/xml-resolver/xml-resolver/
- JDK 17 javax.xml.catalog 包文档:https://docs.oracle.com/en/java/javase/17/docs/api/java.xml/javax/xml/catalog/package-summary.html
- JDK 17 CatalogResolver 类文档(EntityResolver/LSResourceResolver 语义):https://docs.oracle.com/en/java/javase/17/docs/api/java.xml/javax/xml/catalog/CatalogResolver.html
- Xerces XML Commons 项目主页:https://xerces.apache.org/xml-commons/