自动化脚本之于软件开发,犹如地基之于建筑。
在软件开发过程中,缺乏一个好的自动化脚本,与之相伴的往往是日常的开发工作举步维艰:
- 只有少数人能够把整个软件构建起来,因为构建所需的那些东西不太容易弄全。
- 为了能在自己机器上写代码,开发人员要花大量时间把工程在 IDE 上配出来。
- 提交代码之前,开发人员总是忘了在验证。
在本文中, 我们将以一个 Java 的 web 项目为例,展示一个好的“地基”应具备的一些基本素质。在这里,用做自动化的工具是 buildr 。
buildr 是一种构建工具,它专为基于 Java 的应用而设计,也包括了对 Scala、Groovy 等 JVM 语言的支持。相比于 ant 和 maven 这些 Java 世界的“老人”,buildr 算是小字辈,也正是因为年轻,它有着“老人”们不具备的优势:
- 相比于 ant,遵循着 Convention over Configuration 原则的 buildr,让“编译、测试、打包”之类简单的事做起来很容易。
- 相比于 maven,我们无需理解强大且复杂的模型,而采用 Ruby/Rake 作为脚本的基础,也让我们可以定制属于自己的脚本。
简而言之,它满足了我们选择工具的基本原则:“易者易为,难者可为”。
请注意:下面所有的内容并不只是 buildr 的独家专利,而是每个构建工程都应该具备的,差异只在于,选择不同的工具,实现的难度略有差异而已。
易者易为
让我们从一个简单的 buildfile——buildr 的脚本——起步:
GUAVA <b>=</b> 'com.google.guava:guava:jar:r09' define 'killer' <b>do</b> project.version <b>=</b> '0.0.1' define 'domain' <b>do</b> compile.with GUAVA package <b>:jar</b> <b>end</b> define 'web' <b>do</b> DOMAIN <b>=</b> project('killer:domain').packages compile.with DOMAIN package(<b>:war</b>).with(<b>:libs</b>=>DOMAIN) <b>end</b> <b>end</b>
我们先来看看从这个简单的 buildfile 中,我们可以得到什么。
分模块项目
这个项目里有两个子项目:domain 和 web。从架构的角度来看,一个项目从一开始就划分出这样的模块是有好处的:
- 给未来扩展留下接口,比如要提供一个 Web Service,可以从 domain 部分开始即可。
- 给开发人员一个好的规划,有助于引导他们思考程序的模块化,降低代码的耦合度。
使用 buildr 划分模块是非常简单的,只要在 buildfile 里声明模块,项目的根目录下同名的子目录就是对应的模块。
文件布局
虽然在 buildfile 没有直接体现出来,但这里有个缺省的文件布局。一个统一的规则省去了我们从头规划的苦恼。遵循缺省的布局规则,buildr 自己就会找到相应的文件,进行处理。
这个布局规则实际上就是 Maven 的布局规则,如图所示,两个子项目都拥有自己的目录,其结构基本一致:
- src/main/java,源代码文件目录
- src/main/resources,资源文件目录
- src/test/java,测试代码目录
- src/main/webapp,web 相关文件目录
此外,这里还有稍后会提及的:
- profiles.yaml,环境相关的配置
- tasks,自定义任务的目录
这就是所谓的 Convention over Configuration。当然,buildr 是支持自定义文件布局的,详情请参见文档。
基本命令
有了这个基本的 buildfile,我们就可以开展日常的工作了。buildr 自身支持很多命令,比如:
- buildr compile,编译项目
- buildr package,项目打包
- buildr test,运行测试
想要了解更多的命令,可以运行下面的命令:
buildr -T
测试
在这个不测试都不好意思自称程序员的年代,测试,尤其实现级别的测试,诸如单元测试、集成测试,已经成了程序员的常规武器。
诚如上面所见,src/test/java 就是我们的测试文件存放的目录。对于 Java 项目,JUnit 是缺省的配置,只要在这个目录下的 Java 类继承自 junit.framework.TestCase(JUnit 3),或是,在类上标记了 org.junit.runner.RunWith,抑或在方法上标记了 org.junit.Test(JUnit 4)。buildr 就会找到它们,并帮我们料理好编译运行等事宜。约定的力量让我们无需操心这一切。
依赖管理
依赖管理一直是一项令人头疼的问题,也是让许多开发人员搭建纠结于开发环境搭建的一个重要因素。
各种语言的社区分别给出了自己的依赖管理解决方案,对于 Java 社区而言,一种比较成熟的解决方案来自于 Maven。它按照一定规则建立起一个庞大的中央仓库,成熟的 Java 库都会在其中有一席之地。
于是,很多新兴的构建工具都会建立在这个仓库的基础之上,buildr 也不例外。在前面的例子里面,domain 依赖了 Guava 这个库。当我们开始构建应用时,buildr 会自动从中央仓库下载我们缺失的依赖。
不仅仅是依赖,我们还可以拿到对应的文档和源码:
- buildr artifacts,下载依赖
- buildr artifacts:javadoc,下载 javadoc
- buildr artifacts:sources,下载源码
如果不知道如何在 buildfile 里编写依赖,那 mvnrepository.com 是个不错的去处,那里针对不同的构建工具都给出了相应的依赖写法。
与 IDE 集成
除非这个工程是用 IDE 创建出来的,否则把工程集成到一个 IDE 里通常要花费很大的力气。所幸,buildr 替我们把这些工作做好了。我们只要键入一个命令即可,比如与 IntelliJ IDEA 集成,运行下面的命令:
buildr idea
它会生成一个 IDEA 的工程文件,我们要做的只是用 IDEA 打开它。同样的,还有一个为 Eclipse 准备的命令:
buildr eclipse
不知道你是否有这样的经验,初到一个项目组,开始为一个项目贡献代码之前,先需要花几天时间,在不同的人的协助之下把环境搭出来,为的只是在自己的机器上能够把应用构建出来。
而现在,有了这样的自动化脚本,一个项目组新人的行为模式就变成了:
- 初入一个项目组,他从源码管理系统上得到检出代码库。
- 调用 buildr artifacts,其所依赖的文件就会下载到本机。
- 调用 buildr idea:generate(或是 buildr eclipse),生成 IDE 工程。
- 打开工程,开始干活。
迄今为止,我们看到的只是一个基本的 buildfile,这些命令也是 buildr 内置的一些基本能力,也就是所谓的“易者易为”。
难者可为
接下来,我们将超越基础,做一些“难者可为”的东西。
不同的环境
在实际的开发中,我们经常会遇到不同的环境,比如,在开发环境下,数据库和应用服务器是在同一台机器上,而在生产环境下,二者会部署到机器上。这里所列举的配置功能,只是最简单的例子,而实际情况下,不同的环境下,会有各种差异,甚至需要执行不同的代码。
一种解决方案是为数不少的“直觉式”设计采用的方案,在代码里根据条件进行判断,可想而知,无处不在的 if…else 很快就会把代码变成一团浆糊,更糟糕的是,这些信息散落在各处。
另一种方案是在自动化脚本中支持,buildr 让这个工作变得很简单。
配置信息
使用 buildr,配置信息可以放到一个名为 profile.yaml 的文件里,下面是一个例子:
<b>development:</b> <b>db:</b> <b>url:</b> jdbc:mysql://localhost/killer_development <b>driver:</b> com.mysql.jdbc.Driver <b>username:</b> root <b>password:</b> <b>jar:</b> mysql:mysql-connector-java:jar:5.1.14 <b>production:</b> <b>db:</b> <b>url:</b> jdbc:mysql://deployment.env/killer_production <b>driver:</b> com.mysql.jdbc.Driver <b>username:</b> root <b>password:</b> ki1152 <b>jar:</b> mysql:mysql-connector-java:jar:5.1.14
我们看到,针对不同的环境,有不同的数据库配置,在 buildfile 里可以这样引用这些配置:
db_settings <b>=</b> <b>Buildr</b>.settings.profile['db']
随后,我们就可以使用这个配置,比如生成一个配置文件:
task <b>:config</b> <b>do</b> CONFIG_PROPERTIES = <<EOF jdbc.driverClassName= #{db_settings['driver']} jdbc.url=#{db_settings['url']} jdbc.username=#{db_settings['username']} jdbc.password=#{db_settings['password']} EOF <b>File</b>.open('config.properties'), "w") <b>do </b>|f| f.write config <b>end</b> <b>end</b>
有了这样的基础,只要我们指定不同的环境就会产生不同的配置。
系统组件
在 buildr 里,有一个叫做 ENV[‘BUILDR_ENV’] 的变量,这是 buildr 内置的一个变量,通过它,我们可以获得当前环境的名字,在这个例子里,它可以是 development 或是 production。
有了这个变量,我们可以进行更加深度的配置,比如,在测试环境下,我们可以采用一些假的实现,让整个系统运行的更快。
下面是一个例子,有一个搜索组件的配置文件,在生产环境下,它会采用真实的搜索引擎实现,而在开发环境时,它只是一个简单内存实现。我们把不同环境的实现放到不同的配置文件里。
<<b>beans</b> xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd"> <<b>bean</b> id="searcher" class="com.killer.SuperSearcher"/> </<b>beans</b>>
(searcher.production.xml)
<<b>beans</b> xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd"> <<b>bean</b> id="searcher" class="com.killer.InMemorySearcher"/> </<b>beans</b>>
(searcher.development.xml)
要使用这个组件只要引用 searcher.xml,把 searcher 拿过来用就好了:
<<b>import</b> resource="searcher-context.xml"/> ... <<b>property</b> name="searcher" ref="searcher"/>
接下来,用一个 task 就可以处理这些差异:
task <b>:searcher</b> <b>do</b> cp "searcher.#{ENV['BUILDR_ENV']}.xml", "searcher.xml" <b>end</b>
其实,实现这一点真正困难的并不在于配置文件,而在于这些组件的设计。只有识别出这些组件,把它们独立出来,才会可能根据不同的环境进行配置。没有恰当的抽象,自动化脚本也无能为力。
有了上面准备的基础,我们可以通“-e”这个选项,在命令行中指定我们的环境。下面的命令会让我们得到 production 的配置,不论是配置信息,还是系统组件。:
buildr -e production
静态检查
在团队开发中,统一的代码风格很重要。即便我们很认真,疏忽也在所难免。Java 世界里, checkstyle 常常扮演了代码检察官角色。
坏消息是,buildr 并不提供对 checkstyle 的直接支持。好消息是,buildr 可以集成 ant task,所以,我们通过 ant task 集成 checkstyle。
CHECKSTYLE <b>=</b> transitive('checkstyle:checkstyle:jar:4.4') task <b>:checkstyle</b> <b>do</b> <b>begin</b> ant('checkstyle') <b>do </b>|ant| rm_rf 'reports/checkstyle_report.xml' mkdir_p 'reports' ant.taskdef <b>:resource</b>=>"checkstyletask.properties", <b>:classpath</b>=><b>Buildr</b>.artifacts(CHECKSTYLE) .each(<b>&:invoke</b>).map(<b>&:name</b>).join(<b>File</b>::PATH_SEPARATOR) ant.checkstyle <b>:config</b>=>"tasks/checkstyle_checks.xml", <b>:maxWarnings</b>=>0 <b>do</b> ant.formatter <b>:type</b>=>'plain' ant.formatter <b>:type</b>=>'xml', <b>:toFile</b>=>"reports/checkstyle_report.xml" ant.property <b>:key</b>=>'javadoc.method.scope', <b>:value</b>=>'public' ant.property <b>:key</b>=>'javadoc.type.scope', <b>:value</b>=>'package' ant.property <b>:key</b>=>'javadoc.var.scope', <b>:value</b>=>'package' ant.property <b>:key</b>=>'javadoc.lazy', <b>:value</b>=>'false' ant.property <b>:key</b>=>'checkstyle.cache.file', <b>:value</b>=>'target/checkstyle.cache.src' ant.property <b>:key</b>=>'checkstyle.header.file', <b>:value</b>=>'buildconf/LICENSE.txt' ant.fileset <b>:dir</b>=>"domain/src/main/java", <b>:includes</b>=>'**/*.java', ant.fileset <b>:dir</b>=>"web/src/main/java", <b>:includes</b>=>'**/*.java' <b>end</b> ant.xslt <b>:in</b>=>'reports/checkstyle_report.xml', <b>:out</b>=>'reports/checkstyle_report.html', <b>:style</b>=>'tasks/checkstyle-noframes.xsl' <b>end</b> <b>end</b> <b>end</b>
这里的绝大多数内容 checkstyle 的 ant task 介绍都可以很容易了解到,唯一需要注意的点在于 checkstyle 任务的 maxWarnings 属性。
maxWarning 表示可以容忍的最大警告数,也就是说,当 checkstyle 检查的警告数大于这个数字时,构建就会失败。在这里,我们将其设置为 0,换句话说,我们不接受任何警告。
在实践中,对低级错误容忍度越低的团队,代码质量往往也会越高。
像这种独立性很强的任务,我们通常会把放到一个独立的文件中。在《文件布局》一节,我们曾提及 tasks 目录,其中存放的就是自定义任务,buildr 在启动时会自动加载该目录下的这些任务。
如果熟悉 rake,你会发现上面的 task 就是一个标准的 rake task。实际上,buildr 是集了多个构建工具的本领于一身的。把上面的代码放到 checkstyle.rake,然后放到 tasks 目录下。运行下面的命令,就可以对代码执行静态检查:
buildr checkstyle
测试覆盖率
测试覆盖率这东西,高了不代表有多好,但低了肯定是有问题。
当我们把测试覆盖率限制为 100%,其结果是全部内容都会有测试覆盖。坚持这样的标准,在做重构的时候,可以比较放心。
工具支持
buildr 缺省支持一个测试覆盖率工具——cobertura,所以,一切做起来很简单。
<b>require</b> 'buildr/java/cobertura' cobertura.check.branch_rate <b>=</b> 100 cobertura.check.line_rate <b>=</b> 100
(buildfile)
在命令行里运行如下命令就可以运行测试,进行检查,生成报告:
buildr cobertura:html cobertura:check
如果覆盖率不达标,构建就会失败。这时,我们可以通过它生成的报告来看,到底是哪里没有覆盖。报告位于
reports/cobertura/html/index.html
例外情况
真的是所有代码都会在这个 100% 的监控之下吗?不一定。有一些代码只是为了调用一个特定的 API,这样的代码是否 100% 意义不大,这样的测试本质上是在 API 写测试,而非自己的代码逻辑。所以,这种代码会被排除在外。
cobertura.exclude /.*.integration.*/
但是,这样的代码同样会有相应的集成测试,只是不在单元测试的层面。一个基本的原则是,被排除的代码要尽可能少。
提交脚本
作为一个专业程序员,我们应该保证自己提交的代码不会对代码库造成破坏。但怎么才算是不破坏呢?也许我们要编译、运行单元测试、打包,也许我们还要进行静态检查,查看测试覆盖率,也许还要进行更多的检查,运行各种各样的测试。有了前面的基础,我们可以做一个 task,把所有这些都依赖上去。
好,有了这个 task,要提交代码,我们会怎么做呢?
- 如果用的 git 这样的分布式版本管理系统,现在本地提交代码。
- 如果有新增文件,需要把文件纳入版本控制之中。
- 从远端代码库更新出代码,如果有需要冲突,需要合并代码
- 运行 task,进行检查和测试
- 运行成功,则提交代码
显然,这是一个说多不多,说少也不少的操作,对于这种繁琐的操作,如果能够自动化,自然是最好的选择。下面就是一段这样的脚本,这里用的到版本控制系统是 git。
首先看到的是版本控制系统部分:
namespace <b>:git</b> <b>do</b> <b>def</b> <b>sys</b>(cmd) puts cmd <b>raise</b> "System execution failed!" <b>unless</b> system(cmd) <b>end</b> <b>def</b> <b>get_info</b>(name, prompt) <b>begin</b> value <b>=</b> <b>File</b>.open(".#{name}") { |f| f.read }.strip <b>rescue</b> value <b>=</b> "" <b>end</b> prompt <b>+=</b> " (#{value})" <b>unless</b> value.empty? new_value <b>=</b> <b>Readline</b>::readline("[#{prompt}]: ").strip value <b>=</b> new_value <b>unless</b> new_value.empty? <b>File</b>.open(".#{name}", "w") { |f| f.write(value) } value <b>end</b> <b>def</b> <b>commit</b> dev_name <b>=</b> get_info('pair', 'Pair') story_number <b>=</b> get_info('story', 'Story #') comment <b>=</b> get_info('comment', 'Comment') commit_cmd <b>=</b> %Q(git commit -am "#{dev_name} - KILLER-#{story_number} - #{comment}") sys(commit_cmd) <b>end</b> <b>def</b> <b>add</b>(files) files.each { |file| sys("git add #{file}") } <b>end</b> <b>def</b> <b>add_files</b>(files) puts "Add the following new files:\n#\t#{files.join("\n#\t")}\n" reply <b>=</b> <b>Readline</b>::readline("[Y/N]") <b>return</b> add(files) <b>if</b> reply.strip.downcase.start_with?('y') <b>raise</b> 'new files should be added before commit' <b>end</b> task <b>:add</b> <b>do</b> files <b>=</b> `git status -s | awk '/\\?\\?/ {print $2}'` files <b>=</b> files.split("\n") add_files(files) <b>if</b> files.size <b>></b> 0 <b>end</b> <b>def</b> <b>nothing_to_commit?</b> `git status -s`.empty? <b>end</b> task <b>:pull</b> <b>do</b> sys('git pull') <b>end</b> task <b>:push</b> <b>do</b> sys('git push') <b>end</b> task <b>:status</b> <b>do</b> sys('git status') <b>end</b> task <b>:commit</b> => <b>:add</b> <b>do</b> commit <b>unless</b> nothing_to_commit? <b>end</b> <b>end</b>
这里,我们看到了 git 的一些基础操作,比如 pull、push、commit 等等,并且,我们在基础操作之上进行了封装,让使用更便捷。比如:
- 在提交之前,根据当前的状态,确定是否要添加文件。
- 如果代码曾经手工提交过,则继续。
- 提示之前,提示开发人员填写名字、开发的 Story 号,以及相应的注释。
之所以在这里还要填写名字,而不仅仅是利用 git 自有的用户名,因为在开发的过程中,我们可能是结对开发,显然一个用户名是不够的。
有了这个基础,我们继续向前,创建一个 task,把提交之前要做的事情都放在这里:
task <b>:commit_build</b> => [<b>:clean</b>, <b>:artifacts</b>, <b>:checkstyle</b>, "cobertura:html", "killer:domain:cobertura:check", "killer:web:cobertura:check"]
以上面这个 task 为例,实际上包括编译、测试、静态检查和测试覆盖率检查,我们可以根据自己的需要加入更多的东西。
接下来,把版本控制部分结合进去,就是我们的提交脚本了。
task <b>:commit</b> => ["git:commit", "git:pull", <b>:commit_build</b>, "git:push", "git:status"]
有了这个基础,我们就可以提交代码了,而不用担心忘记了什么:
buildr commit
在执行过程中,任何一步失败都会让整个提交过程停下来,比如,当 pull 代码产生冲突,或是运行测试之后,push 代码时,我们发现又有人提交,提交过程就会停止,错误的代码是不会提交的。
数据库迁移
数据库脚本通常不像代码那样受人重视,但在实际的发布过程中,它常常把我们弄得焦头烂额。
dbdeploy 为我们提供了一种管理数据迁移的方式,buildr 没有提供对 dbdeploy 直接的支持,但如我们之前所见,ant task 可以帮我们打造一条直通之路。
namespace <b>:db</b> <b>do</b> DB_SETTINGS <b>=</b> <b>Buildr</b>.settings.profile['db'] <b>def</b> <b>dbdeploy</b>(options) ant('dbdeploy') <b>do </b>|ant| ant.taskdef(<b>:name</b> => "dbdeploy", <b>:classname</b> => "com.dbdeploy.AntTarget", <b>:classpath</b>=>DBDEPLOY_CLASSPATH) ant.dbdeploy(options) <b>end</b> <b>end</b> task(<b>:migrate</b>) <b>do </b>|t, args| dbdeploy(<b>:driver</b> => <b>DB_SETTINGS</b>['driver'], <b>:url</b> => <b>DB_SETTINGS</b>['url'], <b>:userid</b> => <b>DB_SETTINGS</b>['username'], <b>:password</b> => <b>DB_SETTINGS</b>['password'], <b>:dir</b> => "#{db_script_dir}/migration") <b>end</b> <b>end</b>
这里,我们用到之前提及的管理配置的方式,配置信息存放在 profile.yaml 里。另外,数据库迁移文件存放在数据库脚本目录(db_script_dir)的 migration 子目录下。
有了这段 task,我们就可以进行数据库迁移了:
buildr db:migrate
集成 Web 服务器
对于一个 Web 应用而言,仅仅测试打包还是不够的,我们还要把它部署到 Web 服务器上。这是一个基础任务,有了它,我们就可以使用一些 web 测试框架,对我们的系统进行验收测试。
通常在开发过程中,我们会选择一个部署起来很容易的 Web 服务器,在 Java 的世界里,jetty 往往扮演着这样的角色。当然,这样做的前提是,我们编写的是一个标准的 Java WebApp,没有用到特定于某个具体应用服务器的 API,换句话说,我们的 Web 应用是跨应用服务器的,事实上,这种做法是值得鼓励的。
使用特定于具体应用服务器的 API,其结果只能是与这个服务器产生耦合,而通常提供这个 API 的服务器对于开发并不那么友好,在其上部署的周期会很长,这无疑会大大降低开发效率。
一个好消息是,buildr 有很好的对 jetty 的集成。
<b>require</b> 'buildr/jetty' define 'killer' <b>do</b> define 'web' <b>do</b> <b>…</b> task("jetty-deploy"=>[package(<b>:war</b>), jetty.use]) <b>do </b>|task| jetty.deploy("http://localhost:8080", task.prerequisites.first) <b>end</b> task("jetty"=> ["jetty-deploy"]) <b>do </b>|task| <b>Readline</b>::readline('[Type ENTER to stop Jetty]') <b>end</b> <b>end</b> <b>end</b>
这里,我们启动了一个 jetty,启动之前,我们需要确保已经有了可以部署的 WAR,所以这个部署任务要依赖于 package(:war)。最后的 jetty task 给了我们一个手工停止 Jetty 的机会。我们可以这样将启动它:
buildr killer:web:jetty
验收测试
写好代码,完成部署,还少不了验收测试。
对于一个 Web 项目,我们可能会考虑采用 selenium 或是 waitr 之类的自动化测试框架进行测试。不过,通常直接用这些框架去写通常会把业务需求和测试实现细节混合起来,结果往往不如我们预期。
Cucumber 的出现给了我们一种分离业务需求和实现细节的方式。对我们这个项目而言,不好的消息是,想到 Cucumber,出现在我们脑子里的是 Ruby 语言,好消息是 buildr 就是 Ruby 语言。下面就是一段脚本,把它放到 buildfile 里,就可以运行 acceptance/features 目录下那堆 feature 文件了。
<b>require</b> 'cucumber' <b>require</b> 'cucumber/rake/task' define 'killer' <b>do</b> define 'web' <b>do</b> <b>…</b> <b>Cucumber</b>::<b>Rake</b>::<b>Task</b>.<b>new</b>(<b>:acceptance</b> => "jetty-deploy") <b>do </b>|t| t.cucumber_opts <b>=</b> ["acceptance/features"] <b>end</b> <b>end</b> <b>end</b>
有了它,我们就可以运行我们的验收测试了:
buildr killer:web:acceptance
路还长
至此,我们已经展示了一个基本的自动化脚本。正如我们所见,作为地基的自动化脚本,仅仅有编译、测试、打包这些基本操作是远远不够的。即便是这里所列出的这些,也不过是一些通用的任务,每个项目都应该把项目内的繁琐操作自动化起来,比如,在我参与的实际项目中,我们会把部署到用户验收测试(User Acceptance Testing,简称 UAT)环境的过程自动化起来。
不过,除了脚本自身,为了让地基真正的发挥作用,还需要有一些实践与之配合,比如有了持续集成,提交脚本才好发挥最大的威力,比如有了合适的测试策略,开发人员才能在一个合理的时间内完成上的本地构建。
软件开发永远都不是一个单点,只有各方各面联动起来,才会向着健康的方向前进,而所有一切的基础,就是自动化。
感谢张凯峰对本文的审校。
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家加入到 InfoQ 中文站用户讨论组中与我们的编辑和其他读者朋友交流。
评论