wenmo8 发布的文章

本文分享自华为云社区
《【GaussTech技术专栏】GaussDB性能调优》
,作者:GaussDB 数据库。

数据库性能调优是一项复杂且系统性的工作,需要综合考虑多方面的因素。因此,调优人员应对系统软件架构、软硬件配置、数据库配置参数、并发控制、查询处理和数据库应用拥有广泛而深刻的理解。

本文旨在剖析GaussDB性能调优的总体思路,探讨系统整体性能问题,以及对锁阻塞问题进行分析和优化。

1. 性能调优思路

GaussDB总体性能调优的思路是:先进行性能瓶颈点分析,找到相应的瓶颈点之后,再针对性地进行优化,直到系统性能到达业务可接受的范围内。

调优思路,如图1所示:

图1 GaussDB总体性能调优思路

首先,应该确认应用压力是否传递到数据库,可以通过分析数据库节点的资源使用情况,如CPU、I/O、内存以及数据库线程池、活跃会话等信息来辅助判断。GaussDB数据库的管控平台提供了丰富的监控指标体系,便于性能分析人员查看数据库的实时或者历史资源使用情况。

登录管控平台后,进入监控巡检菜单,选择监控大盘,即可查看对应实例的CPU/内存使用率,如图2所示:

图2 对应实例的CPU/内存使用率

点击磁盘/存储菜单,可以查看磁盘I/O使用率,重点关注磁盘读写速率以及时延是否符合预期,如图3所示:

图3 磁盘读写速率以及时延情况

点击网络菜单,可以查看网络传输速率及网卡是否有丢包、错包等情况,如图4所示:

图4 网络传输速率及网卡发送速率

选择连接菜单,可以查看数据库的连接及会话状态,如图5所示:

图5 连接及会话状态

图5中,如果活跃会话的占比远低于应用的并发数,说明数据库中大量会话处于空闲状态。同时,如果CPU使用率也很低,那么,就可以判断压力没到达数据库,此时需要排查应用端是否存在瓶颈。

导致应用侧瓶颈的问题比较常见的原因有:

  • 应用服务器资源瓶颈。比如,应用服务器的CPU满载,应用程序内存分配不足等;

  • 应用到数据库网络问题。比如,网络时延高,带宽满,存在丢包现象等;

  • 应用自身逻辑处理速度慢;

  • 应用配置不优,比如连接池参数、内存相关配置等设置不当。

例如,某个客户通过 jmeter 做大并发压测,性能不及业务预期。经过分析,发现是 jmeter 工具分配的最大可用内存不足,导致压力没有到达数据库。通过修改如下配置,问题得到了解决。

编辑jmeter.sh文件:set HEAP=-Xms1g -Xmx4g

确认压力到达数据库后,再针对相应的瓶颈点进行分析优化。主要从以下两个方面进行:

1)排查数据库中是否存在性能不优的业务SQL语句,并对性能不优的SQL进行优化。通过如下语句,查看数据库中耗时高的TOP SQL语句,并对那些执行性能不符合预期的SQL语句逐一进行分析与调优。

select unique_sql_id,substr(query,1,50) as query ,n_calls,round(total_elapse_time/n_calls/1000,2) avg_time,round(total_elapse_time/1000,2) as total_time from dbe_perf.summary_statement t where  n_calls>10 and avg_time>3  and user_name='root' order by total_time desc;

如图6所示,n_calls 表示该SQL语句在数据库中的执行次数,avg_time 为该SQL 语句的平均执行时间,total_time 为该SQL语句的总耗时。对于平均执行时间超过阈值的SQL语句,重点进行分析与优化。

图6 SQL语句指标及对应数据展示

针对执行性能不优的SQL语句,通过unique_sql_id可以查看该SQL语句的执行详情,帮助分析SQL语句的性能瓶颈点。

select * from dbe_perf.statement where unique_sql_id=3508314654;

如图7所示,该视图记录了SQL语句在数据库的详细执行情况,比如,总执行次数(n_calls)和总耗时(total_elapse_time),便于获取该SQL的总耗时以及平均耗时。

图7 SQL语句在数据库中的详细执行情况视图

行活动,
包括随机扫描、顺序扫描行数、返回的行数、插入/更新/删除的行数以及buffer命中的页面数等信息。此外,还记录了软解析(n_soft_parse)、硬解析(n_hard_parse)的次数,比如SQL大量硬解析导致的数据库CPU飚高,可以通过该指标进行分析定位。

时间模型
,包含db_time、cpu_time、execution_time、plan_time、data_io_time、net_send_info、net_recv_info、sort_time以及hash_time等指标,有助于判断SQL在数据库中的时间消耗在哪个阶段。例如,若某环境磁盘性能不佳,则data_io_time的耗时占比就会比较高。

如果需要进一步分析SQL本身的性能问题,比如执行计划是否最优、索引是否最优等性能问题,可以借助SQL的执行计划进行分析。

通过如下方式,可查看SQL的执行计划:

explain analyze SELECT c_id     FROM bmsql_customer     WHERE c_w_id = 1 AND c_d_id = 1 AND c_last = 'ABLEABLEABLE'     ORDER BY c_first;

结合SQL的执行计划,分析SQL性能的瓶颈点,再进行性能优化,如图8所示:

图8 SQL性能优化过程

2)从系统层面进行操作系统级和数据库系统级的调优,充分利用机器的CPU、内存、I/O和网络资源,避免资源冲突,从而提升整个系统查询的吞吐量。

2. 系统级性能问题分析

2.1 CPU使用率高

数据库的CPU使用率高,通常是由业务SQL语句引起的,我们可以通过如下方式,获取数据库中消耗CPU资源高的SQL语句,并对相应的业务SQL语句进行优化。

select unique_sql_id,substr(query,1,50) as query ,n_calls,round(total_elapse_time/n_calls/1000,2) avg_time,round(total_elapse_time/1000,2) as total_time,round(cpu_time/1000,2) as cup_time from dbe_perf.statement t where  n_calls>10 and avg_time>3  and user_name='root'  order by cpu_time desc limit 5;

常见的导致CPU资源消耗高的原因有:

  • SQL语句大量使用了全表扫描,这可能是由索引缺失、索引失效、执行计划不优等因素所导致。

  • SQL语句大量进行硬解析,通常是因为应用逻辑未使用PBE(Prepare Bind Execute)。

  • SQL语句扫描了大量的元组,比如,分区表分区剪枝失效,扫描了全分区,表中存在大量的死元组,导致扫描了大量无用页面等。

如果CPU使用率高是由非业务SQL语句引起的,可以借助火焰图来进行分析定位。通过火焰图,可以直观地了解程序中哪些函数占用了大量的 CPU 时间或资源,并且可以追踪函数调用路径。

GaussDB在内核505版本中内置了火焰图工具,默认每5分钟会自动采集一次,保存在$GAUSSLOG/gs_flamegraph/{datanode}路径下,详细信息可参考GaussDB产品文档《内置perf工具》章节。

例如,某客户在压测过程中发现数据库服务器的CPU SYS占用率超过70%,通过抓取压测期间的火焰图进行分析,如图9所示,发现数据库加载时,区文件的线程占比超过40%。

图9 某客户压测期间的火焰图

