找回密码
 用户注册

QQ登录

只需一步,快速开始

查看: 4888|回复: 0

使用 Visual Studio 和 TFS 进行 Agile C++ 开发和测试

[复制链接]
发表于 2011-12-28 10:54:36 | 显示全部楼层 |阅读模式
致力于在 Visual C++ 中构建的应用程序的开发人员和测试人员。作为一名开发人员,如果能够提高工作效率,编写较高质量的代码,并能够根据需要重写代码以改善体系结构,而不必担心妨碍任何内容,岂不是很好?作为一名测试人员,是否希望花更少的时间来编写和维护测试,以便有时间进行其他测试活动?
使用 Visual Studio 2010 和 Team Foundation Server (TFS) 2010。使用 TFS 2010 来进行版本控制、工作跟踪、持续集成、代码覆盖率收集和报告。作为开发人员,以下是目标:
  • 无构建破坏
  • 无回归
  • 自信地进行重构
  • 自信地修改体系结构
  • 通过测试驱动开发 (TDD) 来推动设计

当然,质量是这些目标背后的重要“原因”。实现这些目标后,开发人员的生活将比其不是开发人员时更加高效和有趣。
对于我们的测试人员,我将仅关注 Agile 测试人员的一个方面:编写自动化测试。测试人员编写自动化测试时,他们的目标包括无回归、接受驱动开发以及收集和报告代码覆盖率。
当然,我们的测试人员所做的不仅仅是编写自动化测试。我们的测试人员负责收集代码覆盖率,因为我们希望代码覆盖率数字包含所有测试结果,而不仅仅是单元测试结果(稍后会对此进行详细介绍)。
在本文中,我将介绍我们团队用以实现此处所述目标的不同工具和技术。使用网关签入来消除构建破坏
过去,我们的团队使用分支来确保测试人员始终拥有用于测试的稳定版本。但是,维护分支会产生开销。我们拥有网关签入后,仅使用分支进行发布,这是一个很好的转变。
使用网关签入需要您设置构建控件以及一个或多个构建代理。我不打算在此对该主题进行介绍,但是您可以在 bit.ly/jzA8Ff 中 MSDN 库页面上的“管理 Team Foundation Build”中找到详细信息。
设置并运行构建代理后,您可以通过在 Visual Studio 中执行以下步骤来创建网关签入的新构建定义:
  • 在菜单栏中单击“视图”,然后单击“团队资源管理器”,以确保团队资源管理器工具窗口可见。
  • 展开您的团队项目,然后右键单击“构建”。
  • 单击“新建构建定义”。
  • 单击左侧的“触发器”,然后选择“网关签入”,如图 1 所示。

    图 1 为您的新建构建定义选择网关签入选项
  • 单击“构建默认值”,然后选择构建控制器。
  • 单击“处理”,然后选择要构建的项目。

保存该构建定义后(我们称为“网关签入”),您将会在提交您的签入后看到一个新对话框(请参见图 2)。单击“构建更改”来创建搁置集并将其提交至构建服务器。如果没有构建错误,并且通过所有单元测试,则 TFS 将为您签入更改。否则,它会拒绝签入。

图 2 网关签入对话框
网关签入十分有用,因为其可确保您永远不会破坏构建。它们也能确保通过所有单元测试。开发人员在签入前,往往很容易忘记运行所有测试。但是使用网关签入后,这类情况将不再出现。编写 C++ 单元测试
现在,您已了解如何运行作为网关签入一部分的单元测试,那么我们来看看您为本机 C++ 代码编写这些单元测试的一种方法。
我非常喜欢 TDD,原因如下:它能帮我关注行为,使我的设计更简单。我也有一个测试形式的安全网,这些测试定义了行为约定。我可以重构,而不必担心因不小心违反行为约定而引入错误。我也了解其他开发人员不会中断他们所不知道的所需行为。
团队中的一名开发人员有方法使用内置测试运行程序 (mstest) 来测试 C++ 代码。他使用调用本机 C++ DLL 公开的公共函数的 C++/CLI 来编写 Microsoft .NET Framework 单元测试。我在本部分对该方法进行了进一步的介绍,使您能够直接实例化生产代码内部的本机 C++ 类。换句话说,除公共接口外,您还可以对其他内容进行测试。
解决方案是将生产代码添加到静态库中,该静态库能链接至单元测试 DLL、生产 EXE 或 DLL 中,如图 3 所示。

