
最近折腾 .NET 命令行工具的数据展示层,踩了几个渲染格式的坑,最后挖到了 Kiwify.Kiwi.Renderer 这个库,用下来感觉不错,这篇把核心设计说清楚。
Kiwify.Kiwi.Renderer 是 Kiwi Foundation 的数据展示层——整个项目由三个库组成(Presentation、Renderer、CLI),覆盖了构建专业 .NET 命令行工具的完整表面。
Renderer 位于 Presentation 之上:通过 IOutputWriter 写入数据,并使用 Presentation 库中的 TextStyle / OutputTheme。CLI 工具用 Renderer 来展示查询结果、配置转储和结构化状态输出。依赖是单向的:Renderer → Presentation。CLI 层同时依赖两者。
GitHub:kiwifylabs/kiwi-foundation-tooling - Kiwify.Kiwi.Renderer
NuGet:Kiwify.Kiwi.Renderer
dotnet add package Kiwify.Kiwi.Renderer
Renderer 围绕三层分离构建:数据整形、渲染和输出传输。这三层可以独立组合。
数据整形由 JSON 解析管道或底层网格 API 处理。渲染层——TextGridRenderer、HtmlGridRenderer、RtfGridRenderer 及其对象表对应实现——操作 DataGrid,这是一种与格式无关的行列模型,与数据来源完全解耦。输出传输完全由 Kiwi Presentation 的 IOutputWriter 处理,将渲染层与输出目标解耦。
这意味着同一个渲染管道可以输出到支持 ANSI 的终端、重定向的控制台流(样式会被自动剥离)、HTML 文件、RTF 文档,或任何实现了 IOutputWriter 的自定义目标。格式选择是运行时参数,而非架构决策。
实际意义是:应用可以从简单的终端工具演化为多格式报表管道,而无需重构展示层基础设施。渲染配置保持不变,只需要改格式常量和输出写入器。
Kiwi Presentation 和 Kiwi Renderer 通过共享抽象(IOutputWriter、TextStyle、OutputTheme)组合,而非共享实现。Renderer 在保留相同输出管道模型(Presentation 用于提示、进度条和样式化控制台输出的模型)的同时增加了结构化数据渲染能力。库的设计有意模块化:每个都可以独立使用,搭配使用时无缝集成。
终端中展示结构化数据涉及比表面看起来更多的细节。列宽必须容纳任意行中最宽的值,而非仅考虑表头。对齐和内边距必须在各行之间一致应用。同一个数据集可能需要在不同场景中以不同形式出现——运维会话中的终端表、归档报告的 HTML 导出、干系人摘要的 RTF 文档——而无需复制格式化逻辑。单个 JSON 对象和 JSON 数组需要完全不同的布局:键值对 vs 多列。
除了格式问题,源数据通常已经是 JSON 形式:API 响应、序列化配置、管道输出、ORM 投影。引入反序列化步骤和中间视图模型在目标是展示而非处理时是徒增摩擦。
Renderer 通过统一渲染管道解决这些问题。最高抽象层级接收 JSON 字符串并按请求的格式产生格式化输出。最低层级提供 DataGrid API,完全不需要 JSON 即可接收编程式构建的行。两者的边界定义清晰、显式——JSON 是输入和传输格式,而非渲染模型本身。
| 优势 | 说明 |
|---|---|
| JSON 优先,非 JSON 唯一 | 高层管道接受 string、byte[] 或 JsonDocument。底层网格 API 无需 JSON 即可操作,支持编程式构建和非 JSON 数据源。JSON 是输入格式,渲染模型是 DataGrid。 |
| 多目标渲染 | 相同的渲染配置生成文本、HTML 和 RTF 输出。切换格式只需改一个字符串常量。无需修改库即可通过 GridRendererFactory 在运行时注册自定义格式。 |
| 输出抽象 | 所有渲染都通过 Kiwi Presentation 的 IOutputWriter 流动。输出目标——终端、文件、流、内存缓冲区——独立于渲染逻辑,可以自由替换。 |
| 主题隔离 | OutputTheme 永远不会被渲染器修改。主题对象可以在并发渲染调用中共享,无需防御性复制。 |
| 可扩展格式注册表 | GridRendererFactory 和 ObjectTableRendererFactory 用 ConcurrentDictionary 存储注册。注册是线程安全的,可以从模块初始化器调用。 |
| 交互式分页 | PagedDataGrid<T> 将键盘导航层叠在任何网格上,不改变数据模型或渲染配置。当 stdin 重定向时静默穿透。 |
| 模块化组合 | Renderer 和 Presentation 可独立部署,共享抽象而非实现。已使用 Presentation 的应用可以添加 Renderer 而无需重构输出基础设施。 |
Renderer 适用于远超交互式终端展示的各类工具和自动化场景。
渲染管道设计为支持所有这些场景,无需在应用层编写格式特定的代码路径。
ConsoleTables 从类型化对象生成简洁的 ASCII 表。非常适合简单终端展示——不需要 JSON 输入、多格式输出和列模式控制。
Kiwi Renderer 覆盖更广的表面:无需反序列化的 JSON 输入、跨文本/HTML/RTF 的多格式渲染、预定义列模式、单个对象的键值布局、JSON 语法高亮、交互式分页,以及 IOutputWriter 输出抽象管道。
Spectre.Console 是一个全面的终端渲染框架。在丰富的交互式控制台布局——组件、实时更新、树、面板和高级视觉组合——方面表现出色。对于以复杂终端展示为首要需求的应用,它是强大且维护良好的选择。
Kiwi Renderer 针对不同的问题集优化:结构化数据渲染,可移植到终端、HTML 和 RTF 目标;JSON 优先输入,无需中间模型;与 Kiwi Presentation 输出管道集成。两库有一些重叠——都可以渲染表格——但解决的首要问题不同,抽象层级也不同。
如果你的应用需要实时终端组件、光标定位更新或丰富的交互式控制台布局,Spectre.Console 是合适的工具。如果你的重点是在共享输出管道中将结构化数据一致地渲染到多种输出格式——特别是在自动化、报表和工具场景中——Kiwi Renderer 为此问题提供了专用的模型。
CsvHelper 处理 CSV 序列化和反序列化。它没有终端渲染能力。两库互补:CsvHelper 处理结构化文件 I/O,Kiwi Renderer 处理格式化展示。
Dapper 和 EF Core 都不提供结果展示。Renderer 与数据库驱动的 CLI 工具自然集成:查询结果可以投影为 JSON 或通过底层网格 API 传递,无需中间视图层即可展示为格式化的表格。
最简单的路径——传入 JSON 字符串,链式调用构建器,调用 AsTable():
var json = """
[ { "Id": 1, "Name": "Alice", "Role": "Engineer", "Salary": 95000 }, { "Id": 2, "Name": "Bob", "Role": "Designer", "Salary": 72000 }
]
""";
RenderEngine.Create(json) .WithTitle("Team Directory") .WithLeftMargin(2) .Build() .AsTable();
输出:
Team Directory ┌────┬───────┬──────────┬────────┐ │ Id │ Name │ Role │ Salary │ ├────┼───────┼──────────┼────────┤ │ 1 │ Alice │ Engineer │ 95000 │ │ 2 │ Bob │ Designer │ 72000 │ └────┴───────┴──────────┴────────┘
列从第一行自动发现。整数值自动右对齐。
var theme = new OutputTheme
{ Header = new TextStyle(ConsoleColor.DarkCyan) { Bold = true }, Border = new TextStyle(ConsoleColor.DarkGray), Cell = new TextStyle(ConsoleColor.White)
}; RenderEngine.Create(json) .WithTitle("Team Directory") .WithTheme(theme) .WithLeftMargin(2) .Build() .AsTable();
OutputTheme 也用于 Warning(无数据消息)和 Error(渲染失败)。同一个主题对象可以在多个 RenderEngine.Create() 调用中复用——渲染器永远不会回写主题。
var columns = new List<PredefinedColumn>
{ new PredefinedColumn { FieldName = "Name", Title = "Employee", Width = 14 }, new PredefinedColumn { FieldName = "Role", Title = "Dept", Width = 12 }, new PredefinedColumn { FieldName = "Salary", Title = "Salary $", Width = 10, RightAlign = true }
}; RenderEngine.Create(json) .WithTitle("Salary Report") .WithTheme(theme) .WithPredefinedColumns(columns) .Build() .AsTable();
FieldName 是 JSON 属性名(大小写不敏感)。Title 是列头。Width 是最小宽度;当值更宽时列会自动扩展。
当 JSON 根是对象时,AsTable() 渲染两列键值布局:
var profile = """
{ "Id": 1, "Name": "Alice", "Role": "Engineer", "Location": "London" }
""";
RenderEngine.Create(profile) .WithTitle("Employee Profile") .WithTheme(theme) .Build() .AsTable();
Employee Profile ┌──────────┬───────────┐ │ Id │ 1 │ ├──────────┼───────────┤ │ Name │ Alice │ ├──────────┼───────────┤ │ Role │ Engineer │ ├──────────┼───────────┤ │ Location │ London │ └──────────┴───────────┘
RenderEngine.Create(profile) .WithLeftMargin(2) .WithJsonTheme(JsonTheme.DefaultTheme) .Build() .AsFormattedJson();
DefaultTheme 使用 VS Code 风格的调色板。每种 token 类型都有独立的 TextStyle:
| Token | DefaultTheme 颜色 |
|---|---|
| Key | Cyan |
| StringValue | Green |
| NumberValue | Yellow |
| BooleanValue | DarkCyan |
| NullValue | DarkGray |
| Punctuation | DarkGray |
自定义主题:
var jsonTheme = new JsonTheme
{ Key = new TextStyle(ConsoleColor.White) { Bold = true }, StringValue = new TextStyle(ConsoleColor.Green), NullValue = new TextStyle(ConsoleColor.DarkRed)
};
using var writer = new StreamWriter("report.html", append: false, Encoding.UTF8); RenderEngine.Create(json) .WithTitle("Sales Report") .WithWriter(writer) .Build() .AsTable(OutputFormat.Html);
HtmlGridStyleDescriptor 将 CSS 框架类名附加到表格元素上,与渲染器生成的 CSS 类共存:
var htmlTheme = new OutputTheme
{ HtmlGridStyle = new HtmlGridStyleDescriptor { TableClasses = { "table", "table-bordered", "table-striped" }, HeaderClasses = { "table-dark" }, RowClasses = { "table-row" }, CellClasses = { "text-nowrap" } }
};
渲染器始终发出包含主题颜色生成 CSS 类的 <style> 块。HtmlGridStyle 类名与生成的类名一起附加到元素 class 属性——两种样式来源同时生效。
RenderEngine.Create(json) .WithFileName("report.rtf") .WithTheme(rtfTheme) .Build() .AsTable(OutputFormat.Rtf);
RTF 渲染器使用从主题 TextStyle 值派生的预建颜色表。生成的 .rtf 文件在 Microsoft Word 和 LibreOffice 中都能正确打开。
当 JSON 字段名遵循 camelCase 或 PascalCase 约定时,WithProperCasedTitles() 通过 StringExtensions.ToSpacedWords 将其转换为可读的列头——无需 WithPredefinedColumns():
// orderId → "Order Id", customerName → "Customer Name", totalAmount → "Total Amount"
RenderEngine.Create(json) .WithProperCasedTitles() .Build() .AsTable();
同时适用于数组(多列)和单个对象(键值)渲染。使用 WithPredefinedColumns() 时此方法无效——预定义的 Title 值优先。
WithWrappingTags(false) 抑制文档级包装器,使输出可嵌入现有页面或文档:
// HTML: emits <style> + <table>, no <!DOCTYPE>/html/head/body wrappers
// RTF: emits table rows only, no {\rtf1…} header or colour table
RenderEngine.Create(json) .WithTheme(theme) .WithWriter(writer) .WithWrappingTags(false) .Build() .AsTable(OutputFormat.Html); // or OutputFormat.Rtf
HTML 片段始终包含 <style> 块,以便注入到父页面时生成的 CSS 类能正确解析。RTF 片段省略颜色表;接收文档必须在匹配索引处声明兼容的颜色条目。
对非 JSON 数据或需要编程式构建行时,直接使用网格 API:
var grid = new DataGrid { LeftMargin = 2 };
grid.AddColumn(new Column("ID", 6, rightAlign: true));
grid.AddColumn(new Column("Name", 24));
grid.AddColumn(new Column("Cost", 10, rightAlign: true)); var renderer = new TextGridRenderer(grid, theme: theme);
renderer.RenderTitle("Inventory");
renderer.RenderHeader();
renderer.RenderRow("001", "Widget Pro", "$49.99");
renderer.RenderRow("002", "Gadget Lite", "$39.99");
renderer.RenderFooter();
DataGrid.UpdateColumnWidth(caption, candidateWidth) 在添加行时动态扩展列宽。
var pagedGrid = new PagedDataGrid<Product>( grid: grid, rows: products, // IReadOnlyList<Product> pageSize: 20, toValues: p => new[] { p.Id.ToString(), p.Name, p.Price.ToString("F2") }, theme: theme); pagedGrid.Render();
用户通过 n(下一页)、p(上一页)、q(退出)导航。当 stdin 重定向时,网格渲染所有行后直接退出,不提示。
camelCase 和 PascalCase JSON 字段名自动转换为可读的列头:
| JSON 字段 | 列头 |
|---|---|
orderId |
Order Id |
customerName |
Customer Name |
orderTotal |
Order Total |
fulfillmentStatus |
Fulfillment Status |
┌──────────┬───────────────┬─────────────┬────────────────────┐ │ Order Id │ Customer Name │ Order Total │ Fulfillment Status │ ├──────────┼───────────────┼─────────────┼────────────────────┤ │ 1001 │ Alice Chen │ 249.99 │ Shipped │ │ 1002 │ Bob Müller │ 89.50 │ Pending │ │ 1003 │ Carol Smith │ 412.00 │ Delivered │ └──────────┴───────────────┴─────────────┴────────────────────┘
从更宽的数据集中选出三列并重命名,Salary $ 右对齐:
Salary Report ┌──────────────┬──────────────┬──────────┐ │ Employee │ Dept │ Salary $ │ ├──────────────┼──────────────┼──────────┤ │ Alice Chen │ Engineering │ 95000 │ │ Bob Müller │ Design │ 72000 │ │ Carol Smith │ Engineering │ 108000 │ └──────────────┴──────────────┴──────────┘
使用 DefaultTheme(VS Code 风格调色板)的 token 颜色:
{ "id": 1, "name": "Alice Chen", "role": "Engineer", "active": true, "salary": null
}
| Token | 颜色 |
|---|---|
键("id"、"name"……) |
Cyan |
| 字符串值 | Green |
| 数值 | Yellow |
true / false |
Dark cyan |
null |
Dark grey |
| 括号、冒号、逗号 | Dark grey |
Inventory (showing 1-20 of 74) ┌─────┬──────────────────────────┬──────────┐ │ ID │ Name │ Cost │ ├─────┼──────────────────────────┼──────────┤ │ 001 │ Widget Pro │ $49.99 │ │ 002 │ Gadget Lite │ $39.99 │ │ 003 │ Sensor Module X │ $124.00 │ │ 004 │ Relay Board v2 │ $18.50 │ │ ... │ ... │ ... │ └─────┴──────────────────────────┴──────────┘ n - next page p - previous page q - quit
当 stdin 重定向时(CI、管道输出),所有行渲染后不显示导航提示。
渲染器发出包含生成的短形式 CSS 类名的 <style> 块。通过 HtmlGridStyleDescriptor 提供的 Bootstrap 或 Tailwind 类名与生成的类名一起附加到相同元素:
<style> .k4f7a2 { color: #008b8b; font-weight: bold; } .k9e3b1 { color: #f8f8f2; }
</style>
<h2>Team Directory</h2>
<table> <tr> <th class="k4f7a2">Id</th> <th class="k4f7a2">Name</th> <th class="k4f7a2">Role</th> <th class="k4f7a2">Salary</th> </tr> <tr> <td class="k9e3b1">1</td> <td class="k9e3b1">Alice</td> <td class="k9e3b1">Engineer</td> <td class="k9e3b1" style="text-align:right">95000</td> </tr>
</table>
片段模式下(WithWrappingTags(false)),<!DOCTYPE>、<html>、<head> 和 <body> 包装器省略;<style> 块和 <table> 元素原样发出,用于注入到现有页面。
HtmlGridStyleDescriptor 的快照存储在私有字段中。永远不会回写 OutputTheme。多个线程可以共享主题对象。GridRendererFactory 和 ObjectTableRendererFactory 使用 ConcurrentDictionary。Register() 从任何线程调用都是安全的,包括模块初始化器。TextStyle 值相等。TextStyle 实现了基于值的相等性 IEquatable<TextStyle>。HTML 渲染器利用此特性去重 CSS 类分配——两个具有相同样式的属性生成一个 CSS 类,而非两个。RtfHelper.ApplyTextStyle 使用 TryGetValue——未在文档颜色表中注册的颜色静默跳过,而非抛出异常。