经分析,原因是在高并发频繁建立连接时,数据库每次建连都需要读取时区文件以获取时区信息,而应用未使用长连接,导致CPU SYS使用率飙升。

2.2 内存不足

内存资源,也是影响数据库性能的关键因素之一。在分析内存问题之前,我们先了解一下GaussDB的内存管理机制。

如图10所示,GaussDB的内存管理采用动态内存与静态内存相结合的方式,由参数 max_process_memory 控制数据库可用的最大内存。其中,静态内存区域主要用作数据库的共享缓冲区,用于缓存数据页面,由shared_buffers参数控制。动态内存区域,则由数据库根据需要进行动态分配,主要包括元数据的缓存、执行计划的缓存、用户建连以及内部线程的内存消耗等。

图10 GaussDB的内存管理机制

内存导致的性能问题,通常分为以下几个方面:

1)共享缓存区不足,导致SQL的buffer命中率低。为了查看相应的性能指标,可以借助GaussDB的管控平台或者WDR报告。通常情况下,TP数据库的buffer命中率应该在99%以上。如果数据库的buffer命中率较低,建议排查数据库的shared_buffers参数设置是否合理(如图11所示)。

图11 数据库的buffer命中率

2)在GaussDB中,SQL的hash join或者sort算子存在数据落盘操作,work_mem参数控制可下盘算子可用的物理内存空间。如果work_mem所限定的物理内存不够,算子运算的数据将被写入临时表空间,会带来5-10倍的性能下降。为了优化性能,可以查看SQL的执行计划,如果算子存在落盘的情况(如图12所示),可适当调整work_mem参数值。

图12 算子落盘情况

3)数据库动态内存不足,导致业务执行报错(ERROR:memory is temporarily unavailable )或者性能不足。当动态内存不足时,可以通过如下SQL语句找出内存消耗高的SQL语句,以便排查是否存在不优的SQL 语句。借助SQL的执行计划分析,可以检查是否有不合理的join顺序,或者是否存在非必要的排序操作,从而避免消耗大量内存。

select unique_sql_id,substr(query,1,50) as query ,n_calls,round(total_elapse_time/n_calls/1000,2) avg_time,round(total_elapse_time/1000,2) as total_time,hash_mem_used,sort_mem_used from dbe_perf.statement t where  n_calls>10 and avg_time>3  and user_name='root' order by (hash_mem_used+sort_mem_used) desc;

如果需要排查由非业务SQL语句导致的异常的内存消耗问题,比如内存堆积、内存泄露等,GaussDB提供了丰富的内存相关的监控视图,可以通过下面的视图(如图13所示),查看数据库节点的内存消耗情况。

图13 GaussDB内存相关的监控视图

基于上面的查询结果,如果dynamic_used_shrctx的占用率高,说明是全局共享动态内存的占用高。可以通过如下SQL语句,查看全局共享动态内存上下文的消耗情况。

select contextname, sum(totalsize)/1024/1024 totalsize, sum(freesize)/1024/1024 freesize, count(*) count from gs_shared_memory_detail group by contextname order by totalsize desc limit 10;

如果max_dynamic_memory的占用率高,但是dynamic_used_shrctx的占用率低,那么说明是线程或者会话占用的内存多。可以通过如下SQL语句,查询数据库线程的内存上下文消耗情况。

select contextname, sum(totalsize)/1024/1024 totalsize, sum(freesize)/1024/1024 freesize, count(*) sum from gs_thread_memory_context group by contextname order by sum desc limit 10;

查询结果如下图所示,可以看出,当前数据库中内存占用最高的为元数据的缓存(LocalSysCacheShareMemory)。结合图14中的查询结果,排查是否存在不合理的内存占用情况。

图14 数据库线程的内存上下文消耗情况

2.3 IO瓶颈

通过 iostat 命令,可以查看数据库节点 I/O 的繁忙度和吞吐量,分析是否存在由于 I/O 导致的性能瓶颈。如图15所示:

图15 数据库节点 I/O 的繁忙度和吞吐量

重点关注磁盘的读写吞吐量和读写时延。通常情况下,SSD盘的读写时延在2ms以下,单盘带宽在300MB以上。如果磁盘性能存在异常,优先排查硬件是否存在故障,如磁盘存在坏盘、慢盘、RAID卡故障或磁盘读写策略不正确等。如果磁盘硬件性能正常,而I/O 压力大,可以适当调整数据库I/O 相关的参数,以降低数据的I/O 消耗,从而优化数据库的整体性能。I/O 相关的关键参数链接如下:

后端写进程:
https://support.huaweicloud.com/distributed-devg-v2-gaussdb/gaussdb-12-1124.html

异步I/O:
https://support.huaweicloud.com/distributed-devg-v2-gaussdb/gaussdb-12-1125.html

2.4 网络异常

在传统集中式数据库环境下,应用服务器与数据库服务器通常部署在同一个机房内,从而确保应用与数据库间的网络开销较小。然而,在云+分布式数据库环境下,应用服务器到数据库服务器的网络链路较长,网络耗时对交易性能至关重要。在此情境下,我们不仅需要关注应用与数据库之间的网络状况(通常应该小于0.2ms),还需考虑数据库内部节点之间的网络情况,也会对性能产生较大的影响。

GaussDB要求AZ内网络时延小于0.2ms,AZ间的网络时延小于2ms,region间网络时延小于100ms。可以通过linux的ping命令,排查两个服务器之间的网络时延及丢包等情况,如图16所示:

图16 ping命令,排查两个服务器之间的网络时延及丢包等情况

通过 sar -n DEV 1 命令,查看网络的传输情况。

如图17所示,“rxkB/s”为每秒接收的千字节数,“txkB/s”为每秒发送的千字节数,主要关注每个网卡的传输量是否达到传输上限。

图17 sar -n DEV 1 命令,网络传输情况

3. 锁阻塞问题分析

数据库锁机制是一种用于管理并发访问的技术。它通过对数据库中的数据进行锁定,来确保在多个用户并发访问数据库时,数据的一致性和完整性。

在并发访问的场景下,经常会遇到因为锁冲突导致的性能问题。下面我们看一下在GaussDB中应该如何定位和分析锁冲突的问题。

如果应用正在运行,可以通过下面的SQL语句,查看当前数据库中正在执行的会话是否存在锁阻塞。

集中式场景:

SELECT a.pid as w_pid,a.query as w_query,a.state,d.query as locking_query,d.state as l_state,d.pid as l_pid,d.sessionid as l_sessionid
FROM pg_stat_activity AS a
JOIN pg_thread_wait_status b ON b.query_id = a.query_id
JOIN pg_thread_wait_status c 
ON c.sessionid = b.block_sessionid and c.node_name=b.node_name
JOIN pg_stat_activity d
on d.sessionid=c.sessionid
;

分布式场景:

SELECT a.pid as w_pid,a.query as w_query,a.state as w_state, a.datname, a.usename,d.query as lock_query,d.state as l_state,d.pid as l_pid,d.sessionid as l_sessionid
FROM pgxc_stat_activity AS a
JOIN pgxc_thread_wait_status b ON b.query_id = a.query_id
JOIN pgxc_thread_wait_status c ON c.sessionid = b.block_sessionid and c.node_name=b.node_name
JOIN pgxc_stat_activity d
on substring(d.global_sessionid,0,instr(d.global_sessionid,'#')) ilike substring(c.global_sessionid,0,instr(c.global_sessionid,'#'))
;