图 3 测试和生产通过静态库共享相同代码
设置项目遵循该过程所需的步骤如下所示:首先创建静态库:
  • 在 Visual Studio 中,依次单击“文件”、“新建”和“项目”。
  • 在“已安装的模板”列表中单击“Visual C++”(您将需要展开“其他语言”)。
  • 单击项目类型列表中的“Win32 项目”。
  • 输入项目的名称,然后单击“确定”。
  • 单击“下一步”,单击“静态”库,然后单击“完成”。

现在创建测试 DLL。设置测试项目还需要执行若干其他步骤。需要创建项目,并使其能够访问静态库中的代码和头文件。
首先,在“解决方案资源管理器”窗口中右键单击解决方案。单击“添加”,然后单击“新项目”。单击模板列表中 Visual C++ 节点下的“测试”。键入项目名称(我们的团队在项目名称末尾添加了“UnitTests”),然后单击“确定”。
在解决方案资源管理器中,右键单击新项目,然后单击“属性”。单击左侧树中的“常见属性”。单击“添加新引用”。单击“项目”选项卡,选择使用静态库的项目,并单击“确定”来取消“添加引用”对话框。
展开左侧树中的“配置属性”节点,然后展开 C/C++ 节点。单击 C/C++ 节点下的“常规”。单击“配置”组合框,然后选择“所有配置”,以确保同时更改“调试”版本和“发布”版本。
单击“附加 Include 库”,然后输入静态库的路径,在这里您需要将静态库名称替换为 MyStaticLib:

$(SolutionDir)\MyStaticLib;%(AdditionalIncludeDirectories)


单击相同属性列表中的“公共语言运行时支持”属性,并将其更改为“公共语言运行时支持(/clr)”。
单击“配置属性”下的“常规”部分,并将“TargetName”属性更改为“$(ProjectName)”。默认情况下,所有测试项目的该属性被设置为“DefaultTest”,但是它应为您项目的名称。单击“OK”(确定)。
您将需要重复此过程的第一部分,以将静态库添加到生产 EXE 或 DLL 中。编写您的第一个单元测试
您现在应该拥有了编写新单元测试所需的所有信息。您的测试方法将为采用 C++ 编写的 .NET 方法,因此语法将与本机 C++ 稍有不同。如果您了解 C#,将会发现它在许多方面是 C++ 和 C# 语法的一个混合。有关详细信息,请参阅 bit.ly/iOKbR0 上的 MSDN 库文档“针对 CLR 的语言功能”。
假设您要测试的类定义如下所示:
  • #pragma once
  • class MyClass {
  • public:
  • MyClass(void);
  • ~MyClass(void);
  • int SomeValue(int input);
  • };


现在您需要为 SomeValue 方法编写测试以指定该方法的行为。图 4 显示了简单单元测试可能的外观,其中显示了整个 .cpp 文件。
图 4 简单单元测试
  • #include "stdafx.h"
  • #include "MyClass.h"
  • #include <memory>
  • using namespace System;
  • using namespace Microsoft::VisualStudio::TestTools::UnitTesting;
  • namespace MyCodeTests {
  • [TestClass]
  • public ref class MyClassFixture {
  • public:
  • [TestMethod]
  • void ShouldReturnOne_WhenSomeValue_GivenZero() {
  • // Arrange
  • std::unique_ptr<MyClass> pSomething(new MyClass);
  • // Act
  • int actual = pSomething->SomeValue(0);
  • // Assert
  • Assert::AreEqual<int>(1, actual);
  • }
  • };
  • }


