BT

如何利用碎片时间提升技术认知与能力? 点击获取答案

软件开发地基

| 作者 郑晔 关注 2 他的粉丝 发布于 2011年9月11日. 估计阅读时间: 41 分钟 | Google、Facebook、Pinterest、阿里、腾讯 等顶尖技术团队的上百个可供参考的架构实例!

自动化脚本之于软件开发,犹如地基之于建筑。

在软件开发过程中,缺乏一个好的自动化脚本,与之相伴的往往是日常的开发工作举步维艰:

  • 只有少数人能够把整个软件构建起来,因为构建所需的那些东西不太容易弄全。
  • 为了能在自己机器上写代码,开发人员要花大量时间把工程在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 = 'com.google.guava:guava:jar:r09'
define 'killer' do
    project.version = '0.0.1'
    define 'domain' do
        compile.with GUAVA
        package :jar
    end
    define 'web' do
        DOMAIN = project('killer:domain').packages
        compile.with DOMAIN
        package(:war).with(:libs=>DOMAIN)
    end
end 

我们先来看看从这个简单的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的文件里,下面是一个例子:

development:
    db:
    url: jdbc:mysql://localhost/killer_development
    driver: com.mysql.jdbc.Driver
    username: root
    password:
    jar: mysql:mysql-connector-java:jar:5.1.14
production:
    db:
    url: jdbc:mysql://deployment.env/killer_production
    driver: com.mysql.jdbc.Driver
    username: root
    password: ki1152
    jar: mysql:mysql-connector-java:jar:5.1.14 

我们看到,针对不同的环境,有不同的数据库配置,在buildfile里可以这样引用这些配置:

db_settings = Buildr.settings.profile['db'] 

随后,我们就可以使用这个配置,比如生成一个配置文件:

task :config do
    CONFIG_PROPERTIES = <<EOF
    jdbc.driverClassName= #{db_settings['driver']}
    jdbc.url=#{db_settings['url']}
    jdbc.username=#{db_settings['username']}
    jdbc.password=#{db_settings['password']}
    EOF
    File.open('config.properties'), "w") do |f|
        f.write config
    end
end 

有了这样的基础,只要我们指定不同的环境就会产生不同的配置。

系统组件

在buildr里,有一个叫做ENV['BUILDR_ENV']的变量,这是buildr内置的一个变量,通过它,我们可以获得当前环境的名字,在这个例子里,它可以是development或是production。

有了这个变量,我们可以进行更加深度的配置,比如,在测试环境下,我们可以采用一些假的实现,让整个系统运行的更快。

下面是一个例子,有一个搜索组件的配置文件,在生产环境下,它会采用真实的搜索引擎实现,而在开发环境时,它只是一个简单内存实现。我们把不同环境的实现放到不同的配置文件里。

<beans 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">
    <bean id="searcher" class="com.killer.SuperSearcher"/>
</beans>

(searcher.production.xml)

<beans 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">
    <bean id="searcher" class="com.killer.InMemorySearcher"/>
</beans>

(searcher.development.xml)

要使用这个组件只要引用searcher.xml,把searcher拿过来用就好了:

<import resource="searcher-context.xml"/>
    ...
<property name="searcher" ref="searcher"/> 

接下来,用一个task就可以处理这些差异:

task :searcher do
    cp "searcher.#{ENV['BUILDR_ENV']}.xml", "searcher.xml"
end 

其实,实现这一点真正困难的并不在于配置文件,而在于这些组件的设计。只有识别出这些组件,把它们独立出来,才会可能根据不同的环境进行配置。没有恰当的抽象,自动化脚本也无能为力。

有了上面准备的基础,我们可以通“-e”这个选项,在命令行中指定我们的环境。下面的命令会让我们得到production的配置,不论是配置信息,还是系统组件。:

buildr -e production 

静态检查

在团队开发中,统一的代码风格很重要。即便我们很认真,疏忽也在所难免。Java世界里,checkstyle常常扮演了代码检察官角色。