查询结果如图18所示,可以获取当前库中存在锁阻塞的SQL语句,同时获取到阻塞它的会话ID、线程ID以及对应的查询。

图18 锁阻塞查询结果展示

要找到并结束阻塞当前查询的会话,可以使用以下语句。

SELECT PG_TERMINATE_BACKEND(pid);

如果是历史的锁阻塞导致的性能问题,可以通过下面语句查询指定时间段内的数据库等待事件。如果发现有大量的acquire lock(包括transaction ID、relation、tuple)事件,表示该时间段内数据库存在锁阻塞问题。

select wait_status,event,count(*) from gs_asp where sample_time>='20241016 18:45:00' and sample_time <='20241016 19:00:00' group by 1,2 order by 3 desc;

ASP(Active Session Profile,活跃会话概要信息),通过采样实例中活跃会话的状态信息,以低成本的方式复现过去一段时间内的系统活动,主要包含会话基本信息、会话事务、执行的语句,等待事件,会话状态(如active、idle等)、当前正阻塞在哪个事件上、正在等待哪个锁或被哪个会话阻塞。

如图19所示,该时间段数据库占比最高的两个等待事件,一个是等待dn_6004_6005_6006分片返回执行结果,这需要进一步排查该分片上性能瓶颈的原因;另外一个等待事件是acquire lock(relation),表示存在大量的表级锁等待。

图19 特定事件内数据库占比最高的两个等待事件

结合数据库的归一化视图,可以获取数据库中存在锁等待的SQL语句,如图20所示:

图20 获取数据库中存在锁等待的SQL语句

通过该语句的Unique_query_id,获得查询阻塞该语句的query_id。

execute direct on datanodes $$select t1.unique_query_id,t1.thread_id,t1.sessionid,t1.wait_status,t1.event,t1.state,t2.query_id as lock_query_id from gs_asp t1,gs_asp t2 where t1.block_sessionid=t2.sessionid and  t1.unique_query_id=168353725$$;

如图21所示,lock_query_id 为阻塞该SQL语句的query_id。

图21 获取阻塞锁等待SQL语句的query_id

利用上一步查询出来的query_id,并结合gs_asp视图,可以通过如下语句获取该SQL语句的详情。查询结果如图22所示,可以看到,阻塞该语句的也是同一张表的update语句,这表明是由于并发更新同一行数据所导致的锁冲突。

图22 锁等待的SQL语句查询结果

通常情况下,解决并发更新锁冲突问题的解决思路需要从业务角度出发,审视存在并发更新同一行的情况是否符合业务场景。如果业务中不存在这样的场景,那应该从业务逻辑或者业务数据上进行优化,以避免并发更新同一行的情况发生。

4. 总结

数据库性能调优涉及硬件、操作系统、数据库、应用等多个层面,因此,在性能调优过程中,需要综合考虑各方面因素的影响。本文介绍了在GaussDB中分析性能问题时常见的手段和思路,帮助大家熟悉GaussDB数据库性能诊断常用的工具及使用方法。


华为开发者空间,汇聚鸿蒙、昇腾、鲲鹏、GaussDB、欧拉等各项根技术的开发资源及工具,致力于为每位开发者提供一台云主机、一套开发工具及云上存储空间,让开发者基于华为根生态创新。
点击链接
,免费领取您的专属云主机。

点击关注,第一时间了解华为云新鲜技术~

在PDF中绘制图形可以增强文档的视觉效果。通过添加不同类型的形状,如实线、虚线、矩形、圆形等,可以使文档更加生动有趣,提高读者的阅读兴趣。这对于制作报告、演示文稿或是教材特别有用。本文将通过以下几个示例介绍如何
使用Python 在PDF中绘制不同的图形

  • Python 在PDF中绘制实线、虚线
  • Python 在PDF中绘制矩形
  • Python 在 PDF 中绘制椭圆形

所需Python库
-
Spire.PDF for Python
。可以通过下面的pip 命令直接安装:

pip install Spire.Pdf

Python 在PDF中绘制实线、虚线

Spire.PDF for Python库提供了
PdfPageBase.Canvas.DrawLine()
方法用于在PDF页面指定位置绘制线条。通过设置画笔PdfPen的样式,可以绘制实线或虚线。

Python 代码:

from spire.pdf.common import *
from spire.pdf import *
 
#创建PDF文档
pdf =PdfDocument()#添加页面
page =pdf.Pages.Add()#保存当前绘图状态
state =page.Canvas.Save()#指定线条起始点的X和Y坐标
x = 100.0y= 50.0   
 
#制定线条长度
width = 300.0  
 
#创建指定颜色和粗细的画笔
pen = PdfPen(PdfRGBColor(Color.get_Blue()), 2.0)#用画笔在页面上绘制实线
page.Canvas.DrawLine(pen, x, y, x +width, y)#设置画笔样式为虚线
pen.DashStyle =PdfDashStyle.Dash#设置虚线样式为[1, 4, 1]
pen.DashPattern = [1, 4, 1]#指定虚线起始点 y 坐标
y = 80.0  
 
#用画笔页面上绘制虚线
page.Canvas.DrawLine(pen, x, y, x +width, y)#恢复之前保存的绘图状态
page.Canvas.Restore(state)#保存PDF文档
pdf.SaveToFile("绘制线条.pdf")
pdf.Close()
pdf.Dispose()

Python 在PDF中
绘制矩形

PdfPageBase.Canvas.DrawRectangle()
方法可用于在PDF页面指定位置绘制矩形。通过向该方法中传递不同的参数,可以指定矩形的大小、填充色等。

Python 代码:

from spire.pdf.common import *
from spire.pdf import *
 
#创建PDF文档
pdf =PdfDocument()#添加页面
page =pdf.Pages.Add()#保存当前绘图状态
state =page.Canvas.Save()#创建指定颜色和粗细的画笔
pen = PdfPen(PdfRGBColor(Color.get_Blue()), 1.5)#用画笔在页面上绘制一个矩形
page.Canvas.DrawRectangle(pen, RectangleF(PointF(20.0, 60.0), SizeF(150.0, 90.0)))#创建一个线性渐变笔刷
linearGradientBrush = PdfLinearGradientBrush(PointF(220.0, 60.0), PointF(350.0, 180.0), PdfRGBColor(Color.get_Green()), PdfRGBColor(Color.get_Pink()))#用线性渐变笔刷绘制一个填充式矩形
page.Canvas.DrawRectangle (linearGradientBrush, RectangleF(PointF(220.0, 60.0), SizeF(150.0, 90.0)))#恢复之前保存的绘图状态
page.Canvas.Restore(state)#保存PDF文档
pdf.SaveToFile("绘制矩形.pdf")
pdf.Close()
pdf.Dispose()

Python 在 PDF 中绘制椭圆形

在PDF页面指定位置绘制椭圆形可以使用
PdfPageBase.Canvas.DrawEllipse
()

方法。通过指定不同的PDF画笔或画刷,可以绘制不同样式的椭圆形。

Python 代码:

from spire.pdf.common import *
from spire.pdf import *
 