如果您对编写单元测试不熟悉,我将使用称为“Arrange,Act,Assert”的模式。“Arrange”部分设置所要测试方案的前置条件。“Act”部分为调用您要测试的方法的地方。“Assert”部分为您检查方法是否按所需方式执行的地方。我想在每部分前添加一个注释,以增加可读性并简化“Act”部分的查找。
测试方法都以“TestMethod”属性标记,如图 4 所示。这些方法又必须包含在以“TestClass”属性标记的类中。
请注意,测试方法中的第一行创建了本机 C++ 类的新实例。我想使用 unique_ptr 标准 C++ 库类来确保该实例会在测试方法结束后自动删除。因此您可以清楚地看到,您能够将本机 C++ 与您的 CLI/C++ .NET 代码混合。当然也会存在某些限制,我将在下一部分中对此进行概述。
同样,如果您以前没有编写过 .NET 测试,可以使用 Assert 类提供的很多有用方法来检查不同的条件。我想使用通用版本来明确我希望从结果获得的数据类型。请充分利用 C++/CLI 测试
如前所述,当您将本机 C++ 代码和 C++/CLI 代码混合时,需要注意一些限制。这些不同是由于两个代码库间内存管理的不同而导致的。本机 C++ 使用 C++ 的新运算符来分配内存,您需要自己释放内存。分配一部分内存后,您的数据将始终位于同一位置。
另一方面,C++/CLI 代码中的指针会因为该代码从 NET Framework 继承的垃圾收集模型而具有完全不同的行为。您可以使用 gcnew 运算符而不是新运算符来在 C++/CLI 中创建新 .NET 对象,这将为对象返回对象句柄,而不是指针。句柄基本上是指向指针的多个指针。当垃圾收集在内存中移动托管对象时,它会根据新位置对句柄进行更新。
混合托管指针和本机指针时必须非常小心。我将介绍其中一些差异,并讲述有关针对本机 C++ 对象充分利用 C++/CLI 测试的提示和技巧。
假设您要测试一个方法,其可返回指向字符串的指针。在 C++ 中,您能够以 LPCTSTR 来表示字符串指针。但是在 C++/CLI 中,通常以“String^”来表示 .NET 字符串。类名称后的托字号表示托管对象的句柄。
以下是一个示例,说明如何测试方法调用返回的字符串的值:
  • // Act
  • LPCTSTR actual = pSomething->GetString(1);
  • // Assert
  • Assert::AreEqual<String^>("Test", gcnew String(actual));


最后一行包含所有详细信息。有一个 AreEqual 方法可接受受管字符串,但是没有针对本机 C++ 字符串的相应方法。因此,您需要使用受管字符串。AreEqual 方法的第一个参数是一个受管字符串,因此尽管没有使用 _T 或 L 将其标注为 Unicode 字符串,它实际上也是一个 Unicode 字符串。
String 类有一个接受 C++ 字符串的构造函数,因此您可以创建一个新的受管字符串,其将包含来自待测试方法的实际值,这样 AreEqual 就可确保它们的值相同。
Assert 类有两个极具吸引力的方法:IsNull 和 IsNotNull。然而,这些方法的参数是句柄而不是对象指针,这意味着您只能将这些方法用于受管对象。相反,您可以使用 IsTrue 方法,如下所示:
  • Assert::IsTrue(pSomething != nullptr, "Should not be null");


它能完成同样的工作,但是需要使用更多的代码。我添加了一个注释,从而使期望在出现在测试结果窗口的消息中清晰明了,如图 5 所示。

图 5 在错误消息中显示附加注释的测试结果共享设置和拆解代码
应将测试代码视为生产代码。换句话说,应重构测试和生产代码,以简化测试代码的维护。在某些情况下,测试类中的所有测试方法可能拥有一些共同的设置和拆解代码。您可以指定在每项测试前运行的方法,也可以指定在每项测试后运行的方法(您可以使用其中一种,两种或都不使用)。
TestInitialize 属性标记了一种将在测试类中每个测试方法前运行的方法。同样,TestCleanup 属性标记了一种将在测试类中每个测试方法后运行的方法。例如:
  • [TestInitialize]
  • void Initialize() {
  • m_pInstance = new MyClass;
  • }
  • [TestCleanup]
  • void Cleanup() {
  • delete m_pInstance;
  • }
  • MyClass *m_pInstance;


首先,请注意我针对 m_pInstance 使用了一个指向类的简单指针。为什么我没有使用 unique_ptr 来管理生存期?
同样,答案与混合本机 C++ 和 C++/CLI 有关。C++/CLI 中的实例变量是托管对象的一部分,因此它们只能是托管对象的句柄以及指向本机对象或值类型的指针。您需要回到新建和删除基础来管理本机 C++ 实例的生存期。使用指向实例变量的指针
如果您正在使用 COM,则可能会遇到这种情况,即您希望编写如下代码:
  • [TestMethod]
  • Void Test() {
  • ...
  • HRESULT hr = pSomething->GetWidget(&m_pUnk);
  • ...
  • }
  • IUnknown *m_pUnk;


