从组合混乱到线性优雅:构建转换引擎架构

Hacker News Top 工具

摘要

Minimal 的一篇博文,详细介绍了他们如何使用中间表示(Intermediate Representation)来线性管理复杂性,构建文件格式转换引擎,并与生物学的蝴蝶结架构进行了类比。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/06/22 01:33

# 在 Swift 中构建转换引擎 来源:https://blog.minimal.app/conversion-engine/ **Minimal 现在支持在 Markdown、富文本、HTML、PDF、纯文本以及我们的专有格式 MNML 之间进行导入和导出。以下文章描述了如何在 Swift 编程语言中实现这一系统。要了解以人为中心的设计以及我们如何将这项新技术融入 iOS 和 macOS 应用,请阅读以设计为中心的文章** **点此阅读** (https://blog.minimal.app/designing-import-export/)**。** 将一种文件格式转换为另一种并不容易,并且随着支持的文件格式越来越多,复杂性也随之增加。为了管理这种复杂性,我们构建了一个内聚的系统,它依靠一种中间表示 (Intermediate Representation) 来充当各种文件格式之间的“中间人”。这种中间表示 (https://en.wikipedia.org/wiki/Intermediate_representation?ref=blog.minimal.app)(或称为“IR”)允许我们只需将给定文件格式与 IR 之间进行互相转换,而无需在每一种可能的文件格式对之间进行转换。 ### 中间表示 如果我们要直接在笔记格式之间进行转换,就会陷入一张复杂的转换网络,而随着我们添加更多文件格式,这种网络只会变得更加复杂。为了避免这种混乱,我们首先构建了一个称为“中间表示”的系统。 **混乱 vs 优雅。** 如果没有 IR,六种文件格式将产生 30 种关系(N² - N)。有了 IR,六种文件格式只产生 12 种不同的关系(N × 2)。IR 位于所有文件格式的中间。采用这种架构后,添加对新格式的支持只需构建该格式自身的专属转换器,而无需担心其他文件格式。随着我们扩展对新数据类型的支持,复杂性呈线性增长。 ### 自然界也是如此。 生物学家称之为蝴蝶结 (https://en.wikipedia.org/wiki/Bow_tie_(biology)?ref=blog.minimal.app) 或沙漏架构。简而言之,一个简化的中间阶段使得交互双方可以各自独立地保持复杂。 例如,细胞消耗大量不同的分子,然后将其消化成一组较少的共享中间产物(分解代谢 (https://en.wikipedia.org/wiki/Catabolism?ref=blog.minimal.app))。另一方面,细胞随后将这些中间产物重新组装成细胞所需的各种复杂分子(合成代谢 (https://en.wikipedia.org/wiki/Anabolism?ref=blog.minimal.app))。如果细胞必须将所有的输入分子映射到所需的输出分子,那么它需要更多倍的内部分子过程才能完全代谢。中间表示使事情变得简单,并使得操作的两端都更容易进化。 然而,自然界给程序员和设计师提供了一个有力的教训:依赖于共享中间体的系统往往会变得僵化。虽然 IR 使得方程式的任何一方都能独立进化,但它却使得整个系统更难进化:改变 IR 的定义要求所有与之交互的部分都必须更新到新的形态。 架构固化的一个绝佳例子是遗传密码,它常被描述为“冻结的意外”。核酸信息(以 DNA 存储,并复制为 RNA)按称为密码子的三个碱基序列进行读取,每个密码子是一条组装氨基酸链的指令。这些符号的含义深深嵌入生命的机制中,以至于不能简单地改变或重新解释;如果某个密码子的含义被改变,细胞就会错误地构建蛋白质。 遗传密码——密码子与氨基酸之间的共享映射——就像一种生物学的中间表示。它位于核酸序列和蛋白质构建之间,通过标准化的密码决定了复杂生命如何表达。这个标准在**生命界几乎普遍存在**,这是有原因的:一旦它成为细胞机制表达的核心,共享的逻辑和规则发生改变的可能性就变得越来越小。 ### 代码 以下是我们的 IR 文档结构,用 Swift 编写。我们将其放在自己的 "IR" 命名空间中,以避免命名冲突,同时无需创建专用的包(该代码与其余代码共存)。 ``` /// Intermediate-Representation 的命名空间。 enum IR { // 故意没有 case。仅用于限定下面的类型作用域。 } // MARK: - Document extension IR { /// 以方言中立形式表示的已解析笔记。 struct Document: Equatable { /// 一系列 `Block`(垂直堆叠),每个包含一系列 `Inline`(水平堆叠)。 var blocks: [Block] var resources: [String: Resource] init(blocks: [Block] = [], resources: [String: Resource] = [:]) { ... } } } // MARK: - Blocks extension IR { /// 垂直堆叠的文本内容单元。 indirect enum Block: Equatable { case blankLine case paragraph([Inline]) case codeBlock(language: String?, content: String) case heading(level: Int, inlines: [Inline]) case bulletList([ListItem]) case orderedList(items: [ListItem], start: Int) case todoList([TodoItem]) case blockquote([Block]) case pullquote([Inline]) case horizontalRule case embed(resourceId: String) } struct ListItem: Equatable { var blocks: [Block] init(blocks: [Block]) { self.blocks = blocks } } struct TodoItem: Equatable { var checked: Bool var blocks: [Block] init(checked: Bool, blocks: [Block]) { ... } } } // MARK: - Inlines extension IR { /// 在块内水平流动的内容。 indirect enum Inline: Equatable { case text(String) case strong([Inline]) case emphasis([Inline]) case underline([Inline]) case link(url: String, inlines: [Inline]) case inlineCode(String) case folder(name: String) case embed(resourceId: String) case lineBreak } } // MARK: - Resources extension IR { /// 嵌入的有效载荷。从树中分离并通过 id 引用。 struct Resource: Equatable { var kind: String var mimeType: String? var data: Data? var url: String? var attributes: [String: String] init(kind: String, mimeType: String? = nil, data: Data? = nil, url: String? = nil, attributes: [String: String] = [:]) { ... } } } ``` 描述 IR(中间表示)结构的实际代码。 并非所有文件格式都支持相同的约定(例如,Markdown 无法表示彩色文本,MNML 不支持表格),因此在转换过程中我们通常需要做出妥协并记录。 ``` /// 记录引擎在转换过程中简化、降级或搁置的内容。 struct Concession: Equatable { var category: Category var description: String var count: Int? init(category: Category, description: String, count: Int? = nil) { ... } enum Category: Equatable { case unsupportedFormatting case downgraded case dropped case truncated } } extension Array where Element == Concession { mutating func appendOrIncrement(_ concession: Concession) { ... } } ``` 描述妥协记录聚合和报告结构的实际代码。 ### 解析与渲染 转换首先将源文件解析为 IR,最后将 IR 渲染为目标文件格式。下面是 `DocumentParser` 和 `DocumentRenderer` 协议。每种文件格式都需要通过实现这些协议的自定义实现来完成其解析和渲染逻辑。 ``` /// 用于从特定 `Format` 的源文本生成 `IR.Document` 的协议。 /// 每个解析器处理恰好一种输入格式 —— `DocumentParserMinimal` 读取 MNML,`DocumentParserHTML` 读取 HTML,依此类推。协调器根据调用方的 `Format` 选择正确的解析器。 /// 解析器不负责渲染。它们的唯一输出是中间表示和任何妥协记录。 protocol DocumentParser { /// 此解析器处理的输入格式。 var format: Format { get } /// 将 `source` 解析为 IR 文档。 /// 任何 IR 无法表示的内容都会记录在结果的 concessions 中。 func parse(_ source: String) throws -> ParseResult } /// 解析的结果:IR 文档,以及被简化内容的记录。 struct ParseResult: Equatable { var document: IR.Document var concessions: [Concession] init(document: IR.Document, concessions: [Concession] = []) { self.document = document self.concessions = concessions } } ``` DocumentParser 协议。实际代码。 ``` /// 用于从 `IR.Document` 生成特定 `Format` 输出的协议。 /// 每个渲染器处理恰好一种输出格式:`HTMLRenderer` 输出 HTML,`MNMLRenderer` 输出 MNML,依此类推。协调器根据调用方的 `Format` 选择正确的渲染器。 /// 渲染器不解析。它们的唯一输入是中间表示;唯一的输出是渲染后的形式以及在生成过程中产生的妥协记录(参见 `RenderResult`)。 protocol DocumentRenderer { /// 此渲染器生成的输出格式。 var format: Format { get } /// 将 `document` 渲染为此渲染器对应的格式。 /// 目标格式无法表达的任何 IR 内容都会记录在结果的 concessions 中。 func render(_ document: IR.Document) throws -> RenderResult } /// 渲染的结果:输出的有效载荷,以及被简化内容的记录。 /// 输出以 `Data` 形式携带,以便此结构体可以同时服务于文本和二进制格式。 /// 文本格式(HTML、Markdown、MNML、纯文本、RTF)将 `data` 填充为 UTF-8 字节;二进制格式(PDF)直接填充。 struct RenderResult: Equatable { var data: Data var concessions: [Concession] init(data: Data, concessions: [Concession] = []) { self.data = data self.concessions = concessions } } ``` DocumentRenderer 协议。实际代码。 例如,为了支持 HTML,我们实现了 `DocumentParserHTML` 和 `DocumentRendererHTML`;为了支持 Markdown,我们实现了 `DocumentParserMarkdown` 和 `DocumentRendererMarkdown`,从而可以在 HTML 和 Markdown 文件之间进行转换。通过为每种文件类型构建解析器和渲染器,我们可以轻松地在各种格式之间进行转换。 以下是我们将 HTML 文件转换为 Markdown 的方法: ``` let content = try readFile(at: url) let parseResult = try? DocumentParserHTML().parse(content) let renderResult = try? DocumentRendererMarkdown().render(parseResult.document) let renderedMarkdownText = String(decoding: renderResult.data, as: UTF8.self) ``` 有了深层的模块和浅层接口(向 John Ousterhout 致敬),使用转换引擎变得非常简单。 ### 在我们的笔记应用中的集成 要让我们的转换引擎成为 Minimal 应用中一个备受赞赏的功能,需要仔细考虑。明显的应用场景很简单:导出笔记和导入笔记,但我们发现还有更多细微的能力机会——这正是我们应用所追求的(如果能在不添加界面的情况下实现功能,我们就会兴奋不已)。 导出一条或多条笔记。导入一个或多个文件。我们最初构建转换引擎是为了响应大量请求,支持从其他写作应用导入以及导出到 Mac 文件系统。一旦转换引擎运行起来了,我们意识到它应该贯穿于 Minimal 的整个使用过程,而不仅仅是在正式的“导出”或“导入”对话框中。 我们遇到了以下导入场景,并为每种情况构建了原生支持: - 拖拽到笔记列表 - 拖拽到笔记中 - 在笔记列表上粘贴 - 将文件粘贴到笔记中,以及将富文本粘贴到笔记中(例如,从 Pages 复制并在 Minimal 中粘贴) - 通过支持系统分享的应用的分享扩展导入笔记 - 菜单 > 文件 > 导入 类似地,导出场景: - 通过笔记操作按钮 > 导出 - 笔记列表 > 右键/长按 > 导出 - Command-Shift-E 导出快捷键 - 快速导出:使用最近使用的导出设置立即导出笔记,快捷键 Command-Option-E - 菜单 > 文件 > 导出 作家可以将文件直接拖入笔记列表,从而创建保留原始格式和结构的新笔记。为了服务所有这些场景,我们构建了一系列实用工具:检测文件类型、递归处理文件夹、发出妥协记录并报告导入/导出降级、在批量导入/导出期间暂存文件,以及支持往返导出-导入流程。 为了支持导出-导入往返,我们构建了一个专有的 `.mnml` 文件类型。当作家导入这些文件时,Minimal 知道要逐字导入笔记,完全避免妥协记录,并且完美地重新导入,100% 成功。 ``` extension UTType { // (通过 Info.plist 中的 `UTExportedTypeDeclarations` 声明) /// Minimal 的一等文件格式。精确保留 MNML 约定。 /// 通过 Minimal 进行往返,保持完全保真。 static let mnml: UTType = UTType("app.minimal.mnml") ?? .plainText /// 在 Markdown 工具中广泛采用的 de facto Markdown UTI。 static let markdown = UTType(importedAs: "net.daringfireball.markdown") } ``` 专有 MNML 文件类型。实际代码。 为了支持富文本复制粘贴,我们构建了自己的剪贴板,可容纳多种格式,同时确保应用内的粘贴避免有损的转换引擎往返: ``` /// 提供各种数据类型(纯文本、RTF、HTML、原生 Minimal 纯文本),使作家可以从 Minimal 复制并粘贴到任何地方。 /// 包含一个原生的 Minimal 特定数据类型,使得 Minimal 内部的复制粘贴不会通过 RTF 进行往返。 enum MinimalPasteboard { /// 我们启用粘贴的剪贴板类型,最富优先。 static let pasteableTypeIdentifiers: [String] = [ UTType.rtf.identifier, UTType.html.identifier, UTType.utf8PlainText.identifier, UTType.plainText.identifier, UTType.text.identifier, ] /// 为复制 `source`(作家方言中的文本)构建剪贴板项。 /// 始终包含原生表示和纯文本表示;RTF/HTML 尽最大努力提供。 static func pasteboardItem(for source: String) -> [String: Any] { var item = MinimalPasteboard.nativeItem(for: source) item[UTType.utf8PlainText.identifier] = source if let document = try? StyleSettings.preferredParser().parse(source).document { if let rtf = try? DocumentRendererRTF().render(document).data { item[UTType.rtf.identifier] = rtf } if let html = try? DocumentRendererHTML().render(document).data { item[UTType.html.identifier] = html } } return item } // MARK: 原生源 private static let nativeType = UTType.mnml.identifier /// 复制时要合并到剪贴板项中的原生条目。 private static func nativeItem(for source: String) -> [String: Any] { [nativeType: Data(source.utf8)] } /// 如果 `pasteboard` 上携带了 Minimal 自身的源,则返回。 static func nativeSource(from pasteboard: UIPasteboard) -> String? { guard let data = pasteboard.data(forPasteboardType: nativeType) else { return nil } return String(data: data, encoding: .utf8) } } ``` 为容纳多种文件格式而覆盖的剪贴板方法。实际代码。

相似文章