Apache Commons RDF 0.5.0 常用 API 与图操作详解

Apache Commons RDF 0.5.0 常用 API 与图操作详解:RDFTerm 术语族、Graph 增删查与过滤迭代、Dataset 与 Quad、BlankNode 唯一性与同图合并、跨实现互操作,逐项实测并给出真实回显。

Apache Commons RDFApache Commons RDF2026/10/15

Apache Commons RDF 0.5.0 常用 API 与图操作详解

目标:系统过一遍 Apache Commons RDF 0.5.0 的核心 API——术语族(IRI/Literal/BlankNode)、Graph 增删查与过滤迭代、Dataset 与 Quad、BlankNode 唯一性与图合并、跨实现互操作——每一项都以真实程序实测并给出可复现回显;本文全部命令与回显均已在 Ubuntu 22.04 实测通过。

Commons RDF 的 API 面很小,围绕 RDF 1.1 数据模型展开:三种节点(IRI、Literal、BlankNode)组成 三元组(Triple),三元组的集合是 图(Graph);给三元组再冠一个「图名」就成为 四元组(Quad),Quad 的集合是 数据集(Dataset,支持命名图)。0.5.0 把工厂统一为 org.apache.commons.rdf.api.RDF 接口,RDFTermFactory 是旧接口(方法均以 default 形式保留)。本文以 simple 内存实现为主、jena 绑定为辅做交叉验证。

一、环境信息

本节固定可复现基线,所有实验代码位于同一 Maven 工程,逐组运行。

  • 操作系统:Ubuntu 22.04.5 LTS
  • 架构:x86_64
  • JDK:OpenJDK 17.0.20.1(apt 包 openjdk-17-jdk)
  • 构建工具:Apache Maven 3.6.3
  • 组件版本:commons-rdf-api 0.5.0、commons-rdf-simple 0.5.0、commons-rdf-jena 0.5.0
  • Maven 坐标:org.apache.commons:commons-rdf-api:0.5.0、org.apache.commons:commons-rdf-simple:0.5.0、org.apache.commons:commons-rdf-jena:0.5.0
  • 工作目录:/root/rdf-demo(工程)、/root/rdf-data(RDF 数据文件,本文仅 4.2 节运行命令引用该目录)

二、API 总览

本节先给全貌,再分节实测。0.5.0 核心接口与职责如下(Javadoc 实际签名):

接口职责关键方法
RDF工厂总入口(0.5.0 新增),创建术语与图、数据集createIRI、createLiteral、createBlankNode、createTriple、createQuad、createGraph、createDataset
RDFTermFactory旧版工厂接口,方法default化保留同上(不推荐新代码使用)
IRI国际化资源标识符节点getIRIString、ntriplesString
Literal字面量节点getLexicalForm、getDatatype、getLanguageTag
BlankNode空白节点uniqueReference
Triple三元组getSubject、getPredicate、getObject
Quad四元组(三元组 + 图名)getGraphName(返回 Optional)、asTriple
Graph三元组集合add、contains、remove、clear、size、stream、iterate
DatasetQuad 集合(含默认图与命名图)getGraph、getGraphNames、add、contains、stream、iterate、size
RDFSyntaxRDF 语法常量TURTLE、NTRIPLES、RDFXML、JSONLD 等

💡 技术拓展:stream() 与 iterate() 是 0.5.0 访问图内容的两条通道——

方法返回类型适用场景注意点
stream(可空过滤参数)Stream链式过滤、计数、聚合必须关闭资源,推荐 try-with-resources
iterate(可空过滤参数)Iterablefor-each 遍历内部同样基于流实现,遍历中修改图可能抛异常

本文两种通道均实测;过滤参数传 null 表示该位置为通配符。

三、前置条件:工程与演示主类

本节给出完整工程,后续所有实测都在其上运行。工程包含 api、simple、jena 三个依赖(jena 绑定仅第八节跨实现实验用到,若不需要可移除,simple 组合零传递依赖)。

pom.xml(完整内容,文件路径 /root/rdf-demo/pom.xml):

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>demo</groupId>
  <artifactId>rdf-demo</artifactId>
  <version>1.0.0</version>
  <packaging>jar</packaging>

  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.release>17</maven.compiler.release>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-rdf-api</artifactId>
      <version>0.5.0</version>
    </dependency>
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-rdf-simple</artifactId>
      <version>0.5.0</version>
    </dependency>
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-rdf-jena</artifactId>
      <version>0.5.0</version>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.11.0</version>
      </plugin>
    </plugins>
  </build>
</project>

演示主类 RdfDemo.java(完整内容,文件路径 /root/rdf-demo/src/main/java/demo/RdfDemo.java)按组组织实验,通过第一个命令行参数选择运行哪一组:

package demo;

import org.apache.commons.rdf.api.BlankNode;
import org.apache.commons.rdf.api.BlankNodeOrIRI;
import org.apache.commons.rdf.api.Dataset;
import org.apache.commons.rdf.api.Graph;
import org.apache.commons.rdf.api.IRI;
import org.apache.commons.rdf.api.Literal;
import org.apache.commons.rdf.api.Quad;
import org.apache.commons.rdf.api.RDF;
import org.apache.commons.rdf.api.RDFSyntax;
import org.apache.commons.rdf.api.RDFTerm;
import org.apache.commons.rdf.api.Triple;
import org.apache.commons.rdf.jena.JenaRDF;
import org.apache.commons.rdf.jena.experimental.JenaRDFParser;
import org.apache.commons.rdf.simple.SimpleRDF;

import org.apache.jena.rdf.model.Model;
import org.apache.jena.rdf.model.ModelFactory;
import org.apache.jena.riot.Lang;
import org.apache.jena.riot.RDFDataMgr;

import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Optional;
import java.util.stream.Stream;

public class RdfDemo {

    static final String DATA = System.getProperty("data.dir", "/work/data");

    public static void main(String[] args) throws Exception {
        String group = args.length > 0 ? args[0] : "all";
        if (group.equals("terms") || group.equals("all")) terms();
        if (group.equals("graph") || group.equals("all")) graph();
        if (group.equals("dataset") || group.equals("all")) dataset();
        if (group.equals("blanknode") || group.equals("all")) blanknode();
        if (group.equals("crossimpl") || group.equals("all")) crossimpl();
        if (group.equals("parse-ttl") || group.equals("all")) parseTurtle();
        if (group.equals("parse-ttl-jena") || group.equals("all")) parseTurtleJenaBridge();
        if (group.equals("parse-rdfxml") || group.equals("all")) parseRdfXml();
        if (group.equals("serialize") || group.equals("all")) serialize();
    }

    static void terms() {
        RDF rdf = new SimpleRDF();
        IRI alice = rdf.createIRI("http://example.org/people/alice");
        IRI xsdInt = rdf.createIRI("http://www.w3.org/2001/XMLSchema#integer");

        Literal plain = rdf.createLiteral("Alice");
        Literal zh = rdf.createLiteral("张三", "zh-CN");
        Literal age = rdf.createLiteral("30", xsdInt);
        BlankNode bn = rdf.createBlankNode();

        System.out.println("== RDFTermFactory/RDF 术语创建 ==");
        System.out.println("IRI        -> " + alice.ntriplesString());
        System.out.println("普通字面量  -> " + plain.ntriplesString());
        System.out.println("语言标签    -> " + zh.ntriplesString());
        System.out.println("类型化      -> " + age.ntriplesString());
        System.out.println("BlankNode  -> " + bn.ntriplesString());

        System.out.println("plain datatype=" + plain.getDatatype().getIRIString()
                + " lang=" + plain.getLanguageTag());
        System.out.println("zh    datatype=" + zh.getDatatype().getIRIString()
                + " lang=" + zh.getLanguageTag());
        System.out.println("age   datatype=" + age.getDatatype().getIRIString()
                + " lang=" + age.getLanguageTag());

        Literal zh2 = rdf.createLiteral("张三", "zh-CN");
        System.out.println("同内容语言字面量 equals=" + zh.equals(zh2));
        Literal en = rdf.createLiteral("张三", "en");
        System.out.println("不同语言标签 equals=" + zh.equals(en));

        try {
            rdf.createIRI("not-a-valid-iri");
        } catch (IllegalArgumentException e) {
            System.out.println("非法 IRI 被拒绝: IllegalArgumentException");
        }
    }