坏消息是,buildr并不提供对checkstyle的直接支持。好消息是,buildr可以集成ant task,所以,我们通过ant task集成checkstyle。

CHECKSTYLE = transitive('checkstyle:checkstyle:jar:4.4')
task :checkstyle do
    begin
        ant('checkstyle') do |ant|
            rm_rf 'reports/checkstyle_report.xml'
            mkdir_p 'reports'
            ant.taskdef :resource=>"checkstyletask.properties", 
                        :classpath=>Buildr.artifacts(CHECKSTYLE)
               .each(&:invoke).map(&:name).join(File::PATH_SEPARATOR)
            ant.checkstyle :config=>"tasks/checkstyle_checks.xml",
                           :maxWarnings=>0 do
                ant.formatter :type=>'plain'
                ant.formatter :type=>'xml',
                              :toFile=>"reports/checkstyle_report.xml"
                ant.property :key=>'javadoc.method.scope', :value=>'public'
                ant.property :key=>'javadoc.type.scope', :value=>'package'
                ant.property :key=>'javadoc.var.scope', :value=>'package'
                ant.property :key=>'javadoc.lazy', :value=>'false'
                ant.property :key=>'checkstyle.cache.file',
                             :value=>'target/checkstyle.cache.src'
                ant.property :key=>'checkstyle.header.file',
                             :value=>'buildconf/LICENSE.txt'
                ant.fileset :dir=>"domain/src/main/java",
                            :includes=>'**/*.java',
                ant.fileset :dir=>"web/src/main/java",
                            :includes=>'**/*.java'
            end
            ant.xslt :in=>'reports/checkstyle_report.xml',
                     :out=>'reports/checkstyle_report.html',
                     :style=>'tasks/checkstyle-noframes.xsl'
        end
    end
end

这里的绝大多数内容checkstyle的ant task介绍都可以很容易了解到,唯一需要注意的点在于checkstyle任务的maxWarnings属性。

maxWarning表示可以容忍的最大警告数,也就是说,当checkstyle检查的警告数大于这个数字时,构建就会失败。在这里,我们将其设置为0,换句话说,我们不接受任何警告。

在实践中,对低级错误容忍度越低的团队,代码质量往往也会越高。

像这种独立性很强的任务,我们通常会把放到一个独立的文件中。在《文件布局》一节,我们曾提及tasks目录,其中存放的就是自定义任务,buildr在启动时会自动加载该目录下的这些任务。

如果熟悉rake,你会发现上面的task就是一个标准的rake task。实际上,buildr是集了多个构建工具的本领于一身的。把上面的代码放到checkstyle.rake,然后放到tasks目录下。运行下面的命令,就可以对代码执行静态检查:

buildr checkstyle 

测试覆盖率

测试覆盖率这东西,高了不代表有多好,但低了肯定是有问题。

当我们把测试覆盖率限制为100%,其结果是全部内容都会有测试覆盖。坚持这样的标准,在做重构的时候,可以比较放心。

工具支持

buildr缺省支持一个测试覆盖率工具——cobertura,所以,一切做起来很简单。

require 'buildr/java/cobertura'
cobertura.check.branch_rate = 100
cobertura.check.line_rate = 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 :git do
    def sys(cmd)
        puts cmd
        raise "System execution failed!" unless system(cmd)
    end
    def get_info(name, prompt)
        begin
            value = File.open(".#{name}") { |f| f.read }.strip
            rescue
                value = ""
            end
            prompt += " (#{value})" unless value.empty?
            new_value = Readline::readline("[#{prompt}]: ").strip
            value = new_value unless new_value.empty?
            File.open(".#{name}", "w") { |f| f.write(value) }
            value
        end
    def commit
        dev_name = get_info('pair', 'Pair')
        story_number = get_info('story', 'Story #')
        comment = get_info('comment', 'Comment')
        commit_cmd = %Q(git commit -am "#{dev_name} - KILLER-#{story_number} - #{comment}")
        sys(commit_cmd)
    end
    def add(files)
        files.each { |file| sys("git add #{file}") }
    end 
    def add_files(files)
        puts "Add the following new files:\n#\t#{files.join("\n#\t")}\n"
        reply = Readline::readline("[Y/N]")
        return add(files) if reply.strip.downcase.start_with?('y')
        raise 'new files should be added before commit'
    end
    task :add do
        files = `git status -s | awk '/\\?\\?/ {print $2}'`
        files = files.split("\n")
        add_files(files) if files.size > 0
    end
    def nothing_to_commit?
        `git status -s`.empty? 
    end
    task :pull do
        sys('git pull')
    end
    task :push do
        sys('git push')
    end
        task :status do
        sys('git status')
    end
    task   :commit => :add do
        commit unless nothing_to_commit?
    end
