2024年3月

使用SQL(Structured Query Language)对数据库/数据仓库进行查询分析操作,几乎成了研发工程师和数据分析师的“家常便饭”,然而要写出高效、清晰、优雅的SQL脚本并非易事。随着大语言模型(LLM)技术的普及,借助大模型微调(Fine Tuning)等技术将自然语言自动翻译为SQL语句(NL2SQL/Text2SQL)便成了非常流行的解决方案,相关的工具框架(
Chat2DB

DB-GPT
等)也是层出不穷。

同样的,在图数据库领域也存在相似的问题,甚至更为严峻。相比于SQL相对成熟的语法标准(
SQL2023
),图查询语言尚未形成成熟的统一标准,目前是多种查询语法并存的状态(
GQL

PGQ

Cypher

Gremlin

GSQL
等),上手门槛高,因此更需要借助大语言模型的自然语言理解能力,降低图数据库查询语言的使用门槛。

1. 图表融合:SQL+GQL

TuGraph计算引擎
TuGraph Analytics
创新性地设计了
SQL+GQL融合语法
,以解决图表混合分析场景的业务诉求,将图上的分析计算能力有机地融合到传统的SQL数据处理链路内,实现了图引擎上一体化的
图数据建模

图数据集成

图存储

图交互式分析
能力。

TuGraph Analytics的SQL+GQL融合语法典型形式为
“SELECT-FROM-WITH-MATCH-RETURN”
结构,通过GQL语法的“MATCH-RETURN”语法单元,为SQL处理提供数据子视图,方便传统数据分析师对数据的进一步处理。

图表融合处理代码示例

通过以上的语法设计,可以满足多样化的图表融合处理的诉求。点边数据源提供构图数据,Request数据源提供图计算触发的起点集合。

图表融合处理典型链路

不同的数据源处理模式的组合,形成了多种“流”与“图”的混合计算形态。而SQL+GQL的融合语法设计,可以很好地表达多样化的计算模式。

多样化的图表混合处理模式

2. 与图对话:ChatTuGraph

我们不否认SQL+GQL融合语法是一个创见性的语言设计,但这并不能解决“新型图查询语言的高上手门槛”这个通病,因此,借助于LLM微调实现专有的图查询语言模型,通过自然语言的方式与图数据交互,实现“与图对话(Chat-to-Graph/Chat-TuGraph)”。

我们初步构想了
面向未来的图数据库智能化
能力,至少具备以下产品形态:

  1. 智能交互分析
    :通过Agent发送图查询指令,同步获取图数据结果。
  2. 智能数据变更
    :通过Agent发送图变更指令,修改图数据,获取修改状态(成功与否、影响行数等)。
  3. 智能任务处理
    :通过Agent发送图处理任务指令,触发异步图计算任务,获取图任务运行状态。
  4. 智能运维管控
    :通过Agent发送管控指令,实时获取图数据库状态(流量、负载、慢查询等),操纵图数据库实例(启停、伸缩、切流等)。

基于AI的图数据库访问

以上设想是一个比较长远的规划,仰望星空后,我们还是要脚踏实地,回归当下,从最基本的“智能交互探索”开始,构建Text2GQL能力。

3. 模型微调:Text2GQL

以下对话场景,便是我们希望第一阶段达成的目标。

Me:
查询蚂蚁集团研发的通过TuGraph支持的产品列表。

ChatTuGraph:
match(a:company where a.name = '蚂蚁集团')-[e:develop]->(b:product)<-[e2:support]-(c:software where c.name = 'TuGraph') return b.name

3.1 语料生成

众所周知,要实现模型微调,构建语料是第一步,也是最关键的一步,语料的质量和丰富度会直接决定微调模型的预测效果。但是前面提到,由于图查询语言标准的不够成熟,想要获取现有的GQL语料是一件很困难的事情。另外,SQL+GQL语法更是TuGraph的“独创”,业务语料的丰富度更低。这种情况下,我们只能“自力更生”想办法创造语料,于是就有了
“语法制导的语料生成策略”

该策略的具体思想如下:

  • GQL抽象语法树(AST)展开后的基本形式就是表达式(Expr),常量(Literal)也是一种特殊的表达式。
  • 通过设计表达式实例生成器,批量生成并组合出大量的AST实例,得到GQL语句样本。
  • 特定的AST可以通过通用生成器产生对应的提示词模板,提示词模板随着AST实例化形成提示词文本。
  • 特殊的不适合通过生成器生成的提示词模板可以通过人工构造。
  • 初步生成的提示词文本可以借助LLM(如GPT-4)进一步泛化和翻译,生成多样的自然语言提示词文本。

语法制导的语料生成

3.2 模型训练

构建完语料后,参考
OpenAI官方模型微调手册
,可以轻松实现模型微调(当然,氪金是必不可少的……)。

这里我们基于模型
gpt-3.5-turbo-1106
进行微调,首先需要将语料按照如下格式进行处理:

{
    "messages": [
        {
            "role": "system",
            "content": "你是TuGraph计算引擎的DSL专家"
        },
        {
            "role": "user",
            "content": "查找与person点有关联的公司节点,并按规格分组返回。"
        },
        {
            "role": "assistant",
            "content": "match(a:person)-[e:belong]-(b:company) return b.scale group by b.scale"
        }
    ]
}

进入
OpenAI模型微调页面
,上传格式化后的语料文件(一般以.jsonl结尾),设置模型前缀,创建微调任务。

创建OpenAI模型微调任务

模型微调完成后,可以看到最终的模型ID,形如:
ft:gpt-3.5-turbo-1106:personal:<Suffix>:<Id>

查看OpenAI微调模型ID

验证微调模型也很简单,调用OpenAI的Chat Completion API时传递微调的模型ID即可。

from openai import OpenAI

client = OpenAI()

completion = client.chat.completions.create(
    model="ft:gpt-3.5-turbo-1106:personal:geaflow:8z·····a",
    temperature=0,
    messages=[
        {
            "role": "user",
            "content": "查询蚂蚁集团研发的通过TuGraph支持的产品列表。"
        }
    ]
)
print(completion.choices[0].message.content)

3.3 本地部署

如果不想“斥资”使用OpenAI的微调服务的话,我们也提供了开源的基于
CodeLlama-7b-hf
微调后的Text2GQL模型
CodeLlama-7b-GQL-hf
给大家测试使用(模型量化后可以在Mac上本地运行,没有4090的小伙伴也不用怕了
v
)。

首先,下载HuggingFace上的模型文件到本地(记得先安装
git-lfs
),如
/home/huggingface
。同时为了方便大家快速部署本地大模型服务,我们提供了预先安装好CUDA的Docker镜像。

$ git lfs install
$ git clone https://huggingface.co/tugraph/CodeLlama-7b-GQL-hf /home/huggingface
$ docker pull tugraph/llam_infer_service:0.0.1

启动Docker容器,将模型文件目录挂载到容器目录
/opt/huggingface
,并开放8000模型服务端口。

$ docker run -it --name text2gql-server \
  -v /home/huggingface:/opt/huggingface \
  -p 8000:8000 \
  -d llama_inference_server:0.0.1

进入Docker容器,对模型文件进行转换,最终会看到转换后的模型文件
ggml-model-f16.gguf

$ docker exec -it text2gql-server /bin/bash
> python3 /opt/llama_cpp/convert.py /opt/huggingface
> ...
> Wrote /opt/huggingface/ggml-model-f16.gguf

默认转换后的模型精度为F16,模型大小为13.0GB。正常情况下Mac本地内存无法支撑如此大的模型推理,需要对模型做精度裁剪(当然如果你有4090/在带GPU卡的ECS上运行,可以跳过该步骤……)。

> # q4_0即将原始模型量化为int4,模型大小压缩至3.5GB
> /opt/llama_cpp/quantize /opt/huggingface/ggml-model-f16.gguf /opt/huggingface/ggml-model-q40.gguf q4_0

不同规格的量化参数对应的模型规模如下:

量化模型规模参考

最后,启动模型推理服务,绑定
0.0.0.0:8000
地址,否则容器外无法访问该服务。如果对模型做过量化,记得更新
-m
参数的值。

> /opt/llama_cpp/server --host 0.0.0.0 --port 8000 -m /opt/huggingface/ggml-model-f16.gguf -c 4096

服务启动完成后,可以对本地模型服务做简单的对话测试。

$ curl http://127.0.0.1:8000/completion \
  -H "Content-Type: application/json" \
  -d '{"prompt": "查询蚂蚁集团研发的通过TuGraph支持的产品列表。","n_predict": 128}'

4. 平台接入:Console+LLM

TuGraph Analytics的
Console平台
提供了初步的大模型能力的集成和对话能力,大家可以下载最新的代码部署体验。

登录Console后,切换到“
系统模式
”,进入
“系统管理-模型管理”
注册模型服务

模型类型可选择OPEN_AI或LOCAL,分别对应上述的OpenAI微调模型和本地部署模型。注册完成后,可以做简单的可用性测试。

大模型服务注册与测试