#创建PDF文档
pdf =PdfDocument()#添加页面
page =pdf.Pages.Add()#保存当前绘图状态
state =page.Canvas.Save()#创建画笔
pen =PdfPens.get_Violet()#用画笔在页面上绘制椭圆形
page.Canvas.DrawEllipse(pen, 30.0, 60.0, 150.0, 100.0)#创建填充画刷对象
brush =PdfSolidBrush(PdfRGBColor(Color.get_Violet()))#绘制填充的椭圆形状
page.Canvas.DrawEllipse(brush, 220.0, 60.0, 150.0, 100.0)#恢复之前保存的绘图状态
page.Canvas.Restore(state)#保存PDF文档
pdf.SaveToFile("绘制椭圆形.pdf")
pdf.Close()
pdf.Dispose()


生成文档中的红色水印,可以通过点击以下链接,申请一个月免费授权来去除:

https://www.e-iceblue.cn/misc/temporary-license.html

在上一篇文章中,我们介绍了Wgpu中的渲染管线与着色器的概念以及基本用法。相信读者还记得,我们在渲染一个三角形的时候,使用了三角形的三个
顶点的索引
作为了顶点着色器的输入,并根据索引值计算了三个几何顶点在视口中的位置,并通过片元着色器的代码逻辑,控制了每一个像素都用红色色值,最终渲染了一个红色三角形:

010

当然,我们不可能一直使用wgpu来渲染这样的简单固定的图形。面对实际的场景,我们有时候需要根据一些上下文来动态的修改渲染图形的大小形状。在本文中,我们将开始介绍顶点缓冲区的概念,来为后续实际的场景做一些铺垫。

认识缓冲区

缓冲区
(Buffer)一个可用于 GPU 操作的内存块(又叫“显存”)。在wgpu(或其他例如OpenGL等库)中的缓冲区概念通常指的是 GPU 能读写的内存区域,与之对应的就是我们常见的CPU内存。回想一下常规的软件运行的过程:程序在启动后,会在“内存”中申请一块能够存放数据的区域。在运行的过程中,我们的代码指令按照既定的逻辑做着计算,并不断的读、写内存区域里面的数据,以达到期望的程序运行的结果。
不严谨地讲
,GPU 与 CPU 是一样的,它同样能够执行计算逻辑,同样会有数据存储的区域,这个区域就是 GPU 的缓冲区。

一般来说,我们都在 CPU 直接阶段,在内存中将一些初始的数据准备好,通过一定的方式发送给 GPU,并存储在GPU上的缓冲区中。在执行的过程中,我们可以通过着色器代码来读取缓冲区中的数据:

020

创建顶点缓冲区

为了更好的管理不同类型的数据(比如常见的有顶点数据、顶点索引数据),我们会按照其不同类型来设定不同的缓冲区。在本文中,我们先介绍如何创建并使用顶点缓冲区,对于其他缓冲区我们会在后续的文章中说明。

顶点缓冲区,顾名思义,就是包含了在渲染过程中会使用到的
顶点数据
的 GPU 显存区域。需要注意的是,图形学中的顶点并不是我们常规意义上的几何顶点,而是包含了位置坐标、颜色信息、纹理坐标以及法线向量等的顶点数据,常规意义上的几何顶点
仅仅
是顶点数据中的一部分。

在上一篇文章中,尽管在最后我们成功终绘制了一个三角形,但实际上它的三个顶点位置是通过三个顶点索引(0、1、2)计算而来的。假设我期望绘制一个比较另类的三角形或其他图形,纯粹靠顶点索引是不够的。这种场景我们一般会按照如下的方式进行:

  1. 准备一些包含自定义位置信息的顶点数据;
  2. 将顶点数据放置到顶点缓冲区中,并进行一定的配置;
  3. 最后,在着色器代码中通过一定的方式读取这些顶点数据,并交给顶点着色器来使用。

接下来让我们开始实践如何通过编程方式创建顶点缓冲区。

假设最终我们期望渲染一个由
(0, 1)

(-0.5, -0.5)

(0.5, 0)
三个2维顶点构成的三角形:

030

首先,让我们在基础项目中增加一个结构体Vertex,用来表达我们的顶点:

040

这个结构体我们现在仅有一个类型为
[f32; 3]
类型的字段
position
,用来表示一个位置坐标。

⚠️这里务必添加Copy派生

引申:关于内存布局

该结构体上的属性,除了我们常见用来派生
Copy

Clone
等trait的
derive
属性外,还有一个特殊的属性:
#[repr(C)]
,在配置该属性后,Rust 编译器会强制
按照 C 编译器
的编译方式来安排结构体字段的顺序和
对齐方式
。假设有如下结构体,在
#[repr(C)]
的加持下,其内存布局会保持4字节对齐:

050

上面的结构体中,age字段的类型是u8,但因为强制使用了
#[repr(C)]
,让其保持了4字节的内存布局。我们可以用如下的代码来验证:

060

当然,有的小伙伴会发现即使不添加
#[repr(C)]
,结果也是24bytes,是因为Rust编译器在某些场景下会进行对齐,
不过这样无法保证是按照和C编译器一样的4字节对齐
;此外,Rust编译器在有时为了内存的高效利用,可能会进行布局压缩。当然,你还可以使用
#[repr(packed)]
来禁用内存对齐填充:

070

好了,让我们回归正文。此时我们已经编写了一个
Vertex
结构体,也理解了
#[repr(C)]
的意义。接下来,我们创建一个数组切片来存放三个顶点的数据:

// 表示三角形三个顶点的顶点列表
pub const VERTEX_LIST: &[Vertex] = &[
    Vertex { position: [0.0, 1.0, 0.0] },
    Vertex { position: [-0.5, -0.5, 0.0] },
    Vertex { position: [0.5, 0.0, 0.0] },
];

在编写了三个顶点的数据后,我们更进一步,将顶点缓冲区创建出来消费顶点数据。

首先,让我们在
async_new
方法中的合适位置通过调用Device实例的
create_buffer_init
方法创建一个顶点缓冲区对象:

080

contents
字段需要我们提供
&[u8]
类型的数据,即字节数组的切片引用,这里我们先传空,待会儿会讲到如何将我们的
VERTEX_LIST
数据转为
&[u8]
类型的数据;
usage
字段我们现在传入
wgpu::BufferUsages::VERTEX
这个枚举,表明我们要创建的是一个顶点缓冲区,而不是其他的缓冲区。

接下来,我们尝试将前面创建的
VERTEX_LIST
数据转换为
&[u8]
字节数据。这里我们使用一个工具库
bytemuck
,该库可以方便的将我们的一些数据结构转为内存中的字节数据。其具体方式如下:

  1. 在依赖中添加
    bytemuck
  2. 修改
    Vertex
    结构体的内容:

090

  1. 在创建缓冲区的地方添加如下的转换代码:

100

调用bytemuck的cast_slice方法,将原始数据转为u8的切片,并作为contents字段的值传入

  1. 修改
    WgpuCtx
    结构体,保存我们本次创建的顶点缓冲区实例:

110