end

这里,我们看到了git的一些基础操作,比如pull、push、commit等等,并且,我们在基础操作之上进行了封装,让使用更便捷。比如:

  • 在提交之前,根据当前的状态,确定是否要添加文件。
  • 如果代码曾经手工提交过,则继续。
  • 提示之前,提示开发人员填写名字、开发的Story号,以及相应的注释。

之所以在这里还要填写名字,而不仅仅是利用git自有的用户名,因为在开发的过程中,我们可能是结对开发,显然一个用户名是不够的。

有了这个基础,我们继续向前,创建一个task,把提交之前要做的事情都放在这里:

task :commit_build => [:clean, :artifacts, :checkstyle, 
    "cobertura:html", "killer:domain:cobertura:check", 
    "killer:web:cobertura:check"] 

以上面这个task为例,实际上包括编译、测试、静态检查和测试覆盖率检查,我们可以根据自己的需要加入更多的东西。

接下来,把版本控制部分结合进去,就是我们的提交脚本了。

task :commit => ["git:commit", "git:pull", 
    :commit_build, "git:push", "git:status"] 

有了这个基础,我们就可以提交代码了,而不用担心忘记了什么:

buildr commit 

在执行过程中,任何一步失败都会让整个提交过程停下来,比如,当pull代码产生冲突,或是运行测试之后,push代码时,我们发现又有人提交,提交过程就会停止,错误的代码是不会提交的。

数据库迁移

数据库脚本通常不像代码那样受人重视,但在实际的发布过程中,它常常把我们弄得焦头烂额。

dbdeploy为我们提供了一种管理数据迁移的方式,buildr没有提供对dbdeploy直接的支持,但如我们之前所见,ant task可以帮我们打造一条直通之路。

namespace :db do
    DB_SETTINGS = Buildr.settings.profile['db']
    def dbdeploy(options)
        ant('dbdeploy') do |ant|
            ant.taskdef(:name => "dbdeploy", 
                        :classname => "com.dbdeploy.AntTarget", 
                        :classpath=>DBDEPLOY_CLASSPATH)
            ant.dbdeploy(options)
        end
    end
    task(:migrate) do |t, args|
        dbdeploy(:driver => DB_SETTINGS['driver'],
                 :url => DB_SETTINGS['url'],
                 :userid => DB_SETTINGS['username'],
                 :password => DB_SETTINGS['password'],
                 :dir => "#{db_script_dir}/migration")
    end
end 

这里,我们用到之前提及的管理配置的方式,配置信息存放在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的集成。

require 'buildr/jetty' 
define 'killer' do
    define 'web' do 
         
        task("jetty-deploy"=>[package(:war), jetty.use]) do |task|
            jetty.deploy("http://localhost:8080", task.prerequisites.first)
        end
        task("jetty"=> ["jetty-deploy"]) do |task|
            Readline::readline('[Type ENTER to stop Jetty]')
        end 
    end
end

这里,我们启动了一个jetty,启动之前,我们需要确保已经有了可以部署的WAR,所以这个部署任务要依赖于package(:war)。最后的jetty task给了我们一个手工停止Jetty的机会。我们可以这样将启动它:

buildr killer:web:jetty 

验收测试

写好代码,完成部署,还少不了验收测试。