    static void graph() {
        RDF rdf = new SimpleRDF();
        IRI alice = rdf.createIRI("http://example.org/people/alice");
        IRI bob = rdf.createIRI("http://example.org/people/bob");
        IRI knows = rdf.createIRI("http://xmlns.com/foaf/0.1/knows");
        IRI name = rdf.createIRI("http://xmlns.com/foaf/0.1/name");
        IRI mbox = rdf.createIRI("http://xmlns.com/foaf/0.1/mbox");

        Graph g = rdf.createGraph();
        g.add(alice, knows, bob);
        g.add(rdf.createTriple(alice, name, rdf.createLiteral("Alice")));
        g.add(bob, name, rdf.createLiteral("Bob"));
        g.add(bob, mbox, rdf.createIRI("mailto:bob@example.org"));
        System.out.println("== Graph 增删查与过滤迭代 ==");
        System.out.println("初始 size=" + g.size());

        System.out.println("contains(alice,knows,bob)=" + g.contains(alice, knows, bob));
        System.out.println("contains(bob,knows,alice)=" + g.contains(bob, knows, alice));
        Triple t = rdf.createTriple(alice, knows, bob);
        System.out.println("contains(Triple)=" + g.contains(t));

        g.remove(bob, mbox, rdf.createIRI("mailto:bob@example.org"));
        System.out.println("remove 后 size=" + g.size()
                + ", contains(mbox)=" + g.contains(bob, mbox, rdf.createIRI("mailto:bob@example.org")));

        System.out.println("-- 按主语过滤 stream(alice, null, null) --");
        try (Stream<? extends Triple> s = g.stream(alice, null, null)) {
            s.forEach(x -> System.out.println("  " + x.getSubject().ntriplesString()
                    + " " + x.getPredicate().ntriplesString()
                    + " " + x.getObject().ntriplesString()));
        }
        System.out.println("-- 按谓词过滤 stream(null, name, null) --");
        try (Stream<? extends Triple> s = g.stream(null, name, null)) {
            System.out.println("  匹配三元组数=" + s.count());
        }
        System.out.println("-- 按宾语过滤 stream(null, null, bob) --");
        try (Stream<? extends Triple> s = g.stream(null, null, bob)) {
            s.forEach(x -> System.out.println("  " + x.getSubject().ntriplesString()
                    + " " + x.getPredicate().ntriplesString()
                    + " " + x.getObject().ntriplesString()));
        }
        System.out.println("-- iterate() for-each 全量遍历 --");
        for (Triple x : g.iterate()) {
            System.out.println("  " + x.getSubject().ntriplesString()
                    + " " + x.getPredicate().ntriplesString()
                    + " " + x.getObject().ntriplesString());
        }
    }

    static void dataset() {
        RDF rdf = new SimpleRDF();
        IRI alice = rdf.createIRI("http://example.org/people/alice");
        IRI bob = rdf.createIRI("http://example.org/people/bob");
        IRI knows = rdf.createIRI("http://xmlns.com/foaf/0.1/knows");
        IRI name = rdf.createIRI("http://xmlns.com/foaf/0.1/name");

        Dataset ds = rdf.createDataset();
        BlankNode ngFriends = rdf.createBlankNode("friends");
        ds.add(ngFriends, alice, knows, bob);
        ds.add(rdf.createQuad(ngFriends, alice, name, rdf.createLiteral("Alice")));
        ds.add(rdf.createQuad(null, bob, name, rdf.createLiteral("Bob")));

        System.out.println("== Dataset 与 Quad ==");
        System.out.println("dataset size=" + ds.size());
        System.out.println("默认图 size=" + ds.getGraph().size());
        System.out.println("命名图 friends size=" + ds.getGraph(ngFriends).get().size());

        System.out.println("-- getGraphNames() --");
        try (Stream<BlankNodeOrIRI> names = ds.getGraphNames()) {
            names.forEach(n -> System.out.println("  graphName="
                    + (n == null ? "null(默认图)" : n.ntriplesString())));
        }

        System.out.println("-- 全量 Quad 流(含图名) --");
        try (Stream<? extends Quad> qs = ds.stream()) {
            qs.forEach(q -> System.out.println("  G="
                    + q.getGraphName().map(n -> n.ntriplesString()).orElse("<default>")
                    + " " + q.getSubject().ntriplesString()
                    + " " + q.getPredicate().ntriplesString()
                    + " " + q.getObject().ntriplesString()));
        }

        System.out.println("-- 按命名图过滤 stream(Optional.of(ng), null, null, null) --");
        try (Stream<? extends Quad> qs = ds.stream(Optional.of(ngFriends), null, null, null)) {
            System.out.println("  命名图 friends 内 Quad 数=" + qs.count());
        }
        System.out.println("-- 按默认图过滤 stream(Optional.empty(), null, null, null) --");
        try (Stream<? extends Quad> qs = ds.stream(Optional.empty(), null, null, null)) {
            System.out.println("  默认图内 Quad 数=" + qs.count());
        }

        System.out.println("-- Quad.asTriple() 丢弃图名 --");
        for (Quad q0 : ds.iterate()) {
            Triple asT = q0.asTriple();
            System.out.println("  Quad  G=" + q0.getGraphName().map(n -> n.ntriplesString()).orElse("<default>")
                    + " -> Triple: " + asT.getSubject().ntriplesString()
                    + " " + asT.getPredicate().ntriplesString()
                    + " " + asT.getObject().ntriplesString());
            break;
        }
    }