总结一下,为了创建一个顶点缓冲区,我们经历如下几步:

  1. 定义一个结构体(
    Vertex
    )来表示一个顶点数据,该结构体除开配置
    #[derive(Copy, Clone)]
    属性外,还需要使用
    #[repr(C)]
    来保证该结构体在编译后的内存布局及对齐字节数据保持和C编译器一样;以及,让结构体实现来自
    bytemuck
    库提供的
    Pod

    Zeroable
    两个trait,以供后续通过
    bytemuck
    的提供的API来将数据转为
    &[u8]
  2. 完成
    Vertex
    结构体的定义后,我们又根据最终想要渲染的三角形的几何结构,使用
    VERTEX_LIST
    来存储了三个顶点数据。
  3. 使用
    bytemuck
    提供的API将
    VERTEX_LIST
    通过将其转为了u8字节数组切片字节数据。
  4. 调用Device提供的API
    create_buffer_init
    ,传入顶点数组字节数据,以创建一个顶点缓冲区实例。
  5. 将顶点缓冲区实例存储到WgpuCtx实例,以供后续消费使用。

至此,对于创建顶点缓冲区部分的介绍就到此为止。接下来我们需要介绍另一个同样重要的内容:
顶点缓冲区布局(VertexBufferLayout)

创建顶点缓冲区布局

首先,我们需要明白为什么会有
缓冲区布局
这一东西。假设现在在 GPU 显存中有如下的一段数据:

120

在没有其他上下文的情况下,我们无法理解这段内存中的数据有何意义。同样的,如果我们单是把先前创建的顶点数据放入顶点缓冲区中,在实际渲染的过程中,GPU 也无法理解这一堆的二进制数据应该如何使用。此时,我们就需要用一些配置上下文来解释顶点缓冲区中的数据的具体意义。

还是以上图数据为例,如果现在告诉你这是一段包含了
3
个顶点数据的内存布局,其步进(stride)是3字节(即每三个字节就算做一个顶点数据);同时,单看每一份顶点数据,按照从其
偏移字节为0
的地方开始是一份位置数据,其类型为3个float32(32bits,即4bytes)数据,现在对于这段内存中的数据的布局结构是不是变的比较清晰了呢:

130

有了上述的说明,再回过头来就不难理解缓冲区布局的意义了。接下来就让我们通过代码实践来定义一个顶点缓冲区布局实例。

首先,我们依然在
vertex.rs
文件中增加一个方法,用来返回一个顶点缓冲区布局实例:

140

对于该方法的实现,我们就是返回了如下的一个结构体:

wgpu::VertexBufferLayout {
    array_stride: size_of::<Vertex>() as wgpu::BufferAddress,
    step_mode: wgpu::VertexStepMode::Vertex,
    attributes: &[
        wgpu::VertexAttribute {
            offset: 0,
            shader_location: 0,
            format: wgpu::VertexFormat::Float32x3,
        },
    ],
}
  • 字段
    array_stride
    表示的就是每一份顶点数据在内存中的
    步进
    长度,在本例中,一个
    Vertex
    结构体在
    #[repr(C)]
    的属性配置下能够确保是12bytes。

  • 字段
    step_mode
    我们暂时不详细介绍,读者可以简单理解为告诉渲染管线每一份数据代表的是一个顶点数据(),这里默认使用该枚举值
    VertexStepMode::Vertex
    即可。

  • 字段
    attributes
    是一个数组切片引用,在这里我们只传递了一个
    VertexAttribute
    数据,表示就目前而言,我们一份顶点数据中,只有一份有意义的“子数据”。对于这份“子数据”,我们配置了
    offset

    shader_location
    以及
    format
    字段。这三个字段整体表达了这样一个事实:在一份顶点数据中,从
    offset = 0
    开始有一段格式为
    Float32x3
    (float32 = 32bits = 4bytes, 乘以3就等于12bytes)的数据,这段数据在shader着色器上的location为0的位置。相信读者对offset和format应该能够理解,但是对于“这段数据在shader着色器上的location为0的位置”这句话还有些难以理解,别着急,我们后面会讲到的。

消费缓冲区及布局

总结下现状,我们首先创建了顶点缓冲区并将其作为
vertex_buffer
存放到了
WgpuCtx
实例中;同时,我们还编写一个名为
create_vertex_buffer_layout
的方法用来构造一个顶点缓冲区布局实例,接下来我们会使用到上面准备工作的成果了。

首先,让我们在
WgpuCtx

draw
方法中适当修改代码来消费顶点缓冲区:

150

在调用渲染通道(RenderPass)实例的
draw
方法前,我们先调用
set_vertex_buffer
方法。该方法接受两个参数,第一个参数slot指的是我们要把顶点缓冲区中的数据放置到显存内部的顶点缓冲区域的哪个索引位置,这里我们设置为0,表示我们会设置到默认0的位置;第二个参数使用的缓冲区的数据片段,这里我们直接消费整个顶点数据,因此代码编写为
slice(..)

然后,修改
draw
的参数传递。将原来固定的
0..3
(即3个顶点)修改为动态的,根据我们创建的
VERTEX_LIST
的实际长度,这样在将来我们会创建更多的顶点的时候,就能够正确对应顶点数量。

完成消费顶点缓冲区的代码编写以后,接下来我们就需要再适当的位置创建缓冲区布局实例并消费它,其具体做法是:

调用
create_vertex_buffer_layout
方法得到缓冲区布局实例对象;把该实例对象传递给如下
VertexState

buffers
字段:

160

这个地方叫做buffers,但是实际上是要传buffer布局,maybe命名有点让人误导。

到目前为止内容偏多,让我们通过下图做一个简单的总结:

170

修改顶点着色器程序

上面的实践过程,我们仅仅是创建并消费了顶点缓冲区以及顶点缓冲区布局实例。然而,如果在此时运行程序代码,读者会发现窗口中依然是先前的一个撑满窗口的红色三角形。很显然,我们需要适当的修改着色器程序的代码,才能真正消费到我们在上面产生的有关顶点数据。让我们对
shader.wgsl
做出如下的修改:

180

首先,我们在着色器代码中定义了一个结构体
VertexInput
,这个结构体包含有一个
position
字段,其类型为
vec3f

值得注意的是,这个字段有一个前置的注解
@location(0)
。还记得前面我们说过:“这段数据在shader着色器上的location为0的位置”这句话吗?其实这里的
location(0)
对应匹配的就是前面在定义顶点缓冲区布局的
shader_location
配置:

190

对于顶点数据、顶点缓冲区布局配置以及着色器中
VertexInput
的结构定义,我们就可以用下图来解释它们的关系了:

200

再看顶点着色器
vs_main
的部分,其入参由原来的
@builtin(vertex_index) in_vertex_index: u32
修改为了
vertex_in: VertexInput
。在每次顶点着色器运行的时候,渲染管线会结合顶点缓冲区布局配置以及每一份内存中的顶点数据,为我们构建一个
VertexInput
结构体实例,并传入该顶点着色器方法中。在这里我们就可以直接读取到对应的position位置字段数据并直接返回了。

而对于片元着色器,我们暂时没有任何改动。因此,在一切准备工作结束以后,让我们运行程序,会发现最终渲染的三角形确实如我们所期望的结构那样展示了:

210

给顶点数据加入更多的信息

在本文中,由于我们的顶点数据结构体
Vertex
中只包含了一个类型为
[f32; 3]
的位置数据字段,因此在设置
VertexBufferLayout