切换到“
租户模式
”,选择一个图查询任务,进入查询页面。点击“
执行
”按钮右侧的机器人图标,即可开启对话。对话回答的GQL语句可以点击复制按钮一键执行。

自然语言的图交互查询

5. 借图发声:Graph+AI

截至此时,借助于大模型微调技术,实现自然语言转图查询,仅仅是一个开端。未来我们相信“图与AI”还有更多的结合场景值得去探索和尝试。

  • 首先,当前的模型微调训练语料仍不够完备,真实测试中还是会出现推理幻觉和不准确的问题。进一步丰富训练语料以及在GQL标准不完善的情况下,构建模型的有效性验证方案是亟待解决的问题。
  • 其次,面向GQL查询分析场景的微调只解决了图上自然语言交互式分析的问题,对SQL+GQL融合语言中的数据变更、任务提交能力并未覆盖。当然这部分能力也需要TuGraph引擎能力不断迭代改进以获得支持,才能最大化地丰富自然语言操纵图数据库的场景。
  • 再次,面向图数据库的智能化运维管控也是一个很值得研究的方向。在以LLM为基础,Agent大行其道的环境下,借助AI能力释放基础软件的运维压力,也符合ESG的战略宗旨。
  • 另外,借助于图计算技术,为大模型推理提供更准确的知识库,消除模型幻觉,也是当下AI工程技术RAG研究的热门方向。
  • 最后,利用AIGC技术,为图应用场景生成更丰富的解决方案,提供高质量的内容输出,将技术红利带入到行业,对图计算技术的普及也大有裨益。

最后的最后,诚邀大家参与本周六(3.30)TuGraph的社区Meetup,大家喝喝茶、吹吹风,一起聊一聊图计算与AI技术。

活动详情:
https://mp.weixin.qq.com/s/LaeGge_oExLce23cxUme3g

本文将介绍 Windows 下,使用 CLion 和 WSL 配置 MPI 运行及调试环境的方法。

0. 前提

阅读本文前,请确保:

  • Windows 下已启用 WSL2,并安装了任一 Linux 发行版

1. WSL环境配置

(1) 配置编译环境

sudo apt-get update
sudo apt-get install build-essential cmake gdb

(2) 配置MPI

MPI 环境可选择通过包管理器配置,也可自行编译源码配置。简便起见,本文采用包管理器进行配置,选用的 MPI 实现为 MPICH,安装命令为:

# Debian、Ubuntu等发行版使用如下命令安装:
sudo apt-get install mpich

如果安装成功,运行命令
mpichversion
,将输出:

mpichversion

之后,尝试运行测试程序,将以下代码保存到文件 mpi_hello.c 中:

/* File:       
 *    mpi_hello.c
 *
 * Purpose:    
 *    A "hello,world" program that uses MPI
 *
 * Compile:    
 *    mpicc -g -Wall -std=C99 -o mpi_hello mpi_hello.c
 * Usage:        
 *    mpiexec -n<number of processes> ./mpi_hello
 *
 * Input:      
 *    None
 * Output:     
 *    A greeting from each process
 *
 * Algorithm:  
 *    Each process sends a message to process 0, which prints 
 *    the messages it has received, as well as its own message.
 *
 * IPP:  Section 3.1 (pp. 84 and ff.)
 */
#include <stdio.h>
#include <string.h>  /* For strlen             */
#include <mpi.h>     /* For MPI functions, etc */ 

const int MAX_STRING = 100;

int main(void) {
   char       greeting[MAX_STRING];  /* String storing message */
   int        comm_sz;               /* Number of processes    */
   int        my_rank;               /* My process rank        */

   /* Start up MPI */
   MPI_Init(NULL, NULL); 

   /* Get the number of processes */
   MPI_Comm_size(MPI_COMM_WORLD, &comm_sz); 

   /* Get my rank among all the processes */
   MPI_Comm_rank(MPI_COMM_WORLD, &my_rank); 

   if (my_rank != 0) { 
      /* Create message */
      sprintf(greeting, "Greetings from process %d of %d!", 
            my_rank, comm_sz);
      /* Send message to process 0 */
      MPI_Send(greeting, strlen(greeting)+1, MPI_CHAR, 0, 0,
            MPI_COMM_WORLD); 
   } else {  
      /* Print my message */
      printf("Greetings from process %d of %d!\n", my_rank, comm_sz);
      for (int q = 1; q < comm_sz; q++) {
         /* Receive message from process q */
         MPI_Recv(greeting, MAX_STRING, MPI_CHAR, q,
            0, MPI_COMM_WORLD, MPI_STATUS_IGNORE);
         /* Print message from process q */
         printf("%s\n", greeting);
      } 
   }

   /* Shut down MPI */
   MPI_Finalize(); 

   return 0;
}  /* main */

编译并执行:

# 编译
mpicc -g -Wall -std=C99 -o mpi_hello mpi_hello.c

# 运行,4表示进程数
mpiexec -n 4 ./mpi_hello

输出应为:

Greetings from process 0 of 4!
Greetings from process 1 of 4!
Greetings from process 2 of 4!
Greetings from process 3 of 4!

2. CLion运行环境配置

(1) 新建项目

CLion 新建一个项目 mpi,将 main.c 或 main.cpp 内容修改为:

/* File:       
 *    mpi_hello.c
 *
 * Purpose:    
 *    A "hello,world" program that uses MPI
 *
 * Compile:    
 *    mpicc -g -Wall -std=C99 -o mpi_hello mpi_hello.c
 * Usage:        
 *    mpiexec -n<number of processes> ./mpi_hello
 *
 * Input:      
 *    None
 * Output:     
 *    A greeting from each process
 *
 * Algorithm:  
 *    Each process sends a message to process 0, which prints 
 *    the messages it has received, as well as its own message.
 *
 * IPP:  Section 3.1 (pp. 84 and ff.)
 */
#include <stdio.h>
#include <string.h>  /* For strlen             */
#include <mpi.h>     /* For MPI functions, etc */ 

const int MAX_STRING = 100;

int main(void) {
   char       greeting[MAX_STRING];  /* String storing message */
   int        comm_sz;               /* Number of processes    */
   int        my_rank;               /* My process rank        */

   /* Start up MPI */
   MPI_Init(NULL, NULL); 

   /* Get the number of processes */
   MPI_Comm_size(MPI_COMM_WORLD, &comm_sz); 

   /* Get my rank among all the processes */
   MPI_Comm_rank(MPI_COMM_WORLD, &my_rank); 

   if (my_rank != 0) { 
      /* Create message */
      sprintf(greeting, "Greetings from process %d of %d!", 
            my_rank, comm_sz);
      /* Send message to process 0 */
      MPI_Send(greeting, strlen(greeting)+1, MPI_CHAR, 0, 0,
            MPI_COMM_WORLD); 
   } else {  
      /* Print my message */
      printf("Greetings from process %d of %d!\n", my_rank, comm_sz);
      for (int q = 1; q < comm_sz; q++) {
         /* Receive message from process q */
         MPI_Recv(greeting, MAX_STRING, MPI_CHAR, q,
            0, MPI_COMM_WORLD, MPI_STATUS_IGNORE);
         /* Print message from process q */
         printf("%s\n", greeting);
      } 
   }

   /* Shut down MPI */
   MPI_Finalize(); 

   return 0;
}  /* main */

(2) 添加工具链

在设置 => 构建、运行、部署 => 工具链中添加一个工具链,类型选 WSL。

添加工具链

CLion 会自动检测填写,但 C 编译器和 C++ 编译器路径需要手动指定,相应路径使用 which 命令获取:

# 获取 C 编译器路径
which mpicc

# 获取 C++ 编译器路径
which mpicxx

设置工具链

(3) 指定项目所用工具链

添加工具链后,必须手动设置项目使用新添加的工具链,在设置 => 构建、运行、部署 => CMake 中进行设置:

指定项目所用工具链

(4) 修改项目终端(可选)

在设置 => 工具 => 终端中,将 Shell 路径修改为所用的发行版路径,该路径一般为:

# C:\Users\<你的电脑用户名>\AppData\Local\Microsoft\WindowsApps\<发行版名称>.exe,如:
C:\Users\username\AppData\Local\Microsoft\WindowsApps\ubuntu.exe

修改项目终端

(5) 修改CMake配置文件

修改项目根目录下的 CMake 配置文件 CMakeLists.txt:

  • 在 add_executable 所在行之前添加:
    find_package(MPI REQUIRED)
  • 在 add_executable 所在行之后添加:
    • 如果为 C 项目:
      target_link_libraries(<your_project_name> PRIVATE MPI::MPI_C)
    • 如果为 C++ 项目:
      target_link_libraries(<your_project_name> PRIVATE MPI::MPI_CXX)
  • 可能需要修改 cmake_minimum_required 中的版本号,使其不高于 WSL 中安装的 CMake 版本

示例:

cmake_minimum_required(VERSION 3.22)
project(mpi)

set(CMAKE_C_STANDARD 11)

find_package(MPI REQUIRED)
add_executable(mpi main.c)
target_link_libraries(mpi PRIVATE MPI::MPI_C)

之后,重新加载 CMake 配置:

重新加载CMake配置

尝试构建项目:

构建项目

