入门:JavaScript / WebAssembly
npm 包 @blackcatinformatics/purrdf 是编译为 wasm32 的同一个 Rust 引擎,并通过 RDF/JS 形态的 API(DataFactory、DatasetCore、Stream/Sink)对外提供。它可在浏览器与 Node 中运行,完全在内存中工作。
译注:中国大陆镜像。 如需使用 npm registry 镜像,可参考 npmmirror.com(原 cnpm / 淘宝源)的说明:npm config set registry https://registry.npmmirror.com。具体配置以该镜像站的说明为准。
npm install @blackcatinformatics/purrdf
想在安装任何东西之前先试用?RDF-1.2 playground 在浏览器中运行的正是这一 wasm 构建——在客户端解析、执行 SPARQL、SHACL、序列化,以及对 RDF-1.2 图做规范化/比较,无需工具链,也无需服务器。
第一个数据集
在做任何其他事情之前,先 await 一次 ready()——它执行一次性的异步 wasm 实例化:
import { ready, DataFactory, Dataset, QueryEngine } from "@blackcatinformatics/purrdf";
await ready(); // 一次性的异步 wasm 实例化
const f = new DataFactory();
const rtl = f.directionalLiteral("مرحبا", "ar", "rtl");
const ds = new Dataset();
ds.add(f.quad(f.namedNode("https://ex/s"), f.namedNode("https://ex/says"), rtl));
const nq = ds.serialize("nquads"); // 方向在往返(转换)中得以保留
const reparsed = Dataset.parse(nq, "nquads");
const engine = new QueryEngine();
const ask = engine.ask(reparsed, "ASK { <https://ex/s> <https://ex/says> ?msg }");
RDF 1.2 的切入点
现有的 RDF/JS 库都不支持 RDF 1.2 的引用三元组项或带方向的字面量。PurRDF 的 DataFactory 两者都提供:
// 一个引用三元组,可用作主语/宾语(RDF-star / RDF 1.2)。
const quoted = f.quotedTriple(
f.namedNode("https://ex/alice"),
f.namedNode("https://ex/knows"),
f.namedNode("https://ex/bob"),
);
// 一个基础方向字面量(rdf:dirLangString)。
const hello = f.directionalLiteral("مرحبا", "ar", "rtl");
API 接口
ready(bytesOrUrl?)——在做任何事之前 await 一次;它也接受已编译的WebAssembly.Module。DataFactory——namedNode、blankNode、literal(value, languageOrDatatype?)、typedLiteral、directionalLiteral、variable、defaultGraph、quad、quotedTriple、fromTerm、fromQuad。Dataset(RDF/JSDatasetCore)——Dataset.parse(input, format, base?)、serialize(format)、add/delete/has/match/quads/size,以及迭代(for (const quad of dataset))。格式:turtle、ntriples、nquads、trig、rdfxml(或它们的媒体类型);serialize还额外接受jsonld。- 图标识——
Dataset.canonicalize()返回该图的 RDFC-1.0 规范化扁平 N-Quads;Dataset.isomorphic(other)判定在空节点重新标记意义下的 RDF 图相等性(一个由完整 RDFC-1.0 规范化支撑的精确判定依据(oracle))。 - 图/表格/Research Object 载体——
Dataset.project(profile, configJson)返回规范的 USTAR 字节与损失台账 JSON;Dataset.projectWithAssets("ro-crate-1.3", configJson, payloadArchive)增加有界的 attached RO-Crate 载荷;liftProjection(...)为双向 profile 重建 RDF。参见图、表格与 Research Object 投影。 - SPARQL——
QueryEngine在多次调用之间保持原生计划缓存存活,并暴露带类型的select/ask/construct/describe、原子的update,以及queryRaw序列化。Dataset.query(...)仍作为兼容用的裸字符串辅助方法保留。每个求值方法都有一个返回 Promise 的孪生方法,接受宿主为SERVICE与LOAD提供的处理函数——见下文。 - SHACL——
shaclValidateToSarif(shapesTtl, dataNt)依据 Turtle 形状图验证 N-Triples 数据图,并返回 SARIF 2.1.0 报告;shaclEntail(shapesTtl, dataNt)物化 SHACL-AFsh:rule推论,并返回一个ShaclEntailment:ntriples以 N-Triples 形式保存结果,diagnostics保存形状图的强制诊断。须对它调用free()。 Sink——流式消费者(push(quad)/finish() → Dataset);datasetToStream/streamToDataset是异步的 RDF/JS Stream/Sink 辅助函数。
关于 RDF/JS 映射的更多内容见 JavaScript 中的 RDF/JS。
Asynchronous queries and federation
同步方法是离线通道:它们不安装任何 SERVICE 或 LOAD 来源,因此 SERVICE 或 LOAD 会按名称失败,除非写了 SILENT——此时它成功而不抓取任何内容,这正是 SPARQL 1.1 对失败调用的要求。每个求值方法还有一个返回 Promise 的孪生方法——QueryEngine 上的 queryAsync、selectAsync、askAsync、constructAsync、describeAsync、queryRawAsync、queryRawBytesAsync、queryRawWithContextAsync、queryGovernedAsync、queryEntailmentGovernedAsync、updateAsync、updateGovernedAsync 与 explainQueryAsync,以及 Dataset.queryAsync——此外还有 queryGovernedNegotiatedAsync,它把受 governor 管控的查询按 HTTP Accept 请求头协商出的格式作为文档返回。孪生方法在调用开始时为数据集拍下快照,并在快照上运行同一个求值器;它以作业的形式执行:宿主应答 SERVICE 或 LOAD 时作业挂起,求值期间作业把事件循环让出,最终兑现为与其同步孪生方法完全相同的返回值。I/O 由宿主完成,相应的策略也归宿主所有;解析、求值、连接、SILENT 语义与结果编码仍由 PurRDF 负责。
EXPLAIN 也是这类求值方法之一:它的计费台账是实际运行查询测得的,而不是根据查询文本预测的。因此,explainQuery 会因缺少来源而拒绝 SERVICE 查询,而 explainQueryAsync 则基于宿主的应答来解释它。它的测量运行与其他作业一样会让出事件循环,并在其 signal 触发时停止;它不接受任何上限,因为这次运行只计量、从不设限。
SHACL 验证同样会对 SPARQL 求值:sh:SPARQLTarget 查询、SHACL-SPARQL 约束,以及 SHACL-AF 的节点表达式与规则。因此,SHACL 函数也有孪生方法:shaclValidateToSarifAsync、shaclValidateChangesToSarifAsync、shaclEntailAsync、shaclApplyRulesAsync、shaclEvalNodeExprAsync,以及 shaclProductValidateToSarifAsync 与它的 Rebuild、Expecting 和 RebuildExpecting 形式。每个孪生方法依次接受与其同步孪生方法完全相同的参数和宿主选项,并返回与同步孪生方法完全相同的结果;被拒绝的预编译形状产物(prepared shapes product)会以同一个 ShaclProductRefusal 拒绝,手头没有的 owl:imports 闭包则以同一个 ShaclImportError 拒绝。signal 既在查询内部轮询,也在焦点节点之间轮询,因此即使一次验证中没有任何 SPARQL,它也会让出事件循环,并在信号触发时停止。SHACL-SPARQL 不允许任何查询使用 SERVICE,sh:SPARQLTarget 的查询也不例外;使用了它的查询会被拒绝,两条通道都是如此。
孪生方法运行在 WebAssembly JavaScript Promise Integration(JSPI)之上,它在 Chrome 与 Edge 137+、Firefox 139+、Safari 27、Node 24.20+ 以及 Cloudflare Workers(workerd)中默认启用。hasAsyncQueries() 报告当前引擎是否支持 JSPI;在不支持的引擎上,每个孪生方法都会在触及 wasm 之前以同一个错误拒绝,而同步 API 照常工作。
应答 SERVICE
resolveService(request, ctx) 收到的是需要发送的 SPARQL 1.1 Protocol POST 请求:endpoint、queryText、contentType(application/sparql-query)、accept(application/sparql-results+json)、userAgent、timeoutMs、headers——目录 profile 的请求头与凭据,形式为按发送顺序排列的 [name, value] 对——以及 cacheable(携带凭据的请求为 false)。ctx 携带 signal(在取消、请求超时或到达截止时间时触发)、remainingDeadlineMs、silent 与 maxIntermediateCells。处理函数的应答可以是 SPARQL Results JSON(字节或字符串)、一个 Response(非 2xx 状态视为传输失败)、{ kind: "transport", message } 或 { kind: "denied", message }。处理函数若抛出异常、返回被拒绝的 Promise、返回任何其他值,或指明了协议未定义的失败种类,即视为发生故障,而故障就是它所应答的那一次调用的失败,即 { kind: "fault" }。任何失败都会让查询失败——故障以 native-sparql-host-fault 失败,其消息携带处理函数自己的原话;在 SERVICE SILENT 下它是连接的单位元,证据中的 silenced 会记录该端点与失败的 kind(transport、denied、fault 等)。ctx.silent 仅供参考:处理函数无权自行编造一个空应答。
import { ready, Dataset, QueryEngine } from "@blackcatinformatics/purrdf";
await ready();
// 一个 SERVICE 请求,以 SPARQL 1.1 Protocol POST 发送。
async function resolveService(request, { signal }) {
try {
// Response 本身就是应答:2xx 响应体按 SPARQL Results JSON 读取,
// 其他任何状态都是传输失败。
return await fetch(request.endpoint, {
method: "POST",
headers: [
["Content-Type", request.contentType], // application/sparql-query
["Accept", request.accept], // application/sparql-results+json
...request.headers, // 目录 profile 的请求头,按发送顺序排列
],
body: request.queryText,
// 在请求超时、作业到达截止时间或被取消时触发。
signal,
});
} catch (error) {
// 网络错误是端点的失败;抛出异常则会被报告为
// 此处理函数自身的故障。
return { kind: "transport", message: String(error) };
}
}
const engine = new QueryEngine();
const dataset = Dataset.parse(
"<https://example.org/a> <https://example.org/p> <https://example.org/o1> .\n",
"nquads",
);
const { rows } = await engine.selectAsync(
dataset,
`SELECT ?s ?x WHERE {
?s <https://example.org/p> ?o
SERVICE <https://remote.example.org/sparql> { ?o <https://example.org/q> ?x }
}`,
{ resolveService },
);
for (const row of rows) console.log(row.s.value, row.x.value);
以 catalog 传入的 ServiceCatalog 会在调用处理函数之前授权每一个请求——每一个 SERVICE、每一个 LOAD 来源,以及 LOAD 被重定向到的每一个位置(默认拒绝,每个端点一个 profile,另可设一个兜底 profile),其 profile 的 timeoutMs 限定每个请求的时长;被拒绝的请求会让查询失败,在 SERVICE SILENT 下则是连接的单位元。localServices 在进程内用一个 Dataset 应答指定的端点。resolveLoad 以同样的方式应答 LOAD 的一跳:返回一份文档及其媒体类型、一个 Response、一个 Dataset、{ kind: "redirect", location }(作业会解析该位置并重新鉴权,最多五跳),或一个带类型的失败:每种失败都会让请求失败,而 LOAD SILENT 会在失败时成功返回、不加载任何内容。本包抛出的每个错误都以 error.code 携带其稳定代码。
在浏览器中,由远程端点的 CORS 策略决定 fetch 能否读取其应答:不允许页面所在源的端点会表现为网络错误,上面的处理函数把它报告为传输失败。若某个端点可能不可达、且其结果行可有可无,请写 SERVICE SILENT。
变量端点 SERVICE ?e { … } 应答的每一行都带有产生该行的端点的 ?e。到达该子句的每个解都必须绑定 ?e,而 ?e 在何处绑定决定了该子句发出多少个请求。若由同一组中位于其前的模式绑定(三元组模式、VALUES、BIND,或 LATERAL { SERVICE ?e { … } }),该子句会对每个解各求值一次,并把该解的绑定代入转发的查询:每个解一个请求,因此指明同一 IRI 的两个解会向它发出两个请求,二者携带的绑定各不相同。若由右侧包含该子句的 OPTIONAL、MINUS 或组连接(group join)的左侧绑定——例如 ?g ex:endpoint ?e OPTIONAL { SERVICE ?e { … } },换成 MINUS 或 { ?g ex:endpoint ?e } { SERVICE ?e { … } } 亦然——或者,由于连接满足交换律,由包含该子句的组连接的另一侧绑定,例如 { SERVICE ?e { … } ?g ex:endpoint ?e }(OPTIONAL 或 MINUS 的右侧则不行,因为它不一定绑定 ?e),那么右侧仍然独立求值,左侧只提供需要询问的端点列表,每个不同的 IRI 只询问一次。若某个左侧行的端点没有应答任何结果,该行在 OPTIONAL 下保留其原有绑定,在 MINUS 下也不会被移除。在 SERVICE SILENT 下,失败的端点只对它自己的左侧行充当连接的单位元:在组连接或 OPTIONAL 下,它只贡献一行、且只绑定 ?e,因此它的左侧行原样保留、不被扩展,其他端点的结果行也不受影响;在 MINUS 下,左侧行按端点逐个相减,因此它不会移除自己的任何行,而应答了的端点仍会移除与之匹配的行。绑定到字面量或空节点的 ?e 并未指明任何端点:这是一个错误,在 SILENT 下则对其所在的行充当连接的单位元。若没有任何解为某个子句绑定 ?e——无处绑定、只在部分左侧解中绑定,或者只在另一个 OPTIONAL 或 MINUS 右侧、EXISTS、LIMIT/OFFSET、未按 ?e 分组的聚合或未投影 ?e 的子 SELECT 之外绑定——该子句即被拒绝,无论是否写了 SILENT:根本没有发出任何调用可供 SILENT 吸收。错误消息会给出改写方式:在该子句之前绑定 ?e,例如 ?s ex:endpoint ?e . SERVICE ?e { … }。
让出、取消与并发
- 作业每经过
yieldEveryPolls次 governor 轮询就把事件循环让出一轮(默认 65 536;0表示每次轮询都让出)。每一轮在每个宿主上都是一个setTimeout(…, 0)任务,因此已在等待的请求、定时器与 fetch 响应都会在作业的两轮之间运行。只有求值阶段会让出:冻结数据集与序列化结果都会一次运行到底,evidence.async报告每个阶段的耗时。 signal: AbortSignal会在作业下一次让出或发出宿主请求时取消它。在受 governor 管控的孪生方法上,deadlineMs包含等待处理函数的时间;一次 governor 触发——包括截止时间与取消——是一个结果,而不是一次 Promise 拒绝。- 查询读取自己的快照。同一数据集同一时刻只能有一个异步更新在途:在此期间开始的另一个更新会以
native-sparql-update-in-flight被拒绝,而不是排队等待;并且只有在其运行期间数据集未被修改时,更新才会应用。configureAsync({ maxConcurrentJobs })限定同时在途的作业数(默认 16)。 - 每个作业在自己的栈区域上求值,其大小与模块自身的影子栈——同步通道运行所用的栈——完全相同;没有任何选项可以改变它。
evidence.async.stackHighWaterBytes报告作业用到了多深;对该区域而言嵌套过深的请求,与对同步通道而言嵌套过深的请求一样,以同一个带类型的栈拒绝错误失败。每个通道都会在自身的帧上花费少量栈,因此两者各自能求值的最深请求可能相差几层。 - 栈区域只决定影子栈的大小。V8 为作业自身的调用栈分配的大小与同步通道相同(默认 984 KiB,由整个进程统一设定),PurRDF 为求值每个请求保留其中固定的一份预算,因此在两条通道上,请求的图模式最多只能嵌套 284 层。超出即为
native-sparql-host-stack-exhausted,两条通道都会在求值之前拒绝:请减少请求的嵌套深度。解析把请求的嵌套保存在线性内存中,不消耗这两种栈。 - 若某个作业的栈帧覆写了其区域底部的金丝雀字(canary word),该作业会以指名该区域的故障失败。trap、Rust panic,或穿过模块栈帧抛出的 JavaScript 异常都会毒化该实例,两条通道皆然:此后对本包的每一次调用——包括同步调用,以及对 trap 之前创建的对象的调用——都会抛出错误,异步孪生方法与
ready()会拒绝,而释放对象(free()、[Symbol.dispose]())什么也不做。只有全新的 JavaScript realm(新的页面、Worker isolate 或进程)才能再次加载本包。
Cloudflare Workers
@blackcatinformatics/purrdf/cloudflare 提供 createFetchServiceResolver、createFetchLoadResolver 与 handleSparqlRequest;后者以一个 Response 应答一个 SPARQL 1.1 Protocol 请求:200 附带协商出的文档,请求体超出 maxRequestBytes(默认 1 MiB——一个查询或更新的文本是一段程序,不是一份负载)时返回 413,governor 叫停请求时返回 422 或 503(绝不会以 200 返回部分结果),错误以 application/problem+json 给出,Server-Timing 取自作业的证据,并在配置后发送 CORS 头。引擎拒绝按原样求值的请求——不受支持的构造、未注册的函数、超出宿主栈预算的嵌套——返回 400,其 code 为引擎的诊断代码;无法解析的操作也是如此。宿主拒绝联系的 SERVICE 端点或 LOAD 来源——目录未授予所需的能力,或解析器依据自身策略拒绝——返回 403;在同一数据集的另一个更新在途时开始的更新返回 409;已联系但没有给出可用应答的端点或来源返回 502;它们都以引擎的诊断代码作为 code,并给出固定的 detail,绝不使用引擎的消息,因为那会回显目录的策略,或解析器、远程端点自己的原话。500 的 detail 只在失败确实是查询自身的失败时才是引擎的原话(解析失败、求值失败、被叫停的 governor);本 SPARQL 端点无法归因于查询本身的故障——没有解析器能到达所指名的端点、resolveService/resolveLoad 抛出异常,或适配器未能分类的任何其他异常——绝不会以自身的消息或调用栈进入响应:客户端得到的是固定的通用 detail 与一个 correlationId,真正的错误只会交给 onInternalError(默认输出一行 console.error)。若抛出异常的处理函数位于 SERVICE SILENT 或 LOAD SILENT 之下,响应则是该子句自身的应答(200,更新为 204),而该缺陷仍会以其关联 id 交给 onInternalError。超出上限的 Content-Length 会在任何内容被读取之前就被拒绝;缺失或偏小的 Content-Length 同样会被捕获——请求体流入时按字节计数,谎报的请求头绝不会因此换来比诚实请求头更大的请求体。两个解析器都以 redirect: "manual" 发起请求:SERVICE 请求绝不跟随重定向(3xx 是一个带类型的传输失败,因此目录中登记的端点的请求头与凭据绝不会到达另一个源),而 LOAD 解析器把重定向交回作业,由作业在每一跳之前按目录重新为该位置鉴权,最多五跳。一个完整的 Worker:
import wasm from "@blackcatinformatics/purrdf/purrdf_wasm_bg.wasm";
import { ready, Dataset, QueryEngine, ServiceCatalog } from "@blackcatinformatics/purrdf";
import { createFetchServiceResolver, handleSparqlRequest } from "@blackcatinformatics/purrdf/cloudflare";
await ready(wasm);
const engine = new QueryEngine();
const dataset = Dataset.parse(
"<https://example.org/a> <https://example.org/p> <https://example.org/o1> .\n",
"nquads",
);
const catalog = new ServiceCatalog();
catalog.addService(
"https://remote.example.org/sparql",
JSON.stringify({ capabilities: ["query", "network"], timeoutMs: 5_000 }),
);
export default {
fetch(request, env) {
const resolveService = createFetchServiceResolver({
bindings: { "https://remote.example.org": env.REMOTE },
cache: caches.default,
cacheTtlSeconds: 300,
});
return handleSparqlRequest(request, {
engine,
dataset,
catalog,
resolveService,
cors: { origins: "*" },
governors: { deadlineMs: 10_000, maxRemoteRequests: 40 },
});
},
};
Workers 限制单次调用可以发出的子请求数量,而 maxRemoteRequests 正是与之精确对应的控制项:每个 SERVICE 请求和每个 LOAD 都在到达处理函数之前计数,因此一个请求发出的子请求绝不会超过该上限。由同一组中位于其前的模式绑定的 SERVICE ?e,按到达它的每个解各计数一次,而不是按每个端点计数一次,因此请按这些解的数量设定上限。Cache API 在 workers.dev 主机名上不起任何作用,因此缓存在那里实际上处于关闭状态;在自定义域名上它可以正常工作。未配置缓存就启动的运行时(例如在本地运行的 workerd)会以“No Cache was configured“拒绝 cache.match 与 cache.put,而失败的缓存调用就是该请求的传输失败——在 SERVICE SILENT 下是连接的单位元,不写 SILENT 则是查询的失败——因此在那里不要配置 cache。携带凭据的请求绝不会接触共享缓存。在 Workers 上,Date.now() 在 CPU 密集执行期间不会前进,因此同步的 deadlineMs 在那里无法在 CPU 密集的工作中触发;异步通道则在每一次让出和每一次宿主请求时检查截止时间。
包的 README 是这些约定的完整参考。
范围与当前限制
- 仅限内存。 SPARQL 查询在内存数据集上运行。同步方法不安装任何
SERVICE或LOAD来源,因此在那里,远程SERVICE或LOAD会显式失败,除非写了SILENT;异步孪生方法只能经由宿主传入的处理函数到达远程端点。 - 按格式区分的三元组项支持。
serialize是写出器原生通道:宾语位置的引用三元组项与 RDF 1.2 陈述层在 Turtle、N-Triples、N-Quads 与 TriG(作为<<( … )>>)、RDF/XML(作为rdf:parseType="Triple")以及 JSON-LD / YAML-LD(作为@triple)中得以保留。TriX 与 HexTuples 没有可承载三元组项的语法,因此把携带三元组项的数据集序列化为二者之一时会抛出异常,而不是静默丢弃这一层。单图目标格式(Turtle、N-Triples、RDF/XML)只输出默认图。
从源码构建
Rust cdylib 位于 crates/rdf-wasm;发布的 ESM 包由它生成:
make wasm-pkg # 发布版 wasm + wasm-bindgen ESM 绑定 → js/pkg/
make wasm-pkg-test # 上述内容 + TypeScript、Node 与打包 tarball 门禁
这需要 wasm32-unknown-unknown Rust 目标,以及一个固定到该 crate 所用 wasm-bindgen 版本的 wasm-bindgen-cli。