attributes
字段的时候,我们只传入了一个
VertexAttribute
配置,并且其offset为0,代表了我们的一份在内存中的顶点数据,只包含一份属性数据,且是从偏移字节为0开始的。当然,正如前面提到的,顶点数据并非只会有位置数据,通常伴随着的还会有颜色信息、法线信息等。在这里,我们尝试给顶点加入颜色数据,好在着色器处理阶段能够定制三角形的颜色。

首先,让我们尝试修改
Vertex
结构体,加入一个颜色字段:

220

完成以后,可以想象到,把
VERTEX_LIST
数据转为字节数据放到顶点缓冲区以后,其内存布局会是如下形式:

230

如果读者理解了前面提到的offset的含义,那么就不难想到,为了让顶点着色器能够访问到颜色信息。我们需要将顶点缓冲区布局中关于attributes字段增加一条配置:

240

  1. 在顶点缓冲区布局对象的
    attributes
    字段,在原有基础上,再插入一份
    VertexAttribute
    配置,代表了要配置颜色信息;
  2. 对于新加的
    VertexAttribute
    ,其
    offset
    字段填入的值是偏移过position字节数据长度;
  3. 将颜色信息数据指定为着色器中的location为1的地方。

接下来,我们只需要修改着色器代码
VertexInput
结构体,增加一个color字段,

250

此时,渲染管线在构造这个
VertexInput
实例的时候,就能知道除了原有position字段数据外,还会把显存中的一份顶点数据的后面float32x3的大小数据映射到
@location(1) color: vec3f
上了。

当然,仅仅给
VertexInput
增加color字段,对于我们最终的渲染效果目前来说是没有任何影响的,因为我们压根儿没有消费这个color字段。为了消费这个字段,并让最终渲染的三角形的颜色产生变化,接下来就让我们关注一下着色器代码,看看还需要做什么。

首先,我们之前讲到过,对于顶点着色器的方法,我们返回的是
@builtin(position) vec4<f32>
,这意味着每次顶点着色器运行以后,会得到一个顶点的位置数据。在本例中,在所有顶点都执行以后,我们会得到3个顶点位置,渲染管线会拿着这3个位置构建一个三角形,并进行栅格化,再调用片元着色器,然后我们会再片元着色器中为每一个像素指定颜色。那么这里有一个问题:我们只能够在
顶点
着色器中返回每个顶点的位置吗?答案当然是否定的。除了直接返回一个
@builtin(position)
修饰的数据类型,我们还可以返回一个结构体,只要这个结构体中有一个字段用
@bultiin(position)
修饰即可:

260

这段修改后的代码的效果其实和之前是一样的,只不过我们用过了结构体来包裹。在返回结构体的形式下,我们可以在结构体中加入一些其他的字段,并且,在片元着色器节点还可以访问到顶点着色器输入结构体数据:

270

上述的着色器代码编写完成以后,理论上运行程序,你会发现如下的效果:

280

wow,一个
渐变
的三角形!然而,这就结束了吗?非也!

如何得到颜色

⚠️笔者水平有限,因此后面的内容笔者仅能靠自己目前浅显的理解进行总结,其中可能会存在一些不到位或不正确的理解,这里恳请相关专业人士对错误的内容批评指出。

如果读者认真看到现在,并且仔细思考了以后,我相信你会有一些疑问。首先,目前我们只有3个顶点,那么顶点着色器理论上来讲只会被调用3次,也就是说,我们总共只会得到3个
VertexOutput
数据并返回给渲染管线,且这3个
VertexOutput
实例的color字段分别只会是
(1.0, 0.0, 0.0)
(红色)、
(0.0, 1.0, 0.0)
(绿色)以及
(0.0, 0.0, 1.0)
(蓝色)。然而,我们最终渲染的三角形是一个
渐变
三角形。根据片元着色器的作用,它会在每一个像素处理阶段被调用,这是否能够表明一件事:片元着色器代码中的消费的
color
字段,和前面的
VertexOutput

color
其实不是一个东西?答案确实如此。

290

细心的读者会发现,在
fs_main
入参,尽管类型是
VertexOutput
,但我刻意的避免了使用
vertex_output
作为名称,而是使用了
data
,其实就在暗示从顶点着色器
vs_main
返回的
VertexOutput
跟这里片元着色器
fs_main
得到的输入
VertexOutput
实例并不是一个东西。让我们开拓下思维,结构体的本质是什么?
实际上,结构体只是一种对内存数据的具名表达而已
,在这里我们仅仅通过了
VertexOutput
这个具名的描述内存数据形态的标识作为了顶点着色器和片元着色器的桥梁而已。

300

换句话说,如果我们改成下面的代码,我们的程序同样能够正确的运行:

310

那顶点着色器输出的位置和颜色信息,最终是如何影响到片元着色器的输入的呢?对于渲染管线来说,在顶点着色器执行以后,它会得到三个顶点数据,而其中就有通过
@builtin(position)
标识的,能够表达位置信息的数据。很显然,有了三个点的位置信息,在图元装配结合光栅化以后,我们能够得到一个最终图形的上的任意一个像素点位置信息:

320

每一个顶点中我们都增加了一份数据用来表示颜色(color字段)。那么,对于三角面中任意一个像素的点位置的color字段数据,实际上是三个顶点颜色数据在
此位置
上的算法叠加:

330

即,我们可以用一个方法来表达三角面上任意一个点的颜色:

fn color((v1_pos, v1_color), (v2_pos, v2_color), (v3_pos, v3_color), any_pos) -> color

通过输入三个顶点的位置和颜色,以及任何一个三角面上的点位置,就能算出该点的颜色数据。但值得注意的是,在我们的场景中,我们对
@location(0)
位置的数据取名为了color,表明用该字段作为颜色字段,但是在内存中,管线只知道这里有一份类型为
vec3f
的数据罢了。所以,对应的更加通用的公式应该是:

fn get_data((v1_pos, v1_data), (v2_pos, v2_data), (v3_pos, v3_data), any_input_pos) -> data_in_pos

那么关于这个的具体实现在本文中不再细讲,但最简单的方式应该就是线性叠加,读者可以自行深入这块的内容。

关于@location

另外我们还需要着色器代码解释另一个东西。仔细观察代码,无论是
VertexOutput
还是
FragmentInput
结构体,我们都在
color
这个字段使用了注解
@location(0)
。在前面的
VertexInput
结构体的中的
color
字段我们使用了同样的注解,其含义是我们把一份内存中对应位置的数据设置为了着色器中一个结构体中
location = 0
的位置的字段。那么这里是不是也是同样的意义呢?答案确实是如此的。

读者可以这样理解,在光栅化后,每一次片元着色器的输入也是一份内存数据,这份内存数据我们同样可以使用一个结构体来访问(因为结构体是内存中的数据的可读性表达),但是结构体中的字段可以有很多个,每个字段究竟是内存中哪一块的数据,需要有一个明确的指明:

340

在上图中,无论是
VertexOutput
结构体还是
FragmentInput
结构体,其内存的布局是一致的,因此在片元着色器执行的时候,渲染管线提供的数据我们用上述两种结构体够可以表达对应的内存数据。再想的远一点,这个
location
只能是0吗?其实也不是,因为本质上讲,它是一段数据的标识,只是本例中,我们使用了0这个位置标识而已,如果你乐意,你还可以编写如下标识:

350

甚至,你还可以不编写任何的结构体作为输入,而是直接使用
@location
来定位:

360