(6) 修改运行配置

为了可以一键运行,需要修改运行配置:

修改运行配置

需要将可执行文件修改为 mpiexec 的路径(WSL 内通过 which 命令获取),并将程序实参填写为:
-n 4 "$CMakeCurrentProductFile$"
,其中表示运行时使用 4 个进程,可根据需要修改

运行配置

之后,点击运行,程序输出如图:

点击运行

3. CLion调试MPI程序

CLion 调试 MPI 程序需要通过使用 GDB 的附加到进程功能间接实现。

(1) 添加断点

在需要打断点的位置打上断点,并在所有断点位置上方添加如下代码:

int i = 0;
while (!i)
    sleep(5); // 需要包含unistd.h头文件

之后在
sleep(5)
一行添加断点。

添加断点

(2) 运行程序

点击运行按钮,运行程序。

运行程序

(3) GDB附加到进程功能

点击工具 => 附加到进程:

附加到进程功能

在 SSH/WSL 中搜索当前可执行文件:

搜索当前可执行文件

选取想要调试的进程(在实例中,因为 0 号进程中未添加断点,故不应选取进程号小的进程138113),点击使用WSL GDB附加,进入调试模式。

调试

为了程序可以向下运行,需要将 i 的值设置成 1。

设置值

之后正常调试即可。

4. 后记

  • 使用 CLion 只能通过 GDB 一个进程一个进程地调试,未找到方法调用 TotalView 同时跟踪和调试多个进程
  • 运行配置中的程序实参可以填写为:
    -n $Prompt$ "$CMakeCurrentProductFile$"
    ,实现每次运行前弹窗填写需要运行的进程数

参考资料:

  1. How to work with OpenMPI projects in CLion | CLion Documentation
  2. Clion 可以编译调用 MPI 并行库的项目么? - 知乎

最近在合并上游代码,遇到了一个问题:某个 commit 杂糅了几个不同的特性修改,这可能会导致 rebase 上游代码时需要再对该 commit 进行额外的代码冲突处理

解决方法:合并上游分支前,拆分杂糅的 commit,并将其中不同的特性修改合并(Squash)回相关的 commit。可以直接通过命令行进行操作,可以参考:
Break a previous commit into multiple commits
。也可以通过 JetBrains 家内置的 Git 进行操作,下面会介绍 IDEA 图形化操作的方法

非先前 commit 的拆分

对于刚提交的 commit,要拆分多个 commit 是非常容易的,因为我们只要
soft reset
commit,将 commit 内容撤销回至
暂存区
,就可以随意提交 commit

如果对于
soft reset
不太了解,可以参考我之前的博客:
Git 中的回退操作:reset 和 revert

先前 commit 的拆分

先前 commit 指的是:在目标 commit 后已经有了若干个 commit。它无法直接通过
soft reset
进行拆分,因为这样会丢失后续的 commit,如下图,我们需要拆分
B
commit,我们就无法直接使用
soft reset
,因为这样会丢失
C

D
commit 的修改

所以我们需要使用 rebase,具体步骤:

  1. 在 交互式 (interactive) rebase 中将
    B
    标记为
    edit
    ,这时
    B
    后面的 commit 会被暂时隐藏起来
  2. 使用
    soft reset

    B
    撤销回
    暂存区

  3. B
    的修改内容分多个 commit 提交
    B1

    B2
  4. 使用 rebase 的
    continue
    将刚才隐藏的
    C

    D
    恢复回来

实例准备

演示使用 IDEA,其实 JetBrains 家的使用逻辑差不多,示例仓库使用:
Learn Go with Tests

git clone git@github.com:quii/learn-go-with-tests.git

需要用到 Rebase,IDEA 默认保护主分支,改写 commit 记录的功能会被禁用

需要先取消分支保护,移除
Protected branches
中的
master;main

假设我们需要将下面 commit 拆分为两个:

1. 启动 Rebase 并标记目标 commit 为 edit

点击
Interactively Rebase from Here...

选择需要拆分的 commit,右键选择
Stop to Edit
,然后再点击
Start Rebasing

这时有右下角会提示您正在处于
Rebase
状态
选择框可以选择
Continue
即继续 Rebase,
Abort
则会退出 Rebase

commit 列表也会显示感叹号

2. 使用 soft reset 将 commit 撤销回 暂存区

我们需要先 soft reset 到目标 commit 的上一个 commit
选择上一个 commit,右键选择:
Reset Current Branch to Here...

选择
Soft
,这样目标 commit 的修改就会退回到暂存区

3. 将暂存区改动分为若干个 commit 提交

这个时候我们就可以继续分开提交两次 commit

查看 log,两条 commit 被成功提交

4. 使用 rebase 的 continue 恢复剩下的 commit

这时候我们需要继续 Rebase,将剩下的 commit 还原回去:点击右下角分分支按钮,选择
Continue Rebase

完成 Rebase 后,剩余的 commit 就会被追加到我们新提交的 commit 后面,至此我们就完成了先前 commit 的拆分

Rebase edit 的拓展

因为这里的案例只是拆分 commit,没有对 commit 进行修改,如果是修改的话,修改完成后需要使用
git add
将文件标记为已修改,才能使用 rebase 的
continue
,这样该 commit 就会被修改。后续的 commit 有可能与本次的改动产生冲突,需要手动处理冲突

参考资料

Break a previous commit into multiple commits

深入在线文档系统的 MarkDown/Word/PDF 导出能力设计

当我们实现在线文档的系统时,通常需要考虑到文档的导出能力,特别是对于私有化部署的复杂
ToB
产品来说,文档的私有化版本交付能力就显得非常重要,此外成熟的在线文档系统还有很多复杂的场景,都需要我们提供文档导出的能力。那么本文就以
Quill
富文本编辑器引擎为基础,探讨文档导出为
MarkDown

Word

PDF
插件化设计实现。

文章中我们即将要聊到的每个转换器设计都有相关示例
https://github.com/WindrunnerMax/QuillBlocks/tree/master/examples
,在实现
DEMO
的过程中也是踩了很多坑,给予的示例可以完成纯前端的数据转换,也可以通过
Node
来实现,还是比较有参考价值的。

  • delta-set.ts
    : 数据转换格式转换,从扁平数据结构转换到嵌套结构。
  • delta-to-md.ts
    : 将文档数据结构转换为
    Markdown
    ,输出为纯文本结构。
  • delta-to-word.ts
    : 将文档数据结构转换为
    docx
    文件,输出直接写入当前目录。
  • delta-to-word.html
    : 文档数据转换
    docx
    文件的
    HTML
    版本,可直接在浏览器编写文档并下载
    word
    文件。
  • delta-to-pdf.ts
    : 将文档数据结构转换为
    PDF
    文件,输出直接写入当前目录。
  • delta-to-pdf.html
    : 文档数据转换
    PDF
    文件的
    HTML
    版本,可直接在浏览器编写文档并下载
    PDF
    文件。
  • pdf-with-outline.ts
    : 将存量
    PDF
    文件写入大纲
    Outline
    ,作为输出
    PDF
    能力的补充,输出直接写入当前目录。

周末两天时间键盘都搓出火星子了才写完这篇文章,我觉得还是很有必要一键三连一下的,手动狗头。

描述

前段时间有位朋友跟我讲了个有趣的事,他们公司的某个
B
端大客户提了个需求,需要支持在他们的在线文档系统中直接支持远程连接打印机来打印文档,理由非常充分,就是他们公司的大老板不喜欢盯着电脑屏幕看文档,而是希望能够阅读纸质版的文档,为了不失去这家大客户就必须高优支持这个能力,当然这也确实是一个完整的在线文档
SaaS
系统所需要支持的能力。

虽然我们的在线文档主要是以
SaaS
提供服务的,但是同样我们也可以作为
PaaS
平台来提供服务,实际上这样的场景也比较明确,例如我们的文档系统存储的数据结构通常都是自定义的数据结构,当用户想通过本地生成
MarkDown
模版的方式进行初始化文档内容时,我们就需要提供导入的能力,此时如果用户又想将文档转换为
MarkDown
模版,我们通常就又需要导出的能力,还有跨平台的数据迁移或者合作时,通常就需要我们通过
OpenAPI
提供各种各样数据转换的能力,而本质上还是基于我们的数据结构设计的一套转换系统。

回到数据转换能力本身,我们实际上可以以某种通用的数据结构类型为基准,在此基准上进行各种数据格式的转换,在我们的文档系统中,成本最小的通用数据结构就是
HTML
,我们可以以
HTML
为基准进行数据转换,并且有很多开源的实现可以参考。通过这种思路实现的数据转换是成本比较低的,但是效率上就没有那么高了,所以我们在这里聊的还是从我们的基准数据结构
DSL - Domain Specific Language
来进行数据转换,
quill-delta
的数据结构是设计的非常棒的扁平化富文本描述
DSL
,所以本文就以
quill-delta
的数据结构设计来聊聊数据转换导出。并且我们在设计转换模型的时候,需要考虑到插件化的设计,因为我们不能够保证文档系统后边不会扩展块类型,所以这个设计思想是非常有必要的。

MarkDown