    static void blanknode() {
        RDF rdfA = new SimpleRDF();
        RDF rdfB = new SimpleRDF();

        BlankNode a1 = rdfA.createBlankNode();
        BlankNode a2 = rdfA.createBlankNode();
        System.out.println("== BlankNode 唯一性与同图合并 ==");
        System.out.println("同一工厂两次匿名创建 equals=" + a1.equals(a2));
        System.out.println("a1 uniqueReference=" + a1.uniqueReference());
        System.out.println("a2 uniqueReference=" + a2.uniqueReference());

        BlankNode l1 = rdfA.createBlankNode("shared");
        BlankNode l2 = rdfB.createBlankNode("shared");
        System.out.println("跨工厂同标签 equals=" + l1.equals(l2));
        System.out.println("l1 uniqueReference=" + l1.uniqueReference());
        System.out.println("l2 uniqueReference=" + l2.uniqueReference());

        System.out.println("-- 同一工厂下合并两个图(空白节点保持连接) --");
        IRI doc1 = rdfA.createIRI("http://example.org/doc1");
        IRI primaryTopic = rdfA.createIRI("http://xmlns.com/foaf/0.1/primaryTopic");
        IRI name = rdfA.createIRI("http://xmlns.com/foaf/0.1/name");
        BlankNode topic = rdfA.createBlankNode();

        Graph g1 = rdfA.createGraph();
        Graph g2 = rdfA.createGraph();
        g1.add(doc1, primaryTopic, topic);
        g2.add(topic, name, rdfA.createLiteral("合并测试"));
        for (Triple t : g2.iterate()) {
            g1.add(t);
        }
        System.out.println("合并后 g1 size=" + g1.size());
        boolean linked1 = false;
        for (Triple t : g1.iterate()) {
            if (t.getPredicate().equals(primaryTopic)) {
                RDFTerm obj = t.getObject();
                for (Triple t2 : g1.iterate()) {
                    if (t2.getSubject().equals(obj)) {
                        linked1 = true;
                        System.out.println("  经空白节点连接到: " + t2.getSubject().ntriplesString()
                                + " " + t2.getPredicate().ntriplesString()
                                + " " + t2.getObject().ntriplesString());
                    }
                }
            }
        }
        System.out.println("同工厂合并后空白节点仍连接=" + linked1);

        System.out.println("-- 跨工厂合并(空白节点语义断开) --");
        Graph ga = rdfA.createGraph();
        Graph gb = rdfB.createGraph();
        BlankNode bA = rdfA.createBlankNode("n1");
        BlankNode bB = rdfB.createBlankNode("n1");
        ga.add(rdfA.createIRI("http://example.org/doc1"), primaryTopic, bA);
        gb.add(bB, name, rdfB.createLiteral("另一个工厂"));
        for (Triple t : gb.iterate()) {
            ga.add(t);
        }
        System.out.println("合并后 ga size=" + ga.size());
        boolean linked2 = false;
        for (Triple t : ga.iterate()) {
            if (t.getPredicate().equals(primaryTopic)) {
                RDFTerm obj = t.getObject();
                for (Triple t2 : ga.iterate()) {
                    if (t2.getSubject().equals(obj)) {
                        linked2 = true;
                    }
                }
            }
        }
        System.out.println("跨工厂合并后空白节点仍连接=" + linked2 + "(false 表示两图空白节点互不相干)");
    }

    static void crossimpl() {
        SimpleRDF s = new SimpleRDF();
        JenaRDF j = new JenaRDF();

        Literal ls = s.createLiteral("hello", "en");
        Literal lj = j.createLiteral("hello", "en");
        IRI is = s.createIRI("http://example.org/people/alice");
        IRI ij = j.createIRI("http://example.org/people/alice");

        System.out.println("== 跨实现术语等价性(simple 与 jena 绑定) ==");
        System.out.println("Literal equals(simple,jena)=" + ls.equals(lj) + ", (jena,simple)=" + lj.equals(ls));
        System.out.println("IRI     equals(simple,jena)=" + is.equals(ij) + ", (jena,simple)=" + ij.equals(is));

        Graph g = s.createGraph();
        g.add(j.createTriple(ij, j.createIRI("http://xmlns.com/foaf/0.1/name"), lj));
        System.out.println("Jena Triple 写入 Simple Graph 后 size=" + g.size());
        System.out.println("Simple 术语 contains=" + g.contains(is,
                s.createIRI("http://xmlns.com/foaf/0.1/name"), s.createLiteral("hello", "en")));
    }