对于最后的一种使用方式,请读者自己揣摩一下~

写在最后

本文的内容较多引申了不少额外的内容,读者可以慢慢阅读消化,希望能够对认识wgpu以及图形学工程有更进一步的理解和认识。在接下来的内容,我们将会认识wgpu中有关于以及图形学工程相关的更多的内容,敬请期待!

本章的代码仓库在这里:

https://github.com/w4ngzhen/wgpu_winit_example/tree/main/ch03_buffer

后续文章的相关代码也会在该仓库中添加,所以感兴趣的读者可以点个star,谢谢你们的支持!


Reviewbot
是七牛云开源的一个项目,旨在提供一个自托管的代码审查服务, 方便做 code review/静态检查, 以及自定义工程规范的落地。


在日常的编程协作中,Git commit 记录的质量往往反映了一个工程师的工程素养。然而,我经常能看到一些不太规范的 commit 记录。有时,真的不敢恭维。

比如这种:

这种大概率是提交 commit 之后,又有变动,就随手重新复用上一条 git commit 命令了。

这种记录如果出现在个人仓库,可能还好. 但如果是多人协作的仓库,就有点不专业了。

在我看来,这些 commit 记录完全没必要,是非常不好的习惯,完全可以避免。

好在 Git 为我们提供了优雅的解决方案。如果没必要生成新的 commit,那直接使用
git commit --amend
就可以避免。

少用
git merge
多用
git rebase

比如这种:

Merge branch 'feature-A' of https://github.com/qiniu/reviewbot into feature-B

说的是把远程分支 feature-A 的代码合并到 feature-B 里。这里的 feature-A 通常是主分支。

这种 Commit 信息如果出现在你的 PR 里,那是完全没必要。PR 里的 commit 信息应当仅包含针对本次改动的有用信息。

我个人日常几乎不使用
git merge
,即使是为了同步远程分支,我一般都会使用
git rebase

比如:

git rebase 除了上述好处外,还可以保持主仓库的 commit history 非常干净。所以强烈推荐大家使用。

Reviewbot 的 git commit check

为了更好的规范上述两种行为,Reviewbot 也添加了 git commit check 能力,就是用来检查 git commit 记录是否符合规范的。

如果不符合规范,Reviewbot 就会提示你:

更多 git flow 使用规范和技巧

当然 git 操作其实有很多实用技巧,建议大家有兴趣的话可以去研究下。我在 1024 实训营的时候,有给同学们做个相关分享:

超实用! 从使用视角的 Git 协作实战,告别死记硬背

文档里面有视频链接,感兴趣的同学可以去看下。

最后,作为专业的工程师,我们应该始终追求卓越的工程实践。良好的 commit 记录不仅体现了个人的专业素养,更是提升团队协作效率的重要基石。

通过合理使用 git rebase 和 git commit --amend,我们可以维护一个更清晰、更专业的代码提交历史。这不仅让代码审查变得更加轻松,也为后续的代码维护和问题追踪带来极大便利。

你觉得呢?

Slate文档编辑器-WrapNode数据结构与操作变换

在之前我们聊到了一些关于
slate
富文本引擎的基本概念,并且对基于
slate
实现文档编辑器的一些插件化能力设计、类型拓展、具体方案等作了探讨,那么接下来我们更专注于文档编辑器的细节,由浅入深聊聊文档编辑器的相关能力设计。

关于
slate
文档编辑器项目的相关文章:

Normalize


slate
中数据结构的规整是比较麻烦的事情,特别是对于需要嵌套的结构来说,例如在本项目中存在的
Quote

List
,那么在规整数据结构的时候就有着多种方案,同样以这两组数据结构为例,每个
Wrap
必须有相应的
Pair
的结构嵌套,那么对于数据结构就有如下的方案。实际上我觉得对于这类问题是很难解决的,嵌套的数据结构对于增删改查都没有那么高效,因此在缺乏最佳实践相关的输入情况下,也只能不断摸索。

首先是复用当前的块结构,也就是说
Quote Key

List Key
都是平级的,同样的其
Pair Key
也都复用起来,这样的好处是不会出现太多的层级嵌套关系,对于内容的查找和相关处理会简单很多。但是同样也会出现问题,如果在
Quote

List
不配齐的情况下,也就是说其并不是完全等同关系的情况下,就会需要存在
Pair
不对应
Wrap
的情况,此时就很难保证
Normalize
,因为我们是需要可预测的结构。

{
    "quote-wrap": true,
    "list-wrap": true,
    children: [
        { "quote-pair": true, "list-pair": 1, children: [/* ... */] },
        { "quote-pair": true, "list-pair": 2, children: [/* ... */] },
        { "quote-pair": true,  children: [/* ... */] },
        { "quote-pair": true, "list-pair": 1, children: [/* ... */] },
        { "quote-pair": true, "list-pair": 2, children: [/* ... */] },
    ]
}

那么如果我们不对内容做很复杂的控制,在
slate
中使用默认行为进行处理,那么其数据结构表达会出现如下的情况,在这种情况下数据结构是可预测的,那么
Normalize
就不成问题,而且由于这是其默认行为,不会有太多的操作数据处理需要关注。但是问题也比较明显,这种情况下数据虽然是可预测的,但是处理起来特别麻烦,当我们维护对应关系时,必须要递归处理所有子节点,在特别多层次的嵌套情况下,这个计算量就颇显复杂了,如果在支持表格等结构的情况下,就变得更加难以控制。

{
    "quote-wrap": true,
    children: [
        {
            "list-wrap": true,
            children: [
                { "quote-pair": true, "list-pair": 1, children: [/* ... */] },
                { "quote-pair": true, "list-pair": 2, children: [/* ... */] },
            ]
        },
        { "quote-pair": true,  children: [/* ... */] },
        { "quote-pair": true,  children: [/* ... */] },
    ]
}

那么这个数据结构实际上也并不是很完善,其最大的问题是
wrap - pair
的间隔太大,这样的处理方式就会出现比较多的边界问题,举个比较极端的例子,假设我们最外层存在引用块,在引用块中又嵌套了表格,表格中又嵌套了高亮块,高亮块中又嵌套了引用块,这种情况下我们的
wrap
需要传递
N
多层才能匹配到
pair
,这种情况下影响最大的就是
Normalize
,我们需要有非常深层次的
DFS
处理才行,处理起来不仅需要耗费性能深度遍历,还容易由于处理不好造成很多问题。

那么在这种情况下,我们可以尽可能简化层级的嵌套,也就是说我们需要避免
wrap - pair
的间隔问题,那么很明显我们直接严格规定
wrap
的所有
children
必须是
pair
,在这种情况下我们做
Normalize
就简单了很多,只需要在
wrap
的情况下遍历其子节点以及在
pair
的情况下检查其父节点即可。当然这种方案也不是没有缺点,这让我们对于数据的操作精确性有着更严格的要求,因为在这里我们不会走默认行为,而是全部需要自己控制,特别是所有的嵌套关系以及边界都需要严格定义,这对编辑器行为的设计也有更高的要求。

{
    "quote-wrap": true,
    children: [
        {
            "list-wrap": true,
            "quote-pair": true,
            children: [
                { "list-pair": 1, children: [/* ... */] },
                { "list-pair": 2, children: [/* ... */] },
                { "list-pair": 3, children: [/* ... */] },
            ]
        },
        { "quote-pair": true,  children: [/* ... */] },
        { "quote-pair": true,  children: [/* ... */] },
        { "quote-pair": true,  children: [/* ... */] },
    ]
}