在工作中我们可能会遇到类似的场景,用户希望将在线文档嵌入到产品本身的站点中,作为
API
文档或者帮助中心的文档使用,而由于成本的关系,这些帮助中心大都是基于
MarkDown
搭建的,毕竟维护一款富文本产品成本相当之高,那么作为
PaaS
产品我们就需要提供数据转换的能力,当然提供
SDK
直接渲染我们的数据结构也可以是我们的产品能力,但是在很多情况下是比较难以投入人力做文档渲染迁移的,所以直接通过数据转换是最低成本的方式。

实际上各种产品文档慢慢从
MarkDown
迁移到富文本是趋势所在,作为研发我们使用
MarkDown
来编写文档是比较比较常见的,所以最开始各个产品使用
MD
渲染器搭建是合理的,但是随着随着产品的迭代和用户的不断增加,运营团队与专业
TW
团队介入进来,特别是海内外都要维护的产品,就更需要运营与
TW
团队支持,而此时我们可能只是完成初稿的编写,而后续的维护与更新就需要运营团队来维护,而运营团队通常不会使用
MD
来编写文档,特别是文档站如果是使用
Git
来管理的话,就更加难以接受了,所以对于类似的情况所见即所得在线文档产品就比较重要,而维护一款在线文档产品的成本是非常高的,那么大部分团队都可能会选择接入文档中台,由此上边我们提到的能力都变的非常重要了。

当然,作为在线文档的
PaaS
不光要提供数据转换到
MD
的能力,从
MD
导入的能力同样也是非常重要的,这里也有比较常见的场景,除了上边我们提到的用户可能是使用
MD
来编写文档模版并且导入到文档系统之外,还有已经上线的产品暂时并没有配置运营团队,而就是使用
MD
来编写文档,而这些产品的文档是使用我们提供的文档
SDK
渲染器来提供的,都需要统一走我们的
PaaS
平台来更新文档内容,所以这种场景下数据转换为我们的
DSL
又比较重要了,实际上如果将我们定位为
PaaS
产品的话,就是要不断兼容各种场景与系统,更加类似于中台的概念,当然本文就不太涉及数据导入的能力,我们还是主要关注于数据正向转出的方案。

那么此时我们正式开始数据到
MD
的转换,首先我们需要想到一个问题,各种
MD
解析器对于语法的支持程度是不一样的,例如最基本的换行,有些解析器对于单个回车就会解析为段落,而有些解析器必须要有两个空格加回车或者两个回车才能正常解析为段落,所以为了兼容类似的情况,我们的插件化设计就必不可少。那么紧接着我们思考第二个问题,
MD
毕竟是轻量级的格式描述,而我们的
DSL
是复杂的格式描述,我们的块结构种类是非常多的,所以我们还需要
HTML
来辅助我们进行复杂格式的转换。那么问题又来了,为什么我们不直接将其转换为
HTML
而是要混着
MD
格式呢,实际上这也是为了兼容性考虑,用户的
MD
可能组合了不同的插件,用
HTML
组合的话样式会有差异,复杂的样式组合起来会比较麻烦,特别是需要借助
mixin-react
类似
MDX
实现的方式,所以我们还是选择
MD
作为基准
HTML
作为辅助来实现数据转换。

前边我们已经提到了我们的块是比较复杂的,并且实际上是会存在很多嵌套结构,对应到
HTML
就类似于表格中嵌套了代码块的格式,而
quill-delta
的数据结构是扁平化的,所以我们也需要将其转换为方便处理的嵌套结构,而如果是完整的树形结构转换的复杂度就会就会比较高,所以我们采取一种折中的方案,在外部包裹一层
Map
结构,通过
key
的方式取得目标
delta
结构的数据,由此在数据获取的时候可以动态构成嵌套结构。

// 用于对齐渲染时的数据表达
// 同时为了方便处理嵌套关系 将数据结构拍平
class DeltaSet {
  private deltas: Record<string, Line[]> = {};

  get(zoneId: string) {
    return this.deltas[zoneId] || null;
  }

  push(id: string, line: Line) {
    if (!this.deltas[id]) this.deltas[id] = [];
    this.deltas[id].push(line);
  }
}

同时,我们需要选取处理数据的基准,而我们的文档实际上就是由段落格式与行内格式组成,那么很明显我们就可以将其拆分为两部分,行格式与行内格式,映射到
delta
中就相当于
Line
嵌套了
Ops
并且携带了本身的行格式例如标题、对齐等,实际上加上我们的
DeltaSet
结构就是分为了三部分来描述我们初步处理希望转换到的数据结构。

const ROOT_ZONE = "ROOT";
const CODE_BLOCK_KEY = "code-block";
type Line = {
  attrs: Record<string, boolean | string | number>;
  ops: Op[];
};
const opsToDeltaSet = (ops: Op[]) => {
  // 构造`Delta`实例
  const delta = new Delta(ops);
  // 将`Delta`转换为`Line`的数据表达
  const group: Line[] = [];
  delta.eachLine((line, attributes) => {
    group.push({ attrs: attributes || {}, ops: line.ops });
  });
  // ...
}

对于
DeltaSet
我们需要定义入口
Zone
,在这里也就是
"ROOT"
标记的
delta
结构,而在
DEMO
中我们只定义了
CodeBlock
的块级嵌套结构,所以在下面的示例中我们只处理了代码块的数据嵌套表达,因为原本的数据结构是扁平的,我们就需要处理一些边界条件,也就是代码块结构的起始与结束,当遇到代码块结构时,将正在处理的
Zone
指向为新的
delta
块,并且需要在原本的结构中建立一个指向关系,在这里是通过
op
中指定
zoneId
标识符来实现的,在结束的时候将指针恢复到之前的
Zone
目标。当然通常我们还需要处理多层嵌套的块,这里只是简单的处理了一层嵌套,多层嵌套的情况下就需要用借助栈来处理,这里就不再展开了。

const deltaSet = new DeltaSet();
// 标记当前正在处理的的`ZoneId`
// 实际情况下可能会存在多层嵌套 此时需要用`stack`来处理
let currentZone: string = ROOT_ZONE;
// 标记当前处理的类型 如果存在多种类型时会用得到
let currentMode: "NORMAL" | "CODEBLOCK" = "NORMAL";
// 用于判断当前`Line`是否为`CodeBlock`
const isCodeBlockLine = (line: Line) => line && !!line.attrs[CODE_BLOCK_KEY];
// 遍历`Line`的数据表达 构造`DeltaSet`
for (let i = 0; i < group.length; ++i) {
  const prev = group[i - 1];
  const current = group[i];
  const next = group[i + 1];
  // 代码块结构的起始
  if (!isCodeBlockLine(prev) && isCodeBlockLine(current)) {
    const newZoneId = getUniqueId();
    // 存在嵌套关系 构造新的索引
    const codeBlockLine: Line = {
      attrs: {},
      ops: [{ insert: " ", attributes: { [CODE_BLOCK_KEY]: "true", zoneId: newZoneId } }],
    };
    // 需要在当前`Zone`加入指向新`Zone`的索引`Line`
    deltaSet.push(currentZone, codeBlockLine);
    currentZone = newZoneId;
    currentMode = "CODEBLOCK";
  }
  // 将`Line`置入当前要处理的`Zone`
  deltaSet.push(currentZone, group[i]);
  // 代码块结构的结束
  if (currentMode === "CODEBLOCK" && isCodeBlockLine(current) && !isCodeBlockLine(next)) {
    currentZone = ROOT_ZONE;
    currentMode = "NORMAL";
  }
}

现在数据已经准备好了,我们就需要设计整个转换系统了,前边我们已经提到了整个转换器是由两种类型组成的,所以我们的插件系统也就分为了两部分,而实际上对于
MD
来说,本质上就是字符串拼接,所以对于插件的输出主要就是字符串了,此时需要注意一个问题,同一个
Op
描述可能会有多个格式,例如某个块可能是加粗与斜体的组合,此时我们的格式是由两个插件分别处理的,那么这样的话就不能在插件中直接输出结果,而是需要通过
prefix

suffix
的方式拼接,同样的对于行格式也是如此,特别是需要
HTML
标签来辅助表达的情况下。此外,有时候我们可能会明确节点不会存在嵌套的情况,例如图片的格式,那么此时就可以通过
last
标识符来标记最后一个节点,由此避免多余的检查。

type Output = {
  prefix?: string;
  suffix?: string;
  last?: boolean;
};

由于存在需要
HTML
辅助的节点,而我们迭代的方式非常类似于递归拼接字符串的方式,所以我们需要穿插一个标识符,标识此时需要解析成
HTML
而不是
MD
标记,例如此时我们匹配到行节点是居中的,那么此时该行内部所有的节点都需要解析成
HTML
标记,而且要注意的是这个标记在每次行迭代开始前都需要重置,避免前边的内容对后边的内容造成影响。

type Tag = {
  isHTML?: boolean;
  isInZone?: boolean;
};