这将无法编译,并会产生如下错误消息:

无法将参数 1 从“cli::interior_ptr<Type>”转换为“IUnknown **”


在本示例中,C++/CLI 实例变量地址类型为 interior_ptr<IUnknown *>,该类型与本机 C++ 代码不兼容。您想知道为什么吗?我仅需要一个指针。
测试类为托管类,因此该类的实例可以通过垃圾收集器在内存中移动。因此,如果您有指向实例变量的指针,则对象移动后,该指针将无效。
可以在本机调用过程中锁定对象,如下所示: cli::pin_ptr<IUnknown *> ppUnk = &m_pUnk; HRESULT hr = pSomething->GetWidget(ppUnk);

第一行锁定实例,直至变量超出范围,这将使您可以将指向实例变量的指针传递给本机 C++,尽管变量包含在托管测试类中。编写可测试的代码
在本文的开头,我提到了编写可测试代码的重要性。我使用 TDD 来确保代码具有可测试性,但是某些开发人员更喜欢在编写代码后编写测试。无论怎么做,我们都不仅要考虑单元测试,同时还要考虑整个测试堆栈,这点非常重要。
Mike Cohn 是 Agile 著名的多产作者,他绘制了测试自动化金字塔,该金字塔提供了有关测试类型的内容以及每层上应进行的测试数。开发人员应该编写所有或大多数单元和组件测试,并可能编写一些集成测试。有关该测试金字塔的详细信息,请参阅 Cohn 的博客文章“测试自动化金字塔的遗忘层”(bit.ly/eRZU2p)。
测试人员通常负责编写验收测试和 UI 测试。这些测试有时也称为端到端测试或 E2E 测试。在 Cohn 的金字塔中,与其他类型测试的区域相比,UI 三角形是最小的。这意味着您需要编写尽可能少的自动化 UI 测试。自动化 UI 测试往往非常脆弱,且编写和维护成本较高。对 UI 所做的细小更改极易破坏 UI 测试。
如果您的代码没有编写为可测试代码,则您将可以轻松得到倒金字塔,其中大部分自动化测试为 UI 测试。这是一种很不好的情况,但底线是开发人员需确保测试人员可以在 UI 下编写集成和验收测试。
此外,无论出于什么原因,我遇到的大部分测试人员都非常熟悉以 C# 编写测试,而回避以 C++ 编写测试。因此,我们的团队需要在测试和自动化测试下的 C++ 代码之间架起一个桥梁。桥梁采用装置形式,这些装置为 C++/CLI 类,它们像任何其他管理类一样呈现给 C# 代码。构建 C# 到 C++ 装置
此处使用的技术与我在编写 C++/CLI 测试中介绍的并没有太大区别。所有这些技术均使用相同类型的混合模式代码。区别在于它们最终的用法。
第一步是创建新项目,该项目将包含您的装置:
  • 在解决方案资源管理器中,右键单击解决方案节点,单击“添加”,然后单击“新项目”。
  • 在其他语言、Visual C++ 和 CLR 下,单击“类库”。
  • 输入要勇于该项目的名称,然后单击“确定”。
  • 重复以上步骤以创建测试项目,从而添加引用和 include 文件。

装置类本身与测试类有些类似,但是不具有各种属性(请参阅图 6)。
图 6 C# 到 C++ 测试装置
  • #include "stdafx.h"
  • #include "MyClass.h"
  • using namespace System;
  • namespace MyCodeFixtures {
  • public ref class MyCodeFixture {
  • public:
  • MyCodeFixture() {
  • m_pInstance = new MyClass;
  • }
  • ~MyCodeFixture() {
  • delete m_pInstance;
  • }
  • !MyCodeFixture() {
  • delete m_pInstance;
  • }
  • int DoSomething(int val) {
  • return m_pInstance->SomeValue(val);
  • }
  • MyClass *m_pInstance;
  • };
  • }


