2

前言

大约 10 年前,我开始重构和改进ChartSeriesJohn Walkenbach 的类。不幸的是,原来的它似乎不再在线提供。

关注Rubberduck博客已经有一段时间了,现在我尝试提高我的 VBA 技能。但过去我只写过——我猜专家会称之为——“脚本般的上帝程序”(因为不知道更好)。所以我对类,尤其是接口和工厂很陌生。

实际问题

我尝试通过使用接口将其划分为多个类而不是添加单元测试来重构整个类。对于仅阅读Series.Formula公式的各个部分,获取然后进行所有处理就足够了。所以在函数中调用Runsub会很好。Create但到目前为止,我所做的一切都失败了。因此,我目前Run在所有Get属性等中运行(并测试,如果公式更改并退出Run比。这可能吗?如果是,如何?

其次,要添加单元测试——当然是使用——我目前依赖于真正的 Charts/ ChartObjects。如何为 stub/mock/fake 创建 stub/mock/fake Series?(对不起,我不知道正确的术语。)

这里是代码的简化版本。

非常感谢您的帮助。

普通模块

'@Folder("ChartSeries")

Option Explicit

Public Sub ExampleUsage()

    Dim wks As Worksheet
    Set wks = ThisWorkbook.Worksheets(1)
    
    Dim crt As ChartObject
    Set crt = wks.ChartObjects(1)
    
    Dim srs As Series
    Set srs = crt.Chart.SeriesCollection(3)
    
    Dim MySeries As IChartSeries
    Set MySeries = ChartSeries.Create(srs)
    With MySeries
        Debug.Print .XValues.FormulaPart
    End With
End Sub

IChartSeries.cls

'@Folder("ChartSeries")
'@Interface

Option Explicit

Public Function IsSeriesAccessible() As Boolean
End Function

Public Property Get FullFormula() As String
End Property

Public Property Get XValues() As ISeriesPart
End Property

'more properties ...

ChartSeries.cls

'@PredeclaredId
'@Exposed
'@Folder("ChartSeries")

Option Explicit
Implements IChartSeries

Private Type TChartSeries
   Series As Series
   FullSeriesFormula As String
   OldFullSeriesFormula As String
   IsSeriesAccessible As Boolean
   SeriesParts(eElement.[_First] To eElement.[_Last]) As ISeriesPart
End Type
Private This As TChartSeries

Public Function Create(ByVal Value As Series) As IChartSeries
'NOTE: I would like to run the 'Run' sub somewhere here (if possible)
   With New ChartSeries
      .Series = Value
      Set Create = .Self
   End With
End Function

Public Property Get Self() As IChartSeries
    Set Self = Me
End Property

Friend Property Let Series(ByVal Value As Series)
   Set This.Series = Value
End Property

Private Function IChartSeries_IsSeriesAccessible() As Boolean
   Call Run
   IChartSeries_IsSeriesAccessible = This.IsSeriesAccessible
End Function

Private Property Get IChartSeries_FullFormula() As String
   Call Run
   IChartSeries_FullFormula = This.FullSeriesFormula
End Property

Private Property Get IChartSeries_XValues() As ISeriesPart
   Call Run
   Set IChartSeries_XValues = This.SeriesParts(eElement.eXValues)
End Property

'more properties ...

Private Sub Class_Initialize()
   With This
      Dim Element As eElement
      For Element = eElement.[_First] To eElement.[_Last]
         Set .SeriesParts(Element) = New SeriesPart
      Next
   End With
End Sub

Private Sub Class_Terminate()
   With This
      Dim Element As LongPtr
      For Element = eElement.[_First] To eElement.[_Last]
         Set .SeriesParts(Element) = Nothing
      Next
   End With
End Sub

Private Sub Run()

   If Not GetFullSeriesFormula Then Exit Sub
   If Not HasFormulaChanged Then Exit Sub
   Call GetSeriesFormulaParts

End Sub

'(simplified version)
Private Function GetFullSeriesFormula() As Boolean

   GetFullSeriesFormula = False

   With This
'---
'dummy to make it work
.FullSeriesFormula = _
"=SERIES(Tabelle1!$B$2,Tabelle1!$A$3:$A$5,Tabelle1!$B$3:$B$5,1)"
'---
      .OldFullSeriesFormula = .FullSeriesFormula
      .FullSeriesFormula = .Series.Formula
   End With
   
   GetFullSeriesFormula = True

End Function

Private Function HasFormulaChanged() As Boolean
   With This
      HasFormulaChanged = (.OldFullSeriesFormula <> .FullSeriesFormula)
   End With
End Function

Private Sub GetSeriesFormulaParts()
   
   Dim MySeries As ISeriesFormulaParts
   '(simplified version without check for Bubble Chart)
   Set MySeries = SeriesFormulaParts.Create( _
         This.FullSeriesFormula, _
         False _
   )
   
   With MySeries
      Dim Element As eElement
      For Element = eElement.[_First] To eElement.[_Last] - 1
         This.SeriesParts(Element).FormulaPart = _
               .PartSeriesFormula(Element)
      Next
'---
'dummy which normally would be retrieved
'by 'MySeries.PartSeriesFormula(eElement.eXValues)'
This.SeriesParts(eElement.eXValues).FormulaPart = _
"Tabelle1!$A$3:$A$5"
'---
   End With
   
   Set MySeries = Nothing

End Sub

'more subs and functions ...

ISeriesPart.cls

'@Folder("ChartSeries")
'@Interface

Option Explicit

Public Enum eEntryType
   eNotSet = -1
   [_First] = 0
   eInaccessible = eEntryType.[_First]
   eEmpty
   eInteger
   eString
   eArray
   eRange
   [_Last] = eEntryType.eRange
End Enum

Public Property Get FormulaPart() As String
End Property

Public Property Let FormulaPart(ByVal Value As String)
End Property

Public Property Get EntryType() As eEntryType
End Property

Public Property Get Range() As Range
End Property

'more properties ...

SeriesPart.cls

'@PredeclaredId
'@Folder("ChartSeries")
'@ModuleDescription("A class to handle each part of the 'Series' string.")

Option Explicit

Implements ISeriesPart

Private Type TSeriesPart
   FormulaPart As String
   EntryType As eEntryType
   Range As Range
   RangeString As String
   RangeSheet As String
   RangeBook As String
   RangePath As String
End Type
Private This As TSeriesPart

Private Property Get ISeriesPart_FormulaPart() As String
   ISeriesPart_FormulaPart = This.FormulaPart
End Property

Private Property Let ISeriesPart_FormulaPart(ByVal Value As String)
   This.FormulaPart = Value
   Call Run
End Property

Private Property Get ISeriesPart_EntryType() As eEntryType
   ISeriesPart_EntryType = This.EntryType
End Property

Private Property Get ISeriesPart_Range() As Range
   With This
      If .EntryType = eEntryType.eRange Then
         Set ISeriesPart_Range = .Range
      Else
'         Call RaiseError
      End If
   End With
End Property

Private Property Set ISeriesPart_Range(ByVal Value As Range)
   Set This.Range = Value
End Property

'more properties ...

Private Sub Class_Initialize()
   This.EntryType = eEntryType.eNotSet
End Sub

Private Sub Run()
   '- set 'EntryType'
   '- If it is a range then find the range parts ...
End Sub

'a lot more subs and functions ...

ISeriesParts.cls

'@Folder("ChartSeries")
'@Interface

Option Explicit

Public Enum eElement
   [_First] = 1
   eName = eElement.[_First]
   eXValues
   eYValues
   ePlotOrder
   eBubbleSizes
   [_Last] = eElement.eBubbleSizes
End Enum

'@Description("fill me")
Public Property Get PartSeriesFormula(ByVal Element As eElement) As String
End Property

SeriesFormulaParts.cls

'@PredeclaredId
'@Exposed
'@Folder("ChartSeries")

Option Explicit

Implements ISeriesFormulaParts

Private Type TSeriesFormulaParts
   FullSeriesFormula As String
   IsSeriesInBubbleChart As Boolean
   WasRunCalled As Boolean
   
   SeriesFormula As String

   RemainingFormulaPart(eElement.[_First] To eElement.[_Last]) As String
   PartSeriesFormula(eElement.[_First] To eElement.[_Last]) As String
End Type
Private This As TSeriesFormulaParts

Public Function Create( _
   ByVal FullSeriesFormula As String, _
   ByVal IsSeriesInBubbleChart As Boolean _
      ) As ISeriesFormulaParts
'NOTE: I would like to run the 'Run' sub somewhere here (if possible)
   With New SeriesFormulaParts
      .FullSeriesFormula = FullSeriesFormula
      .IsSeriesInBubbleChart = IsSeriesInBubbleChart
      Set Create = .Self
   End With
End Function

Public Property Get Self() As ISeriesFormulaParts
    Set Self = Me
End Property

'@Description("Set the full series formula ('ChartSeries')")
Public Property Let FullSeriesFormula(ByVal Value As String)
   This.FullSeriesFormula = Value
End Property

Public Property Let IsSeriesInBubbleChart(ByVal Value As Boolean)
   This.IsSeriesInBubbleChart = Value
End Property

Private Property Get ISeriesFormulaParts_PartSeriesFormula(ByVal Element As eElement) As String
'NOTE: Instead of running 'Run' here, it would be better to run it in 'Create'
   Call Run
   ISeriesFormulaParts_PartSeriesFormula = This.PartSeriesFormula(Element)
End Property

'(replaced with a dummy)
Private Sub Run()

   If This.WasRunCalled Then Exit Sub
   'extract stuff from
   This.WasRunCalled = True
   
End Sub

'a lot more subs and functions ...
4

1 回答 1

3

你已经可以了!

Public Function Create(ByVal Value As Series) As IChartSeries
   With New ChartSeries <~ With block variable has access to members of the ChartSeries class
      .Series = Value
      Set Create = .Self
   End With
End Function

...只是,像.Seriesand.Self属性一样,它必须是接口/类的Public成员ChartSeries(在 VBA 中这条线是模糊的,因为每个类都有一个默认接口/也是一个接口)。

惯用对象分配

关于此属性的说明:

Friend Property Let Series(ByVal Value As Series)
   Set This.Series = Value
End Property

使用对象引用的Property Let成员Set将起作用 - 但它不再是惯用的 VBA 代码,正如您在.Create函数中看到的那样:

      .Series = Value

如果我们在不了解属性性质的情况下阅读这一行,这看起来就像任何其他值分配。唯一的问题是,我们没有分配value,而是引用- VBA 中的引用分配通常使用Set关键字进行。如果我们更改属性定义中的Letfor a ,我们将不得不这样做:SetSeries

      Set .Series = Value

这看起来更像是参考分配!没有它,似乎会发生隐含的 let-coercion,这使得它的代码模棱两可:VBA 需要一个Set关键字来进行引用分配,因为任何给定的对象都可以具有无参数的默认属性(例如,如何foo = Range("A1")隐式分配fooValueRange)。


缓存和责任

现在,回到Run方法 - 如果它是PublicChartSeries类上创建的,但没有在实现的IChartSeries接口上公开,那么它是一个只能从 1)ChartSeries默认实例或 2) 具有ChartSeries声明类型的任何对象变量调用的成员。而且由于我们的“客户端代码”正在运行IChartSeries,我们可以防止 1 并摆脱 2。