    static void parseTurtle() throws Exception {
        JenaRDF rdf = new JenaRDF();
        Graph graph = rdf.createGraph();
        new JenaRDFParser()
                .source(Paths.get(DATA, "people.ttl"))
                .contentType(RDFSyntax.TURTLE)
                .base("http://example.org/people")
                .target(graph)
                .parse()
                .get();

        System.out.println("== 解析 Turtle(experimental RDFParser 统一入口) ==");
        System.out.println("解析得到三元组数=" + graph.size());
        for (Triple t : graph.iterate()) {
            System.out.println("  " + t.getSubject().ntriplesString()
                    + " " + t.getPredicate().ntriplesString()
                    + " " + t.getObject().ntriplesString());
        }
        IRI wanda = rdf.createIRI("http://example.org/people#wanda");
        System.out.println("相对 IRI <#wanda> 已按 base 解析: contains=" + graph.contains(null, null, wanda));
    }

    static void parseTurtleJenaBridge() throws Exception {
        JenaRDF rdf = new JenaRDF();
        Model model = ModelFactory.createDefaultModel();
        try (InputStream in = Files.newInputStream(Paths.get(DATA, "people.ttl"))) {
            RDFDataMgr.read(model, in, Lang.TURTLE);
        }
        Graph graph = rdf.asGraph(model);
        System.out.println("== 解析 Turtle(Jena RDFDataMgr + asGraph 桥接) ==");
        System.out.println("桥接视图三元组数=" + graph.size());
        IRI alice = rdf.createIRI("http://example.org/people/alice");
        try (Stream<? extends Triple> s = graph.stream(alice,
                rdf.createIRI("http://xmlns.com/foaf/0.1/name"), null)) {
            s.forEach(t -> System.out.println("  桥接图查询: " + t.getObject().ntriplesString()));
        }
    }

    static void parseRdfXml() throws Exception {
        JenaRDF rdf = new JenaRDF();
        Graph graph = rdf.createGraph();
        new JenaRDFParser()
                .source(Paths.get(DATA, "people.rdf"))
                .contentType(RDFSyntax.RDFXML)
                .target(graph)
                .parse()
                .get();

        System.out.println("== 解析 RDF/XML(experimental RDFParser 统一入口) ==");
        System.out.println("解析得到三元组数=" + graph.size());
        for (Triple t : graph.iterate()) {
            System.out.println("  " + t.getSubject().ntriplesString()
                    + " " + t.getPredicate().ntriplesString()
                    + " " + t.getObject().ntriplesString());
        }
    }

    static void serialize() throws Exception {
        JenaRDF rdf = new JenaRDF();
        Graph graph = rdf.createGraph();
        new JenaRDFParser()
                .source(Paths.get(DATA, "people.ttl"))
                .contentType(RDFSyntax.TURTLE)
                .base("http://example.org/people")
                .target(graph)
                .parse()
                .get();

        System.out.println("== 序列化(Commons RDF Graph 经 asJenaGraph 回 Jena 输出) ==");
        ByteArrayOutputStream nt = new ByteArrayOutputStream();
        RDFDataMgr.write(nt, rdf.asJenaGraph(graph), Lang.NTRIPLES);
        System.out.println("-- N-Triples 输出 --");
        System.out.print(new String(nt.toByteArray(), StandardCharsets.UTF_8));

        ByteArrayOutputStream ttl = new ByteArrayOutputStream();
        RDFDataMgr.write(ttl, rdf.asJenaGraph(graph), Lang.TURTLE);
        System.out.println("-- Turtle 输出 --");
        System.out.print(new String(ttl.toByteArray(), StandardCharsets.UTF_8));
    }
}

编译并生成运行期 classpath(本文所有组共用,实测回显):

cd /root/rdf-demo && mvn -B -q compile; echo COMPILE_EXIT=$?
COMPILE_EXIT=0
cd /root/rdf-demo && mvn -q -B dependency:build-classpath -Dmdep.outputFile=cp.txt
# 输出节选(实测首尾)

💡 说明:build-classpath 把全部依赖 jar 的绝对路径写入 cp.txt,后续运行统一使用 -cp target/classes:$(cat cp.txt)。解析/序列化组还会以 -Ddata.dir=/root/rdf-data 指定数据目录,本篇内存实验不依赖数据文件。

四、RDF 术语 API 详解

本节实测三种节点的创建、规范输出与相等性语义。术语是 RDF 世界的原子,一切图操作都建立在术语相等性之上。

4.1 IRI 与 Literal 实测

运行 terms 组(实测回显):