请注意,没有头文件!这是我最喜欢的 C++/CLI 功能之一。因为该类库构建了托管程序集,并且有关类的信息存储为 .NET 类型的信息,因此您不需要头文件。
该类也包含了析构函数和终结器。此处的析构函数并不是真正的析构函数。而在 IDisposable 接口中,编译器会将析构函数重新编写为 Dispose 方法的实现。因此,任何拥有析构函数的 C++/CLI 类都会实现 IDisposable 接口。
!MyCodeFixture 方法为终结器,垃圾收集器决定释放该对象时会调用该方法,除非您之前已调用 Dispose 方法。您既可以采用 using 语句来控制嵌入式本机 C++ 对象的生存期,也可以让垃圾收集器来处理生存期。有关此行为的详细信息,请访问bit.ly/kW8knr 上的 MSDN 库文章“析构函数语义更改”。
拥有 C++/CLI 装置类后即可编写类似图 7 的 C# 单元测试。
图 7 C# 单元测试系统
  • using Microsoft.VisualStudio.TestTools.UnitTesting;
  • using MyCodeFixtures;
  • namespace MyCodeTests2 {
  • [TestClass]
  • public class UnitTest1 {
  • [TestMethod]
  • public void TestMethod1() {
  • // Arrange
  • using (MyCodeFixture fixture = new MyCodeFixture()) {
  • // Act
  • int result = fixture.DoSomething(1);
  • // Assert
  • Assert.AreEqual<int>(1, result);
  • }
  • }
  • }
  • }


我想使用 using 语句来明确控制装置对象的生存期,而不是依赖垃圾收集器。这在测试方法中尤为重要,它能确保测试不与其他测试交互。捕获和报告代码覆盖率
我在本文开头处介绍的最后一部分为代码覆盖率。我们团队的目标是使代码覆盖率能自动被构建服务器捕获,能发布至 TFS 且易于获取。
第一步是了解如何从运行的测试中捕获 C++ 代码覆盖率。在 Web 上搜索时,我找到了 Emil Gustafsson 的一篇信息丰富的博客文章,标题为“使用 Visual Studio 2008 Team System 的本机 C++ 代码覆盖率报告”(bit.ly/eJ5cqv)。此文章显示了捕获代码覆盖率信息所需的步骤。我将其转换为 CMD 文件,这样就可以随时在我的开发计算机上运行该文件,以捕获代码覆盖率信息:
  • "%VSINSTALLDIR%\Team Tools\Performance Tools\vsinstr.exe" Tests.dll /COVERAGE
  • "%VSINSTALLDIR%\Team Tools\Performance Tools\vsperfcmd.exe" /START:COVERAGE /WaitStart /OUTPUT:coverage
  • mstest /testcontainer:Tests.dll /resultsfile:Results.trx
  • "%VSINSTALLDIR%\Team Tools\Performance Tools\vsperfcmd.exe" /SHUTDOWN


您将需要用包含测试的 DLL 实际名称替换 Tests.dll。还需要准备要检测的 DLL:
  • 在解决方案资源管理器窗口中,右键单击测试项目。
  • 单击“属性”。
  • 选择“调试配置”。
  • 展开“配置属性”,然后展开“链接器”并单击“高级”。
  • 将“配置文件”属性更改为“是(/PROFILE)”。
  • 单击“OK”(确定)。

这些步骤启用了分析,您需要启用该功能以实现程序集,从而捕获代码覆盖率信息。
重构您的项目并运行 CMD 文件。这将创建一个覆盖率文件。将该覆盖率文件加载至 Visual Studio,以确保可以从测试中捕获代码覆盖率。
在构建服务器上执行这些步骤并将结果发布至 TFS 需要一个自定义构建模板。TFS 构建模板存储在版本控制中,并且它们属于特定团队项目。您将在每个团队项目下找到名为“BuildProcessTemplates”的文件夹,该文件夹很可能拥有多个构建模板。
若要使用下载中包含的自定义构建模板,请打开“源代码控制资源管理器”窗口。导航到团队项目中的“BuildProcessTemplates”文件夹,并确保已将其映射到您计算机的一个目录。将 BuildCCTemplate.xaml 文件复制到该映射位置。将该模板添加至源代码控制并将其签入。
必须先将模板文件签入,这样才可在构建定义时使用这些文件。
现在您已签入构建模板,可以创建构建定义来运行代码覆盖率。可以使用 vsperfmd 命令来收集 C++ 代码覆盖率,如前所示。Vsperfmd 会侦听所有检测的可执行文件的代码覆盖率信息,这些可执行文件会随着 Vsperfmd 的运行而运行。因此,您不希望其他的检测测试同时运行。您也应该确保仅有一个构建代理在计算机上运行,该构建代理将处理这些代码覆盖率的运行。
我创建了一个会在夜间运行的构建定义。您可以通过以下步骤达到这一目的:
  • 在“团队资源管理器”窗口中,展开您团队项目的节点。
  • 右键单击“构建”,其为您团队项目下的一个节点。
  • 单击“新建构建定义”。
  • 在“触发器”部分,单击“计划”并选择要运行代码覆盖率的时间。
  • 在“处理”部分,单击调用顶部构建过程模板部分中的“显示详细信息”,然后选择签入到源代码控制中的构建模板。
  • 填写其他必填部分并保存。