请注意,Call关键字是多余的,该Run方法实际上只是从封装的Series对象中提取元数据,并将其缓存在实例级别 - 我会给它起一个听起来更像“刷新缓存的属性”而不是“运行某些东西”的名称。

你的预感很好:Property Get应该是一个简单的返回函数,没有任何副作用。调用一种扫描对象并在Property Get访问器中重置实例状态的方法会产生副作用,这在理论上是一种设计气味。

如果在创建后在函数返回实例Run之前立即调用,那么这个方法归结为“解析系列并缓存一些我稍后将重用的元数据”,这没有什么问题:从 调用它,然后从访问器。CreateRunCreateProperty Get

结果是一个对象,其状态为只读且定义更健壮;与之相对的是,您现在有一个对象,其状态可能与Series工作表上的实际 Excel 对象不同步:如果代码(或用户)在初始化Series后调整对象,IChartSeries则该对象及其状态是陈旧的。

一种解决方案是不遗余力地识别系列何时过时,并确保保持缓存为最新。

另一种解决方案是通过不再缓存状态来完全消除问题 - 这意味着两件事之一:

  1. 在创建时生成一次对象图,有效地将缓存责任转移给调用者:调用代码获得一个只读的“快照”来使用。

  2. 每次调用代码需要它时,从系列元数据中生成一个新的对象图:实际上,它将缓存责任转移给调用者,这根本不是一个坏主意。