那么为什么说数据结构会变得复杂了起来,就以上述的结构为例,假如我们将
list-pair: 2
这个节点解除了
list-wrap
节点的嵌套结构,那么我们就需要将节点变为如下的类型,我们可以发现这里的结构差别会比较大,除了除了将
list-wrap
分割成了两份之外,我们还需要处理其他
list-pair
的有序列表索引值更新,这里要做的操作就比较多了,因此我们如果想实现比较通用的
Schema
就需要更多的设计和规范。

而在这里最容易忽略的一点是,我们需要为原本的
list-pair: 2
这个节点加入
"quote-pair": true
,因为此时该行变成了
quote-wrap
的子元素,总结起来也就是我们需要将原本在
list-wrap
的属性再复制一份给到
list-pair: 2
中来保持正确的嵌套结构。那么为什么不是借助
normalize
来被动添加而是要主动复制呢,原因很简单,如果是
quote-pair
的话还好,如果是被动处理则直接设置为
true
就可以了,但是如果是
list-pair
来实现的话,我们无法得知这个值的数据结构应该是什么样子的,这个实现则只能归于插件的
normalize
来实现了。

{
    "quote-wrap": true,
    children: [
        {
            "list-wrap": true,
            "quote-pair": true,
            children: [
                { "list-pair": 1, children: [/* ... */] },
            ]
        },
        { "quote-pair": true,  children: [/* ... */] },
        {
            "list-wrap": true,
            "quote-pair": true,
            children: [
                { "list-pair": 1, children: [/* ... */] },
            ]
        },
        { "quote-pair": true,  children: [/* ... */] },
        { "quote-pair": true,  children: [/* ... */] },
        { "quote-pair": true,  children: [/* ... */] },
    ]
}

Transformers

前边也提到了,在嵌套的数据结构中是存在默认行为的,而在之前由于一直遵守着默认行为所以并没有发现太多的数据处理方面的问题,然而当将数据结构改变之后,就发现了很多时候数据结构并不那么容易控制。先前在处理
SetBlock
的时候通常我都会通过
match
参数匹配
Block
类型的节点,因为在默认行为的情况下这个处理通常不会出什么问题。

然而在变更数据结构的过程中,处理
Normalize
的时候就出现了问题,在块元素的匹配上其表现与预期的并不一致,这样就导致其处理的数据一直无法正常处理,
Normalize
也就无法完成直至抛出异常。在这里主要是其迭代顺序与我预期的不一致造成的问题,例如在
DEMO
页上执行
[...Editor.nodes(editor, {at: [9, 1, 0] })]
,其返回的结果是由顶
Editor
至底
Node
,当然这里还会包括范围内的所有
Leaf
节点相当于是
Range

[]          Editor
[9]         Wrap
[9, 1]      List
[9, 1, 9]   Line
[9, 1, 0]   Text

实际上在这种情况下如果按照原本的
Path.equals(path, at)
是不会出现问题的,在这里就是之前太依赖其默认行为了,这也就导致了对于数据的精确性把控太差,我们对数据的处理应该是需要有可预期性的,而不是依赖默认行为。此外,
slate
的文档还是太过于简练了,很多细节都没有提及,在这种情况下还是需要去阅读源码才会对数据处理有更好的理解,例如在这里看源码让我了解到了每次做操作都会取
Range
所有符合条件的元素进行
match
,在一次调用中可能会发生多次
Op
调度。

此外,因为这次的处理主要是对于嵌套元素的支持,所以在这里还发现了
unwrapNodes
或者说相关数据处理的特性,当我调用
unwrapNodes
时仅
at
传入的值不一样,分别是
A-[3, 1, 0]

B-[3, 1, 0, 0]
,这里有一个关键点是在匹配的时候我们都是严格等于
[3, 1, 0]
,但是调用结果却是不一样的,在
A

[3, 1, 0]
所有元素都被
unwrap
了,而
B
中仅
[3, 1, 0, 0]

unwrap
了,在这里我们能够保证的是
match
结果是完全一致的,那么问题就出在了
at
上。此时如果不理解
slate
数据操作的模型的话,就必须要去看源码了,在读源码的时候我们可以发现其会存在
Range.intersection
帮我们缩小了范围,所以在这里
at
的值就会影响到最终的结果。

unwrapNodes(editor, { match: (_, p) => Path.equals(p, [3, 1, 0]), at: [3, 1, 0] }); // A
unwrapNodes(editor, { match: (_, p) => Path.equals(p, [3, 1, 0]), at: [3, 1, 0, 0] }); // B

上边这个问题也就意味着我们所有的数据都不应该乱传,我们应该非常明确地知道我们要操作的数据及其结构。其实前边还提到一个问题,就是多级嵌套的情况很难处理,这其中实际上涉及了一个编辑边界情况,使得数据的维护就变得复杂了起来。举个例子,加入此时我们有个表格嵌套了比较多的
Cell
,如果我们是多实例的
Cell
结构,此时我们筛选出
Editor
实例之后处理任何数据都不会影响其他的
Editor
实例,而如果我们此时是
JSON
嵌套表达的结构,我们就可能存在超过操作边界而影响到其他数据特别是父级数据结构的情况。所以我们对于边界条件的处理也必须要关注到,也就是前边提到的我们需要非常明确要处理的数据结构,明确划分操作节点与范围。

{
    children: [
        {
            BLOCK_EDGE: true, // 块结构边界
            children: [
                { children: [/* ... */] },
                { children: [/* ... */] },
            ]
        },
        {  children: [/* ... */] },
        {  children: [/* ... */] },
    ]
}

此外,在线上已有页面中调试代码可能是个难题,特别是在
editor
并没有暴露给
window
的情况下,想要直接获得编辑器实例则需要在本地复现线上环境,在这种情况下我们可以借助
React
会将
Fiber
实际写在
DOM
节点的特性,通过
DOM
节点直接取得
Editor
实例,不过原生的
slate
使用了大量的
WeakMap
来存储数据,在这种情况下暂时没有很好的解决办法,除非
editor
实际引用了此类对象或者拥有其实例,否则就只能通过
debug
打断点,然后将对象在调试的过程中暂储为全局变量使用了。

const el = document.querySelector(`[data-slate-editor="true"]`);
const key = Object.keys(el).find(it => it.startsWith("__react"));
const editor = el[key].child.memoizedProps.node;

最后

在这里我们聊到了
WrapNode
数据结构与操作变换,主要是对于嵌套类型的数据结构需要关注的内容,而实际上节点的类型还可以分为很多种,我们在大范围上可以有
BlockNode

TextBlockNode

TextNode
,在
BlockNode
中我们又可以划分出
BaseNode

WrapNode

PairNode

InlineBlockNode

VoidNode

InstanceNode
等,因此文中叙述的内容还是属于比较基本的,在
slate
中还有很多额外的概念和操作需要关注,例如
Range

Operation

Editor

Element

Path
等。那么在后边的文章中我们就主要聊一聊在
slate

Path
的表达,以及在
React
中是如何控制其内容表达与正确维护
Path
路径与
Element
内容渲染的。