添加测试设置文件
构建定义也需要测试设置文件。其为 XML 文件,其中列出了要为其捕获并发布结果的 DLL。以下是为代码覆盖率设置该文件的步骤:
  • 双击 Local.testsettings 文件,然后打开“测试设置”对话框。
  • 单击左侧列表中的“数据”和“诊断”。
  • 单击“代码覆盖率”并选中复选框。
  • 单击列表上的“配置”按钮。
  • 选中包含您的测试(也包含测试所测试的代码)的 DLL 旁的框。
  • 取消选中准备就绪的检测程序集,因为构建定义将处理该问题。
  • 单击“确定”和“应用”,然后单击“关闭”。

如果您想构建多个解决方案或者有多个测试项目,则您将需要复制一个测试设置文件,其包含应监视其代码覆盖率的所有程序集的名称。
若要完成此任务,请将测试设置文件复制到您分支的根目录下,并给定一个描述性名称,如 CC.testsettings。编辑 XML。文件将至少包含来自上述步骤中的一个 CodeCoverageItem 元素。您需要为要捕获的每个 DLL 添加一个条目。请注意,路径与项目文件的位置而不是测试设置文件的位置相关。将该文件签入源代码控制中。
最后,您需要修改构建定义来使用以下测试设置文件:
  • 在“团队资源管理器”窗口中,展开您团队项目的节点,然后展开“构建”。
  • 双击之前创建的构建定义。
  • 单击“编辑构建定义”。
  • 在“处理”部分,展开“自动化测试”,然后展开 1。测试程序集并单击“TestSettings”文件。单击“…”按钮,然后选择我们之前创建的测试设置文件。
  • 保存更改。

您可以通过右键单击并选择“排队新构建”来测试该构建定义,从而立即启动新构建。报告代码覆盖率
我创建了自定义 SQL Server Reporting Services 报告,其显示了代码覆盖率,如图 8 所示(我模糊了实际项目的名称以防止犯错)。该报告使用 SQL 查询来读取 TFS 仓库中的数据,并显示 C++ 和 C# 代码的组合结果。

图 8 代码覆盖率报告
在此我将不详述此报告的工作方式,但是我想提醒您注意几个方面。出于以下两个原因,数据库包含了来自 C++ 代码覆盖率的太多信息:测试方法代码包括在结果中,标准 C++ 库(在头文件中)包括在结果中。
我将代码添加至 SQL 查询,其将筛选出此额外数据。如果您看一下报告中的 SQL,就会发现这一点:
  • and CodeElementName not like 'std::%'
  • and CodeElementName not like 'stdext::%'
  • and CodeElementName not like '`anonymous namespace'':%'
  • and CodeElementName not like '_bstr_t%'
  • and CodeElementName not like '_com_error%'
  • and CodeElementName not like '%Tests::%'


这些行排除了特定命名空间(std、stdext 和匿名)的代码覆盖率结果、Visual C++ 附带的一些类(_bstr_t 和 _com_error)以及以“Tests”结尾的命名空间中的任何代码。
后者排除了以“Tests”结尾的命名空间,从而排除了测试类中的所有方法。创建新测试项目时,因为项目名称以“Tests”结尾,因此,默认情况下所有测试类都位于以“Tests”结尾的命名空间中。您可以在此处添加您希望排除的其他类或命名空间。
您需要登录后才可以回帖 登录 | 用户注册

本版积分规则

Archiver|手机版|小黑屋|ACE Developer ( 京ICP备06055248号 )

GMT+8, 2024-11-23 21:07 , Processed in 0.020468 second(s), 5 queries , Redis On.

Powered by Discuz! X3.5

© 2001-2023 Discuz! Team.

快速回复 返回顶部 返回列表