对于一个Web项目,我们可能会考虑采用selenium或是waitr之类的自动化测试框架进行测试。不过,通常直接用这些框架去写通常会把业务需求和测试实现细节混合起来,结果往往不如我们预期。

Cucumber的出现给了我们一种分离业务需求和实现细节的方式。对我们这个项目而言,不好的消息是,想到Cucumber,出现在我们脑子里的是Ruby语言,好消息是buildr就是Ruby语言。下面就是一段脚本,把它放到buildfile里,就可以运行acceptance/features目录下那堆feature文件了。

require 'cucumber'
require 'cucumber/rake/task' 
define 'killer' do
    define 'web' do 
         
        Cucumber::Rake::Task.new(:acceptance => "jetty-deploy") do |t|
            t.cucumber_opts = ["acceptance/features"]
        end 
    end
end 

有了它,我们就可以运行我们的验收测试了:

buildr killer:web:acceptance

路还长

至此,我们已经展示了一个基本的自动化脚本。正如我们所见,作为地基的自动化脚本,仅仅有编译、测试、打包这些基本操作是远远不够的。即便是这里所列出的这些,也不过是一些通用的任务,每个项目都应该把项目内的繁琐操作自动化起来,比如,在我参与的实际项目中,我们会把部署到用户验收测试(User Acceptance Testing,简称UAT)环境的过程自动化起来。

不过,除了脚本自身,为了让地基真正的发挥作用,还需要有一些实践与之配合,比如有了持续集成,提交脚本才好发挥最大的威力,比如有了合适的测试策略,开发人员才能在一个合理的时间内完成上的本地构建。

软件开发永远都不是一个单点,只有各方各面联动起来,才会向着健康的方向前进,而所有一切的基础,就是自动化。


感谢张凯峰对本文的审校。

给InfoQ中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家加入到InfoQ中文站用户讨论组中与我们的编辑和其他读者朋友交流。

评价本文

专业度
风格

您好,朋友!

您需要 注册一个InfoQ账号 或者 才能进行评论。在您完成注册后还需要进行一些设置。

获得来自InfoQ的更多体验。

告诉我们您的想法

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

没怎么看,只是简单地用鼠标拖着过了一遍 by She 萍踪浪迹

没看出来比Maven "易者易为,难者可为" 在哪儿? 倒是感觉到了Ant的味道了

Re: 没怎么看,只是简单地用鼠标拖着过了一遍 by Tseng Joseph

依我看, 构建这事儿只有两种思路:脚本,或对象模型。
ant偏向脚本, maven基于对象模型。
从本文例子来看,buildr更像ant。

maven的”难者可为“:就是要学习一下maven生命周期和几个主要的概念,以及mojo的写法。

拓展到很多领域, 对象模型的初期学习成本是高于脚本的, 但一旦熟悉了这个领域的概念, 对象模型的描述能力会远远大于脚本,因此长期维护成本会更低。

没觉得他比maven简单在什么地方 by 杨 思勇

只感觉比maven复杂多了,尤其是配置项。

buildr和gradle有什么区别呢 by Wang Frank

@郑晔 @张凯峰

还是觉得rake 舒服一些 by D destinyd

还是觉得rake 舒服一些

Re: 没觉得他比maven简单在什么地方 by Guo Eidson

ant简单易学
maven主要是生命周期及依赖管理比较麻烦
在构建持续交付系统的过程中
觉得ant和maven的代码量比较大
很多东西服用起来很麻烦
而且字符串处理,逻辑运算,算数运算等等也比较麻烦
在寻找一种基于OO的脚本语言
貌似ruby就是

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

6 讨论

登陆InfoQ,与你最关心的话题互动。


找回密码....

Follow

关注你最喜爱的话题和作者

快速浏览网站内你所感兴趣话题的精选内容。

Like

内容自由定制

选择想要阅读的主题和喜爱的作者定制自己的新闻源。

Notifications

获取更新

设置通知机制以获取内容更新对您而言是否重要

BT