对于插件的类型的输入部分主要是在迭代的时候将相邻的描述一并传递,这对于处理列表的格式非常有用,很多
MD
解析器是需要列表的前后都需要额外空行的,对于行内格式的合并也是非常有用的,可以避免描述块产生多个标记。此外,我们需要对插件设置唯一的标识,前边提到了我们是需要对多种场景进行兼容的,在实际处理插件的时候就可以按照实例化的顺序覆盖处理,设置插件的优先级也是很有必要的,例如引用与列表叠加的行格式,引用格式需要在列表前解析才能正确展示样式。

type LineOptions = {
  prev: Line | null;
  current: Line;
  next: Line | null;
  tag: Tag;
};
type LinePlugin = {
  key: string; // 插件重载
  priority?: number; // 插件优先级
  match: (line: Line) => boolean; // 匹配`Line`规则
  processor: (options: LineOptions) => Promise<Omit<Output, "last"> | null>; // 处理函数
};
type LeafOptions = {
  prev: Op | null;
  current: Op;
  next: Op | null;
  tag: Tag;
};
type LeafPlugin = {
  key: string; // 插件重载
  priority?: number; // 插件优先级
  match: (op: Op) => boolean; // 匹配`Op`规则
  processor: (options: LeafOptions) => Promise<Output | null>; // 处理函数
};

接下来是入口的处理函数,首先我们需要处理行格式,因为行内格式可能会因为行格式出现不同的结果,例如居中的行格式会导致行内格式解析成
HTML
标记,这个标记是通过可变的
tag
对象来实现的,我们的行格式是有可能会匹配到多个插件的,所有的结果都应该保存起来,同样的对于行内格式也是如此,在处理函数的最后,我们将结果拼接为字符串即可。

const parseZoneContent = async (
  zoneId: string,
  options: { defaultZoneTag?: Tag; wrap?: string }
): Promise<string | null> => {
  const { defaultZoneTag = {}, wrap: cut = "\n\n" } = options;
  const lines = deltaSet.get(zoneId);
  if (!lines) return null;
  const result: string[] = [];
  for (let i = 0; i < lines.length; ++i) {
    // ... 取行数据
    const prefixLineGroup: string[] = [];
    const suffixLineGroup: string[] = [];
    // 不能影响外部传递的`Tag`
    const tag: Tag = { ...defaultZoneTag };
    // 先处理行内容 // 需要先处理行格式
    for (const linePlugin of LINE_PLUGINS) {
      if (!linePlugin.match(currentLine)) continue;
      // ... 执行插件
      if (!result) continue;
      result.prefix && prefixLineGroup.push(result.prefix);
      result.suffix && suffixLineGroup.push(result.suffix);
    }
    const ops = currentLine.ops;
    // 处理节点内容
    for (let k = 0; k < ops.length; ++k) {
      // ... 取节点数据
      const prefixOpGroup: string[] = [];
      const suffixOpGroup: string[] = [];
      let last = false;
      for (const leafPlugin of LEAF_PLUGINS) {
        if (!leafPlugin.match(currentOp)) continue;
        // ... 执行插件
        if (!result) continue;
        result.prefix && prefixOpGroup.push(result.prefix);
        result.suffix && suffixOpGroup.unshift(result.suffix);
        if (result.last) {
          last = true;
          break;
        }
      }
      // 如果没有匹配到`last`则需要默认加入节点内容
      if (!last && currentOp.insert && isString(currentOp.insert)) {
        prefixOpGroup.push(currentOp.insert);
      }
      prefixLineGroup.push(prefixOpGroup.join("") + suffixOpGroup.join(""));
    }
    result.push(prefixLineGroup.join("") + suffixLineGroup.join(""));
  }
  return result.join(cut);
};

那么有了调度器,我们接下来只需要关注插件的实现,在这里以标题插件为例实现转换逻辑,实际上这部分逻辑非常简单,只需要解析
LineAttributes
来决定返回值就可以了。

const HeadingPlugin: LinePlugin = {
  key: "HEADING",
  match: line => !!line.attrs.header,
  processor: async options => {
    if (options.tag.isHTML) {
      options.tag.isHTML = true;
      return {
        prefix: `<h${options.current.attrs.header}>`,
        suffix: `</h${options.current.attrs.header}>`,
      };
    } else {
      const repeat = Number(options.current.attrs.header);
      return { prefix: "#".repeat(repeat) + " " };
    }
  },
};

对于行内的插件也是类似的逻辑,在这里以加粗插件为例实现转逻辑,同样也是仅需要判断
OpAttributes
来决定返回值即可。

const BoldPlugin: LeafPlugin = {
  key: "BOLD",
  match: op => op.attributes && op.attributes.bold,
  processor: async options => {
    if (options.tag.isHTML) {
      options.tag.isHTML = true;
      return { prefix: "<strong>", suffix: "</strong>" };
    } else {
      return { prefix: "**", suffix: "**" };
    }
  },
};


https://github.com/WindrunnerMax/QuillBlocks/blob/master/examples/
中有完整的
DeltaSet
数据转换
delta-set.ts

MarkDown
数据转换
delta-to-md.ts
,可以通过
ts-node
来执行测试,实际上我们可能也注意到了,这个调度器不仅可以转换
MD
格式,实际上还可以进行完整的
HTML
格式转换,那么既然
HTML
转换逻辑有了,我们就有了非常通用的中间产物来生成各种文件了,并且如果将插件改装成同步的模式,这个方案还可以用来处理在线文档的复制行为,实际的用途就非常丰富了。此外,在实际使用的过程中对于插件的单测是非常有必要的,在开发的时候就应该就测试用例全部积累起来,用以避免改动所造成的未知问题,特别是当多个插件组合的时候,兼容的业务场景一旦复杂起来,对于各种
case
的处理就会变的尤为重要,特别是全量同步更新的场景下,积累边界的测试用例就变得更加重要。

Word

在前边我们聊了作为
PaaS
平台的数据转换兼容能力,而作为
SaaS
平台直接生成交付文档是必不可少的能力,特别是在产品需要私有化部署以及提供多版本线上能力的时候。
Word
是最常见的文档交付格式之一,特别是在需要导出后再次修改的情况下生成
Word
文档就变得非常有用,所以在本节我们就来聊一下如何生成
Word
格式的交付文档。

OOXML

Office Open XML
是微软在
Office 2007
中提出的一种新的文档格式,
Office 2007
中的
Word

Excel

PowerPoint
默认均采用
OOXML
格式,
OOXML
同样也成为了
ECMA
规范的一部分,编号为
ECMA-376
。实际上对于现在的
Word
文档,我们可以直接将其解压从而得到封装的数据,将其扩展名修改为
zip
之后,就可以得到内部的文件,下面是
docx
文件中的部分组成。

  • [Content_Types].xml
    : 用于定义里面每个文件的内容类型,例如可以标记一个文件是图片
    .jpg
    还是文本内容
    .xml
  • _rels
    : 通常会存在
    .rels
    文件,用以保存各个
    Part
    之间的关系,用来描述不同文件之间的关联,例如某文本与图片存在关联。
  • docProps
    : 其中存放了整个
    word
    文档的属性信息,如作者、创建时间、标签等。
  • word
    : 存储的是文档的主要内容,包括文本、图片、表格以及样式等。
    • document.xml
      : 保存了所有的文本以及对文本的引用。
    • styles.xml
      : 保存了文档中所有使用到的样式。
    • theme.xml
      : 保存了应用于文档的主题设置。
    • media
      : 保存了文档中使用的所有媒体文件,如图片。

看到这些描述我们可能会非常迷茫应该如何真正组装成
word
文件,毕竟这里有如此多复杂的关系描述。那么既然我们不能瞬间了解整个
docx
文件的构成,我们还是可以借助于框架来生成
docx
文件的,在调研了一些框架后,我发现大概有两种生成方式,一种就是我们常说的通过通用的
HTML
格式来生成,例如
html-docx-js

html-to-docx

pandoc
,还有一种是代码直接控制生成,相当于减少了转
HTML
这一步,例如
officegen

docx
。在观察到很多库实际上很多年没有过更新了,并且在这里我们更希望直接输出
docx
,而不是需要
HTML
中转,毕竟在线文档的交付对于格式还是需要有比较高的控制能力的,综上最后选择使用
docx
来生成
word
文件。

docx
帮我们简化了整个
word
文件的生成过程,通过构建内建对象的层级关系,我们就可以很方便的生成出最后的文件,并且无论是在
Node
环境还是浏览器环境中都可以运行,所以在本节的
DEMO
中会有
Node
和浏览器两个版本的
DEMO
。那么现在我们就以
Node
版本为例聊聊如何生成
word
文件,首先我们需要定义样式,在
word
中有一个称作样式窗格的模块,我们可以将其理解为
CSS

class
,这样我们就可以在生成文档的时候直接引用样式,而不需要在每个节点中都定义一遍样式。

