拓冰建站拓冰建站
首页 / 资讯中心 / 正文

GoogleTest gMock(Google Mock)C++ 模拟框架实战指南:从 Mock 类编写到期望验证

GoogleTest gMockGoogle MockC 模拟框架实战指南从 Mock 类编写到期望验证【免费下载链接】googletestGoogleTest - Google Testing and Mocking Framework项目地址: https://gitcode.com/gh_mirrors/googl/googletestgMockGoogle Mock是 GoogleTest 项目中专门用于编写和使用 C Mock 类的子框架它帮助开发者以声明式语法定义模拟对象、控制其行为并自动验证调用期望。本指南以 googlemock/README.md 为骨架结合 docs/gmock_for_dummies.md 教程与仓库源码完整讲解 Mock 对象的定义、期望expectation的设置、匹配器matcher、基数cardinality与动作action的用法读完后你将能够独立为任意 C 接口编写 Mock 类并在 googletest 中完成交互式测试。gMock 是什么gMock 是 Google 官方推出的 C Mock 框架属于 GoogleTestGoogle Testing and Mocking Framework项目的一部分与 googletest 同源、同步发布并遵循相同的要求。它专门用于解决 C 测试中的一个难题如何验证你的模块与其他模块之间的交互是否正确。gMock 的设计深受 Java/Python 生态中成熟 Mock 框架的启发其灵感来源包括jMockJava 动态代理式 mock 框架EasyMockJava 经典 mock 框架Hamcrest通用匹配器断言库但 gMock 不是简单照搬而是针对 C 语言特性专门设计例如通过宏与模板在编译期生成 mock 实现并充分兼容 C 的虚函数、重载、const 成员函数等机制。gMock 的核心能力根据仓库根目录下的 googlemock/README.mdgMock 提供以下核心能力声明式语法定义 Mock用MOCK_METHOD宏即可声明 mock 方法无需手写实现支持部分混合Mock可以构造真实对象与 mock 行为交叉的混合对象mock 掉部分方法、保留真实实现支持任意类型与重载函数包括 const 方法、引用返回、模板参数等复杂签名内置丰富的匹配器用于校验函数实参如Eq、Ge、_等直观的行为控制语法用WillOnce、WillRepeatedly等子句描述方法被调用时的行为自动验证期望无需 record-and-replay 模式调用违规会立即报错支持任意部分顺序约束可以用InSequence、After表达调用顺序高度可扩展用户可以自定义新的匹配器与动作不使用异常全部基于 googletest 的断言机制报告失败易于学习和使用。这些能力在 googlemock/include/gmock/gmock.h 中得到印证——该文件是用户唯一需要包含的主头文件它聚合导出了 actions、cardinalities、function-mocker、matchers、more-actions、more-matchers、nice-strict、spec-builders 等全部子模块并在文件头注释中给出了ON_CALL与EXPECT_CALL的完整语法骨架。先厘清概念Fake 与 Mock 不是一回事在使用 gMock 之前必须先区分两个在 TDD测试驱动开发社区中常被混淆的概念Fake假对象有可工作的实现但通常走捷径比如为了降低成本因此不适合生产环境。典型例子是内存文件系统——功能完整但数据不落盘。Mock模拟对象预先用期望编程的对象这些期望构成了对它将要接收的调用的规格说明。最核心的记忆点是Mock 允许你检查自身与使用它的代码之间的交互。用 gMock 的流程分三步用简单的宏描述要 mock 的接口宏会展开为 mock 类的实现创建 mock 对象用直观的语法指定其期望与行为执行使用 mock 对象的代码gMock 会在违规发生的瞬间捕获错误。为什么需要 gMock手写 Mock 的三大痛点虽然 mock 对象能帮测试移除不必要的依赖、使其快速可靠但在 C 中手动编写 mock 非常痛苦实现枯燥且易错每个 mock 方法都要手写人们宁可绕远路也不愿做质量不可控手工 mock 质量参差不齐常有各种临时限制经验不迁移用过一个 mock 得到的经验无法复用到下一个。相比之下Java 和 Python 社区已有成熟框架jMock、EasyMock 等将 mock 创建自动化mock 在这些社区中被证明是有效的实践。gMock 正是为 C 程序员补齐这一能力而生的。如果你遇到以下问题gMock 就是你的答案受困于次优设计想尽早做更多原型验证而 C 原型开发太慢测试依赖太多库或昂贵资源如数据库而运行缓慢测试依赖网络等不可靠资源而变得脆弱想测试代码对失败如文件校验和错误的处理但难以人为制造需要确认模块与其他模块的交互方式正确但难以直接观察交互只能笨拙地在动作结束后检查副作用想 mock 掉依赖但依赖还没有现成的 mock 实现又不满意手写的。gMock 的双重价值既是设计工具让你早期反复试验接口设计迭代越多设计越好也是测试工具切断测试的外部依赖、探测模块与协作者的交互。快速开始gMock 随 googletest 一起分发gMock 与 googletest 捆绑发布不需要单独安装。在 CMake 工程中可以通过根目录的 CMakeLists.txt 引入 gmock 与 gmock_main 目标其自身的构建定义位于 googlemock/CMakeLists.txt其中通过add_library生成gmock与gmock_main两个库目标并通过target_include_directories暴露头文件路径。编译产物由 googlemock/src/gmock-all.cc包含全部 gmock 源码、googlemock/src/gmock_main.cc提供main函数等构成。提示gmock_main自带main入口并自动调用InitGoogleMock见 googlemock/include/gmock/gmock.h它会解析 gmock 与 googletest 的命令行参数。如果使用gmock_main测试中分配在堆上的 mock 对象会自动获得堆检查能力从而保证析构时的期望最终验证得以执行。实战案例为海龟绘图接口编写 Mock假设你在开发一个依赖 LOGO 风格绘图 API 的图形程序。直接运行程序与黄金截图比对的做法昂贵又脆弱显卡升级导致抗锯齿变化就得更新所有黄金图。正确的做法是利用依赖注入把系统 API 包装成一个Turtle接口让程序面向接口编程class Turtle { ... virtual ~Turtle() {} virtual void PenUp() 0; virtual void PenDown() 0; virtual void Forward(int distance) 0; virtual void Turn(int degrees) 0; virtual void GoTo(int x, int y) 0; virtual int GetX() const 0; virtual int GetY() const 0; };注意Turtle的析构函数必须是虚函数——所有打算被继承的类都应如此否则通过基类指针 delete 对象时派生类析构函数不会被调用导致内存泄漏等状态损坏。PenUp()/PenDown()控制移动是否留下轨迹Forward()/Turn()/GoTo()控制移动GetX()/GetY()返回当前位置。生产代码使用真实实现测试中则换成 mock 实现从而轻松检查程序调用了哪些绘图原语、参数是什么、顺序如何——测试更健壮不会因机器差异而失败、更易读、运行快得多。编写 Mock 类如何定义MOCK_METHOD 宏按以下步骤定义MockTurtle从Turtle派生MockTurtle取一个Turtle的虚函数模板方式也能 mock 非虚方法但复杂得多参见 docs/gmock_cook_book.md在子类的public:区域写MOCK_METHOD();把函数签名剪贴进宏返回类型与方法名之间加一个逗号方法名与参数列表之间再加一个逗号mock const 方法时加第 4 个参数(const)括号必须建议加override关键字——const 方法第 4 个参数写成(const, override)非 const 方法写成(override)非强制重复直到所有要 mock 的虚函数完成抽象类中所有纯虚方法必须被 mock 或 override。完成后效果如下#include gmock/gmock.h // 引入 gMock class MockTurtle : public Turtle { public: ... MOCK_METHOD(void, PenUp, (), (override)); MOCK_METHOD(void, PenDown, (), (override)); MOCK_METHOD(void, Forward, (int distance), (override)); MOCK_METHOD(void, Turn, (int degrees), (override)); MOCK_METHOD(void, GoTo, (int x, int y), (override)); MOCK_METHOD(int, GetX, (), (const, override)); MOCK_METHOD(int, GetY, (), (const, override)); };不需要在别处定义这些 mock 方法——MOCK_METHOD宏会自动生成定义。从源码看该宏定义于 googlemock/include/gmock/gmock-function-mocker.h内部通过GMOCK_PP_VARIADIC_CALL预处理变长参数并展开为GMOCK_INTERNAL_MOCK_METHOD_ARG_*模板实现最终生成的FunctionMockerF负责记录调用、匹配期望与执行动作同文件还保留了MOCK_METHOD0~MOCK_METHOD10、MOCK_CONST_METHOD0~MOCK_CONST_METHOD10等旧式宏gmock-function-mocker.h以保证向后兼容。放在哪里Mock 类的组织策略定义 mock 类时要决定放置位置放在_test.cc中当被 mock 的接口Foo归自己团队所有时没问题否则Foo一改你的测试就可能崩。一般原则不要 mock 你不拥有的类。如果必须 mock 别人拥有的类把 mock 类定义在Foo所在的 Bazel 包通常是同一目录或testing子目录中放在.h文件里并构建为testonlyTrue的cc_library。这样所有人都能引用Foo变化时只有一份MockFoo需要同步修改。另一方案在Foo之上引入一层薄的FooAdaptor并面向新接口编程。因为FooAdaptor归你所有能更从容地吸收Foo的变化虽然初期工作更多但精心设计适配器接口能让代码更易写易读。在测试中使用 Mock典型工作流从testing命名空间导入 gMock 名称每个文件只需一次即可不加限定符地使用创建 mock 对象指定期望方法被调用几次带什么参数应该做什么执行使用 mock 的代码可选地用 googletest 断言检查结果若 mock 方法被多调用或参数错误会立即报错mock 析构时gMock 自动检查其上所有期望是否已满足。示例#include path/to/mock-turtle.h #include gmock/gmock.h #include gtest/gtest.h using ::testing::AtLeast; // #1 TEST(PainterTest, CanDrawSomething) { MockTurtle turtle; // #2 EXPECT_CALL(turtle, PenDown()) // #3 .Times(AtLeast(1)); Painter painter(turtle); // #4 EXPECT_TRUE(painter.DrawCircle(0, 0, 10)); // #5 }如果painter没调用PenDown()测试失败并输出如下信息path/to/my_test.cc:119: Failure Actual function call count doesnt match this expectation: Actually: never called; Expected: called at least once. Stack trace: ...两个实用提示Tip 1在 Emacs 缓冲区内运行测试时可在失败行号上按Enter直接跳转到失败的期望处Tip 2若 mock 对象永不被删除最终验证就不会发生。因此在堆上分配 mock 时建议开启堆检查器——使用gtest_main库即可自动获得。期望必须预先设置重要规则gMock 要求期望必须在 mock 函数被调用之前设置否则行为是未定义的。不要在EXPECT_CALL()与 mock 函数调用之间交替进行也不要在把 mock 传给某个 API 之后再设置期望。因此EXPECT_CALL()应理解为期望未来发生一次调用而非已经发生了调用。原因在于预先声明期望gMock 才能在违规发生的第一时间栈信息等上下文仍然可用的时刻报告错误调试会容易得多。设置期望掌握 EXPECT_CALL 语法成功使用 mock 的关键是设置恰到好处的期望太严测试会被无关变更搞挂太松bug 会漏过去。gMock 提供了做到恰到好处所需的全部手段。通用语法EXPECT_CALL(mock_object, method(matchers)) .Times(cardinality) .WillOnce(action) .WillRepeatedly(action);宏有两个参数先是 mock 对象再是方法及其参数两者用逗号,而非句点.分隔这是出于技术原因的必要设计。如果方法没有重载还可以不带 matcher 调用EXPECT_CALL(mock_object, non-overloaded-method) .Times(cardinality) .WillOnce(action) .WillRepeatedly(action);这种省略参数列表的写法表示接受任意参数为免歧义只能用于非重载方法。语法设计得读起来像英文。例如using ::testing::Return; ... EXPECT_CALL(turtle, GetX()) .Times(5) .WillOnce(Return(100)) .WillOnce(Return(150)) .WillRepeatedly(Return(200));意思是turtle的GetX()将被调用 5 次第一次返回 100第二次返回 150之后每次返回 200。这种风格常被称为领域特定语言DSL。为什么用宏实现两个目的一是让期望容易被识别无论是grep还是人眼二是让 gMock 能在失败消息中包含期望所在的源文件位置便于调试。从源码看EXPECT_CALL及配套子句定义于 googlemock/include/gmock/gmock-spec-builders.h其完整语法还包括.With(multi-argument-matchers)、.InSequence(sequences)、.After(expectations)、.RetiresOnSaturation()子句均可选其中.InSequence()/.After()/.WillOnce()可出现任意多次。该头文件同时实现ON_CALL(mock_object, Method(...)).With(...).WillByDefault(...)用于指定 mock 方法的默认动作。Matchers期望什么样的参数当 mock 函数带参数时可以指定期望的参数// 期望海龟前进 100 个单位。 EXPECT_CALL(turtle, Forward(100));但经常不必如此精确——过度具体会让测试脆弱、掩盖意图。只指定必要的条件即可。对不关心的参数写_表示任意值都行using ::testing::_; ... // 期望海龟跳到 x50 线上的某处。 EXPECT_CALL(turtle, GoTo(50, _));_是匹配器matcher的一个实例。匹配器像一个谓词可检验实参是否符合预期能用在EXPECT_CALL()中任何期望函数参数的位置。上面例子里的100和50其实也是匹配器隐式等价于Eq(100)和Eq(50)——要求实参用operator等于该值。常用类型的内置匹配器参见 docs/reference/matchers.md自定义匹配器见 docs/gmock_cook_book.mdusing ::testing::Ge; ... // 期望海龟至少前进 100。 EXPECT_CALL(turtle, Forward(Ge(100)));若所有参数都不关心可省略整个参数列表仅限非重载方法// 期望海龟前进。 EXPECT_CALL(turtle, Forward); // 期望海龟跳到某处。 EXPECT_CALL(turtle, GoTo);若方法是重载的则需要通过指定参数个数乃至参数类型来帮助 gMock 分辨期望哪个重载。Cardinalities将被调用多少次EXPECT_CALL()后的第一个可选子句是Times()其参数称为基数cardinality描述调用应发生的次数。基数可以像匹配器一样模糊从而精确表达测试意图。特殊情形Times(0)表示该函数完全不应以给定参数被调用一旦被错误地调用gMock 就报告 googletest 失败。内置基数的完整列表见 docs/gmock_cheat_sheet.md从源码看这些工厂函数在 googlemock/include/gmock/gmock-cardinalities.h 中实现包括AtLeast(n)、AtMost(n)、AnyNumber()、Between(min, max)、Exactly(n)。其底层是一个拷贝式、不可变的Cardinality值对象内部通过std::shared_ptrconst CardinalityInterface持有实现gmock-cardinalities.h接口提供ConservativeLowerBound()/ConservativeUpperBound()、IsSatisfiedByCallCount()、IsSaturatedByCallCount()等判定方法用户也可实现CardinalityInterface定义新基数。Times()可省略。省略时 gMock 自动推断基数规则如下若EXPECT_CALL()中既无WillOnce()也无WillRepeatedly()推断为Times(1)若有n个WillOnce()且无WillRepeatedly()n 1基数为Times(n)若有n个WillOnce()且有一个WillRepeatedly()n 0基数为Times(AtLeast(n))。小测验若函数期望被调用两次实际却被调用四次会发生什么Actions应该做什么mock 对象没有真实实现用户必须告诉它在方法被调用时做什么。首先默认动作mock 函数的返回类型是内建类型或指针时它有默认动作——void函数直接返回bool函数返回false其他函数返回 0C11 及以上返回类型可默认构造即有默认构造函数时默认动作是返回一个默认构造的值。什么都不写就采用默认行为。其次当默认动作不适用时用一系列WillOnce()后跟可选的WillRepeatedly()来指定每次匹配时的动作using ::testing::Return; ... EXPECT_CALL(turtle, GetX()) .WillOnce(Return(100)) .WillOnce(Return(200)) .WillOnce(Return(300));表示turtle.GetX()将被调用恰好三次gMock 从三个WillOnce()推断出基数因为我们没写Times()分别返回 100、200、300。using ::testing::Return; ... EXPECT_CALL(turtle, GetY()) .WillOnce(Return(100)) .WillOnce(Return(200)) .WillRepeatedly(Return(300));表示turtle.GetY()将被调用至少两次两个WillOnce()加一个WillRepeatedly()而无显式Times()前两次分别返回 100、200第三次起返回 300。若显式写了Times()gMock 不再自行推断。当指定的次数超过WillOnce()的数量时所有WillOnce()用尽后每次执行默认动作除非有WillRepeatedly()。WillOnce()里除了Return()还能做什么可以用ReturnRef(variable)返回引用或调用预定义函数更多动作见 docs/gmock_cook_book.md。重要警告EXPECT_CALL()语句对动作子句只求值一次即使动作会被执行多次。注意副作用using ::testing::Return; ... int n 100; EXPECT_CALL(turtle, GetX()) .Times(4) .WillRepeatedly(Return(n));并不会依次返回 100、101、102……而是每次都返回 100因为n只在EXPECT_CALL()执行时求值一次。同理Return(new Foo)会在执行EXPECT_CALL()时创建一次Foo对象之后每次返回同一个指针。若希望副作用每次发生需要定义自定义动作见 docs/gmock_cook_book.md。再一个小测验using ::testing::Return; ... EXPECT_CALL(turtle, GetY()) .Times(4) .WillOnce(Return(100));turtle.GetY()被期望调用四次但不会每次都返回 100——每次调用会消耗一个WillOnce()之后执行默认动作。正确答案是第一次返回 100从第二次起返回 0int函数的默认动作。多个期望倒序匹配与覆盖规则现实中往往对多个 mock 方法甚至多个 mock 对象设置期望。默认情况下mock 方法被调用时gMock 会按期望定义的相反顺序搜索停在第一个参数匹配的活动期望上可理解为新规则覆盖旧规则。若匹配的期望已无法再接受调用会报上界违例失败using ::testing::_; ... EXPECT_CALL(turtle, Forward(_)); // #1 EXPECT_CALL(turtle, Forward(10)) // #2 .Times(2);若Forward(10)连续被调用三次第三次报错——因为最后匹配的期望#2已饱和。但若第三次调用的是Forward(20)则没问题——此时 #1 匹配。为什么倒序匹配因为这允许用户在 mock 构造函数或测试夹具的 setup 阶段设置默认期望然后在测试体内用更具体的期望来定制。所以同一方法的两个期望更具体的匹配器应放在后面否则会被后面的通用规则遮蔽。实用技巧常用Times(AnyNumber())开一个兜底期望非重载方法可省略参数重载方法用_覆盖所有参数使任何调用都在预期内。这对完全没有提及的方法无趣调用不是必需的但对已有部分期望、同时允许其他调用的方法很有用。参见 docs/gmock_cook_book.md 中 Understanding Uninteresting vs Unexpected Calls 一节。顺序调用InSequence 严格排序默认情况下期望不必按声明顺序满足。需要严格顺序时用InSequenceusing ::testing::InSequence; ... TEST(FooTest, DrawsLineSegment) { ... { InSequence seq; EXPECT_CALL(turtle, PenDown()); EXPECT_CALL(turtle, Forward(100)); EXPECT_CALL(turtle, PenUp()); } Foo(); }创建InSequence对象后其作用域内的所有期望被放入序列必须顺序发生。由于只依赖对象的构造与析构名字无关紧要。此例验证Foo()按书写顺序调用三个函数乱序即报错。若只关心部分调用的相对顺序任意偏序gMock 也支持——细节见 docs/gmock_cook_book.md。相关的语法约束在 googlemock/test/gmock-spec-builders_test.cc 中有专门测试例如InSequenceTest.AllExpectationInScopeAreInSequence验证作用域内全部期望入序列、ExpectCallSyntaxTest系列验证子句顺序约束Times必须在InSequence之前等。期望默认是粘性的小测验如何测试海龟被要求回到原点恰好两次忽略其他指令using ::testing::_; using ::testing::AnyNumber; ... EXPECT_CALL(turtle, GoTo(_, _)) // #1 .Times(AnyNumber()); EXPECT_CALL(turtle, GoTo(0, 0)) // #2 .Times(2);假设turtle.GoTo(0, 0)被调用三次第三次时 gMock 发现参数匹配期望 #2总是选最后一个匹配的期望而 #2 只允许两次调用于是立即报错。这个例子说明gMock 中的期望默认是粘性的——即使已达调用上界仍保持活动。这与许多其他 mock 框架不同因为该规则让常见场景更容易表达和理解。再验证一下理解下面的代码是什么意思using ::testing::Return; ... for (int i n; i 0; i--) { EXPECT_CALL(turtle, GetX()) .WillOnce(Return(10*i)); }如果认为GetX()会被调用 n 次并依次返回 10、20、30……就错了期望是粘性的第二次调用GetX()时最后一个最新的EXPECT_CALL()会匹配并立即触发上界违例错误——这段代码毫无用处。正确的写法是显式声明期望不粘性饱和即退休using ::testing::Return; ... for (int i n; i 0; i--) { EXPECT_CALL(turtle, GetX()) .WillOnce(Return(10*i)) .RetiresOnSaturation(); }还有更好的方式既然这里调用顺序很重要就用序列显式表达using ::testing::InSequence; using ::testing::Return; ... { InSequence s; for (int i 1; i n; i) { EXPECT_CALL(turtle, GetX()) .WillOnce(Return(10*i)) .RetiresOnSaturation(); } }另一种期望不粘性的情形处于序列中的期望当序列中排在其后的期望被使用后它会自动退休不再匹配任何调用。无趣调用Uninteresting Callsmock 对象往往有许多方法并非全部有趣。如果对某方法不感兴趣什么也不用说。该方法被调用时测试输出会出现警告但不会失败——这称为 naggy唠叨行为。要改变这种行为见 docs/gmock_cook_book.md 中 The Nice, the Strict, and the Naggy 一节。从源码看gMock 默认是 naggy 的googlemock/include/gmock/gmock-nice-strict.h 实现了三个模板类NiceMockMockFoo允许无趣调用静默NaggyMockMockFoo无趣调用时打印警告当前默认行为MockFoo与NaggyMockMockFoo表现相同StrictMockMockFoo把所有无趣调用视为错误。实现上NiceMockImpl/NaggyMockImpl/StrictMockImpl在构造时分别调用Mock::AllowUninterestingCalls/WarnUninterestingCalls/FailUninterestingCalls注册回调析构时注销gmock-nice-strict.h。三者还继承基类构造函数例如NiceMockMockFoo(5, a)可用于构造带参的 nice mock。已知限制NiceMock/NaggyMock/StrictMock只对在MockFoo类中直接用MOCK_METHOD*宏定义的方法生效基类中定义的 mock 方法可能不受影响且不支持嵌套使用。深入源码EXPECT_CALL 的底层实现理解底层实现有助于正确使用 gMock。核心组件FunctionMockerF与UntypedFunctionMockerBase定义于 googlemock/include/gmock/gmock-spec-builders.hUntypedFunctionMockerBase是类型无关的基类负责VerifyAndClearExpectationsLocked()验证并清除期望、报告无趣/意外调用消息、维护默认动作等所有 mock 函数调用与期望操作通过静态互斥量g_gmock_mutex串行化gmock-spec-builders.h保证多线程环境下 mock 对象状态的一致性——这正是期望匹配采用倒序搜索能够可靠实现的并发前提TypedExpectationF是类型化的期望实现持有 matcher、cardinality、action 序列与 retirement 状态。因此EXPECT_CALL(turtle, GetX()).Times(5).WillOnce(...)实际上是在构造一个TypedExpectation将其注册进FunctionMocker的期望列表中每次真实调用发生时mocker 会加锁、倒序遍历期望、用 matcher 匹配实参、按 cardinality 判断是否可接受再执行对应 action——违规超调、参数不匹配、顺序错误会立即转成 googletest 断言失败。延伸阅读gMock 的完整文档体系位于仓库的 docs 目录可按需深入docs/gmock_for_dummies.mdgMock 入门教程本文主要依据docs/gmock_cook_book.mdgMock 高级用法手册自定义动作与匹配器、部分顺序、Nice/Strict/Naggy 等docs/gmock_cheat_sheet.md语法速查表含基数列表docs/gmock_faq.mdgMock 常见问题docs/reference/matchers.md内置匹配器完整参考docs/reference/actions.md 与 docs/reference/mocking.md动作与 mock 语法参考。同时可阅读 googlemock/test/gmock-spec-builders_test.cc 等测试文件观察EXPECT_CALL各子句在真实用例中的组合方式全部测试由 googlemock/test/BUILD.bazel 组织可用 Bazel 或 CMake 直接运行验证本文示例的行为。【免费下载链接】googletestGoogleTest - Google Testing and Mocking Framework项目地址: https://gitcode.com/gh_mirrors/googl/googletest创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门