【Hello CC.NET】CC.NET 实现自动化集成
一、背景
公司的某一金融项目包含 12 个子系统,新需求一般按分支来开发,测完后合并到主干发布。开发团队需要同时维护开发环境、测试环境、模拟环境(主干)。目前面临最大的两个问题:
1.子系统太多,每次发布(包括开发环境、测试环境、模拟环境)都比较耗时间
2.业务数据和报表的数据(主要是汇率、金额、手续费之类)仍依赖 Tester 人工验证,一个 sprint 一般需要走一周 以上。
自动化测试和持续集成提了近一年,但由于各种因素的影响,还是在原地踏步。所幸仍有一两个不怕背黑锅的搭档,今日终于迈出这最艰难的第一步。
二、关于持续集成(Continuous integration,CI)
一个比较简单的定义如下:持续集成(CI)是一种实践,可以让团队在持续的基础 上收到反馈并进行改进,不必等到开发周期后期才寻找和修复缺陷。
一般地包括以下几个步骤:
1.CI 服务器从版本管理服务器检查代码版本,如果有新的提交,则下载最新的源码版本
2.编译最新版本的源码
3.运行所有测试脚本
4.进行代码分析
5.产生更新包,提供发布(或写脚本自动发布)
以上步骤串行执行,任何一个环节失败,则该 build 失败。CI 服务器将给开发团队相应的反馈。
三、关于 CruiseControl.Net
CruiseControl.Net 是一款开源的自动集成工具 http://www.cruisecontrolnet.org/projects/ccnet/wiki。
源码地址:https://github.com/ccnet/CruiseControl.NET 或 http://sourceforge.net/projects/ccnet/files/
四、环境准备
找一个干净的虚拟机环境,WInServer2008/Win7,安装以下工具:
安装 IIS / SFTP
.NET/编译/Test 环境:VS 2010(用到其中的 MsBuild,MsTest)
源码管理服务器:VisualSVNServer 2.1
CC.NET 服务端:CruiseControl.NET-1.8.3
开发机(本机)安装以下工具:
开发工具:VS 2010
源码管理客户端:TortoiseSVN-1.8
CC.NET 客户端:CruiseControl.NET-CCTray
步骤
1.安装 IIS / SFTP
控制版面>程序和功能>打开或关闭 Windows 功能
2. VS 2010
这里用的是旗舰版
3.安装好 VisualSVNServer 2.1 ,添加一个项目 Test,添加两个用户 ci、harvey.choi
(申请公司开通 ci@XXXcompany.com 邮箱,后面配置 publisher 时会用到)
4.安装 CC.NET 服务端,安装目录下有一个 webdashboard 文件夹,在 IIS 中添加一个网站,指向这个文件夹
5.创建一个测试解决方案,添加 Lib/LibTest 项目,提交到 SVN Server
测试的逻辑尽量简单
1 //Lib/Calculator.cs 2 namespace Lib 3 { 4 public class Calculator 5 { 6 public int Add(params int[] items) 7 { 8 var result = 0; 9 foreach (var i in items) 10 result += i; 11 return result; 12 } 13 } 14 } 15 16 //LibTest/Test.cs 17 namespace LibTest 18 { 19 [TestClass] 20 public class Test 21 { 22 [TestMethod] 23 public void Calculator_Add() 24 { 25 var c = new Lib.Calculator(); 26 27 var result = c.Add(1, 2, 3); 28 29 Assert.AreEqual(result, 6); 30 } 31 } 32 }
五、CC.NET 配置
CC.NET 的配置文件是 安装目录\server\ccnet.config
官方文档:http://www.cruisecontrolnet.org/attachments/download/10/customisingcruisecontrol-net.pdf
在线:http://www.cruisecontrolnet.org/projects/ccnet/wiki/Configuration
CC.NET 提供 windows 服务 及 控制台程序两种方式运行。windows 服务需要手动启动(可按需修改为自动启动);控制台程序为 安装目录\安装目录\server\ccnet.exe。本 demo 中用控制台方式运行,方便查看运行结果。
1.源代码管理
ccnet.config 配置如下:
<cruisecontrol xmlns:cb="urn:ccnet.config.builder"> <project name="Lib.Sln"> <!--标签--> <labeller type="dateLabeller"/> <artifactDirectory>C:\CC.NET\Server\Test\ArtifactDirectory</artifactDirectory> <!--项目的目录--> <workingDirectory >C:\CC.NET\Server\Test\WorkingDirectory</workingDirectory> <!--自动构建结果的查看地址--> <webURL>http://vw-caihaihua/CC/server/local/project/Lib.Sln/ViewProjectReport.aspx</webURL> <!--自动运行时间间隔--> <triggers> <!--源码修改触发--> <intervalTrigger seconds="10" /> </triggers> <maxSourceControlRetries>5</maxSourceControlRetries> <!--源代码管理(SVN)--> <sourcecontrol type="svn"> <trunkUrl>https://vw-caihaihua/svn/Test/trunk/</trunkUrl> <executable>C:\Program Files (x86)\VisualSVN Server\bin\svn.exe</executable> <workingDirectory>C:\CC.NET\Server\Test\WorkingDirectory\</workingDirectory> <username>ci</username> <password>123456</password> </sourcecontrol> </project> </cruisecontrol>
执行 ccnet.exe 得到结果如下。配置的工作目录中已经是最新版本的代码:
我们可以 CC.NET 自带的 Dashboard 站点查看实时的信息,另外 Dashboard 站点还提供了 ForceBuild 功能:
2.触发器
CC.NET 提供多种触发器。本 Demo 中使用两种触发器
intervalTrigger 间隔触发,条件为如果源码有更新(IfModificationExists),同时配置 modificationDelaySeconds 节点;为了方便看到效果,这里 intervalTrigger seconds 设为 10,modificationDelaySeconds 设为 30。
scheduleTrigger 定时触发,条件为强制触发(ForceBuild),用于 DailyBuild。
ccnet.config 配置如下:
<!--自动运行时间间隔--> <triggers> <!--源码修改触发--> <intervalTrigger seconds="10" buildCondition="IfModificationExists" /> <!--每日构建--> <scheduleTrigger time="19:00" buildCondition="ForceBuild"> <weekDays> <!--<weekDay>Sunday</weekDay>--> <weekDay>Monday</weekDay> <weekDay>Tuesday</weekDay> <weekDay>Wednesday</weekDay> <weekDay>Thursday</weekDay> <weekDay>Friday</weekDay> <!--<weekDay>Saturday</weekDay>--> </weekDays> </scheduleTrigger> </triggers> <!--对源码修改延迟处理时间间隔--> <modificationDelaySeconds>30</modificationDelaySeconds> <maxSourceControlRetries>5</maxSourceControlRetries>
客户端修改代码并提交到 SVN,结果如下:
3.编译
编译方式可以用 VS 工具,也可以用 .NET Framework 自带的 MsBuild。
ccnet.config 配置如下:
<tasks>
<!--<devenv> <solutionfile>C:\CC.NET\Server\Test\WorkingDirectory\Lib.sln</solutionfile> <configuration>Debug</configuration> </devenv>--> <!--清理解决方案--> <msbuild> <executable>C:\Windows\Microsoft.NET\Framework\v4.0.30319\MSBuild.exe</executable> <buildArgs>/t:clean /t:rebuild /p:configuration=debug</buildArgs> <workingDirectory>C:\CC.NET\Server\Test\WorkingDirectory</workingDirectory> <projectFile>Lib.sln</projectFile> <logger>ThoughtWorks.CruiseControl.MsBuild.XmlLogger,C:\Program Files (x86)\CruiseControl.NET\server\ThoughtWorks.CruiseControl.MsBuild.dll</logger> </msbuild> </tasks>
Dashboard 站点中触发 ForceBuild,结果如下:
4.运行 UnitTest
测试脚本用的是 VS 自带的 UnitTest。
ccnet.config 配置如下:
<tasks> <!--运行UnitTest--> <exec> <executable>C:\Program Files (x86)\Microsoft Visual Studio 10.0\Common7\IDE\MSTest.exe</executable> <baseDirectory>C:\CC.NET\Server\Test\WorkingDirectory</baseDirectory> <buildArgs>/testcontainer:LibTest\bin\Debug\LibTest.dll</buildArgs> <buildTimeoutSeconds>6000</buildTimeoutSeconds> </exec> </tasks>
Dashboard 站点中触发 ForceBuild,结果如下:
客户端修改测试脚本,提交,结果如下:
[TestMethod] public void Calculator_Add() { var c = new Lib.Calculator(); var result = c.Add(1, 2, 3); Assert.AreEqual(result, 6); //Error Test Assert.AreEqual(7, 6); }
5.发布网站(Asp.NET 网站/WebServcie/WcfService)
首先在解决方案中添加 WcfService/WcfServiceTest 项目,提交到 SVN
公司的 Wcf 服务是寄宿在 IIS 上,我们需要用 MsBuild 编译成安装包。
ccnet.config 配置如下:
<tasks>
<!--发布Wcf服务到本机--> <msbuild> <executable>C:\Windows\Microsoft.NET\Framework\v4.0.30319\MSBuild.exe</executable> <workingDirectory>C:\CC.NET\Server\Test\WorkingDirectory\WcfService</workingDirectory> <projectFile>WcfService.csproj</projectFile> <buildArgs> /t:ResolveReferences;Compile /t:_CopyWebApplication /p:Configuration=Release /p:WebProjectOutputDir=C:\CC.NET\Server\Test\PublishDirectory\WcfService /p:OutputPath=C:\CC.NET\Server\Test\PublishDirectory\WcfService\bin </buildArgs> </msbuild> </tasks>
结果如下:
6.WcfService Test
由于项目的 Wcf 服务是寄宿在 IIS。VS 工具中用到的是 Asp.NET Development Server(WebDev.WebServer40.EXE),但我没想到怎么用。
此处用到的方法是在 IIS 中创建一个站点,用 MsBuild 发布 Wcf 服务到该站点(第 5 点)。然后把 WcfServiceTest 的服务引用指向该站点。
ccnet.config 配置如下:
<!--运行UnitTest--> <exec> <executable>C:\Program Files (x86)\Microsoft Visual Studio 10.0\Common7\IDE\MSTest.exe</executable> <baseDirectory>C:\CC.NET\Server\Test\WorkingDirectory</baseDirectory> <buildArgs>/testcontainer:WcfServiceTest\bin\Debug\WcfServiceTest.dll</buildArgs> <buildTimeoutSeconds>6000</buildTimeoutSeconds> </exec>
结果如下:
7.ftp 发布
当开发环境(或测试环境)在 CC.NET 服务器,可以用第 5 点的方法来完成自动发布。
但是往往 CI 跟环境是独立的。这时候我们可以用 ftp 来发布。
首先添加一个 ftp 站点,指向开发环境(或测试环境)的 Wcf 服务的目录,创建一个账户 admin 并分配权限到该 ftp 站点。
ccnet.config 配置如下:
<tasks> <ftp> <serverName>127.0.0.1</serverName> <userName>admin</userName> <password>admin</password> <action>UploadFolder</action> <ftpFolderName></ftpFolderName> <localFolderName>C:\CC.NET\Server\Test\PublishDirectory\WcfService</localFolderName> <recursiveCopy>true</recursiveCopy> <timeDifference>1</timeDifference> </ftp> </tasks>
ForceBuild 结果如下:
8.打包
发布更新包配置如下:
<tasks>
<package> <name>Lib.sln</name> <compression>9</compression> <manifest type="defaultManifestGenerator" /> <packageList> <packageFile sourceFile="C:\CC.NET\Server\Test\PublishDirectory\WcfService\*.svc" targetFolder="WcfService" /> <packageFile sourceFile="C:\CC.NET\Server\Test\PublishDirectory\WcfService\*.Release.config" targetFolder="WcfService" /> <packageFile sourceFile="C:\CC.NET\Server\Test\PublishDirectory\WcfService\bin\*.dll" targetFolder="WcfService\bin" /> <!--<packageFolder sourceFolder="C:\CC.NET\Server\Test\PublishDirectory\WcfService" targetFolder="WcfService" fileFilter="*.*" flatten="false" includeSubFolders="false" />--> </packageList> </package> </tasks>
ForceBuild 结果如下:
我们可以通过 CCTracy 工具下载下载更新包:
9.历史备份
配置 publishDir ,则每次 Build 成功后都会把该版本的源码备份到指定目录,配置如下:
<publishers> <!--标签备份(如果成功)--> <buildpublisher> <sourceDir>C:\CC.NET\Server\Test\WorkingDirectory</sourceDir> <publishDir>C:\CC.NET\Server\Test\HistoryVersion</publishDir> </buildpublisher> <modificationHistory/> <statistics/> <xmllogger/> </publishers>
10.邮件通知
CC.NET 提供 email 方式反馈给开发团队,触发条件有 Always/Success/Change/Fixed/Failed/Exception
(1)Modifier:一般情况下,SVN 的用户名跟公司邮箱名是对应的,用 converters 配置简单实现,更复杂的场景尚待研究
(2)特定人员或角色:配置 users + groups。
按设想的场景中,CI leader 需要得到 Change 和 Exception 的反馈;Tester 需要得到 Fixed 的反馈;开发团队需要得到 Success/Fixed/Failed 的反馈;源码修改者需要得到 Failed/Fixed 的反馈。合理性尚待验证。
ccnet.config 配置如下:
<publishers> <!--邮件通知--> <email mailhost="smtp.live.com" mailport="25" mailhostUsername="ci@XXXXCompany.com" mailhostPassword="*********" from="ci@XXXXCompany.com" useSSL="TRUE" includeDetails="true"> <!--邮件标题配置--> <subjectPrefix>[CI@XXXCompany]</subjectPrefix> <subjectSettings> <!-- Success/Broken/StillBroken/Fixed/Exception--> <subject buildResult="Success" value="${CCNetProject} Build Successful: Label ${CCNetLabel}, last checkin(s) by ${CCNetModifyingUsers}.(at ${CCNetBuildDate} ${CCNetBuildDate})" /> <subject buildResult="Fixed" value="${CCNetProject} Build Fixed: Label ${CCNetLabel}, last checkin(s) by ${CCNetModifyingUsers}.(at ${CCNetBuildDate} ${CCNetBuildDate})" /> <subject buildResult="Broken" value="${CCNetProject} Broke: last checkin(s) by ${CCNetFailureUsers}.(at ${CCNetBuildDate} ${CCNetBuildDate})" /> <subject buildResult="StillBroken" value="${CCNetProject} Still Broken: last checkin(s) by ${CCNetFailureUsers}.(at ${CCNetBuildDate} ${CCNetBuildDate})" /> <subject buildResult="Exception" value="${CCNetProject} In Exception: Please check status of network / sourcecontrol.(at ${CCNetBuildDate} ${CCNetBuildDate})" /> </subjectSettings> <!--通知相关的源码修改者--> <converters> <regexConverter find="$" replace="@XXXXCompany.com"/> </converters> <modifierNotificationTypes> <notificationType>Failed</notificationType> <notificationType>Fixed</notificationType> </modifierNotificationTypes> <!--通知特定人员(角色)-->
<users> <user group="leader" name="ci.XXXCompany" address="ci@XXXCompany.com"/> <user group="developer" name="harvey.choi" address="harvey.choi@XXXXCompany.com"/> <user group="tester" name="jolin" address="jolin.tang@XXXCompany.com"/> </users> <groups> <group name="leader"> <notifications> <!--Always/Success/Change/Fixed/Failed/Exception --> <notificationType>Change</notificationType> <notificationType>Exception</notificationType> </notifications> </group> <group name="developer"> <notifications> <notificationType>Success</notificationType> <notificationType>Fixed</notificationType> <notificationType>Failed</notificationType> </notifications> </group> <group name="tester"> <notifications> <notificationType>Fixed</notificationType> </notifications> </group> </groups> </email> <xmllogger/> </publishers>
用 harvey.choi 账户修改源码并提交到 SVN。结果如下:
harvey.choi@XXXXCompany.com 收到以下邮件:
完整的 ccnet.config 配置如下,注意 tasks 是按顺序串行执行,任一步骤出错则整个 build 失败:
<cruisecontrol xmlns:cb="urn:ccnet.config.builder"> <project name="Lib.Sln"> <!--标签--> <labeller type="dateLabeller"/> <artifactDirectory>C:\CC.NET\Server\Test\ArtifactDirectory</artifactDirectory> <!--项目的目录--> <workingDirectory >C:\CC.NET\Server\Test\WorkingDirectory</workingDirectory> <!--自动构建结果的查看地址--> <webURL>http://vw-caihaihua/CC/server/local/project/Lib.Sln/ViewProjectReport.aspx</webURL> <!--自动运行时间间隔--> <triggers> <!--源码修改触发--> <intervalTrigger seconds="10" buildCondition="IfModificationExists " /> <!--每日构建--> <scheduleTrigger time="19:00" buildCondition="ForceBuild"> <weekDays> <!--<weekDay>Sunday</weekDay>--> <weekDay>Monday</weekDay> <weekDay>Tuesday</weekDay> <weekDay>Wednesday</weekDay> <weekDay>Thursday</weekDay> <weekDay>Friday</weekDay> <!--<weekDay>Saturday</weekDay>--> </weekDays> </scheduleTrigger> </triggers> <!--对源码修改延迟处理时间间隔--> <modificationDelaySeconds>30</modificationDelaySeconds> <maxSourceControlRetries>5</maxSourceControlRetries> <!--源代码管理(SVN)--> <sourcecontrol type="svn"> <trunkUrl>https://vw-caihaihua/svn/Test/trunk/</trunkUrl> <executable>C:\Program Files (x86)\VisualSVN Server\bin\svn.exe</executable> <workingDirectory>C:\CC.NET\Server\Test\WorkingDirectory\</workingDirectory> <username>ci</username> <password>123456</password> </sourcecontrol> <tasks> <!--<devenv> <solutionfile>C:\CC.NET\Server\Test\WorkingDirectory\Lib.sln</solutionfile> <configuration>Debug</configuration> </devenv>--> <!--清理解决方案--> <msbuild> <executable>C:\Windows\Microsoft.NET\Framework\v4.0.30319\MSBuild.exe</executable> <buildArgs>/t:clean /t:rebuild /p:configuration=debug</buildArgs> <workingDirectory>C:\CC.NET\Server\Test\WorkingDirectory</workingDirectory> <projectFile>Lib.sln</projectFile> <logger>ThoughtWorks.CruiseControl.MsBuild.XmlLogger,C:\Program Files (x86)\CruiseControl.NET\server\ThoughtWorks.CruiseControl.MsBuild.dll</logger> </msbuild> <!--运行UnitTest--> <exec> <executable>C:\Program Files (x86)\Microsoft Visual Studio 10.0\Common7\IDE\MSTest.exe</executable> <baseDirectory>C:\CC.NET\Server\Test\WorkingDirectory</baseDirectory> <buildArgs>/testcontainer:LibTest\bin\Debug\LibTest.dll</buildArgs> <buildTimeoutSeconds>6000</buildTimeoutSeconds> </exec> <!--发布Wcf服务到本机--> <msbuild> <executable>C:\Windows\Microsoft.NET\Framework\v4.0.30319\MSBuild.exe</executable> <workingDirectory>C:\CC.NET\Server\Test\WorkingDirectory\WcfService</workingDirectory> <projectFile>WcfService.csproj</projectFile> <buildArgs> /t:ResolveReferences;Compile /t:_CopyWebApplication /p:Configuration=Release /p:WebProjectOutputDir=C:\CC.NET\Server\Test\PublishDirectory\WcfService /p:OutputPath=C:\CC.NET\Server\Test\PublishDirectory\WcfService\bin </buildArgs> </msbuild> <!--运行UnitTest--> <exec> <executable>C:\Program Files (x86)\Microsoft Visual Studio 10.0\Common7\IDE\MSTest.exe</executable> <baseDirectory>C:\CC.NET\Server\Test\WorkingDirectory</baseDirectory> <buildArgs>/testcontainer:WcfServiceTest\bin\Debug\WcfServiceTest.dll</buildArgs> <buildTimeoutSeconds>6000</buildTimeoutSeconds> </exec> <!--启动 Asp.NET Development Server--> <!--<exec> <executable>C:\Program Files (x86)\Common Files\microsoft shared\DevServer\10.0\WebDev.WebServer40.EXE</executable> <buildArgs>/port:9999 /path:C:\CC.NET\Server\Test\PublishDirectory\WcfService</buildArgs> <buildTimeoutSeconds>6000</buildTimeoutSeconds> </exec>--> <!--ftp发布Wcf服务到开发环境--> <ftp> <serverName>127.0.0.1</serverName> <userName>admin</userName> <password>admin</password> <action>UploadFolder</action> <ftpFolderName></ftpFolderName> <localFolderName>C:\CC.NET\Server\Test\PublishDirectory\WcfService</localFolderName> <recursiveCopy>true</recursiveCopy> <timeDifference>1</timeDifference> </ftp> <package> <name>Lib.sln</name> <compression>9</compression> <manifest type="defaultManifestGenerator" /> <packageList> <packageFile sourceFile="C:\CC.NET\Server\Test\PublishDirectory\WcfService\*.svc" targetFolder="WcfService" /> <packageFile sourceFile="C:\CC.NET\Server\Test\PublishDirectory\WcfService\*.Release.config" targetFolder="WcfService" /> <packageFile sourceFile="C:\CC.NET\Server\Test\PublishDirectory\WcfService\bin\*.dll" targetFolder="WcfService\bin" /> <!--<packageFolder sourceFolder="C:\CC.NET\Server\Test\PublishDirectory\WcfService" targetFolder="WcfService" fileFilter="*.*" flatten="false" includeSubFolders="false" />--> </packageList> </package> </tasks> <state type="state" directory="C:\CC.NET\server\CCState"/> <publishers> <!--标签备份(如果成功)--> <buildpublisher> <sourceDir>C:\CC.NET\Server\Test\WorkingDirectory</sourceDir> <publishDir>C:\CC.NET\Server\Test\HistoryVersion</publishDir> </buildpublisher> <modificationHistory/> <statistics/> <!--邮件通知--> <email mailhost="smtp.live.com" mailport="25" mailhostUsername="ci@XXXCompany.com" mailhostPassword="******" from="ci@XXXCompany.com" useSSL="TRUE" includeDetails="true"> <!--邮件标题配置--> <subjectPrefix>[CI@XXXCompany]</subjectPrefix> <subjectSettings> <!-- Success/Broken/StillBroken/Fixed/Exception--> <subject buildResult="Success" value="${CCNetProject} Build Successful: Label ${CCNetLabel}, last checkin(s) by ${CCNetModifyingUsers}.(at ${CCNetBuildDate} ${CCNetBuildDate})" /> <subject buildResult="Fixed" value="${CCNetProject} Build Fixed: Label ${CCNetLabel}, last checkin(s) by ${CCNetModifyingUsers}.(at ${CCNetBuildDate} ${CCNetBuildDate})" /> <subject buildResult="Broken" value="${CCNetProject} Broke: last checkin(s) by ${CCNetFailureUsers}.(at ${CCNetBuildDate} ${CCNetBuildDate})" /> <subject buildResult="StillBroken" value="${CCNetProject} Still Broken: last checkin(s) by ${CCNetFailureUsers}.(at ${CCNetBuildDate} ${CCNetBuildDate})" /> <subject buildResult="Exception" value="${CCNetProject} In Exception: Please check status of network / sourcecontrol.(at ${CCNetBuildDate} ${CCNetBuildDate})" /> </subjectSettings> <!--收件人配置--> <converters> <regexConverter find="$" replace="@XXXCompany.com"/> </converters> <modifierNotificationTypes> <notificationType>Failed</notificationType> <notificationType>Fixed</notificationType> </modifierNotificationTypes> <users> <user group="leader" name="ci.XXXCompany" address="ci@XXXCompany.com"/> <user group="developer" name="harvey.choi" address="harvey.choi@XXXCompany.com"/> <user group="tester" name="jolin" address="jolin.tang@XXXCompany.com"/> </users> <groups> <group name="leader"> <notifications> <!--Always/Success/Change/Fixed/Failed --> <notificationType>Change</notificationType> <notificationType>Exception</notificationType> </notifications> </group> <group name="developer"> <notifications> <notificationType>Success</notificationType> <notificationType>Fixed</notificationType> <notificationType>Failed</notificationType> </notifications> </group> <group name="tester"> <notifications> <notificationType>Fixed</notificationType> </notifications> </group> </groups> </email> <xmllogger/> </publishers> </project> </cruisecontrol>
六、结语
CI走得好累,概括来说:高层的态度是关键,如果领导不批啥都不用说;旧员工的态度往往是最大的阻力,一个有历史的团队总有一些人更愿意按旧的流程走,不愿意接受新的东西,不求有功但求无过。
关于 CC.NET 中文的资料相对比较少,最好还是耐心的啃下官方的 E 文文档。
这个 CC.NET 的 Demo 断断续续搭了一周(日常任务不变,时间得自己挤),也差不多有定论。当然从 HelloWorld 到真正落地还需解决n多问题。
如果你发现 Demo 有哪些不正确的,欢迎跟帖指正。