const PAGE_SIZE = {
  WIDTH: sectionPageSizeDefaults.WIDTH - 1440 * 2,
  HEIGHT: sectionPageSizeDefaults.HEIGHT - 1440 * 2,
};
const DEFAULT_FORMAT_TYPE = {
  H1: "H1",
  H2: "H2",
  CONTENT: "Content",
  IMAGE: "Image",
  HF: "HF",
};
// ... 基本配置
const PRESET_SCHEME_LIST: IParagraphStyleOptions[] = [
  {
    id: DEFAULT_FORMAT_TYPE.CONTENT,
    name: DEFAULT_FORMAT_TYPE.CONTENT,
    quickFormat: true,
    paragraph: {
      spacing: DEFAULT_LINE_SPACING_FORMAT,
    },
  },
  // ... 预设格式
]

紧接着我们需要处理单位的转换,在我们使用
word
的时候可能会注意到我们的单位都是磅值
PT
,而在我们的浏览器中通常是
PX
,因为在
DEMO
中我们仅涉及到了图片大小的处理,其他的都是直接使用
DAX
与比例的方式实现的,所以在这里只是列举了用到的单位转换。

const daxToCM = (dax: number) => (dax / 20 / 72) * 2.54;
const cmToPixel = (cm: number) => cm * 10 * 3.7795275591;
const daxToPixel = (dax: number) => Math.ceil(cmToPixel(daxToCM(dax)));

与转换
MD
类似,我们同样需要定义转换调度的逻辑,但是有一点不一样的是
MD
中输出是字符串,我们的可操作性很大,在
docx
中是有严格的对象结构关系的,所以在这里我们需要严格定义行与行内的类型关系,并且传递的
Tag
需要有更多的内容。

type LineBlock = Table | Paragraph;
type LeafBlock = Run | Table | ExternalHyperlink;
type Tag = {
  width: number;
  fontSize?: number;
  fontColor?: string;
  spacing?: ISpacingProperties;
  paragraphFormat?: string;
  isInZone?: boolean;
  isInCodeBlock?: boolean;
};

插件的输入设计与
MD
类似,但是输出的内容就需要更加严格,行内元素的插件输出必须是行内的对象类型,行元素的插件输出必须要是行对象类型,特别要注意的是在行插件中,我们传递了
leaves
参数,这里也就意味着此时我们的行内元素与行元素的调度是由行插件来管理,而不是在外部
Zone
调度模块来管理。

type LeafOptions = {
  prev: Op | null;
  current: Op;
  next: Op | null;
  tag: Tag;
};
type LeafPlugin = {
  key: string; // 插件重载
  priority?: number; // 插件优先级
  match: (op: Op) => boolean; // 匹配`Op`规则
  processor: (options: LeafOptions) => Promise<LeafBlock | null>; // 处理函数
};
type LineOptions = {
  prev: Line | null;
  current: Line;
  next: Line | null;
  tag: Tag;
  leaves: LeafBlock[];
};
type LinePlugin = {
  key: string; // 插件重载
  priority?: number; // 插件优先级
  match: (line: Line) => boolean; // 匹配`Line`规则
  processor: (options: LineOptions) => Promise<LineBlock | null>; // 处理函数
};

接下来就是入口的
Zone
调度函数,这里与之前的
MD
调度不同,我们需要首先处理叶子节点也就是行内样式,因为这里有一个特别需要关注的点是
Paragraph
对象是不能包裹
Table
对象的,而此时如果我们需要实现一个块级结构那么外部是需要包裹
Table
而不是
Paragraph
,也就是说此时我们的行内元素内容是会决定行元素的格式,即
A
影响
B
那就先处理
A
,所以此时是先处理行内元素,并且单个块结构仅会匹配到一个插件,所以相关的通用内容处理是需要封装到通用函数中的。

const parseZoneContent = async (
  zoneId: string,
  options: { defaultZoneTag?: Tag }
): Promise<LineBlock[] | null> => {
  const { defaultZoneTag = { width: PAGE_SIZE.WIDTH } } = options;
  const lines = deltaSet.get(zoneId);
  if (!lines) return null;
  const target: LineBlock[] = [];
  for (let i = 0; i < lines.length; ++i) {
    // ... 取行数据
    // 不能影响外部传递的`Tag`
    const tag: Tag = { ...defaultZoneTag };
    // 处理节点内容
    const ops = currentLine.ops;
    const leaves: LeafBlock[] = [];
    for (let k = 0; k < ops.length; ++k) {
      // ... 取节点数据
      const hit = LEAF_PLUGINS.find(leafPlugin => leafPlugin.match(currentOp));
      if (hit) {
        // ... 执行插件
        result && leaves.push(result);
      }
    }
    // 处理行内容
    const hit = LINE_PLUGINS.find(linePlugin => linePlugin.match(currentLine));
    if (hit) {
      // ... 执行插件
      result && target.push(result);
    }
  }
  return target;
};

接下来同样的我们需要定义插件,这里以文本插件为例实现转换逻辑,因为基本的文本样式都封装在
TextRun
这个对象中,所以我们只需要处理
TextRun
对象的属性即可,当然对于其他的
Run
类型对象例如
ImageRun
等,我们还是需要单独定义插件处理的。

const TextPlugin: LeafPlugin = {
  key: "TEXT",
  match: () => true,
  processor: async (options: LeafOptions) => {
    const { current, tag } = options;
    if (!isString(current.insert)) return null;
    const config: WithDefaultOption<IRunOptions> = {};
    config.text = current.insert;
    const attrs = current.attributes || {};
    if (attrs.bold) config.bold = true;
    if (attrs.italic) config.italics = true;
    if (attrs.underline) config.underline = {};
    if (tag.fontSize) config.size = tag.fontSize;
    if (tag.fontColor) config.color = tag.fontColor;
    return new TextRun(config);
  },
};

对于行类型的插件,我们以段落插件为例实现转换逻辑,对于段落插件是当匹配不到其他段落格式时需要最终并入的插件。前边我们提到的
Paragraph
对象是不能包裹
Table
元素的问题也需要在此处处理,因为我们的块级表达就是借助
Table
对象实现的,那么如果叶子节点没有匹配到块元素,则直接返回段落元素即可,如果匹配到了块元素且仅有单个元素,那么将其直接提升并返回即可,如果匹配到块元素且还有其他元素,那么此时就需要将所有的元素包裹一层块元素再返回,实际上这部分逻辑应该封装起来为所有的行级元素插件共同调用来兼容解析,否则层级嵌套出现问题的话生成的
word
是无法打开的。

const ParagraphPlugin: LinePlugin = {
  key: "PARAGRAPH",
  match: () => true,
  processor: async (options: LineOptions) => {
    const { leaves, tag } = options;
    const config: WithDefaultOption<IParagraphOptions> = {};
    const isBlockNode = leaves.some(leaf => leaf instanceof Table);
    config.style = tag.paragraphFormat || DEFAULT_FORMAT_TYPE.CONTENT;
    if (!isBlockNode) {
      if (tag.spacing) config.spacing = tag.spacing;
      config.children = leaves;
      return new Paragraph(config);
    } else {
      if (leaves.length === 1 && leaves[0] instanceof Table) {
        // 单个`Zone`不需要包裹 通常是独立的块元素
        return leaves[0] as Table;
      } else {
        // 需要包裹组合嵌套`BlockTable`
        return makeZoneBlock({ children: leaves });
      }
    }
  },
};

接下来我们再来聊一下页眉和页脚,在
word
中我们常见的一个页眉表达是在右上角标识当前页的标题,这是个很有意思的功能,在
word
中是通过域来实现的,借助于
OOXML
的表达和
docx
的封装,我们同样也可以实现这个功能,而且对于类似域表达的实现同样都是可以实现的,引用标题常用的域表达是
STYLEREF
,我们直接拼装字符串即可,常见的一个页脚表达是在右下角或者居中显示页码的功能,这部分就不需要域引用的表达了,我们可以非常简单地实现页码的展示,主要关注的部分还是位置的控制。

const HeaderSection = new Header({
  children: [
    new Paragraph({
      style: DEFAULT_FORMAT_TYPE.HF,
      tabStops: [{ type: TabStopType.RIGHT, position: TabStopPosition.MAX }],
      // ... 格式控制
      children: [
        new TextRun("页眉"),
        new TextRun({
          children: [
            new Tab(),
            new SimpleField(`STYLEREF "${DEFAULT_FORMAT_TYPE.H1}" \\* MERGEFORMAT`),
          ],
        }),
      ],
    }),
  ],
});

const FooterSection = new Footer({
  children: [
    new Paragraph({
      style: DEFAULT_FORMAT_TYPE.HF,
      tabStops: [{ type: TabStopType.RIGHT, position: TabStopPosition.MAX }],
      // ... 格式控制
      children: [
        new TextRun("页脚"),
        new TextRun({
          children: [new Tab(), PageNumber.CURRENT],
        }),
      ],
    }),
  ],
});


word
中还有一个非常重要的功能,那就是生成目录的能力,我们先来想一个问题,不知道大家注意到没有我们整篇文档没有提到字体的引入,如果我们想知道某个字或者某个段落渲染在
word
中的某一页,那么我们是需要知道字体的大小的,这样我们才可以将其排版,由此得到标题所在的页数,那么既然我们连字体都没引入,那么实际上很明显我们是没有在生成文档的时候就进行渲染排版的执行,而是在用户打开文档的时候才会进行这个操作,所以我们引入目录之后,会出现类似于是否更新该文档中的这些域的提示,这就是因为目录是字段,根据设计其内容仅由
word
生成或更新,我们无法以编程方式做到这一点。