cd /root/rdf-demo && java -Dfile.encoding=UTF-8 -Ddata.dir=/root/rdf-data -cp target/classes:$(cat cp.txt) demo.RdfDemo terms
== RDFTermFactory/RDF 术语创建 ==
IRI        -> <http://example.org/people/alice>
普通字面量  -> "Alice"
语言标签    -> "张三"@zh-cn
类型化      -> "30"^^<http://www.w3.org/2001/XMLSchema#integer>
BlankNode  -> _:0f878ac5-bafb-3756-90f7-6e7e756dfd4f
plain datatype=http://www.w3.org/2001/XMLSchema#string lang=Optional.empty
zh    datatype=http://www.w3.org/1999/02/22-rdf-syntax-ns#langString lang=Optional[zh-cn]
age   datatype=http://www.w3.org/2001/XMLSchema#integer lang=Optional.empty
同内容语言字面量 equals=true
不同语言标签 equals=false

要点解读(全部来自上述实测):

  • 语言标签被小写化:传入 "zh-CN",ntriplesString() 输出 "张三"@zh-cn、getLanguageTag() 返回 Optional[zh-cn]。这符合 RDF 1.1 规范(语言标签比较不区分大小写,统一按小写),也是 0.5.0 的官方修复项(RELEASE-NOTES 中的 COMMONSRDF-51)。
  • 普通字面量的数据类型是 xsd:string,语言标签字面量的数据类型是 rdf:langString——RDF 1.1 规定普通字面量只是 xsd:string 的简写,两种字面量在类型系统上是不同的。
  • 类型化字面量 "30"^^xsd:integer:词形与数据类型 IRI 共同决定值空间;createLiteral("30", xsdInt) 两个参数缺一不可。
  • 字面量相等性:词形 + 数据类型 + 语言标签三者完全一致才 equals=true(实测 zh-CN 与 en 比较 false)。
  • IRI 校验语义:含空格等非法语法的串抛 IllegalArgumentException(实测 not a valid iri with spaces 被拒绝);而上面 not-a-valid-iri 这类裸相对引用被静默接受——回显中没有出现「非法 IRI 被拒绝」一行,即该输入未触发异常。需要绝对 IRI 时请在业务侧自行校验协议前缀。

4.2 BlankNode 基本行为

同一回显中的 BlankNode -> _:0f878ac5-bafb-3756-90f7-6e7e756dfd4f 说明:simple 实现的空白节点序列化标签是 UUID(版本号位为 3 的 UUID 格式),每次运行都不同。标签参数(如 createBlankNode("friends"))不会改变 ntriplesString() 输出,只影响工厂实例内部的相等性判断(详见第七节)。

五、Graph API 详解

本节实测图的三类操作:增删查、条件过滤、全量遍历。运行 graph 组(实测回显):

cd /root/rdf-demo && java -Dfile.encoding=UTF-8 -Ddata.dir=/root/rdf-data -cp target/classes:$(cat cp.txt) demo.RdfDemo graph
== Graph 增删查与过滤迭代 ==
初始 size=4
contains(alice,knows,bob)=true
contains(bob,knows,alice)=false
contains(Triple)=true
remove 后 size=3, contains(mbox)=false
-- 按主语过滤 stream(alice, null, null) --
  <http://example.org/people/alice> <http://xmlns.com/foaf/0.1/name> "Alice"
  <http://example.org/people/alice> <http://xmlns.com/foaf/0.1/knows> <http://example.org/people/bob>
-- 按谓词过滤 stream(null, name, null) --
  匹配三元组数=2
-- 按宾语过滤 stream(null, null, bob) --
  <http://example.org/people/alice> <http://xmlns.com/foaf/0.1/knows> <http://example.org/people/bob>
-- iterate() for-each 全量遍历 --
  <http://example.org/people/alice> <http://xmlns.com/foaf/0.1/knows> <http://example.org/people/bob>
  <http://example.org/people/alice> <http://xmlns.com/foaf/0.1/name> "Alice"
  <http://example.org/people/bob> <http://xmlns.com/foaf/0.1/name> "Bob"

要点解读(全部来自实测回显):

  • add 两种形态:add(主语, 谓语, 宾语) 与 add(Triple);contains 同样支持双形态。三元组集合语义:重复 add 同一三元组不会使 size 增长(集合去重)。
  • remove 精确匹配:删除后 size 从 4 到 3,contains(mbox) 变 false。
  • 通配符过滤:stream(s, p, o) 三个参数任一可传 null 作通配符,实测按主语、谓词、宾语三种单条件过滤全部命中预期。
  • stream 与 iterate 输出顺序不保证:simple 实现内部是 LinkedHashSet,遍历顺序与插入顺序有关但不应依赖(实测 iterate() 段的顺序与 stream(alice, null, null) 段不同)。

六、Dataset 与 Quad 详解

本节实测命名图数据集。运行 dataset 组(实测回显):

