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 ResolverApache XML Commons Resolver2026/10/15

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.2JDK 内置 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/ResolvingXMLFilterCatalogResolver(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 内置路线(第八章)。

十、参考链接

  1. OASIS XML Catalogs 1.0 规范(catalog.xml 条目语义权威来源):https://www.oasis-open.org/committees/entity/spec.html
  2. Apache XML Commons Resolver 组件主页:https://xerces.apache.org/xml-commons/components/resolver/
  3. Maven 中央仓库 xml-resolver 坐标页:https://repo1.maven.org/maven2/xml-resolver/xml-resolver/
  4. JDK 17 javax.xml.catalog 包文档:https://docs.oracle.com/en/java/javase/17/docs/api/java.xml/javax/xml/catalog/package-summary.html
  5. JDK 17 CatalogResolver 类文档(EntityResolver/LSResourceResolver 语义):https://docs.oracle.com/en/java/javase/17/docs/api/java.xml/javax/xml/catalog/CatalogResolver.html
  6. Xerces XML Commons 项目主页:https://xerces.apache.org/xml-commons/