【问题标题】:Best way to test a MS Access application?测试 MS Access 应用程序的最佳方法?
【发布时间】:2010-09-08 00:35:48
【问题描述】:

对于同一数据库中的代码、表单和数据,我想知道为 Microsoft Access 应用程序(比如 Access 2007)设计一套测试的最佳实践是什么。

测试表单的主要问题之一是只有少数控件具有hwnd 句柄,而其他控件只有一个它们具有焦点,这使得自动化非常不透明,因为您无法获得表单上的控件列表以采取行动。

有什么经验可以分享吗?

【问题讨论】:

    标签: database unit-testing ms-access vba


    【解决方案1】:

    1。编写可测试的代码

    首先,停止将业务逻辑写入您的表单代码中。那不是它的地方。它无法在那里正确测试。事实上,你真的不应该测试你的表单本身。它应该是一个非常愚蠢的简单视图,它响应用户交互,然后将响应这些操作的责任委托给另一个可测试的类。

    你是怎么做到的?熟悉Model-View-Controller pattern 是一个好的开始。

    这在 VBA 中无法完美地完成,因为我们获得了事件或接口,而不是两者,但您可以非常接近。考虑一下这个简单的表单,它有一个文本框和一个按钮。

    在后面的表单代码中,我们将 TextBox 的值包装在一个公共属性中,并重新引发我们感兴趣的任何事件。

    Public Event OnSayHello()
    Public Event AfterTextUpdate()
    
    Public Property Let Text(value As String)
        Me.TextBox1.value = value
    End Property
    
    Public Property Get Text() As String
        Text = Me.TextBox1.value
    End Property
    
    Private Sub SayHello_Click()
        RaiseEvent OnSayHello
    End Sub
    
    Private Sub TextBox1_AfterUpdate()
        RaiseEvent AfterTextUpdate
    End Sub
    

    现在我们需要一个模型来使用。在这里,我创建了一个名为MyModel 的新类模块。这是我们将要测试的代码。请注意,它自然与我们的视图具有相似的结构。

    Private mText As String
    Public Property Let Text(value As String)
        mText = value
    End Property
    
    Public Property Get Text() As String
        Text = mText
    End Property
    
    Public Function Reversed() As String
        Dim result As String
        Dim length As Long
    
        length = Len(mText)
    
        Dim i As Long
        For i = 0 To length - 1
            result = result + Mid(mText, (length - i), 1)
        Next i
    
        Reversed = result
    End Function
    
    Public Sub SayHello()
        MsgBox Reversed()
    End Sub
    

    最后,我们的控制器将它们连接在一起。控制器侦听表单事件并将更改传达给模型并触发模型的例程。

    Private WithEvents view As Form_Form1
    Private model As MyModel
    
    Public Sub Run()
        Set model = New MyModel
        Set view = New Form_Form1
        view.Visible = True
    End Sub
    
    Private Sub view_AfterTextUpdate()
        model.Text = view.Text
    End Sub
    
    Private Sub view_OnSayHello()
        model.SayHello
        view.Text = model.Reversed()
    End Sub
    

    现在可以从任何其他模块运行此代码。出于本示例的目的,我使用了标准模块。我强烈建议您使用我提供的代码自己构建它并查看它的功能。

    Private controller As FormController
    
    Public Sub Run()
        Set controller = New FormController
        controller.Run
    End Sub
    

    所以,这很好,所有但它与测试有什么关系?!朋友,它有一切 与测试有关。我们所做的是让我们的代码可测试。在我提供的示例中,甚至没有理由尝试测试 GUI。我们唯一真正需要测试的是model。这就是所有真正的逻辑所在。

    那么,进入第二步。

    2。选择单元测试框架

    这里没有很多选择。大多数框架都需要安装 COM 插件、大量样板、奇怪的语法、将测试编写为 cmets 等。这就是我参与building one myself 的原因,所以我的这部分答案并不公正,但我会尝试对可用的内容进行公平的总结。

    1. AccUnit

      • 仅适用于 Access。
      • 要求您将测试编写为 cmets 和代码的奇怪混合体。 (评论部分没有智能感知。
      • 有一个图形界面可以帮助您编写那些看起来很奇怪的测试。
      • 该项目自 2013 年以来没有任何更新。
    2. VB Lite Unit 我不能说我个人使用过它。它在那里,但自 2005 年以来没有看到更新。

    3. xlUnit xlUnit 并不糟糕,但也不是很好。它很笨重,并且有很多样板代码。这是最好的,最坏的,但它在 Access 中不起作用。所以,这就结束了。

    4. 构建你自己的框架

      我有been there and done that。它可能比大多数人想要的要多,但完全可以用原生 VBA 代码构建单元测试框架。

    5. Rubberduck VBE Add-In's Unit Testing Framework
      免责声明:我是共同开发者之一

      我有偏见,但这是迄今为止我最喜欢的。

      • 几乎没有样板代码。
      • Intellisense 可用。
      • 项目处于活动状态。
      • 比大多数这些项目更多的文档。
      • 它适用于大多数主要的办公应用程序,而不仅仅是 Access。
      • 很遗憾,它是一个 COM 插件,因此必须将其安装到您的计算机上。

    3。开始编写测试

    所以,回到第 1 节中的代码。我们真正需要测试的唯一代码是 MyModel.Reversed() 函数。那么,让我们来看看这个测试会是什么样子。 (给出的示例使用 Rubberduck,但它是一个简单的测试,可以转化为您选择的框架。)

    '@TestModule
    Private Assert As New Rubberduck.AssertClass
    
    '@TestMethod
    Public Sub ReversedReversesCorrectly()
    
    Arrange:
        Dim model As New MyModel
        Const original As String = "Hello"
        Const expected As String = "olleH"
        Dim actual As String
    
        model.Text = original
    
    Act:
        actual = model.Reversed
    
    Assert:
        Assert.AreEqual expected, actual
    
    End Sub
    

    编写良好测试的指南

    1. 一次只测试一件事。
    2. 只有在系统引入错误或需求发生变化时,良好的测试才会失败。
    3. 不要包含外部依赖项,例如数据库和文件系统。这些外部依赖项会使测试因您无法控制的原因而失败。其次,它们会减慢您的测试速度。如果你的测试很慢,你就不会运行它们。
    4. 使用描述测试所测试内容的测试名称。如果它变长,请不要担心。最重要的是它具有描述性。

    我知道这个答案有点长而且晚了,但希望它可以帮助一些人开始为他们的 VBA 代码编写单元测试。

    【讨论】:

    • “迟到”是一种委婉说法;)
    • 控制器代码需要在一个名为 FormController 的类模块中,模型-视图-控制器代码才能工作。当我第一次尝试代码时错过了这一点。
    • 我运行了示例代码并注意到模型不会更新视图,而是通过 MsgBox 呈现结果。我不清楚是否应该将反向函数的结果返回给控制器,以便可以将 view.textbox1 设置为新值(假设我想将结果返回给输入文本框)。另一种选择是向模型添加表单引用并从模型中写入结果,但这对我来说似乎很难看。尝试了第一个选项,无法使其工作。您能否提供有关如何将值从模型返回到视图的线索。
    • @AndrewM 我更新了控制器的视图事件处理程序。现在它设置视图的文本并让模型打招呼。
    • my new answerthis question 中所述,可以使用 ViewAdapter 对象同时拥有 both 事件和接口。
    【解决方案2】:

    我很欣赏诺克斯和大卫的回答。我的答案介于他们之间:只需制作不需要调试的表单

    我觉得表格应该是排他用的,就是说图形界面只有,这里的意思就是不用调试!然后,调试作业仅限于您的 VBA 模块和对象,这更容易处理。

    将 VBA 代码添加到表单和/或控件当然是一种自然趋势,尤其是当 Access 为您提供这些出色的“更新后”和“更改时”事件时,但我绝对建议您不要 将任何表单或控件特定代码放入表单的模块中。这使得进一步的维护和升级非常昂贵,您的代码在 VBA 模块和表单/控件模块之间拆分。

    这并不意味着您不能再使用这个AfterUpdate 事件!只需将标准代码放入事件中,如下所示:

    Private Sub myControl_AfterUpdate()  
        CTLAfterUpdate myControl
        On Error Resume Next
        Eval ("CTLAfterUpdate_MyForm()")
        On Error GoTo 0  
    End sub
    

    地点:

    • CTLAfterUpdate 是每次在表单中更新控件时运行的标准过程

    • CTLAfterUpdateMyForm 是每次在 MyForm 上更新控件时运行的特定过程

    然后我有 2 个模块。第一个是

    • utilityFormEvents
      我将在哪里举行 CTLAfterUpdate 通用事件

    第二个是

    • MyAppFormEvents
      包含 MyApp 应用程序的所有特定形式的特定代码 并包括 CTLAfterUpdateMyForm 过程。当然,CTLAfterUpdateMyForm 如果没有要运行的特定代码,则可能不存在。这就是为什么我们转 “发生错误”到“继续下一步”...

    选择这样一个通用的解决方案意义重大。这意味着您正在达到高水平的代码规范化(意味着代码的无痛维护)。而当你说你没有任何特定于表单的代码时,也意味着表单模块是完全标准化的,它们的生产可以自动化:只需说出哪些事件您想在表单/控件级别进行管理,并定义您的通用/特定程序术语。
    编写您的自动化代码,一劳永逸。
    这需要几天的工作,但它会产生令人兴奋的结果。在过去的 2 年里,我一直在使用这个解决方案,它显然是正确的:我的表单是使用“表单表”从头开始完全自动创建的,链接到“控件表”。
    然后,我可以花时间处理表单的具体程序(如果有的话)。

    即使使用 MS Access,代码规范化也是一个漫长的过程。但这真的是值得的!

    【讨论】:

    • 这听起来很有趣,你为什么不在某处发布一些例子?
    • @GUI Junkie,我会及时通知你的。
    • 为什么不把 afterupdate 属性放到 =myModule.AfterUpdate(me.controlname) 中呢?这样你就可以编写一个很好的通用函数,它可以在没有任何 eval 魔法的情况下传递对特定控件的引用。还是我错过了什么?
    • 我想看看您提到的表单和控件表的架构。我不太明白它们是如何工作的。
    • @PhilippeGrondier 如果您发布了一些示例 accdb,我将不胜感激。顺便说一句,这是一篇博文的好机会;)
    【解决方案3】:

    Access being a COM application 的另一个优点是您可以创建一个.NET application to run and test an Access application via Automation。这样做的好处是,您可以使用更强大的测试框架(例如 NUnit)来编写针对 Access 应用程序的自动断言测试。

    因此,如果您精通 C# 或 VB.NET 并结合 NUnit 之类的东西,那么您可以更轻松地为您的 Access 应用创建更大的测试覆盖率。

    【讨论】:

    • 我确实做到了。我强烈推荐这种方式,因为这样您就拥有 .net 的所有优势来测试您的 Access/VBA 应用程序。
    【解决方案4】:

    虽然这是一个非常古老的答案:

    AccUnit,一个专门用于 Microsoft Access 的单元测试框架。

    【讨论】:

    • 我认为这可能是最有用的答案,所以我将其更改为接受的答案。
    【解决方案5】:

    我从Python's doctest 概念中提取了一个页面,并在Access VBA 中实现了一个DocTests 过程。这显然不是一个成熟的单元测试解决方案。它还比较年轻,所以我怀疑我已经解决了所有的错误,但我认为它已经足够成熟,可以放生了。

    只需将以下代码复制到标准代码模块中,然后在 Sub 内按 F5 即可查看它的运行情况:

    '>>> 1 + 1
    '2
    '>>> 3 - 1
    '0
    Sub DocTests()
    Dim Comp As Object, i As Long, CM As Object
    Dim Expr As String, ExpectedResult As Variant, TestsPassed As Long, TestsFailed As Long
    Dim Evaluation As Variant
        For Each Comp In Application.VBE.ActiveVBProject.VBComponents
            Set CM = Comp.CodeModule
            For i = 1 To CM.CountOfLines
                If Left(Trim(CM.Lines(i, 1)), 4) = "'>>>" Then
                    Expr = Trim(Mid(CM.Lines(i, 1), 5))
                    On Error Resume Next
                    Evaluation = Eval(Expr)
                    If Err.Number = 2425 And Comp.Type <> 1 Then
                        'The expression you entered has a function name that ''  can't find.
                        'This is not surprising because we are not in a standard code module (Comp.Type <> 1).
                        'So we will just ignore it.
                        GoTo NextLine
                    ElseIf Err.Number <> 0 Then
                        Debug.Print Err.Number, Err.Description, Expr
                        GoTo NextLine
                    End If
                    On Error GoTo 0
                    ExpectedResult = Trim(Mid(CM.Lines(i + 1, 1), InStr(CM.Lines(i + 1, 1), "'") + 1))
                    Select Case ExpectedResult
                    Case "True": ExpectedResult = True
                    Case "False": ExpectedResult = False
                    Case "Null": ExpectedResult = Null
                    End Select
                    Select Case TypeName(Evaluation)
                    Case "Long", "Integer", "Short", "Byte", "Single", "Double", "Decimal", "Currency"
                        ExpectedResult = Eval(ExpectedResult)
                    Case "Date"
                        If IsDate(ExpectedResult) Then ExpectedResult = CDate(ExpectedResult)
                    End Select
                    If (Evaluation = ExpectedResult) Then
                        TestsPassed = TestsPassed + 1
                    ElseIf (IsNull(Evaluation) And IsNull(ExpectedResult)) Then
                        TestsPassed = TestsPassed + 1
                    Else
                        Debug.Print Comp.Name; ": "; Expr; " evaluates to: "; Evaluation; " Expected: "; ExpectedResult
                        TestsFailed = TestsFailed + 1
                    End If
                End If
    NextLine:
            Next i
        Next Comp
        Debug.Print "Tests passed: "; TestsPassed; " of "; TestsPassed + TestsFailed
    End Sub
    

    从名为 Module1 的模块中复制、粘贴和运行上述代码会产生:

    Module: 3 - 1 evaluates to:  2  Expected:  0 
    Tests passed:  1  of  2
    

    一些快速说明:

    • 它没有依赖关系(在 Access 中使用时)
    • 它利用了Eval,这是Access.Application对象模型中的一个函数;这意味着您可以在 Access 之外使用它,但需要创建一个 Access.Application 对象并完全限定 Eval 调用
    • 需要注意一些idiosyncrasies associated with Eval
    • 它只能用于返回适合单行的结果的函数

    尽管有其局限性,但我仍然认为它物有所值。

    编辑:这是一个简单的函数,该函数必须满足“doctest rules”。

    Public Function AddTwoValues(ByVal p1 As Variant, _
            ByVal p2 As Variant) As Variant
    '>>> AddTwoValues(1,1)
    '2
    '>>> AddTwoValues(1,1) = 1
    'False
    '>>> AddTwoValues(1,Null)
    'Null
    '>>> IsError(AddTwoValues(1,"foo"))
    'True
    
    On Error GoTo ErrorHandler
    
        AddTwoValues = p1 + p2
    
    ExitHere:
        On Error GoTo 0
        Exit Function
    
    ErrorHandler:
        AddTwoValues = CVErr(Err.Number)
        GoTo ExitHere
    End Function
    

    【讨论】:

    • 这个编译 VBA 的测试究竟做了什么?
    • @David:它验证了逻辑的正确性。当然,编译是不行的。
    • 我根本看不到这个测试的价值。 Access 应用程序中发生的绝大多数错误不是算法,而是与 UI 相关且特定于运行时(即,由于遇到不符合编写代码的假设的数据而导致)。除了 VBA 代码之外,Access 应用程序还有更多功能。
    • @David-W-Fenton 能够以自动化方式测试代码,如果您在某处进行的更改可能在其他地方破坏了某些东西,这将非常有用。通过以系统的方式运行测试,您可以验证您的代码是否具有全局一致性:失败的测试会突出显示在手动 UI 测试人员或最终用户偶然发现之前可能保持不可见的问题。代码测试并不是要测试所有东西,它只是为了测试代码。它也有它的缺点(测试中断,增加了创建测试的时间),但对于大型项目来说是值得的。
    • 我并不是说自动化测试本身没有用。我只是在暗示,像 Access 这样的平台不可能以任何有意义的方式实现。
    【解决方案6】:

    我会将应用程序设计为在查询和 vba 子例程中完成尽可能多的工作,以便您的测试可以由填充测试数据库、针对这些数据库运行生产查询集和 vba 组成,然后查看输出和比较以确保输出良好。这种方法显然不会测试 GUI,因此您可以使用手动执行的一系列测试脚本(这里我的意思是像打开表单 1 并单击控件 1 的 word 文档)来增加测试。

    这取决于项目的范围作为测试方面所需的自动化水平。

    【讨论】:

      【解决方案7】:

      如果您有兴趣在更精细的级别测试您的 Access 应用程序,特别是 VBA 代码本身,那么VB Lite Unit 是一个很好的单元测试框架。

      【讨论】:

        【解决方案8】:

        这里有很好的建议,但我很惊讶没有人提到集中式错误处理。您可以获得允许快速函数/子模板和添加行号的插件(我使用 MZ 工具)。然后将所有错误发送到一个可以记录它们的函数。然后,您还可以通过设置单个断点来中断所有错误。

        【讨论】:

        【解决方案9】:

        我发现在我的应用程序中进行单元测试的机会相对较少。我编写的大多数代码都与表数据或文件系统交互,因此基本上很难进行单元测试。早期,我尝试了一种可能类似于模拟(欺骗)的方法,其中我创建了具有可选参数的代码。如果使用了该参数,则该过程将使用该参数而不是从数据库中获取数据。设置与一行数据具有相同字段类型的用户定义类型并将其传递给函数非常容易。我现在有一种方法可以将测试数据放入我想要测试的过程中。在每个过程中都有一些代码将真实数据源替换为测试数据源。这使我可以使用我自己的单元测试功能对更广泛的功能进行单元测试。编写单元测试很容易,只是重复和无聊。最后,我放弃了单元测试并开始使用不同的方法。

        我主要为自己编写内部应用程序,这样我就可以等到问题找到我,而不必拥有完美的代码。如果我确实为客户编写应用程序,通常客户并不完全了解软件开发成本有多少,因此我需要一种低成本的方式来获得结果。编写单元测试就是编写一个测试,将坏数据推送到程序中,以查看程序是否可以适当地处理它。单元测试还确认正确处理了良好的数据。我目前的方法是基于将输入验证写入应用程序中的每个过程,并在代码成功完成时引发成功标志。每个调用过程在使用结果之前都会检查成功标志。如果出现问题,将通过错误消息进行报告。每个函数都有一个成功标志、一个返回值、一条错误消息、一条注释和一个来源。用户定义类型(fr 表示函数返回)包含数据成员。任何给定的函数都只填充用户定义类型中的一些数据成员。当一个函数运行时,它通常返回success = true 和一个返回值,有时还有一个注释。如果函数失败,则返回 success = false 和错误消息。如果函数链失败,错误消息会以菊花形式更改,但结果实际上比正常的堆栈跟踪更具可读性。起源也被链接起来,所以我知道问题发生在哪里。该应用程序很少崩溃并准确报告任何问题。结果比标准错误处理要好得多。

        Public Function GetOutputFolder(OutputFolder As eOutputFolder) As  FunctRet
        
                '///Returns a full path when provided with a target folder alias. e.g. 'temp' folder
        
                    Dim fr As FunctRet
        
                    Select Case OutputFolder
                    Case 1
                        fr.Rtn = "C:\Temp\"
                        fr.Success = True
                    Case 2
                        fr.Rtn = TrailingSlash(Application.CurrentProject.path)
                        fr.Success = True
                    Case 3
                        fr.EM = "Can't set custom paths – not yet implemented"
                    Case Else
                        fr.EM = "Unrecognised output destination requested"
                    End Select
        
            exitproc:
                GetOutputFolder = fr
        
            End Function
        

        代码解释。 eOutputFolder 是用户定义的枚举,如下所示

        Public Enum eOutputFolder
            eDefaultDirectory = 1
            eAppPath = 2
            eCustomPath = 3
        End Enum
        

        我使用 Enum 将参数传递给函数,因为这会创建函数可以接受的一组有限的已知选择。将参数输入函数时,枚举还提供智能感知。我想他们为一个函数提供了一个基本的接口。

        'Type FunctRet is used as a generic means of reporting function returns
        Public Type  FunctRet
            Success As Long     'Boolean flag for success, boolean not used to avoid nulls
            Rtn As Variant      'Return Value
            EM As String        'Error message
            Cmt As String       'Comments
            Origin As String    'Originating procedure/function
        End Type
        

        诸如 FunctRet 之类的用户定义类型也提供了帮助的代码完成。在过程中,我通常将内部结果存储到匿名内部变量 (fr),然后再将结果分配给返回变量 (GetOutputFolder)。这使得重命名过程非常容易,因为只更改了顶部和底部。

        总之,我开发了一个带有 ms-access 的框架,涵盖了所有涉及 VBA 的操作。测试被永久写入程序,而不是开发时的单元测试。在实践中,代码仍然运行得非常快。我非常小心地优化了每分钟可以调用一万次的低级函数。此外,我可以在生产中使用正在开发的代码。如果发生错误,它是用户友好的,并且错误的来源和原因通常是显而易见的。错误是从调用表单报告的,而不是从业务层的某个模块报告的,这是应用程序设计的一个重要原则。此外,我没有维护单元测试代码的负担,这在我改进设计而不是编写清晰概念化的设计时非常重要。

        有一些潜在的问题。测试不是自动化的,只有在应用程序运行时才会检测到新的错误代码。该代码看起来不像标准 VBA 代码(通常更短)。尽管如此,该方法仍有一些优势。使用错误处理程序来记录错误要好得多,因为用户通常会联系我并给我一个有意义的错误消息。它还可以处理处理外部数据的过程。 JavaScript 让我想起了 VBA,我想知道为什么 JavaScript 是框架之乡,而 ms-access 中的 VBA 不是。

        写完这篇文章几天后,我发现一个article on The CodeProject 与我上面写的很接近。文章比较和对比了异常处理和错误处理。我上面的建议类似于异常处理。

        【讨论】:

        • 刚刚查看了我正在开发的应用程序。 105 个函数中只有大约 15 个是普通意义上的可单元测试的。其余的从操作系统、文件系统或记录集(而不是单个记录)中获取值。我需要的更像是集成测试和模拟/伪造。到目前为止,将继续使用上述方法,我找不到任何简单的集成测试方法。 Faking 是在测试数据表中用假数据交换。
        • 我已经弄清楚如何在 ms-access 中使用单元测试,现在正在使用测试驱动设计。关键是使用大量的小代码模块,并将创建或更改值的过程与使用这些值或存储这些值的过程分开。然后,我可以在使用任何值之前对其进行单元测试。我使用成功标志的方法在更高级别的代码中仍然很有用,其中许多事情需要正确运行才能使代码正常工作,其中许多事情都在非托管的外部环境中。
        【解决方案10】:

        我没有尝试过,但您可以尝试publish your access forms as data access web pages to something like sharepointjust as web pages,然后使用selenium 等工具通过一组测试来驱动浏览器。

        显然,这不如直接通过单元测试来驱动代码那么理想,但它可能会让您参与其中。祝你好运

        【讨论】:

          【解决方案11】:

          Access 是一个 COM 应用程序。使用 COM,而不是 Windows API。在 Access 中进行测试。

          Access 应用程序的最佳测试环境是 Access。您所有的表单/报告/表格/代码/查询都可用,有一种类似于 MS Test 的脚本语言(好吧,您可能不记得 MS Test),有用于保存您的测试脚本和测试结果的数据库环境,并且您在这里建立的技能可以转移到您的应用程序中。

          【讨论】:

            【解决方案12】:

            数据访问页面已经被 MS 弃用了很长一段时间,并且从一开始就没有真正起作用(它们依赖于安装的 Office 小部件,并且只能在 IE 中工作,而且当时很糟糕)。

            确实,能够获得焦点的访问控件只有在获得焦点时才有窗口句柄(而那些不能获得焦点的控件,例如标签,则根本没有窗口句柄)。这使得 Access 非常不适合窗口句柄驱动的测试机制。

            确实,我质疑您为什么要在 Access 中进行这种测试。在我看来,这就像你的极限编程基本信条,并不是 XP 的所有原则和实践都可以适用于 Access 应用程序——方钉、圆孔。

            所以,退后一步,问问自己你想要完成什么,并考虑你可能需要使用完全不同的方法,而不是那些基于在 Access 中无法工作的方法。

            或者这种自动化测试是否完全有效,甚至对 Access 应用程序有用。

            【讨论】:

            • 好吧,如果您像我一样使用 Access 作为框架来构建复杂的应用程序,那么在某些时候您确实需要测试。这不仅仅是一种“极限编程”的东西,它不是一种时尚,而是一种必需品:我在一个地方更改代码,我需要知道我是否破坏了其他东西。
            • 我没有低估测试的价值。我只批评了自动化测试对Access应用的应用。
            猜你喜欢
            • 1970-01-01
            • 2014-05-02
            • 1970-01-01
            • 1970-01-01
            • 2021-10-30
            • 2011-06-05
            • 1970-01-01
            • 1970-01-01
            • 2012-03-14
            相关资源
            最近更新 更多