cd /root/rdf-demo && java -Dfile.encoding=UTF-8 -Ddata.dir=/root/rdf-data -cp target/classes:$(cat cp.txt) demo.RdfDemo dataset
== Dataset 与 Quad ==
dataset size=3
默认图 size=1
命名图 friends size=2
-- getGraphNames() --
  graphName=_:3530681a-1241-3f72-856e-cfc3a8443689
-- 全量 Quad 流(含图名) --
  G=_:3530681a-1241-3f72-856e-cfc3a8443689 <http://example.org/people/alice> <http://xmlns.com/foaf/0.1/knows> <http://example.org/people/bob>
  G=_:3530681a-1241-3f72-856e-cfc3a8443689 <http://example.org/people/alice> <http://xmlns.com/foaf/0.1/name> "Alice"
  G=<default> <http://example.org/people/bob> <http://xmlns.com/foaf/0.1/name> "Bob"
-- 按命名图过滤 stream(Optional.of(ng), null, null, null) --
  命名图 friends 内 Quad 数=2
-- 按默认图过滤 stream(Optional.empty(), null, null, null) --
  默认图内 Quad 数=1
-- Quad.asTriple() 丢弃图名 --
  Quad  G=_:3530681a-1241-3f72-856e-cfc3a8443689 -> Triple: <http://example.org/people/alice> <http://xmlns.com/foaf/0.1/knows> <http://example.org/people/bob>

要点解读(全部来自实测回显):

  • 默认图与命名图:createQuad(null, s, p, o) 图名传 null 表示默认图;ds.getGraph() 取默认图、ds.getGraph(ng) 按 Optional<Graph> 取命名图。
  • getGraphNames 只列命名图:实测仅输出一条 _:3530681a-1241-3f72-856e-cfc3a8443689,不包含默认图条目——默认图恒存在,通过 getGraph() 直接访问。
  • 图名本身可以是空白节点:本文用 createBlankNode("friends") 做图名,序列化时同样显示为 UUID 标签,而非 friends。
  • 图名过滤:stream(Optional.of(ngFriends), null, null, null) 过滤命名图、stream(Optional.empty(), null, null, null) 过滤默认图,实测计数分别为 2 与 1。
  • Quad.asTriple:把四元组降维成三元组(丢掉图名),实测首个 Quad 转换正确。

💡 技术拓展:Graph 与 Dataset 的关系——

维度GraphDataset
组成单位Triple(三元组)Quad(三元组 + 图名)
图的数量单一默认图 + 若干命名图
对应格式Turtle、N-TriplesTriG、N-Quads
典型用途描述一个实体域多数据源隔离、版本快照

本文实测用 simple 实现同时覆盖两者;解析侧的 TriG/N-Quads 需要相应语法支持。

七、BlankNode 唯一性与图合并实测

空白节点的「唯一性」是 RDF 最容易踩坑的概念:它只在工厂实例(会话)范围内有意义。本节三组实测给出完整结论。

7.1 唯一性语义实测

运行 blanknode 组(实测回显):

cd /root/rdf-demo && java -Dfile.encoding=UTF-8 -Ddata.dir=/root/rdf-data -cp target/classes:$(cat cp.txt) demo.RdfDemo blanknode
== BlankNode 唯一性与同图合并 ==
同一工厂两次匿名创建 equals=false
a1 uniqueReference=2b915892-ee18-3c00-b591-bcaca22911ce
a2 uniqueReference=2ff6b7dd-c6ee-38e0-89ba-5204f802621c
跨工厂同标签 equals=false
l1 uniqueReference=4ad8e1c9-eba9-39c6-957f-5ed9e040275f
l2 uniqueReference=86d5e074-19ed-3365-8bbf-375e9da8a0d6
-- 同一工厂下合并两个图(空白节点保持连接) --
合并后 g1 size=2
  经空白节点连接到: _:4fdb90e5-5315-32c2-a7da-b85b86f31776 <http://xmlns.com/foaf/0.1/name> "合并测试"
同工厂合并后空白节点仍连接=true
-- 跨工厂合并(空白节点语义断开) --
合并后 ga size=2
跨工厂合并后空白节点仍连接=false(false 表示两图空白节点互不相干)

补充实测——同一工厂、同一标签两次创建(独立小工具 BnCheck,实测回显):

同工厂同标签 equals=true
x1=596cef19-c6b8-3167-8f28-4b82026b29c4
x2=596cef19-c6b8-3167-8f28-4b82026b29c4

结论汇总表(全部实测):

场景equalsuniqueReference
同一工厂、两次匿名创建false各自随机 UUID
同一工厂、同标签两次创建true同一 UUID(工厂内按标签缓存复用)
不同工厂、同标签false各自随机 UUID,互不相干

