常见的 程序设计范式 (Programming Paradigm)包括面向对象程序设计范式、面向过程程序设计范式、函数式程序设计范式和指令式程序设计范式等,这些程序设计范式有各自的优势和适用的场景,也有各自的不足之处,在基础库的设计过程中需要结合具体场景合理运用程序设计范式,在提高开发效率的同时让代码更简洁清晰。Excelize基础库在设计过程中既有面向过程程序设计范式的应用,也有面向对象程序设计范式的应用,本节接下来将详细讨论为何使用以及在何处使用这些设计范式。
任何程序设计范式都不能自动完成正确的设计,对程序设计范式的把握取决于开发者。面向对象程序设计范式为大家所熟知,但这种设计范式在很多地方被过度地使用了,在程序设计过程中需要避免过度的封装。Excelize基础库的设计过程借鉴了Go语言的设计思考,在面向对象的使用上做了充分的权衡。面向对象程序设计范式是一种层次结构设计范式,在程序开发早期进行层次结构的设计,程序编写完成后,早期的层次结构决策就难以改变了,开发者要在程序开发初期尽可能对程序可能面临的各种需求和用法进行预测,尽早增加抽象层次的定义。但是,随着系统的发展,系统各部分之间的交互方式需要做出相应改变,难以在一开始就固定下来,这也是面向对象程序设计范式容易造成早期过度封装的原因。为了避免过度封装,Excelize基础库通过使用简单结构体类型方法来实现具体功能,在编写这些方法时确定好各函数行为的边界,聚焦于解决各个子问题,接着将它们组合使用来实现某项具体功能。这种方式的好处是将复杂的问题流程化,进而简单化。
Excelize基础库提供了遵循面向过程程序设计范式的函数,用户通过图形界面使用电子表格应用时,几乎不需要考虑电子表格应用中各项功能间的对象关系,只需通过图形界面的交互自然地进行过程式的操作。Excelize基础库将电子表格应用程序中基于图形界面进行的交互操作抽象为基于函数的过程式调用,使用Excelize创建或打开已有电子表格文档后,会得到一个文件对象,它是Excelize对电子表格文档的抽象,绝大部分对电子表格文档的操作函数都位于该文件对象上,在使用过程中主要以面向过程程序设计范式来调用基础库提供的各项函数。这样的设计可以避免多层级的链式调用,让开发者聚焦于功能本身,减少心智负担。对于上层框架或应用,可以根据基础库提供的原始函数进行进一步封装。举一个设置自定义名称的例子,在Excel电子表格中,名称是对单元格引用区域设置的别名,名称的作用范围可以是工作簿或工作表。假设你需要在工作簿中创建一个作用范围在工作簿中的名为Amount的自定义名称,站在基础库设计的角度,如果使用面向对象程序设计范式,你可能会很自然地将目标作用范围(工作簿)作为父类,然后调用成员函数改变对象的值,伪代码表示如下:
f :=excelize.NewFile()
wb :=f.GetWorkbook()
wb.SetDefinedName(&excelize.DefinedName{
Name: "Amount",
RefersTo: "Sheet1!$A$2:$D$5",
})
类似地,如果我们要以某个工作表为作用范围来创建名称,则需要先获取对应的工作表。这种调用方式让使用者优先考虑的不是名称的命名和引用范围,而是所要创建名称的作用范围,这与使用电子表格时的交互操作有所不同。实际上,在电子表格文档内部,处于不同作用范围的名称,在工作簿内部被存储于一处,为了能够在编程模式下延续图形界面操作上的习惯思维方式,Excelize提供了遵循面向过程程序设计范式的函数来设置名称,通过Scope参数指定作用范围:
f :=excelize.NewFile()
f.SetDefinedName(&excelize.DefinedName{
Name: "Amount",
RefersTo: "Sheet1!$A$2:$D$5",
Scope: "Sheet2",
})
这种方式在使用上更简单,也有利于开发者在编写代码时以符合电子表格应用使用习惯的方式调用基础库提供的函数。
在使用Excelize基础库的过程中,你也将看到面向对象程序设计范式的应用。例如,使用Excelize提供的AddPicture()函数在工作表中添加图片时,需要根据添加的图片文件格式,导入对应的基础库。假设我们要在名为Sheet1的工作表的A2单元格中插入一张文件名为image.png的PNG格式图片,代码如下:
package main
import (
"fmt"
-"image/png"
"github.com/xuri/excelize/v2"
)
func main() {
f :=excelize.NewFile()
defer func() {
if err :=f.Close(); err !=nil {
fmt.Println(err)
}
}()
if err :=f.AddPicture("Sheet1", "A2", "image.png", nil); err !=nil {
fmt.Println(err)
}
if err :=f.SaveAs("Book1.xlsx"); err !=nil {
fmt.Println(err)
}
}
第4行代码引入了用来解析PNG格式图片的Go语言标准库image/png,但是main()函数并没有显式地使用该标准库的任何函数,这是因为当通过import关键字导入这个库时,会默认以饿汉式单例模式调用并运行该库中的init()函数,init()函数内部会注册针对PNG格式图片的解析函数,接着当使用AddPicture()函数插入图片时,就可以获取图片的宽度、高度等格式信息,进一步设置图片的位置和缩放比例等属性。
接下来,我们讨论在Excelize基础库的设计过程中,对具有Go语言特色的程序设计范式的思考。 函数式选项模式 (Functional Options Pattern)是一种常用的Go语言程序设计范式,适合对带有较多结构体的字段进行灵活赋值或初始化结构体。在Excelize基础库的设计过程中,曾将该程序设计范式应用于工作表视图与格式属性的设置函数与读取函数中。以设置页面缩放比例和工作表网格线为例,假设基础库要为用户提供一个名为SetSheetView的函数来设置工作表页面属性,这些属性存储在电子表格文档内部一个名为xlsxSheetView类型的对象上,在基础库内部可以通过getSheetView()函数来获取这个对象。
在这种程序设计范式中,我们先来定义一个名为ViewOption的函数类型:
type ViewOption func(*xlsxSheetView)
该函数类型允许接收*xlsxSheetView类型的参数,接着分别实现设置页面缩放比例和网格线开关选项的函数:
func ZoomScale(value float64) ViewOption {
return func(v *xlsxSheetView) {
v.ZoomScale=value
}
}
func ShowGridLines(value bool) ViewOption {
return func(v *xlsxSheetView) {
v.ShowGridLines=value
}
}
最后实现设置工作表视图属性的函数SetSheetView():
func (f *File) SetSheetView (sheet string, opts...ViewOption) {
view :=f.getSheetView(sheet)
for_, opt :=range opts {
opt(view)
}
}
这样开发者就可以通过如下代码来设置页面缩放比例和工作表网格线了:
f :=excelize.NewFile()
f.SetSheetView(
"Sheet1",
excelize.ZoomScale(150),
excelize.ShowGridLines(false),
)
未来,Excelize开发者可以通过编写更多的选项函数来支持其他属性的设置,从而实现扩展。但是在这种设计范式下,导出的选项函数数量会不断增加,而在电子表格文档中有数以百计的各类属性,这种平铺属性的方法不利于属性的归类。假设我们要在基础库的工作表属性设置功能中添加设置默认列宽度和行高度的支持,就要增加对应的选项函数DefaultColWidth()和DefaultRowHeight(),随着各类选项函数越来越多,文档视图属性和工作表属性的选项函数将难以区分。考虑到这个问题,Excelize基础库将与文档视图设置和工作表属性相关的各类设置分别映射到两个结构体ViewOptions和SheetPropsOptions中。
ViewOptions的定义为
type ViewOptions struct {
ShowGridLines *bool
ZoomScale *float64
}
SheetPropsOptions的定义为
type SheetPropsOptions struct {
DefaultColWidth *float64
DefaultRowHeight *float64
}
对设置工作表视图属性的SetSheetView()函数签名进行如下修改:
func (f *File) SetSheetView(sheet string, opts *ViewOptions) error
在其内部实现过程中,需要逐一判断ViewOptions类型的opts实参中各选项是否为零值,通过这样的设计,当用户在调用SetSheetView()函数来设置工作表视图属性时,可通过函数签名得知形参为ViewOptions数据类型,并可以方便地检索该数据类型中定义的各个属性选项,指定所需选项,实现按需设置工作表视图属性,未指定的选项,其值默认为零值,等价于函数式选项模式下的选项缺省设置。
Excelize基础库在设计过程中运用了数据驱动编程的设计方法,通过控制程序复杂度以及将程序中变化的数据部分和处理逻辑进行分离,来提高代码的可读性、扩展性和可复用性。以创建图表的功能实现为例,通过Excelize基础库提供的AddChart()函数,可以基于数据源创建各类原生图表,包括面积图、条形图、柱形图、折线图、饼图和雷达图等,这些图表的参数是不统一的,部分属性和图表的类型有关,例如指定曲线平滑的参数仅适用于折线图。这意味着在基础库内部需要根据图表的类型来实现不同的创建函数,如果使用常规的判断逻辑,将会产生类似如下形式的,包含大量条件判断分支逻辑的代码:
switch chartType {
case Line:
f.drawLineChart()
case Pie:
f.drawPieChart()
case Radar:
f.drawRadarChart()
default:
f.drawBaseChart()
}
可以看到,随着图表类型的增加,条件判断分支将会越来越多,函数的圈复杂度也随之提高,圈复杂度的概念在3.2节有所提及,它是一种衡量代码复杂度的标准。在数据驱动编程的设计方法中,一种典型的模式是通过表驱动法来进行设计,使用这种设计方法来优化上面的代码,可以使各种图表类型的数据和处理逻辑分离。在这个图表创建的案例中,就是将各种图表的绘制函数分离,存储于一张抽象的“表”中。例如,我们可以使用数组、多维数组或哈希表存储图表的绘制函数,修改后的代码如下:
plotAreaFunc :=map[string]func(*chartOptions) *cPlotArea{
Area: f.drawBaseChart,
Bar: f.drawBaseChart,
Line: f.drawLineChart,
Pie: f.drawPieChart,
Radar: f.drawRadarChart,
}
plotAreaFunc[opts.Type](opts)
通过这种直接访问式的表驱动设计范式,可以简化条件判断语句,从而降低代码的圈复杂度。Excelize基础库单元测试的编写过程中也运用了表驱动设计范式。例如,在对单元格公式计算结果进行测试时,通过对定义公式内容与预期结果的列表进行遍历,批量进行公式计算测试:
formulaList :=map[string]string{
"=COVAR(A1:A9,B1:B9)": "16.633125",
"=COVAR(A2:A9,B2:B9)": "16.633125",
}
for formula, expected :=range formulaList {
assert.NoError(t, f.SetCellFormula("Sheet1", "C1", formula))
result, err :=f.CalcCellValue("Sheet1", "C1")
assert.NoError(t, err, formula)
assert.Equal(t, expected, result, formula)
}