const TOC = new TableOfContents("Table Of Contents", {
  hyperlink: true,
  headingStyleRange: "1-2",
  stylesWithLevels: [
    new StyleLevel(DEFAULT_FORMAT_TYPE.H1, 1),
    new StyleLevel(DEFAULT_FORMAT_TYPE.H2, 2),
  ],
}),


https://github.com/WindrunnerMax/QuillBlocks/blob/master/examples/
中有完整的
word
数据转换
delta-to-word.ts

delta-to-word.html
,可以通过
ts-node
和浏览器打开
HTML
来执行测试。从数据层面转换生成
word
实际上是件非常复杂的问题,并且其中还有很多细节需要处理,特别是在富文本内容的转换上,例如多层级块嵌套、流程图/图片渲染、表格合并、动态内容转换等等,实现完备的
word
导出能力同样也需要不断适配各种边界
case
,同样非常需要单元测试来辅助我们保持功能的稳定性。

jcode

PDF

在我们的
SaaS
平台上的交付能力,除了
Word
之外
PDF
也是必不可少的,实际上对于很多需要打印的文档来说,
PDF
是更好的选择,因为
PDF
是一种固定格式的文档,不会因为不同的设备而产生排版问题,我们也可以将
PDF
理解为高级的图片,图片不会因为设备不同而导致排版混乱,高级则是高级在其可添加的内容更加丰富,所以在本节我们就来聊一下如何生成
PDF
格式的交付文档。

生成
PDF
的方法同样可以归为两种,一种是基于
HTML
生成
PDF
,常见的做法是通过
dom-to-image/html2canvas
等库将
HTML
转换为图片,再将图片转换为
HTML
,这种方式缺点比较明显,不能对文字进行选择复制,放大后清晰度会下降,还有一种常见的方式是使用
Puppeteer
,其提供了高级
API
来通过
DevTools
协议控制
Chromium
,可以用来生成
PDF
文件,同样的如果在前端直接使用
window.print
或者
react-to-print
借助
iframe
实现局部打印也是可行的;还有一种方式是自行排版生成
PDF
,对于
PDF
的操作实际上非常类似于
Canvas
的操作,任何东西都可以通过绘制的方式来实现,例如表格我们就可以直接通过画矩形的方式来绘制,常用的库有
pdfkit

pdf-lib

pdfmake
等等。

同样的在这里我们讨论的方法是从我们的
delta
数据直接生成
PDF
,当然因为我们前边也聊了生成
MD

HTML

Word
格式的文件,通过这些文件作为中间层的数据进行转换也是完全可行的,只不过在这里我们还是采用直接输出的方式。同样我们也不太能在短时间内完整熟悉整个
PDF
数据格式的标准,所以我们同样还是借助于库来生成
PDF
文件,这里我们选择了
pafmake
来生成
PDF
,通过
pdfmake
我们可以通过
JSON
配置的方式自动排版和生成
PDF
,相当于是从一种
JSON
生成了另一种
JSON
,而针对于
Outline/Bookmark
的问题,我花了很长时间研究相关实现,最终选择了
pdf-lib
来最终处理生成大纲。

与生成
Word
的描述语言
OOXML
不同,
OOXML
中不包含任何用于直接渲染内容的绘图指令,实际上还是相当于静态标记,当用户打开
docx
文件时会解析标记在用户客户端进行渲染。而创建
PDF
时需要真正绘制路径
PostScript-PDL
,是直接描绘文本、矢量图形和图像的页面描述语言,而不是需要由客户端渲染排版的格式,当
PDF
文件被打开时,所有的绘图指令都已经在
PDF
文件中,内容可以直接通过这些绘图指令渲染出来。

为了保持保持完整的跨平台文档格式,
PDF
文件中通常还需要嵌入字体,这样才能保证在任何设备上都能正确显示文档内容,所以在生成
PDF
文件时我们需要引入字体文件。需要注意的是,很多字体都不是免费使用的,特别是在公司中很多都是需要商业授权的,同样也有很多开源的字体,可以考虑思源宋体与江城斜宋体,这样就包含了
normal

bold

italics

bolditalics
四种格式的字体了,在服务端也可以考虑直接安装
fonts-noto-cjk
字体并引用。此外通常
CJK
的字体文件都会比较大,子集化字体嵌入是更好的选择。

// 需要引用字体 可以考虑思源宋体 + 江城斜宋体
// https://github.com/RollDevil/SourceHanSerifSC
const FONT_PATH = "/Users/czy/Library/Fonts/";
const FONTS = {
  JetBrainsMono: {
    normal: FONT_PATH + "JetBrainsMono-Regular.ttf",
    bold: FONT_PATH + "JetBrainsMono-Bold.ttf",
    italics: FONT_PATH + "JetBrainsMono-Italic.ttf",
    bolditalics: FONT_PATH + "JetBrainsMono-BoldItalic.ttf",
  },
};


pdfmake
中我们同样可以通过预设样式来实现类似
word
的样式窗格功能,当然
pdf
是不能直接编辑的,所以此处的样式窗格主要是方便我们实现不同类型的样式。

const FORMAT_TYPE = {
  H1: "H1",
  H2: "H2",
};
const PRESET_FORMAT: StyleDictionary = {
  [FORMAT_TYPE.H1]: { fontSize: 22, bold: true, },
  [FORMAT_TYPE.H2]: { fontSize: 18, bold: true, },
};
const DEFAULT_FORMAT: Style = {
  font: "JetBrainsMono",
  fontSize: 14,
};

对于转换调度模块,与
word
的调度模块类似,我们需要定义行与行内的类型关系以及
Tag
需要传递的内容。关于
pdfmake
的类型控制是非常松散的,我们可以轻松地实现符合要求的格式嵌套,当然不合法的格式嵌套还是运行时校验的,我们可以做的是尽可能地将这部分校验提升到类型定义时,例如
ContentText
实际上是不能直接以
ContentImage
作为子元素的,但是在类型定义上是允许的,我们可以更加严格地定义类似的嵌套关系。

type LineBlock = Content;
type LeafBlock = ContentText | ContentTable | ContentImage;
type Tag = {
  format?: string;
  fontSize?: number;
  isInZone?: boolean;
  isInCodeBlock?: boolean;
};

关于插件定义的部分我们还是延续之前设计的类型,这部分大致都是相同的设计,入参依然是相邻的块结构以及
Tag
,行插件还并入了叶子节点数据,插件的定义上依旧保持
key
插件重载、
priority
插件优先级、
match
匹配规则、
processor
处理函数,输出依旧是两种块类型,实际上这也从侧面反映了我们之前的设计还是比较通用的。

type LeafOptions = {
  prev: Op | null;
  current: Op;
  next: Op | null;
  tag: Tag;
};
type LeafPlugin = {
  key: string; // 插件重载
  priority?: number; // 插件优先级
  match: (op: Op) => boolean; // 匹配`Op`规则
  processor: (options: LeafOptions) => Promise<LeafBlock | null>; // 处理函数
};
type LineOptions = {
  prev: Line | null;
  current: Line;
  next: Line | null;
  tag: Tag;
  leaves: LeafBlock[];
};
type LinePlugin = {
  key: string; // 插件重载
  priority?: number; // 插件优先级
  match: (line: Line) => boolean; // 匹配`Line`规则
  processor: (options: LineOptions) => Promise<LineBlock | null>; // 处理函数
};

入口的
Zone
调度函数,与处理
word
的部分比较类似,因为不存在单个块结构的嵌套关系,同类型所有的格式配置都可以用同一个插件来实现,所以这里同样是命中单个插件的形式,此外同样是首先处理叶子节点,因为叶子节点的内容会决定行元素的嵌套块格式。

const parseZoneContent = async (
  zoneId: string,
  options: { defaultZoneTag?: Tag }
): Promise<Content[] | null> => {
  const { defaultZoneTag = {} } = options;
  const lines = deltaSet.get(zoneId);
  if (!lines) return null;
  const target: Content[] = [];
  for (let i = 0; i < lines.length; ++i) {
    // ... 取行数据
    // 不能影响外部传递的`Tag`
    const tag: Tag = { ...defaultZoneTag };
    // 处理节点内容
    const ops = currentLine.ops;
    const leaves: LeafBlock[] = [];
    for (let k = 0; k < ops.length; ++k) {
      // ... 取节点数据
      const hit = LEAF_PLUGINS.find(leafPlugin => leafPlugin.match(currentOp));
      if (hit) {
        // ... 执行插件
        result && leaves.push(result);
      }
    }
    // 处理行内容
    const hit = LINE_PLUGINS.find(linePlugin => linePlugin.match(currentLine));
    if (hit) {
      // ... 执行插件
      result && target.push(result);
    }
  }
  return target;
};

紧接着是插件的定义,这里以文本插件为例实现转换逻辑,类似的基本文本样式都封装在
ContentText
这个对象中,所以我们只需要处理
ContentText
对象的属性即可,当然对于其他的
Content
类型对象例如
ContentImage
等,我们还是需要单独定义插件处理的。