将事物设为只读会消除很多复杂性!我会选择第一个选项。


总体而言,代码看起来不错且干净(尽管尚不清楚这篇文章清理了多少),并且您似乎已经理解工厂方法模式利用默认实例并公开外观接口 - 赞!命名总体上非常好(尽管“运行”突出了 IMO),并且这些对象看起来每个都有一个明确的、明确的目的。好工作!


单元测试

我目前依赖真正的图表/图表对象。如何为系列创建存根/模拟/伪造?(对不起,我不知道正确的术语。)

目前,你不能。如果/当此 PR 被合并时,您将能够模拟Excel 的接口(以及更多),并针对您的类编写测试,这些测试注入Excel.Series您可以为测试目的配置的模拟对象......但直到那么,这就是墙所在的地方。

同时,你能做的最好的就是用你自己的接口包装它,然后存根它。换句话说,只要您的代码和 Excel 的对象模型之间存在接缝,我们就会在两者之间设置一个接口:Excel.Series您将接受一些对象,而不是接受一个对象,ISeriesWrapper然后真正的代码将使用一个ExcelSeriesWrapper有效的关闭 a Excel.Series,并且测试代码可能正在使用 a StubSeriesWrapper,其属性返回硬编码值或测试配置的值:在Excel库和项目之间的接缝处工作的代码无法测试 - 我们会无论如何都不想,因为那样我们将测试 Excel,而不是我们自己的代码。

您可以在此处的下一篇 RD 新闻文章的示例代码中看到这一点;那篇文章将使用 ADODB 连接来讨论这个问题。原理是一样的:该项目中的 94 个单元测试都没有打开任何实际连接,但是通过依赖注入和包装器接口,我们能够测试从打开数据库连接到提交事务的每一个功能... 无需访问实际数据库。

于 2020-04-20T14:53:08.540 回答