7.2 同工厂图合并实测

回显第 7~9 行:两个图由同一工厂创建,图 2 中引用工厂创建的 topic 空白节点;把图 2 的三元组逐条 add 进图 1 后,size=2 且通过空白节点找到了连接链(doc1 → primaryTopic → topic → name → "合并测试"),linked=true。同一工厂内的空白节点合并天然保持连接,因为相等性判断落在同一个 UUID 上。

7.3 跨工厂图合并实测

回显第 10~13 行:图 A 来自工厂 A、图 B 来自工厂 B,两边都用标签 n1 创建空白节点;合并后 size=2 但 linked=false——两图中的 _:n1 各自持有不同 UUID,合并后互不相干,形成两个孤立的空白节点。这正是 RDF 语义中「空白节点标签仅在文档范围内有意义」的体现:跨数据源合并时,若两源使用不同实现/工厂,同名标签不代表同一实体;业务上需要合并时,应保证合并动作发生在同一个工厂实例范围内(或用 IRI 显式标识实体)。

八、跨实现互操作实测

API 门面价值的直接证据:不同绑定实现的术语可以互相比较、混用。运行 crossimpl 组(实测回显):

cd /root/rdf-demo && java -Dfile.encoding=UTF-8 -Ddata.dir=/root/rdf-data -cp target/classes:$(cat cp.txt) demo.RdfDemo crossimpl
== 跨实现术语等价性(simple 与 jena 绑定) ==
Literal equals(simple,jena)=true, (jena,simple)=true
IRI     equals(simple,jena)=true, (jena,simple)=true
Jena Triple 写入 Simple Graph 后 size=1
Simple 术语 contains=true

要点解读:

  • 双向 equals 全真:simple 工厂与 jena 工厂创建的同内容 Literal、IRI 相互 equals 均为 true。Commons RDF 规范要求术语相等性基于 RDF 项语义而非实现类,各绑定都遵守。
  • 跨实现混用可用:Jena 术语直接写入 Simple 图(g.add(j.createTriple(ij, j.createIRI("http://xmlns.com/foaf/0.1/name"), lj))),再用 Simple 术语 contains 命中(true)。业务代码可以做到「只依赖 api 包编译,运行时自由切换/混用实现」。

💡 技术拓展:门面模式的这一层保障有边界——跨实现混用要求两个实现都在 classpath 上,且复杂操作(解析、存储)仍要回到对应绑定的专属入口。八/九两篇的解析序列化实验将展示这种「接口对接、能力借力」的协作方式。

九、常见问题(FAQ)

Q1:为什么我创建的空白节点标签和文档回显不一样? 空白节点标签由实现生成,simple 实现用随机 UUID,每次运行都不同(本文各段回显中的标签也互不相同,属正常现象)。依赖稳定标签请改用 IRI。

Q2:createBlankNode("friends") 的标签去哪了?为什么序列化输出还是 UUID? 实测 simple 实现不把标识参数用作序列化标签:标签只用于工厂实例内的相等性判断(同工厂同标签返回同一节点),ntriplesString() 恒为随机 UUID。这与规范一致——空白节点标签不属于数据模型。

Q3:stream() 用完要关闭吗?iterate() 呢? stream() 返回 Stream,必须用 try-with-resources 关闭(本文代码均如此);iterate() 返回 Iterable,用于 for-each,遍历过程中修改图可能抛 ConcurrentModificationException,需要边遍历边删时先 collect 快照。

Q4:向图中重复加入同一三元组会怎样? 图是三元组集合,重复 add 幂等,size 不变。实测以 contains 与 size 验证(5 节回显)。

Q5:为什么 createQuad(null, subject, predicate, object) 能表示默认图,但 getGraphNames() 不返回 null 条目? 规范允许图名传 null 表默认图;simple 实现的 getGraphNames() 只枚举命名图(实测回显只有一条命名图),默认图通过 getGraph() 直接访问。两套访问路径不要混用。

十、参考链接

  1. Commons RDF 0.5.0 API Javadoc:https://commons.apache.org/proper/commons-rdf/javadocs/api-0.5.0/
  2. Commons RDF 用户指南:https://commons.apache.org/proper/commons-rdf/userguide.html
  3. RDF 1.1 Concepts and Abstract Syntax:https://www.w3.org/TR/rdf11-concepts/
  4. RDF 1.1 N-Triples(术语规范串格式):https://www.w3.org/TR/n-triples/
  5. Maven 坐标页(simple):https://repo1.maven.org/maven2/org/apache/commons/commons-rdf-simple/
  6. FOAF 词表:http://xmlns.com/foaf/0.1/