const TextPlugin: LeafPlugin = {
  key: "TEXT",
  match: () => true,
  processor: async (options: LeafOptions) => {
    const { current, tag } = options;
    if (!isString(current.insert)) return null;
    const config: ContentText = {
      text: current.insert,
    };
    const attrs = current.attributes || {};
    if (attrs.bold) config.bold = true;
    if (attrs.italic) config.italics = true;
    if (attrs.underline) config.decoration = "underline";
    if (tag.fontSize) config.fontSize = tag.fontSize;
    return config;
  },
};

对于行类型的插件,我们以段落插件为例实现转换逻辑,对于段落插件是当匹配不到其他段落格式时需要最终并入的插件,前边我们提到的
Content
对象的嵌套关系也需要在此处处理,首先对于空行需要并入一个
\n
,如果是空对象或者空数组的话是不会出现换行行为的,对于单个的
Zone
内容就不需要包裹,例如
CodeBlock
块级结构则直接提升并入到主文档即可,对于多种多种类型的结构例如并行的表格、图片等就需要包裹一层
Table/Columns
结构来实现。此外与
OOXML
不一样的是,层级嵌套关系出现问题不会导致打开报错,只是不正常显示相关区域的内容。

const composeParagraph = (leaves: LeafBlock[]): LeafBlock => {
  if (leaves.length === 0) {
    // 空行需要兜底
    return { text: "\n" };
  } else if (leaves.length === 1 && !leaves[0].text) {
    // 单个`Zone`不需要包裹 通常是独立的块元素
    return leaves[0];
  } else {
    const isContainBlock = leaves.some(leaf => !leaf.text);
    if (isContainBlock) {
      // 需要包裹组合嵌套`BlockTable` // 实际还需要计算宽度避免越界
      return { layout: "noBorders", table: { headerRows: 0, body: [leaves] } };
    } else {
      return { text: leaves };
    }
  }
};
const ParagraphPlugin: LinePlugin = {
  key: "PARAGRAPH",
  match: () => true,
  processor: async (options: LineOptions) => {
    const { leaves } = options;
    return composeParagraph(leaves);
  },
};

紧接着我们来聊一聊如何生成
Outline/Bookmark

Outline
通常就是我们说的大纲,通常会显示在打开的
PDF
左侧。
pdfmake
是不支持直接生成
Outline
的,所以我们需要借助其他的库来实现这个功能,在调研了很长时间之后我发现了
pdf-lib
这个库,可以用来处理已有的
pdf
文件并且生成
Outline
。在这个例子中生成
PDF
之后的
Outline
是通过
id
系统来实现跳转的,实际上还有一个思路,使用
pdfjs-dist
来解析并存储
PDF
相应标题对应的页面与位置信息,然后再使用
pdf-lib

Outline
写入。此外,生成
Outline
在配合
Puppeteer
来生成
PDF
时非常有用,本质上是因为
Chromium
在导出
PDF
时不支持生成
Outline
,那么通过
pdf-lib
来添加
Outline
恰好是不错的能力补充。

// 通过`pdfmake`生成`pdf`
const printer = new PdfPrinter(FONTS);
const pdfDoc = printer.createPdfKitDocument(doc);
const writableStream = new Stream.Writable();
const slice: Uint8Array[] = [];
writableStream._write = (chunk: Uint8Array, _, next) => {
  slice.push(chunk);
  next();
};
pdfDoc.pipe(writableStream);
const buffer = await new Promise<Buffer>(resolve => {
  writableStream.on("finish", () => {
    const data = Buffer.concat(slice);
    resolve(data);
  });
});
pdfDoc.end();

// 通过`pdf-lib`生成`outline`
const pdf = await PDFDocument.load(buffer);
const context = pdf.context;
const root = context.nextRef();
const header1 = context.nextRef();
const header11 = context.nextRef();
// ... 创建`ref`
const header1Map: DictMap = new Map([]);
// ... 置入数据
header1Map.set(PDFName.of("Dest"), PDFName.of("Hash1"));
context.assign(header1, PDFDict.fromMapWithContext(header1Map, context));
const header11Map: DictMap = new Map([]);
// ... 置入数据
header12Map.set(PDFName.of("Dest"), PDFName.of("Hash1.2"));
context.assign(header11, PDFDict.fromMapWithContext(header11Map, context));
// ... 构建完整的层级关系
const rootMap: DictMap = new Map([]);
// ... 构建根节点的引用
context.assign(root, PDFDict.fromMapWithContext(rootMap, context));
pdf.catalog.set(PDFName.of("Outlines"), root);
// 生成并写文件
const pdfBytes = await pdf.save();
fs.writeFileSync(__dirname + "/doc-with-outline.pdf", pdfBytes);


https://github.com/WindrunnerMax/QuillBlocks/blob/master/examples/
中有完整的
PDF
数据转换
delta-to-pdf.ts

delta-to-pdf.html
,以及添加
Outline

pdf-with-outline.ts
,可以通过
ts-node
和浏览器打开
HTML
来执行测试,特别注意使用
ts-node
进行测试的时候需要注意字体的引用。从数据层面转换生成
PDF
本身是件非常复杂的问题,而得益于诸多的开源项目我们可以比较轻松地完成这件事,但是当真正地将其应用到生产环境中时,实现完备的
PDF
导出能力同样也需要不断适配各种边界
case
情况,同样非常需要单元测试来辅助我们保持功能的稳定性。

jcode

每日一题

https://github.com/WindrunnerMax/EveryDay

参考

https://docx.js.org/
https://github.com/parallax/jsPDF
https://github.com/foliojs/pdfkit
https://github.com/Hopding/pdf-lib
https://quilljs.com/playground/snow
https://github.com/puppeteer/puppeteer
https://github.com/lillallol/outline-pdf
https://github.com/bpampuch/pdfmake/tree/0.2
http://officeopenxml.com/WPcontentOverview.php

在分布式系统中,数据的持久化是至关重要的一环。

Orleans 7 引入了强大的持久化功能,使得在分布式环境下管理数据变得更加轻松和可靠。

本文将介绍什么是 Orleans 7 的持久化,如何设置它以及相应的代码示例。

什么是 Orleans 7 的持久化?

Orleans 7 的持久化是指将 Orleans 中的状态数据持久化到外部存储介质,以便在应用程序重新启动或节点故障时能够恢复数据。

这对于构建可靠的分布式系统至关重要,因为它确保了数据的持久性和一致性。

持久化使得 Orleans 可以在不丢失数据的情况下处理节点故障或应用程序的重新启动。

它还可以用于支持扩展性和负载平衡,因为数据可以在集群中的不同节点上进行分布式存储。

Orleans 7 的持久化怎么设置?

持久化目前支持以下数据库:

  • SQL Server
  • MySQL/MariaDB
  • PostgreSQL
  • Oracle

我们拿SQL Server举例,首先需要安装基础包

Install-Package Microsoft.Orleans.Persistence.AdoNet

按照以下链接,创建对应的数据库表

https://learn.microsoft.com/zh-cn/dotnet/orleans/host/configuration-guide/adonet-configuration

并进行ADO.NET配置

var invariant = "System.Data.SqlClient";var connectionString = "Data Source=localhost\\SQLEXPRESS;Initial Catalog=orleanstest;User Id=sa;Password=1234;";//Use ADO.NET for clustering
siloHostBuilder.UseAdoNetClustering(options =>{
options.Invariant
=invariant;
options.ConnectionString
=connectionString;
}).ConfigureLogging(logging
=>logging.AddConsole()); ;
siloHostBuilder.Configure
<ClusterOptions>(options =>{
options.ClusterId
= "my-first-cluster";
options.ServiceId
= "SampleApp";
});
//Use ADO.NET for persistence siloHostBuilder.AddAdoNetGrainStorage("GrainStorageForTest", options =>{
options.Invariant
=invariant;
options.ConnectionString
=connectionString;
});

如何使用

可使用IPersistentState<TState> 的实例作为构造函数参数注入到 grain 中。

并可以使用 PersistentStateAttribute 属性批注这些参数,以标识要注入的状态的名称,以及提供该状态的存储提供程序的名称。

public classProfileState
{
public string Name { get; set; }public Date DateOfBirth { get; set; }
}
public interfaceIUserGrain : IGrainWithStringKey
{
Task
<string>GetNameAsync();
Task SetNameAsync(
stringname);
}
public classUserGrain : Grain, IUserGrain
{
private readonly IPersistentState<ProfileState>_profile;public UserGrain([PersistentState("profile", "GrainStorageForTest")] IPersistentState<ProfileState>profile)
{
_profile
=profile;
}
public async Task<string>GetNameAsync()
{
await_profile.ReadStateAsync();return awaitTask.FromResult(_profile.State.Name);
}
public async Task SetNameAsync(stringname)
{
_profile.State.Name
=name;await_profile.WriteStateAsync();
}
}

也可以使用Grain<TState> 为 grain 添加存储

[StorageProvider(ProviderName="store1")]public class MyGrain : Grain<MyGrainState>, /*...*/{/*...*/}