λ

(conj clojurians me)

站会

站会中的内容是每天工作的开始,也是对昨天工作的回顾。一般会由团队的某位成员主持,这位主持人有责任让电子系统上的story卡片和看板上的保持一致。站会上,大家依看板从右至左依次更新自己负责story的状态,如果遇到阻碍,应该在站会上及时提出,团队之中的成员如果能提供帮助,应该在站会之后,组织解决方案的讨论。

站会反模式

  1. 站会上由组长一人发言、分配任务;
  2. 一人长篇阔论占据大半时间(合理的发言时间为1-2分钟,总的时间控制在15分钟左右);
  3. 站会上谈论和story卡片无关的内容。

迭代计划会议 (IPM)

迭代计划会议是项目组成员在每一个迭代开始时,聚到一起共同决定这个迭代的 backlog (代办事项) 需要交付的故事卡。
这个会议的产出物包含:

  1. 迭代的 backlog 和用户故事的验收条件
  2. 这个迭代的总体业务目标
  3. 故事点数,也即开发测试人员对用户故事的评估及承诺

参与者包含:

  1. Product Owner
  2. Scrum Master
  3. 开发团队
  4. 其他干系人

日程:

  1. 团队确立这个迭代可以完成的工作量(以点数计),一般从历史迭代中获取
  2. 团队根据定义的顺序讨论故事卡,对于每一张故事卡,需要讨论的内容包含:
    1. 衡量故事的相对大小(以点数计),可能会进行故事卡的拆分
    2. 沟通验收条件
    3. 根据已经讨论出来故事卡的价值、时间和风险,PO重新确定故事卡的优先级
    4. 一旦预先设定的工作量到达,就停止本轮迭代会议
  3. 团队就迭代的业务目标达成共识

用户故事 kickoff 会议

Story kick off,指的是启动一个 Stroy 进入开发阶段。Story kick off的时候,通常需要三种角色参与:BA、QA 以及开发 Story 的 Dev。

会议内容:

  • DEV 自己先完整地过一遍 Story 的描述;
  • DEV 给 BA 和 QA 去讲这个 Story 的功能以及 AC;
  • 要能够清晰的讲出来,并且三者达成一致,如果有疑惑,需要当场得到解决;
  • DEV 开始开发 Story,并自行将 Story 拆分成多个子任务列表,开工。

我在ThoughtWorks中的敏捷实践

Desk Check / Shoulder Check

Desk Check 发生在开发人员确信自己已经完成了故事卡,需要另外的人(BA、QA、Dev)坐到旁边一起帮忙验证是否确实完成。

Desk checklist:
参与者:BA、QA、Dev 或者其他感兴趣的人

  • 故事卡测试覆盖率够吗?
  • 自己本地测过功能么?
  • 自己本地跑过全量的测试么?
  • 所有的 AC 是否都满足了?
  • 做故事卡的过程中是否遇到了理解上的问题?

Defect Prevention Using Agile Techniques

Tech Huddle

开发人员聚到一起就项目中遇到的技术展开讨论
前提条件:
会议之前确定并逐条列出会议讨论的技术主题
产出物:

  1. 一个或多个行动方案,合并到用户故事
  2. 方案的优缺点
  3. 方案的责任人

迭代回顾会议

在每个迭代结束时,Scrum Master会主持该会议,目的是为了确定哪些改变可以提升下个迭代的生产效率。在这个会议当中,每个人都可以开诚布公地提出自己的建议,有利于管理者从反馈中获取团队的现状。

最高指导原则 (Prime Directive):

Regardless of what we discover, we understand and truly believe that everyone did the best job he or she could, given what was known at the time, his or her skills and abilities, the resources available, and the situation at hand.
无论我们发现了什么,考虑到当时的已知情况、个人的技术水平和能力、可用的资源,以及手上的状况,我们理解并坚信:每个人对自己的工作都已全力以赴。

内容包含:

  • 上个迭代中做的好的有哪些?
  • 上个迭代中做的不好的有哪些?
  • 我们可以做些什么改善哪些不好的地方?(改善建议)

参与者包含:

  1. Product Owner
  2. Scrum Master
  3. 开发团队
  4. 其他干系人

高效回顾会议的七步议程

迭代review会议 (Showcase)

Showcase 就是开发团队把开发好的功能给客户的 Product Owner(以下简称PO)等业务相关人员演示,以获取他们对所开发系统的反馈,是敏捷开发流程中的一个实践,一般的频率是一个迭代一次,也可以根据项目具体情况做调整。

内容包含:

敏捷实践Showcase的七宗罪
Retro 破冰游戏

用户故事

故事基础

用户故事是指在软件开发和项目管理中用日常语言或商务用语写成的句子。这个句子反映一个用户在其工作职责的范围内要达到的某个目的, 以及此目的所需要的功能。

例子

为了避免遗漏附件延误工作
作为邮件发送者
我希望邮件系统能够在我忘记带附件的时候提醒我

格式

1
2
为了<某个目的或价值>, 作为<某类利益相关者>, 我想要<某个功能>
作为<某类利益相关者>, 我想要<某个功能>, 以便<达到某个目的或获得某种价值>

要点

重点描述商业的价值不只是功能

  • 帮助团队了解需求背后的意图,利于开发团队协同客户、业务部门设计出更好的解决方案

用业务语言描述

  • 利于客户、业务部门理解并区分优先级

利益相关者不仅包含用户

  • 系统拥有者的角度也是需求的重要来源

CCC 组件 (The three ‘C’s)

Card (卡片)

  • 业务价值 remainder
  • 做计划和沟通业务时的 token

Conversation (交流)

  • 用于在做计划和沟通业务时引发沟通,制造共同话题(收敛)
  • 需求文档的生成方式

Confirmation (确认)

  • 将细节以验收测试的方式检测功能的完整性和准确性

划分原则 INVEST

Independent (独立性)

故事和故事之间尽量保持独立,互相依赖的故事对于估算工作量、确定优先级和安排计划都带来很多不便。通常我们可以通过组合和拆分的方式减少依赖性(去除重复)。
独立性更多的指的是实现要完整。前后端拆分通常不是很好的拆分方法

Negotiated (可协商的)

一个故事是可以协商的,故事卡不是合同,它只是包含对一个需求的简短描述。具体的细节在沟通阶段产出,以验收测试的方式。如果带有太多的细节,反而限制了和用户的沟通。

Valuable (有价值的)

每个故事都必须对客户有价值(无论是用户还是客户)。一个让用户故事有价值的好办法就是客户写下它们。

Estimable (可评估的)

开发团队需要去估计一个用户故事以便确定优先级,工作量,安排计划。如果难以估计故事的时间, 意味着:

  • 领域知识的缺乏: 这种情况下需要更多的沟通
  • 技术实现的模糊: 这种情况下要做试验, 做原型
  • 或者故事太大了: 这时需要把故事切分成小些的
  • 还有对其它团队的依赖…

Small (短小的)

一个好的故事在工作量上应该是尽量短小的,至少确保能在一个迭代或 Sprint 中能够完成。用户故事越大,在估算、计划安排等方面的风险就越大。

Testable (可测试的)

一个用户的故事必须是可被测试的,以便它是可以完成的。如果一个故事无法测试,那么就无法知道它何时可以完成。一个不可测试的例子:为了节省时间,作为用户,我希望软件是易用的。

判断标准 (经验准则)

  • 可以写在 Release Notes 里
  • 值得讲给其它行业的人听
  • 可以写在市场宣传材料中

验收条件

捕获预期的行为

一般验收条件都会在开发之前准备好,用于捕获预期的系统行为,同时作为故事卡业务描述的一部分,定义了故事卡的 DoD(Definition of Done)。

格式

  • Given 前置条件,告诉我们在进行操作之前,需要设置和完成什么;
  • When 触发结果的操作
  • Then 操作之后的预期结果

最佳实践模式

1. 可读的

我们希望业务人员审阅和修正验收条件,如果写的内容只有开发人员能懂,我们就失去了获得反馈的机会。使用上述书写格式,可以提高可读性。

1
2
3
4
5
6
7
8
9
Given: that my mobile phone is switched on

And: I have sufficient signal to make a call

When: I dial a number

Then: I am connected to the person I want to talk to

And: incoming calls are diverted to my voicemail

2. 可测试的

反模式1 - 模棱两可的陈述
1
2
3
4
5
Given: that I have the search page loaded

When: I perform a search

Then: the search results come back within a reasonable period of time

这里的 reasonable period of time 就是不可测试的,因为没有人可以决定什么才是 reasonable.

合理的改法是:

1
2
3
4
5
Given: that I have the search page loaded

When: I perform a search

Then: the search results come back within 5ms

5ms 之内,这是一个标量,完全可以衡量。

反模式2 - 非系统输出
1
2
3
4
5
6
7
Given: that I am on the home page

And: I am logged in

When: I navigate to account preferences

Then: I can see my account preferences

这里的 I can see my account preferences 是无法进行断言的,因为这是系统无关的,说得极端些,假如用户闭上眼睛,这个功能就没法通过验收了。

合理的改法是:

1
2
3
4
5
6
7
Given: that I am on the home page

And: I am logged in

When: I navigate to account preferences

Then: my account preferences are displayed

这个时候,我可以检查系统展示了我的用户页面。

反模式3 - when 隐藏到 given
1
2
3
4
5
6
7
Given: that I am on the homepage

And: I navigate to the search

When: I look at the page

Then: the search options are displayed

基本上,这是团队编写 AC 时最容易犯的错误,操作出现在前置条件中,when 反而不是系统行为了。

合理的改法是:

1
2
3
4
5
Given: that I am on the homepage

When: I navigate to the search

Then: the search options are displayed

3. 实现无关的

验收条件应该是实现无关的,它和用户故事一样,是给业务和开发人员提供交流凭证的一种工具,所以它应该聚焦于功能,而不是功能的展现形式。

1
2
3
4
5
6
7
8
9
10
11
12
13
Given: that I am on the home page

And: I am logged in

When: I navigate to advanced search

Then: the advanced search web page must be displayed

And: a text box labelled "Name" is displayed

And: a text box labelled "Description" is displayed

And: a command button named "Search" is displayed

这里已经框死了必须要使用 text box 实现展示功能,而实际上其背后真正的意图是通过属性字段进行搜索,隐藏了业务含义的验收条件是不可取的。

合理的改法是:

1
2
3
4
5
6
7
8
9
10
11
12
13
Given: that I am on the home page

And: I am logged in

When: I navigate to advanced search

Then: the advanced search is displayed

And: an option to search by name is displayed

And: an option search by description is displayed

And: the advanced search is displayed in accordance with the attached wireframe

换句话说,验收条件本身不应该关注于展现形式,当然,为了便于理解,wireframe 是提供直观素材的更好的方式。

代码评审

代码评审是团队针对今天一天提交过的代码的评审会议。这样做的目的是去除代码的坏味道,减少累积的技术债。

团队成员使用版本控制工具,轮流展示自己一天的编码成果并且说明代码的用途。这期间,其他的成员不仅要评审代码的逻辑是否正确(满足验收条件),还必须思考是否有更优雅的方式实现这段功能。大家可以随意表达自己对这些代码的好恶,代码所有者也可以据理力争,所有者一旦被说服就必须无条件地按意见修改。为防止遗忘,可以使用便笺纸记录下来。

结对编程

通俗地讲,结对编程就是两个人同时工作在同一个 Story 上,一起讨论 Story 的解决方案,并编写代码实现功能,一个人敲键盘,一个人看屏幕,穿插着进行。Pair的双方在快速敲击键盘的时候会伴随一些交流。能力相当的两人,可以一人写测试,一人写实现代码。

测试驱动开发 (TDD)

TDD,即测试驱动开发,强调的是测试先行,跟我们先实现功能代码后添加测试的过程恰恰相反。测试驱动开发是一种驱动代码实现和设计的过程。我们说要先有测试,再去实现;保证实现功能的前提下,重构代码以达到较好的设计。整个过程就好比演绎推理,测试就是其中的证明步骤,而最终实现的功能则是证明的结果。

实践方法:

  • Given:初始状态或前置条件
  • When:行为发生
  • Then:断言结果

单元测试和测试驱动开发

测试的价值表现在两个方面:

  1. 防止已有的功能被破坏
  2. 驱动出功能实现

从测试金字塔描述的层级来看,单元测试是基座。这类测试数量应该是非常多的,而且还有一个显著特征——运行速度特别快。一般违反快速这个标准的,基本上可以肯定这不是单元测试。

基于以往带团队的经验,我给出一些写单元测试时常犯的错误和好的实践:

常犯的错误

  1. 覆盖率100%,却没有任何断言
  2. 使用Java反射setAccessible(true)访问私有属性
  3. 依赖某些地方产生的数据才能运行
  4. 单元测试运行速度超级慢
  5. 开始测试之前,准备巨量的数据
  6. 测试之间必须以特定顺序运行
  7. 上帝类(God Object)导致数以千行的测试代码

好的实践

  1. 绝对不测试私有的方法
  2. 当方法没有返回值,可以通过测试异常或者其委托对象的方式
    1
    2
    1. @Test(expected = RuntimeException.class) 
    2. Mockito.verify(mockObj).doSomething(args…)
    3. 静态方法调用是强耦合的信号。需要重构待测类依赖新接口,新接口定义的方法由子类实现,实现方法中包装这些静态方法。

什么时候写单元测试?

  1. 新的需求,首先写单元测试(保证功能实现)
  2. 已经存在的代码,补充单元测试 (保证功能不被破坏)
  3. 发现Bug,首先加测试(保证缺陷不再出现)

自动化测试

测试金字塔和 DOT(Depth of Test)

持续集成/持续交付

再次强调一下,持续集成是一项团队务必遵守的实践。

开发过程中,我们鼓励小步提交的代码控制方式。即当开发人员对story的部分功能编码完成之后,如果确认可以提交到代码仓库(如果是分布式的Git,可以考虑提交到本地仓库),应该尽早提交。开发人员的每日工作可以遵循7步提交法,如下:

  1. 更新代码;
  2. 本地编码;
  3. 本地构建(使用mvn test);
  4. 再次更新代码;
  5. 本地构建;
  6. 提交到代码仓库;
  7. CI上构建。

既然是实践(规矩),就有几项有效的纪律需要遵守:

  • CI红的时候不能提交代码
  • CI红着不能过夜(不能等到第二天才去修复)
  • 任何人提交后CI失败,无论原因,都有责任跟进修复
  • 在提交代码前运行所有测试
  • 不注释掉或删除失败的测试

这也就意味着开发人员需要密切关注CI的状态。CI持续反馈着软件的可工作状态,所以团队应该把CI的健康状况列为项目的最高优先级,甚至高于开发新特性。


友情链接

  1. ThoughtWorks的敏捷开发-XR)

Clojars 介绍

Clojars 是一个为开源 Clojure 类库打造的仓库,截止2017年9月17日,大概有19831个项目发布在上面。整个网站也是用 Clojure 编写的。

发布 Clojure library

1. 注册 clojars

前往 clojars 注册

2. lein 部署

1
$ lein deploy clojars # lein deploy [repository name], here the repo name is clojars.

如果不想加上 clojars 参数,则需要在当前项目下的 project.clj 添加如下内容

1
2
{:deploy-repositories [["releases" :clojars]
["snapshots" :clojars]]}

这里注意一点:
这里使用 :deploy-repositories 而非 :repositories,原因是 :repositories 除了用于部署还会作为依赖源被项目使用。所以,如若必要,还是职责单一点好。这样,也可以加入 :user profile (~/.lein/profiles.clj) 供所有本地项目发布使用。

这个时候可以执行

1
2
3
4
5
6
$ lein deploy
No credentials found for snapshots(did you mean `lein deploy clojars`?)
# 这里应该是 leiningen 的 bug,提示信息 `lein deploy clojars` 显得很奇怪,可以忽略之。
See `lein help deploying` for how to configure credentials to avoid prompts.
Username: username
Password: password

如上,这时会弹出用户名和密码输入框。为了节省时间,避免每次输入,最好把用户凭证 (credentials) 以文件的形式存放到用户范围的目录下,即*~/.lein/credentials.clj*,并做加密处理。

3. 设置全局的 credentials map

首先,把如下的 credentials map 写入 ~/.lein/credentials.clj

1
{#"https://clojars.org/repo" {:username "username_on_clojars" :password "password_on_clojars"}}

其次,使用 gpg 加密该文件

1
2
$ gpg --default-recipient-self -e \
~/.lein/credentials.clj > ~/.lein/credentials.clj.gpg

加密后,即可删除原文件 ~/.lein/credentials.clj。然后在:deploy-repositories map 中指定 :creds :gpg

1
2
3
4
{:deploy-repositories [["releases" :clojars
:creds :gpg]]
["snapshots" :clojars
:creds :gpg]]}

完成上述,lein deploy 的时候即可解密 ~/.lein/credentials.clj.gpg,从中获取对应仓库的usernamepassword注:为了便于索引查找,credentials 使用正则表达式 #”https://clojars.org/repo“ 作为 key)

Error: gpg agent timeout

有时候,deploy 时会出现 gpg agent 超时的错误

1
2
3
4
5
6
$ lein deploy
gpg: problem with the agent: Timeout
gpg: decryption failed: No secret key
Could not decrypt credentials from /Users/qianyan/.lein/credentials.clj.gpg
nil
See `lein help gpg` for how to install gpg.

仔细搜索文档会发现下面这句很重要的话

Due to a bug in gpg you currently need to use gpg-agent and have already unlocked your key before Leiningen launches, but with gpg-agent you only have to enter your passphrase periodically; it will keep it cached for a given period.

大意是,leiningen 需要用到 gpg-agent,而且在 lein deploy 之前,就应该解锁密钥。

不实际操作的话,还是很难弄懂这句话具体的指代。我们不妨思考一下。

1. 看看后台是否有个进程叫做 gpg-agent?

1
2
$ ps -ef |grep gpg
501 87095 1 0 8:27PM ?? 0:00.00 gpg-agent --daemon

嗯,还真有!

2. gpg 直接解密 credentials.clj.gpg

1
2
3
4
5
6
$ gpg --decrypt ~/.lein/credentials.clj.gpg

You need a passphrase to unlock the secret key for
user: "Yan Qian (lambeta) <qianyan.lambda@gmail.com>"
2048-bit RSA key, ID E13DFD8A, created 2016-05-14 (main key ID 3C5030FF)
# 接下来,漫无止境的等待中...

这奇怪的等待让我不安,所以我使出了杀手锏 kill -9,直接把 gpg-agent 干掉。

3. 重新 gpg –decrypt

1
2
3
4
5
6
7
8
9
$ gpg --decrypt ~/.lein/credentials.clj.gpg

You need a passphrase to unlock the secret key for
user: "Yan Qian (lambeta) <qianyan.lambda@gmail.com>"
2048-bit RSA key, ID E13DFD8A, created 2016-05-14 (main key ID 3C5030FF)
# 这里要输入 passphrase
gpg: encrypted with 2048-bit RSA key, ID E13DFD8A, created 2016-05-14
"Yan Qian (lambeta) <qianyan.lambda@gmail.com>"
{ #"https://clojars.org/repo" {:username "username_on_clojars" :password "password_on_clojars"}}

终于可以输入 passphrase 了,解密完成。这大概就是上面引文所说的 unlock your key before Leiningen launches.

4. 重新部署

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ lein deploy
Created /Users/qianyan/Projects/clojure-pr/clj-moco/target/clj-moco-0.1.0-SNAPSHOT.jar
Wrote /Users/qianyan/Projects/clojure-pr/clj-moco/pom.xml
Retrieving clj-moco/clj-moco/0.1.0-SNAPSHOT/maven-metadata.xml
from https://clojars.org/repo/
Sending clj-moco/clj-moco/0.1.0-SNAPSHOT/clj-moco-0.1.0-20170917.122837-4.jar (10k)
to https://clojars.org/repo/
Sending clj-moco/clj-moco/0.1.0-SNAPSHOT/clj-moco-0.1.0-20170917.122837-4.pom (3k)
to https://clojars.org/repo/
Retrieving clj-moco/clj-moco/maven-metadata.xml
from https://clojars.org/repo/
Sending clj-moco/clj-moco/0.1.0-SNAPSHOT/maven-metadata.xml (1k)
to https://clojars.org/repo/
Sending clj-moco/clj-moco/maven-metadata.xml (1k)
to https://clojars.org/repo/

没有出现 gpg-agent timeout 的错误,部署完成。


提示
1
2
3
--default-recipient-self
Use the default key as default recipient if option --recipient is not used and don’t ask if this is a valid one.
The default key is the first one from the secret keyring or the one set with --default-key.

参考链接
[1] Leiningen Deployment
[2] GPG: How to change the configuration

太长不读篇

  1. issues tracker 上注册
  2. 创建 issues
  3. 配置 build.gradle
  4. gpg 生成 key pair 以便签名
  5. 上传 Release Archive
  6. 关闭并验证 Staging 环境的 Archive
  7. 发布 Archive
  8. 通知 issue 管理员开启同步

细读篇

1. 注册

Maven Central 网站并不提供注册的功能,你需要到 Sonatype 网站上进行注册。而事实上,Sonatype 网站也没有直接提供一个注册链接。真正的注册入口在 issues tracker 上。一旦完成注册后,你需要创建包含待发布包信息的 issue。

2. 创建 issue

创建 issues
在 Sonatype 的 dashboard 上点击创建按钮,根据弹出框的提示,填写简介、描述、GroupId、Project URL、SCM url 以及你在 jira 上的用户名。创建完毕后,会被自动跳转到该 issue 的详情页并分配一个唯一的ID,如:OSSRH-33944。余下的时间只需要等待,一般在两个工作日之内,Sonatype 的工作人员就会着手处理,然后他会在该 issue 底下的评论区留言。

创建成功的 issue

如果代码是托管在 github 上,按照惯例,GroupId 应该取 github 上的域名,比如:com.github.qianyan。不过,这里我预备上传的包的 GroupId 是 com.lambeta,这是我购买的域名。审核者对此有所顾虑,所以很贴心地留言如下:

Do you own the domain lambeta.com? If not, please read:
http://central.sonatype.org/pages/choosing-your-coordinates.html
You may also choose a groupId that reflects your project hosting, in this case, something like io.github.qianyan or com.github.qianyan

在回复这个域名确实为我所有之后,工作人员就贴出不同环境的仓库地址。

Configuration has been prepared, now you can:
Deploy snapshot artifacts into repository https://oss.sonatype.org/content/repositories/snapshots
Deploy release artifacts into the staging repository https://oss.sonatype.org/service/local/staging/deploy/maven2
Promote staged artifacts into repository ‘Releases’
Download snapshot and release artifacts from group https://oss.sonatype.org/content/groups/public
Download snapshot, release and staged artifacts from staging group https://oss.sonatype.org/content/groups/staging
please comment on this ticket when you promoted your first release, thanks

最后一句很重要,说的是,当我第一次正式发布的时候,需要留言告知工作人员,以便他们开启中央仓库的同步,这样我的包才会在 Maven Central 仓库中可见。

3. 配置项目的 build.gradle

拿到仓库地址,我们就需要在自己的项目中进行一些必要的配置,包含:jar、sourcesJar、javadocJar 以及对这些产物的 signing(签名)。

maven 插件

1
apply plugin: 'maven'

maven 插件提供了 uploadArchives task,我们需要在这个 task 中配置仓库地址,以及 pom 的相关信息,因为上载到 maven 仓库的包必须要有 pom 文件,否则无法查找或被依赖。具体配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
uploadArchives {
repositories {
mavenDeployer {
beforeDeployment { deployment -> signing.signPom(deployment) }

repository(url: "https://oss.sonatype.org/service/local/staging/deploy/maven2/") {
authentication(userName: ossrhUsername, password: ossrhPassword)
}

snapshotRepository(url: "https://oss.sonatype.org/content/repositories/snapshots") {
authentication(userName: sonatypeUsername, password: sonatypePassword)
}

pom.project {
name project.name
packaging 'jar'
description 'underscore string in java'
url 'https://github.com/qianyan/underscore.string.java'

scm {
url 'https://github.com/qianyan/underscore.string.java'
connection 'https://github.com/qianyan/underscore.string.java.git'
developerConnection 'git@github.com:qianyan/underscore.string.java.git'
}

licenses {
license {
name 'MIT Licence'
url 'https://raw.githubusercontent.com/qianyan/underscore.string.java/master/LICENSE'
distribution 'repo'
}
}

developers {
developer {
id 'lambeta'
name 'Yan Qian'
email 'qianyan.lambeta@gmail.com'
}
}
}
}
}
}

对应生成的 pom.xml 大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<name>underscore.string.java</name>
<description>underscore string in java</description>
<url>https://github.com/qianyan/underscore.string.java</url>
<licenses>
<license>
<name>MIT Licence</name>
<url>https://raw.githubusercontent.com/qianyan/underscore.string.java/master/LICENSE</url>
<distribution>repo</distribution>
</license>
</licenses>
<developers>
<developer>
<id>lambeta</id>
<name>Yan Qian</name>
<email>qianyan.lambeta@gmail.com</email>
</developer>
</developers>
<scm>
<connection>https://github.com/qianyan/underscore.string.java.git</connection>
<developerConnection>git@github.com:qianyan/underscore.string.java.git</developerConnection>
<url>https://github.com/qianyan/underscore.string.java</url>
</scm>

注意 authentication(userName: ossrhUsername, password: ossrhPassword)authentication(userName: sonatypeUsername, password: sonatypePassword),这里的用户名和密码其实就是在 Sonatype 上注册的用户名和密码。为了让 gradle 脚本顺利执行,需要在当前工程下的 gradle.properties 文件中设置对应的变量,如下:

1
2
3
4
sonatypeUsername=
sonatypePassword=
ossrhUsername=
ossrhPassword=

这份文件会作为源代码的一部分提交,所以聪明的我们不会傻傻地把自己的用户名和密码 push 到 github 上面。和大部分 *nix 系统上的工具类似,gradle 也有本地配置,我们可以新建一份 gradle.properties 文件到 ~/.gradle/gradle.properties,然后把用户名和密码写入其中。这样,实际运行时,本地配置就会覆盖项目下对应的这些变量值。

4. 设置 gpg 以签名 Archive

gpg 生成的 key pair 主要是供签名使用的。假定本机已经安装 gpg,首先使用 gpg 生成 key pair。

1
$ gpg --gen-key

然后,查找你的 keyId:

1
2
3
4
$ gpg --list-keys
# ->
pub 2048R/XXXXXX 2017-09-14 [expires: 2018-05-14]
uid [ultimate] Yan Qian (lambeta) <qianyan.lambeta@gmail.com>

其中 XXXXXX 就是你的 keyId。接下来必须发布你的公钥:

1
$ gpg --keyserver hkp://pgp.mit.edu --send-keys XXXXXX

验证公钥已经发布成功:

1
$ gpg --keyserver hkp://pgp.mit.edu --search-keys qianyan.lambeta@gamil.com # user email address

当然,上述操作都可以使用 gpg tools 在 UI 上完成。

signing 插件

完成上述步骤之后,我们需要在 build.gradle 中添加 signing 插件及其配置,如下:

1
2
3
4
5
6
apply plugin: 'signing'

signing {
required { gradle.taskGraph.hasTask("uploadArchives") }
sign configurations.archives
}

上面的配置明确了 gradle task 的 DAG 中必须含有 uploadArchives,之后针对 archives 进行签名。

signing 插件如何同 gpg 生成 key pair 交互呢?这就需要在~/.gradle/gradle.properties再声明三个变量,如下:

1
2
3
signing.keyId=XXXXXX
signing.password=your_key_pair_password
signing.secretKeyRingFile=/Users/your_name/.gnupg/secring.gpg

还剩下最后的一步,归档 Jar,sourceJar(源代码)和javadocJar(API 文档),这些是需要签名的对象。

archive

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
apply plugin: 'java'

task sourcesJar(type: Jar) {
classifier = 'sources'
from sourceSets.main.allSource
}

task javadocJar(type: Jar, dependsOn: javadoc) {
classifier = 'javadoc'
from 'build/docs/javadoc'
}

artifacts {
archives jar
archives sourcesJar
archives javadocJar
}

归档产物最终都会被签名,生成以 .asc 为后缀的签名文件。

1
2
3
4
5
6
7
build/libs
├── underscore.string.java-0.0.1-javadoc.jar
├── underscore.string.java-0.0.1-javadoc.jar.asc
├── underscore.string.java-0.0.1-sources.jar
├── underscore.string.java-0.0.1-sources.jar.asc
├── underscore.string.java-0.0.1.jar
└── underscore.string.java-0.0.1.jar.asc

这些产出物在最终发布的时候,需要经过验证,如果验证失败,比如:缺少 javadoc 或者某个 *.asc 文件,则不被允许发布。

5. 上传 Release Archives

根据的 maven 的标准,日常开发我们会使用 snapshot 版本,如:0.0.1-SNAPSHOT;发布时,去掉后缀-SNAPSHOT,即:0.0.1。而 maven 会根据这个特点,机智地辨识是上传到 snapshotRepository 还是 releaseRepository 的。因为我们得发布包,所以修改版本为 0.0.1 之后,我们只需要简单地执行如下命令:

1
$ gradle uploadArchives # or `gradle uA` this is a shortcut.

6. 关闭并验证 Staging 环境上的 Archive

staging Repo

登录 Sonatype 的 Nexus Repository Manager,然后点击左边侧边栏的 Staging Repositories,搜索comlambeta (GroupId 去掉中间的’.’)。接下来查看 Content tab,重点检查 pom 或者签名文件是否遗漏!当确认无误后,即可关闭 (Close) 这个 Repo。关闭过程中,Nexus 会逐项检查产物是否合规,如果出现验证错误,则在 Activity tab 中显示具体失败的步骤及原因。

7. 发布 Archive

如果上面的验证通过,上面本来不可用的 Release 按钮会变为可用。点击 Release 按钮,直接发布包。

8. 通知 issue 管理员开启同步

发布包之后,就可以通知管理员开启同步。我在原来的 issue 的评论区留言:

I have a first release version 0.0.1 for this library.

很快地,管理员就回复同步已经开启:

Central sync is activated for com.lambeta. After you successfully release, your component will be published to Central, typically within 10 minutes, though updates to search.maven.org can take up to two hours.

不过,由于当时所用 gradle2.1 的版本,导致了上传时 pom 文件被遗漏,在 search.maven.org 中搜索不到。管理员很热心地解释了这个现象:

search.maven.org needs a valid POM file to be a part of your uploaded artifacts. Browsing Maven Central directly:
http://repo1.maven.org/maven2/com/lambeta/underscore.string.java/0.0.1/
it appears that a POM file is missing.

遂升级到 3.1 版本,重新上传之后就能在 search.maven.org 中看到。

9. 检查同步成功

除了通过 search.maven.org 检查同步是否成功之外,查询mvnrepository也是常用的搜索方式。不过,值得一提的是,mvnrepository 相较于 search.maven.org 同步会更慢点,原因是 mvnrepository 引用了 central.maven.org 仓库。而 central.maven.org == repo1.maven.org,两个域名对应的 IP 是一样的,而这个 repo1.maven.org 就是默认的 Maven central repository,也就是 search.maven.org 的仓库。
所以,你可以在以下两个仓库看到发布包:
http://central.maven.org/maven2/com/lambeta/underscore.string.java/
http://repo1.maven.org/maven2/com/lambeta/underscore.string.java/


参考链接
[1] simple library publishing with gradle
[2] release deployment

Underscore.string.java 是什么?

underscore.string.java 是一个Java中方便操作字符串的类库,提供了众多帮助方法。

起源

写过 Javascript 代码的人,估计没有几个不知道 underscore 这个类库的,因为它太好(有)用了,尽管现如今由于实现上不够优雅的缘故,已经被lodash所取代。而我想介绍的是 github 上 star 3000+的 underscore.string,它原本是 underscore 的扩展,不过现在已经演变成独立的库。顾名思义,它的作用就是弥补 Javascript 本身对于字符串操作支持的匮乏。
举个例子:

1
2
3
4
var slugify = require("underscore.string/slugify");
slugify("Hello world!");

-> hello-world

slugify是一种规整字符串的操作,常用于把url中的非法字符规整成 word-word 的模样。比如,我的这篇 blog 是通过 hexo 生成的 hexo new "Underscore.string.java",它会自动帮我转换成 2017-09-06-Underscore-string-java 这样的亲和url的格式。

想法

接触这个类库的时候,我正在使用 google guava,惊讶于这里头的各类操作,包括 ImmutableList、CharMatcher、Strings 等等。一来准备针对 guava 练练手,二来确实想学习一下 underscore.string 的操作,所以就开始着手写起了 underscore.string.java 这个项目。编写的过程中确实学到了不少平常不太可能接触到的设计方法。

项目介绍

这个类库已经正式发布在 Maven Central Repository. 最新版本是 0.2.0.

前置条件

  • java >= 1.6
  • guava 18.0

安装

gradle

1
2
3
4
5
6
7
repositories {
mavenCentral()
}

dependencies {
compile 'com.lambeta:underscore.string.java:0.2.0'
}

maven

1
2
3
4
5
<dependency>
    <groupId>com.lambeta</groupId>
    <artifactId>underscore.string.java</artifactId>
    <version>0.2.0</version>
</dependency>

支持的特性

  • capitalize
  • slugify
  • count
  • trim
  • ltrim
  • rtrim
  • repeat
  • decapitalize
  • join
  • reverse
  • clean
  • chop
  • splice
  • pred
  • succ
  • titleize
  • camelize
  • dasherize
  • underscored
  • classify
  • humanize
  • quote
  • unquote
  • surround
  • numberFormat
  • strRight
  • strRightBack
  • strLeft
  • strLeftBack
  • toSentence
  • truncate
  • lpad
  • rpad
  • lrpad
  • words
  • prune
  • isBlank
  • replaceAll
  • swapCase
  • naturalSort
  • naturalCmp
  • dedent
  • commonPrefix
  • commonSuffix
  • chopPrefix
  • chopSuffix
  • screamingUnderscored
  • stripAccents
  • pascalize
  • translate
  • mixedCase
  • collapseWhitespaces
  • ascii
  • chomp
  • startsWith
  • endsWith
  • levenshtein
  • hamming
  • longestCommonSubstring

New Features in 0.2.1-SNAPSHOT

gradle

1
2
3
4
5
6
7
8
9
10
repositories {
maven {
url 'https://oss.sonatype.org/content/groups/public'
}
}

dependencies {
compile ("com.lambeta:underscore.string.java:0.2.1-SNAPSHOT")
}

maven

1
2
3
4
5
6
7
8
9
10
11
12
13
<repositories>
<repository>
<id>my-repo</id>
<name>sonatype</name>
<url>https://oss.sonatype.org/content/groups/public</url>
</repository>
</repositories>

<dependency>
    <groupId>com.lambeta</groupId>
    <artifactId>underscore.string.java</artifactId>
    <version>0.2.1-SNAPSHOT</version>
</dependency>
  • replaceZeroWidthDelimiterWith

实现步骤

  • 测试类标注@ActiveProfiles(resolver = ProfilesResolver.class)
  • 自定义类 ProfilesResolver 实现接口 ActiveProfilesResolver,并实现接口中唯一的方法resolve(Class<?> targetClass)
  • maven-surefire-plugin 插件中配置
    1
    2
    3
     <systemPropertyVariables>
    <spring.profiles.active>${spring.profiles.active}</spring.profiles.active>
    </systemPropertyVariables>

实现如下:

1. 标注启用

1
2
3
4
5
6
7
8
9
10
11
@RunWith(SpringRunner.class)
@SpringBootTest(classes = {PetstoreApp.class}, // 我们的 application 名为 PetstoreApp
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles(resolver = ProfilesResolver.class)
public abstract class BaseResourceTest {
@BeforeClass
public static void setUp() {
String activeProfiles = System.getProperty("spring.profiles.active");
System.out.println(String.format("Resource Tests resolved profiles are [%s]", activeProfiles));
}
}

这个类存在的意义就是为了让其它类别的 ResourceTest 继承它,并在一次启动当中运行完所有的集成测试。避免每个 ResourceTest 都初始化启动 Application,造成运行速度变慢。

注意abstract关键字
如果不使用abstract关键字,那么maven-surefire-plugin就会抛出如下错误:

Tests in error:
BaseResourceTest.initializationError » No runnable methods

2. 实现自定义类 ProfilesResolver,如下:

1
2
3
4
5
6
7
8
9
10
import org.springframework.test.context.ActiveProfilesResolver;

public class ProfilesResolver implements ActiveProfilesResolver {
@Override
public String[] resolve(Class<?> aClass) {
String activeProfiles = System.getProperty("spring.profiles.active");

return new String[] {activeProfiles != null ? activeProfiles : "local"};
}
}

这里表示我们会从系统变量当中读取spring.profiles.active,但是这个变量从什么地方来呢?
我首先想到的是 maven 的 profiles 中设置 properties,如下:

1
2
3
4
5
6
<profile>
<id>local</id>
<properties>
<spring.profiles.active>local</spring.profiles.active>
</properties>
</profile>

如此,当我们在命令行中运行mvn test -Plocal的时候,就表明启用了 local 这个 profile。相应地,在 maven 的上下文当中,spring.profiles.active变量的值就是local

但是运行测试的时候,我们 ProfilesResolver 中的 System.getProperty("spring.profiles.active")返回的始终是null。其实道理很简单,maven 中定义的 properties 全是给 maven 自己(包含各类插件)用的,它并不会传递给应用程序使用。

注意:


properties 中定义的 spring.profiles.active 其实主要是给插件 maven-resources-plugin 使用的,具体请参看备注。


3. 定义systemPropertyVariables

所以我们需要定义systemPropertyVariables,顾名思义,这是系统变量的定义,在应用程序中就可以使用System.getProperty("spring.profiles.active")获得。

放在哪里合适呢?跑测试的插件中最合适!

所以,我们有如下的配置:

1
2
3
4
5
6
7
8
9
10
11
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<runOrder>alphabetical</runOrder>
<systemPropertyVariables>
<spring.profiles.active>${spring.profiles.active}</spring.profiles.active>
</systemPropertyVariables>
</configuration>
</plugin>

结合上面 properties 的配置,当我们再次运行mvn test -Plocal的时候,就会得到一个名为spring.profiles.active的系统变量,它的值由${spring.profiles.active}决定。此处,就是local。


备注

properties 中 spring.profiles.active 的另外用途
只要 maven 的 properties 中定义了 spring.profiles.active ,运行mvn spring-boot:run -Plocal的时候,spring boot 就会启用applicaiton-local.yml profile 文件。

为什么会这样的呢?按常理推断,应该是spring-boot-maven-plugin的配置项自动读取了我们设置的 properties spring.profiles.active,但是只要看一眼这个插件的文档就会发现,除非显式地在插件的configuration下配置了profiles参数或者手动传入run.profiles系统变量example,否则插件本身(可以像我一样扫一眼插件的源码)并无法感知到底启用 spring 的哪个 profile!所以这个假设不成立。

答案在bootstrap.yml当中!
以下是resources/config/bootstrap.yml中的内容

1
2
3
4
5
6
7
8
9
spring:
application:
name: petstore
profiles:
# The commented value for `active` can be replaced with valid Spring profiles to load.
# *注意底下这句话*
# Otherwise, it will be filled in by maven when building the WAR file
# Either way, it can be overridden by `--spring.profiles.active` value passed in the commandline or `-Dspring.profiles.active` set in `JAVA_OPTS`
active: #spring.profiles.active#

这里的注释很有用,明确地告诉我们在构建 WAR 包的时候,maven 会帮我们把#spring.profiles.active#替换成真正的值。

这又是怎么做到的呢?一切归功于maven-resources-plugin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<version>${maven-resources-plugin.version}</version>
<executions>
<execution>
<id>default-resources</id>
<phase>validate</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>target/classes</outputDirectory>
<useDefaultDelimiters>false</useDefaultDelimiters>
<delimiters>
<delimiter>#</delimiter> <!-- 看这里 -->
</delimiters>
<resources>
<resource>
<directory>src/main/resources/</directory>
<filtering>true</filtering>
<includes>
<include>**/*.xml</include>
<include>**/*.yml</include>
</includes>
</resource>
<resource>
<directory>src/main/resources/</directory>
<filtering>false</filtering>
<excludes>
<exclude>**/*.xml</exclude>
<exclude>**/*.yml</exclude>
</excludes>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>

这个插件除了简单的 copy 功能之外,还能进行 Filtering 操作

Filtering
Variables can be included in your resources. These variables, denoted by the ${…} delimiters, can come from the system properties, your project properties, from your filter resources and from the command line.

大意是说,你可以在 resources 文件定义自己的变量,这些变量可以来自系统属性、maven 工程属性,你过滤的 resources 文件和命令行。

说白了,就是在 copy 资源文件的时候,同时帮你把文件中的变量(占位符)替换成真实的值。而这里就是通过<delimiter>#</delimiter>来规定变量格式的!换句话说,在文件中只要是以#开头和结尾的字符串都会被替换掉(变量有定义的情况下;否则保持原样)。

这里,由于绑定了生命周期——validate,可以直接运行mvn validate -Plocal这样的命令进行快速验证。得到的bootstrap.yml内容如下:

1
2
3
4
5
6
7
8
spring:
application:
name: petstore
profiles:
# The commented value for `active` can be replaced with valid Spring profiles to load.
# Otherwise, it will be filled in by maven when building the WAR file
# Either way, it can be overridden by `--spring.profiles.active` value passed in the commandline or `-Dspring.profiles.active` set in `JAVA_OPTS`
active: dev # 替换成功

回到最开始的疑问,为什么只要 maven 的 properties 中定义了 spring.profiles.active ,运行mvn spring-boot:run -Plocal的时候,就可以spring boot 就会启用applicaiton-local.yml profile 文件呢?

因为,maven 在运行命令之前已经做了 copy-resources 的操作,那时候就已经把bootstrap.yml中的spring.profiles.active替换成 local 了,所以启动 springboot application 的时候,它会启用spring.profiles.active代表的值,此处就是 local,那么启用的文件自然就是application-local.yml

这是Clojure好书《Clojure for the Brave and True》作者 Daniel Higginbotham 写于2017年4月16日的博文。从作者的丰富的经验来看,本文非常具有指导意义。

学习编程语言是一种技巧:做好了,你会感受到掌握新事物之后的快感(dopamine:多巴胺);做不好,就会接二连三的沮丧,甚至放弃。下面这些学习编程语言的最佳技巧是我从多年的著书写作演讲培训中总结出来的。这里头很多技巧来源于对高效学习前沿研究做了解释的书籍。你可以在 Community Picks Learn Programming 中找到那些书(还有其它牛x的编程书籍)。

持续测试自己以抵抗胜任力错觉

最不济的学习方法中的一种就是重读或者重看材料。这种重复会给你一种感觉——似乎不用花什么气力,你就理解了话题所涵盖的内容。研究者们把这种现象称作胜任力错觉。

更好的一种方式(你可以掌握的最佳技巧之一)则是持续地测试自己。不要重读一个函数、类或者一个对象是什么,而是让自己定义这些概念或者把它们用到短小的程序当中——强迫你用某种方式显示自己的理解。这一过程常常很不舒服的,但是对形成长期记忆非常有效。更进一步,你可以在阅读材料之前先去行测试,举个例子,尝试在阅读一个章节之前做做练习。值得一提的是,这也被证明有助于记忆的形成。

测试对于学习的显著影响被称为测试效用,下面是一些具体方法可以利用:

  • 在阅读章节或者看视频之前,尝试猜测你将要学习的东西,并写下来。
  • 在阅读章节之前先做做这个章节的练习题。
  • 一直做练习,即使是最难的那些。暂时(永远)放弃一个练习也是可以的,不过至少要尝试一遍。(下个章节会详细谈到)
  • 阅读短小的程序并且尝试不看源码重新写一个。或者,再小一些,写个函数也行。
  • 在学习了对象、类、方法或者高阶函数等新概念之后,立即编码做示例。
  • 创建阐述这些概念的图示,以及这些概念之间的区别和联系。
  • 把你刚刚学到的概念写成博客。
  • 尝试把概念解释给非技术的朋友听。(在写《Clojure for Brave and True》的时候,我常常这么干。这样能够以外行的话阐述一个想法,进而迫使你深入理解想法本身。)

这些技巧的大部分都要规约到编写代码上!说到编程,由于程序都是重文本和概念性的,所以很容易以为我们仅仅通过阅读(代码)就在学习。但是程序同时也是一门手艺,就像其它手艺一样你得操练才能娴熟。编码是暴露你对程序作有错误假设的最佳方式。你越快地这么做,就会越快地纠正错误和提升技能。

如果你想了解更多测试效应的事情,敬请查看坚持:成功学习的科学(make it stick: The Science of Successful Learning)

花时间放空自己

如果你纠结在一个问题上,或者对刚才读到的东西不能理解,就去散散步甚或洗个澡 —— 只要能进入一种舒缓、放空的状态就行。解除障碍的最佳方式之一就是歇一会儿,这可能听上去有点反直觉,但确实如此。

问题是,当全神贯注解决问题时,我们很容易陷入思维障碍(mental blinder)。我的意思是,这差不多就是“关注”(字面上)的意思。不过,全神贯注会导致我们只能一直探索解决方案空间的一小部分。一旦放空,我们的潜意识就可以探索并联结我们经验中的广泛领域。

对我来说,这就像当你试图在纸质地图上找到一个目的地(是否还记得?)。你不用刻意就确信你想抵达的城市应该就在这里!在地图的左上角的区域,所以你看了一遍又一遍,都没有成功。然后你放下地图,做了深呼吸并让目光游离了一会儿。当你重新看地图时,确切的地点立马映入眼帘。

我们曾经都有过这样的经验,在洗澡的时候突发灵感。现在你对于为什么这么做有了更好的了解,那么也就能刻意地使用这个技巧。个人来讲,如果纠结在某事上,我真的会洗个澡,这个技巧的功效显著。另一方面,我又是多么干净(注:洗澡这件事)。

如果你想多学一些关于思考的关注和分散模式,敬请查看A Mind for Numbers: How to Excel at Math and Science (Even If You FLunked Algebra)

别浪费时间沮丧

和上一个章节相关:别浪费时间为代码沮丧了。沮丧会让我们做一些愚蠢的事情,比如重新编译或者重刷浏览器,期望这次会有所不同。

把沮丧看作你的知识有差距的信号。一旦你意识到自己沮丧了,它可以帮你后退一步,清晰地识别问题。如果你写的代码不起作用,坦率地向自己或者别人说明你期望的结果。使用科学的方法,就非预期行为的根因提出一个假说。然后测试你的假说。再次试验后,如果依然解决不了,就把这个问题放到一边,待会儿回来。

在一些似乎没法解决的问题上,我不知有多少次恼怒地扔掉了自己的笔记本电脑。隔天再看,一个显而易见的解决方案立马跳入脑海。甚至上周就发生过。

确认你正在处理语言的哪个方面

个人观点,我觉得记住这些是有用的——当学习一门编程语言的时候,你实际上正在学四件事情。

  • 怎么写代码:语法、语义以及资源管理
  • 语言的范式:面向对象,函数式,逻辑等
  • 产出物的生态圈:如何构建、运行可执行文件以及如何使用库
  • 工具:编辑器,编译器,调试器,代码质量检测器(linter)

这四项很容易搞混,不幸的结果是,当你遇到问题最终完全找错了地方。

举个例子,某些完全的编程新手,可能准备开始构建iOS应用。他们可能会试着让自己的应用在朋友的手机上运行,只看得到有关需要开发人员证书或其他信息的消息。这是产出物生态圈的一部分,不过小白可能将此视为编写代码的问题。他们可能会浏览自己写的每行代码来尝试解决问题,尽管问题和代码没有半毛钱关系。

如果我系统地处理这些方面,我会发现学习一种语言会更加容易。我将在其它的博客文章中罗列一些待回答的宽泛问题的列表,应该能帮助你学习任何语言。

明确目的,外部模型,内部模型

任何时候你学习使用新工具,明确学习的目的,外部模型和内部模型都是十分有用的。

当你了解了工具的目的时,你的大脑会加载有用的上下文细节,使你更容易吸收新知识。这就好比拼图:当你看到完整拼图之后,更容易把各部件拼到一起。这个道理适用于语言本身以及语言库。

工具的外部模型就是它呈现出来的接口以及它想让你思考问题解决的方式。Clojure 的外部模型就是一个 Lisp,它想让你把编程当做大部分以数据为中心,不可变的转换过程。Ansible 希望你把服务器的整备工作想成定义最终状态,而不是定义抵达那种状态所要采取的步骤。

工具的内部模型就是如何将输入到其接口转换成一些底层的抽象。Clojure 把 Lisp 转换成 JVM 的字节码。Ansible 把任务定义转换成了 shell 命令。在一个理想国中,你不需要理解工具的内部模型,但事实上,理解内部模型总是有用的,因为在某些看上去迷惑或者矛盾的部分,它可以让你有个统一视图。举个例子,当 DNA 双螺旋模型被发现的时候,它帮助科学家们了解更高层次的现象。从我的角度来讲,当然,这篇博文也是历来所有伟大科学成就之一。

很多教程经常混淆工具的外部和内部模型,使学习者感到困惑。意识到这点可以帮你轻松辨别何时你会感到沮丧。

间隔重复帮助记忆

间隔重复被证明是长期记忆中新信息编码的最佳方法之一。 这个想法是以不断增加的时间间隔来测验自己,使用最少重复次数来最小化记忆衰减。 卫报写了一篇很好的介绍性文章

睡眠和练习

保重身体!身体可不仅仅是你脑袋的载体。如果你想保持专注和高效的学习,就要足够的睡眠和练习,而不是(原文:beats the pants off)狂饮咖啡因和能量饮料。

更多小贴士

如果你还有其它有用的技巧,请不吝评论!如果你想知道更多关于学习编程的优质资源,敬请查看Community Picks: Learn Programming,这是一个社区策划编程学习书籍的收集活动,内容广泛,包含入门编程,工艺以及关于软技能和面试方面的书籍。


原文链接
[1] Techniques for Efficiently Learning Programming Languages

2016年11月份的技术雷达中给出了一个简明的定义:流水线即代码 (Pipeline as Code) 通过编码而非配置持续集成/持续交付 (CI/CD) 运行工具的方式定义部署流水线。
其实早在2015年11月份的技术雷达当中就已经有了类似的概念:

The way to avoid programming in your CI/CD tool is to extract the complexities of the build process from the guts of the tool and into a simple script which can be invoked by a single command. This script can then be executed on any developer workstation and therefore eliminates the privileged/singular status of the build environment
大意是将复杂的构建流程纳入一个简单的脚本文件,然后用一条命令调用。这样,任意的开发者都能在自己的工作区中执行脚本重建一套一模一样的构建环境,从而消除 CI/CD 环境由于散乱配置腐化而成的特异性。这么做的原因很好理解,使用 CI/CD 工具是为了暴露产品代码中的问题的,如果它们自身已经复杂到不稳定的地步,我们还使用它就是自找麻烦。

从某种程度上看,实施流水线即代码是不证自明的。在 CI/CD 的时间过程中,凡是可以被编码的东西都已经被代码化了,比如:构建、测试、数据库迁移、部署和基础设施/环境配置 (Infrastruture as Code) 等。说得烂俗点,流水线已经是 CI/CD 实践过程中的“最后一公里”,让流水线变成软件开发中的“一等公民”(即代码)是大势所趋、民心所向。不过,这种论断毕竟欠缺说服力,我们接着从实践的痛点出发总结当前流水线遇到的问题。

实践中的痛点

我给客户搭建和配置过不少 CI/CD 流水线(被同事戏谑地称为“CI/CD搭建兽”),最大的痛苦莫过于每次都得从头来过,即便大部分情况下所用的工具和配置都大同小异。其次是手工操作产生的配置漂移 (configuration drift) 。以 Jenkins 为例,先不谈 1.0 版本不支持流水线这一概念的问题,我们为了解决遇到的构建、测试和部署等问题,一般会在多个文本框中粘贴大量 shell/batch 脚本;甚至会通过这些文本框安装各种插件或者依赖包、设置环境变量等等。久而久之(实际上不需要多久),这台 Jenkins 服务器就变得不可替代(特异化)了,因为没人清楚到底对它做了哪些更改以及这些更改对承载它的系统产生哪些影响,这时 Jenkins 服务器俨然腐化成了老马所说的雪花服务器 (snowflake server)。雪花服务器有两点显著的特征:

  1. 特别难以复现
  2. 几乎无法理解

第一点是由于以往所做的更改并没有被记录下来,所以做过的操作都是七零八落的,没有办法复现同样的操作,也无法复制一个同样的系统。
第二点则是由于绝大部分情况下散乱的配置是没有文档描述的,哪部分是重要的已经无从知晓,改动的风险很大。

这些问题会在流水线的演化过程中恶化得越来越严重。一般来讲,除非不再使用,否则流水线不会保持一成不变。具体实施过程中,考虑到项目,尤其是遗留项目当前的特点和团队成员的“产能”,我们会先将构建和部署自动化;部署节奏稳定后,开始将单元测试和代码分析自动化;接着可以指导测试人员将验收测试自动化;然后尝试将发布自动化。在这之后,就要开始持续优化流水线,包括 CI 的速度和稳定性等。换句话说,流水线的演化其实是和项目的当前进展密切相关的,保证这样的对应关系有时是有必要的,比如:在版本控制下,多发布分支所需流水线和主干分支会存在不同。发布分支是主干分支某个时刻分出去的,它需要在那时的流水线上才能正常工作。由于前面所说雪花服务器的特征,重建这样一条流水线并不是一件容易的事情。

演进式的持续集成

如何解决

其实,流水线即代码本身已经回答这个问题了。当前实现了这一概念的工具大体遵循了两种模式:

  1. 版本控制
  2. DSL(领域特定语言)

对于特别难以复现、没有保证对应关系的痛点,我们就把流水线写成代码放到版本控制工具中管理起来。这样一来,每一次更改都能被记录下来,而且它会始终和此时的项目进展保持同步。

对于几乎无法理解、没有文档支持的痛点,我们就选用领域特定语言描述整条流水线。举个 Jenkins 2.0 例子,它允许我们在项目的特定目录下放置一个 Jenkinsfile 的文件,内容大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
node('master') {
stage('Checkout') {…}
stage('Code Analysis') {…}
stage('Unit Test') {…}
stage('Packing') {…}
stage('Archive') {…}
stage('DEV') {…}
}
stage('SIT') {
timeout(time:4, unit:'HOURS') {
input "Deploy to SIT?"
}
node('master') {…}
}
stage('Acceptance Test') {
node('slave') {…}
}

Jenkins 2.0 使用Groovy实现了一套描述流水线的DSL,即便不了解Groovy语言,只要对流水线稍微熟悉,就能按照例子和文档编写出符合要求的代码。

类似的工具还有Concourse.ci、λCD (LambdaCD) 等。
Concourse.ci 使用了 yaml 实现了DSL,独立抽象出Resource(外部依赖,如:git repo)、Job(函数, get 和 put Resource )和 Task(纯函数,必须明确定义 Input 和 Output )模型。

Concousre.ci

而 λCD 则使用 Clojure 语言实现了 DSL,抽象出 Pipeline 和 Step 模型,使用了Lisp特有的宏 (macro) 和普通函数,编写起来简单明了。

λCD

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(def pipeline-def
`(
(either
manualtrigger/wait-for-manual-trigger
wait-for-repo)

(with-workspace
clone
(in-parallel
run-some-tests
run-smokeing-tests)

run-package
deploy)))

上述的pipeline-def就是这条流水线的定义,极为优雅得是,它的代码和UI事实上构成了一一映射的关系,简单到极致。

值得一提的是,λCD 有别于其它同类型的工具,它本身就是一份用 Clojure 写就的微服务。换句话说,其它的工具可能需要借助基础设施即代码完成自身的安装,但λCD不用,它完全可以采用其它微服务的部署方式,比如用 λCD 部署它自己,类似于编译器的自举 (bootstraping)。这个时候,我们就需要两套 λCD 服务,一套用于部署自身,另一套部署开发中的工程。

流水线自举

小结

流水线即代码是个新概念,也就意味着我们还需要花时间去探索与之相关的实践,比如,调试和测试(既然是代码就需要测试)。一旦有了这些实践,我们就可以把流水线本身作为产品放到流水线上运作起来,那时将会看到一种很好玩的现象——旧的流水线会构建并部署新流水线,完成流水线的自举 (pipeline bootstrap) 。此外,当流水线成为代码,它在最终的交付物中必然占据一席之地,其潜在的价值还等待我们挖掘,至少从精益的角度,流水线能做的事情还有很多。

俗话说,工欲善其事必先利其器,完善开发工具与我而言是一件快乐的事情,分享也是一件令人愉悦的事情,所以我想把学习过程中的点滴记录下,留作备忘。本文不会介绍太多花式或有深度的emacs配置,更多是摸索学习的过程,其中充满了乐趣。

原因

网络上的*.emacs.d/init.el配置数不胜数,各路lisp大神的dot file都已经放在github上了,而且前有牛人撰文推荐学习emacs配置的详实方法,看似确实没有什么必要自己折腾一份配置。这个说法对,也不对。我在转向emacs之前,是一名忠实的vim党,从大学开始就不断折腾vim的配置,还花过一段时间专门学习了vimscript,曾经惊叹于vimscript的动态函数式风格的优美和强大。类似地,.vimrc*配置文件在网络上也多如牛毛,华丽和酷炫的插件极大地提升了vim的操作性。尽管如此,我还是乐于一砖一瓦地打造自己的vim环境,竭力演化它变成我心目中的“编辑器之神”。这个过程一般会充满修改然后重启的重复性机械劳作,偶尔会遭遇无论怎么修改就是不生效、甚至遍寻google也一无所获的挫折,但是我就是无法厌倦它,人天生好奇,探索未知事物本身就充满了乐趣,而且一旦配置奏效,便能获得满满的成就感。新事物对程序员具有极大的吸引力,但是程序员不会止步于使用新事物,而且会在惊奇之余,渴望控制那股背后主导它的力量本身,行使“上帝之力”。

话说回来,为什么我会从vim党摇身一变成为emacs党呢?这就不得不提起Clojure这门lisp方言,出于对lisp和函数式编程的痴迷,我选择了基于JVM的Clojure作为自己的偏好语言,而emacs天生为lisp而生。有了这个充足的理由,我开始收集emacs的cheatsheet并打印出来,天天放在手边翻阅,甚至买了一本英文版的Learning GNU Emacs书籍,只要有机会就打开emacs开始刷4clojure上的编程题。由于emacs对lisp的亲和性,我几乎没花多少时间就掌握住了常用的操作技巧。不过,emacs最负盛名的学习曲线确实让学习者绕过圈子,只要一段时间不用,就会忘记很多基本操作。另外,为了更好地在emacs中编写Clojure,还需要cider-mode和clojure-mode的支持,这时候就不得不编辑init.el文件,本着KISS (keep it simple, stupid)原则,我照着各种插件的说明文档中,把配置项复制粘贴到init.el文件当中,运行起来没有问题就好。随着自定义的内容变多,init.el文件也急剧膨胀起来。膨胀本来算不上问题,但我是个比较有操守的程序员,臃肿的代码是我极力避免的坏味道(bad smell)。所以胸臆之中涌动一股浩然之气,决心学起emacs lisp,把emacs的配置从头来过。

从『头』开始

init.el文件位于*~/.emacs.d目录之下,如果没有,自行创建一份即可。
首先,我们需要用到emacs的包管理工具
package.el*,因为emacs 24及其以上的版本都已经内置,所以无需下载到本地,直接通过require加载到emacs的运行时。

1
2
3
4
5
6
7
(require 'package)
(setq package-archives '(("melpa" . "http://melpa.org/packages/")
("melpa-stable" . "https://stable.melpa.org/packages/")
("marmalade" . "http://marmalade-repo.org/packages/")
("elpy" . "http://jorgenschaefer.github.io/packages/")
("gnu" . "http://elpa.gnu.org/packages/"))
package-enable-at-startup nil)

上面的代码涉及到setq(变量赋值)的操作,package-archives,顾名思义,多个包的下载源,我给package-archives设置了5个包源,它们之间服从顺序的优先级,即先从第一个源中下载包,如果没有,到第二个原种寻找,以此类推。此外,这里("melpa" . "http://melpa.org/packages/")中的点号(dot)表示法也比较奇怪,其实这是lisp中的Dotted pair表示法,用法和普通的列表类似,但因为是pair的缘故,你可以使用(car )获取"melpa"(cdr )获取到的却不再是一个列表,而是"http://melpa.org/packages/" 这个值本身。

emacs lisp不熟悉不要紧,先找个教程练习一下它的用法,比如learnxinyminutes就非常不错。完成这个教程,大体不会对elisp犯怵了。接下来,只需要使用c-h vc-h f查看elisp中定义的变量函数就能很快上手自行配置。
来个实际的例子,在大牛的配置文件中,经常能看到如下成对的配置:

1
2
(setq package-enable-at-startup nil)
(package-initialize)

开始我觉得这是一对矛盾的配置,package-enable-at-startup设置为nil,暗示emacs启动时不会启用package,而package-initialize明显表明在做package的初始化工作。这种时候,我心中就蹦跶出一句话“世界上本没有矛盾,如果出现了,检查你都有哪些前提条件,就会发现其中一个是错的”。这种非异常的知识点很难通过搜索引擎找到满意的答案,而阅读文档恰恰是最合适的解决方式。emacs对elisp文档的支持非常全面,只需将鼠标移到package-enable-at-startup变量上,按下c-h v (control + h, v) 组合键,就能在其它窗口(window) 看到文档描述:

Whether to activate installed packages when Emacs starts.
If non-nil, packages are activated after reading the init file
and before after-init-hook'. Activation is not done if user-init-file’ is nil (e.g. Emacs was started with “-q”).

意思是在读入init.el之后,这个变量才会生效。换句话说,在读取init.el的过程中,该变量不论是nil或是non-nil都不会影响package的加载和初始化。所以,这两者之间并没有矛盾。当然,此时你可能会想把package-enable-at-startup设置为nil意欲何为?官方文档中有如下的解释:

This will automatically set package-enable-at-startup to nil, to avoid loading the packages again after processing the init file.

简单点说,就是防止在package-initialize 之后重复加载包,因为可能会影响性能。

模块化

如果把什么东西都揉到init.el文件中,这个文件一定会很快变得臃肿不堪。为了解决这个问题,需要引入模块化的思想——把特定功能的配置放到独立的文件中,然后require进来。按照惯例,我在*~/.emacs.d目录下建立一个lisp目录用于存放所有自定义的模块文件,随后在init.el中加入下面这句代码,意在把lisp*目录加到emacs的加载路径列表里。

1
(add-to-list 'load-path (expand-file-name "lisp" user-emacs-directory))

看似,接下来就可以在每个独立的模块文件中编写各种功能的配置。但是由于package.el功能的局限,我们很快就会遇到包重复安装和配置漂移(configuration drift)的麻烦。package.el提供了package-install-p(p是predicate的意思)和package-install两个配套使用的函数,也就是说一般得先判断包在不在,才决定安不安装。幸运的是,有人已经很好地解决了这部分问题,use-package就是非常好用的包,它将包的配置和包的定义聚合到了一块,并且保证包一定会安装在你的系统当中。
在使用use-package之前,我们需要先安装它,如下:

1
2
3
4
5
6
(unless (package-installed-p 'use-package)
(package-refresh-contents)
(package-install 'use-package))

(eval-when-compile
(require 'use-package))

由于use-package本身就是一个包,所以可以使用package-install安装到本地,然后require到emacs的运行时,值得一提的是这个eval-when-compile函数,使用c-h f查看它的定义:

Like ‘progn’, but evaluates the body at compile time if you’re compiling.
Thus, the result of the body appears to the compiler as a quoted constant.
In interpreted code, this is entirely equivalent to `progn’.

初次看到compile time,心中难免会有疑问:lisp不是动态语言吗,怎么还需要编译?这种时候,我们就要求助于elisp的文档了。在emacs中按下c-h i获取主话题(topic)的菜单,然后点击Elisp进入它的操作指南。重点查看EvaluationByte Compilation两个章节。不难发现lisp的解析器可以读取解析两种类型的lisp代码,一种是适合人类阅读的代码,以el作为后缀;另一种是编译字节码,以elc作为后缀。编译字节码运行速度优于前一种代码,我们可以通过byte-compile-file把前一种代码的文件编译成字节码文件。有趣的是,如果我们使用package来安装包,对应包的目录下都存在配套的elelc两类文件。
Byte Compilation条目下,有eval-when-compile的完整描述:

If you’re using another package, but only need macros from it (the byte compiler will expand those), then ‘eval-when-compile’ can be used to load it for compiling, but not executing. For example,

1
2
(eval-when-compile
(require 'my-macro-package))

这里头有三个关键字loadcompilingexecuting值得留意一下。为了弄懂它们的含义,我们需要了解lisp解析器基本的工作原理:code text -[characters]-> load -[lisp object]-> evaluation/compiling -[bytecode]-> lisp interpretor。换句话说,除非你想编译包含上述代码的文件,否则它的作用和progn一模一样,顺序地求值包含其中的表达式。当你正在编译文件的时候,包中宏就会原地展开,然后被eval-when-compile宏加载进内存并被编译成字节码,供后续解析器执行。

Clojure相关

载入use-package之后,我需要开始配置自己强大的Clojure开发环境了。首先,引入几个包:

1
2
3
4
5
6
7
8
(use-package rainbow-delimiters
:ensure t)
(use-package clj-refactor
:ensure t)
(use-package company
:ensure t
:defer t
:config (global-company-mode))

rainbow-delimiters能够让括号变得如同彩虹一样绚丽(主要是易于区分forms),clj-refactor是重构Clojure程序的神器,company提供了强大的命令补全提示功能。

clojure mode

接下来,我们在*~/.emacs.d/lisp目录下新建一个init-clojure.el*文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
(require 'clj-refactor)
(require 'rainbow-delimiters)

(use-package midje-mode
:ensure t)

(defun my-clj-refactor-mode-hook ()
(clj-refactor-mode 1)
(yas-minor-mode 1) ; for adding require/use/import
(cljr-add-keybindings-with-prefix "C-c C-m"))

(use-package clojure-mode
:ensure t
:config
(add-hook 'clojure-mode-hook #'rainbow-delimiters-mode)
(add-hook 'clojure-mode-hook #'subword-mode)
(add-hook 'clojure-mode-hook #'midje-mode)
(add-hook 'clojure-mode-hook #'my-clj-refactor-mode-hook)
(add-hook 'clojure-mode-hook #'enable-paredit-mode))

(provide 'init-clojure)

这里就能看出use-package的好处来了,针对clojure-mode的配置项都统一放到:config中管理起来。配置完毕后,使用(provide 'init-clojure)将模块以这样的名字暴露给其它客户端调用。

CIDER mode

有了clojure-mode之后,我们还需要一个Clojure可交互式的开发工具,CIDER便是这么一款工具。同样地,我们在lisp目录下新建一个名为init-clojure-cider.el,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
(require 'init-clojure)
(require 'company)

(use-package cider
:ensure t
:config
(setq nrepl-popup-stacktraces nil)
(add-hook 'cider-mode-hook 'eldoc-mode)
(add-hook 'cider-mode-hook #'rainbow-delimiters-mode)
;; Replace return key with newline-and-indent when in cider mode.
(add-hook 'cider-mode-hook '(lambda () (local-set-key (kbd "RET") 'newline-and-indent)))
(add-hook 'cider-mode-hook #'company-mode)
(add-hook 'cider-repl-mode-hook 'subword-mode)
(add-hook 'cider-repl-mode-hook 'paredit-mode)
(add-hook 'cider-repl-mode-hook #'company-mode)
(add-hook 'cider-repl-mode-hook #'rainbow-delimiters-mode))

(provide 'init-clojure-cider)

配置的首部,我使用(require 'init-clojure)先加载init-clojure,然后对CIDER本身进行一系列的配置。配置的详细信息可以通过CIDER github主页获取到,这里我就不再赘述。

最后,需要在init.el文件中添加入这么一句(require 'init-clojure-cider),重新启动emacs,找到一个Clojure项目,按下C-c M-j (hack-jack-in),就能获得一个Clojure的交互式开发环境。

小结

当然,我的emacs配置绝对不止这些,但是其余的过程大体类似。由于emacs速来有伪装成编辑器的操作系统的称号,所以我的探索是无止境的。如果大家对我的配置感兴趣,可以直接去我github上dotfiles上查看。

参考链接
[1] sriramkswamy dotemacs
[2] purcell emacs.d

我们为什么要写单元测试?

满足需求是所有软件存在的必要条件,单元测试一定是为它服务的。从这一点出发,我们可以总结出写单元测试的两个动机:驱动(如:TDD)和验证功能实现。另外,软件需求易变的特征决定修改代码成为必然,在这种情况下,单元测试能保护已有的功能不被破坏。

基于以上两点共识,我们看看传统的单元测试有什么特征?

基于用例的测试(By Example)

单元测试最常见的套路就是Given、When、Then三部曲。

  • Given:初始状态或前置条件
  • When:行为发生
  • Then:断言结果

编写时,我们会精心准备(Given)一组输入数据,然后在调用行为后,断言返回的结果与预期相符。这种基于用例的测试方式在开发(包括TDD)过程中十分好用。因为它清晰地定义了输入输出,而且大部分情况下体量都很小、容易理解。

但这样的测试方式也有坏处。

第一点在于测试的意图。用例太过具体,我们就很容易忽略自己的测试意图。比如我曾经看过有人在写计算器kata程序的时候,将其中的一个测试命名为return 3 when add 1 and 2,这样的命名其实掩盖了测试用例背后的真实意图——传入两个整型参数,调用add方法之后得到的结果应该是参数之和。我们常说测试即文档,既然是文档就应该明确描述待测方法的行为,而不是陈述一个例子。

第二点在于测试完备性。因为省事省心并且回报率高,我们更乐于写happy path的代码。尽管出于职业道德,我们也会找一个明显的异常路径进行测试,不过这还远远不够。

为了辅助单元测试改善这两点。我这里介绍另一种测试方式——生成式测试(Generative Testing,也称Property-Based Testing)。这种测试方式会基于输入假设输出,并且生成许多可能的数据验证假设的正确性。

生成式测试

对于第一个问题,我们换种思路思考一下。假设我们不需要写具体的测试用例,那么掩盖意图的可能性也就没有了。想法很美好,但如何实践Given、When、Then呢?答案是让程序生成并自动验证它们。这也就引出生成式测试的概念——我们先声明传入数据可能的情况,然后使用生成器生成符合入参情况的数据,调用待测方法,最后才进行验证。

Given阶段

Clojure 1.9(Alpha)新内置的clojure.spec可以很轻松地做到这点:

1
2
3
4
5
6
7
;; 定义输入参数的可能情况:两个整型参数
(s/def ::add-operators (s/cat :a int? :b int?))
;; 尝试生成数据
(gen/generate (s/gen ::add-operators))

;; 生成的数据
-> (1 -122)

首先,我们尝试声明两个参数可能出现的情况或者称为规格(specification),即参数a和b都是整数。然后调用生成器产生一对整数。整个分析和构造的过程中,都没有涉及具体的数据,这样会强制我们揣摩输入数据可能的模样,而且也能避免测试意图被掩盖掉——正如前面所说,return 3 when add 1 and 2并不代表什么,return the sum of two integers才具有普遍意义。

Then阶段

数据是生成了,待测方法也可以调用,但是Then这个断言阶段又让人头疼了,因为我们根本没法预知生成的数据,也就无法知道正确的结果,怎么断言?

拿我们定义的加法运算为例:

1
2
(defn add [a b]
(+ a b))

我们尝试把断言改成一个全称命题的格式:

任取两个整数a, b,a和b加起来的结果总是a, b之和。

借助test.check,我们在Clojure可以这样表达

1
2
3
4
(def test-add
(prop/for-all [a (gen/int)
b (gen/int)]
(= (add a b) (+ a b))))

等等,我们把add方法的实现(+ a b)写到了断言里,这几乎丧失了单元测试的基本意义。换一种断言方式,我们使用加法的逆运算进行描述:

任取两个整数,把a和b加起来的结果减去a总会得到b。

1
2
3
4
(def test-add
(prop/for-all [a (gen/int)
b (gen/int)]
(= (- (add a b) a) b))))

我们通过程序陈述了一个已知的真命题。变换以后,就可以使用quick-check对多组生成的整数进行测试。

1
2
3
4
5
;; 随机生成100组数据测试add方法
(tc/quick-check 100 test-add)

;; 测试结果
-> {:result true, :num-tests 100, :seed 1477285296502}

测试结果表明,刚才运行了100组测试,并且都通过了。理论上,程序可以生成无数的测试数据来验证add方法的正确性。即便不能穷尽,我们也获得一组统计上的数字,而不仅仅是几个纯手工挑选的用例。

至于第二个问题,首先得明确测试是无法做到完备的。很多指导方法保证使用较少的用例做到有效覆盖,比如:等价类、边界值、判定表、因果图、pairwise等等。但是在实际使用过程当中,依然存在问题。举个等价类的例子,假如我们有一个接受自然数并直接返回入参的方法identity-nat,那么对于输入参数而言,全体自然数都互为等价类,其中的一个有效等价类可以是自然数1。如果入参被假定在整数的范围,我们很容易找到一个无效等价类,比如-1。

用Clojure测试代码表现出来:

1
2
3
4
5
(deftest test-with-identity-nat
(testing "identity of natural integers"
(is (= 1 (identity-nat 1))))
(testing "throw exception for non-natural integers"
(is (thrown? RuntimeException (identity-nat -1)))))

不过如果有人修改了方法identity-nat的实现,单独处理入参为0的情况,这个测试还是能够照常通过。也就是说,实现发生改变,基于等价类的测试有可能起不到防护作用的。当然你完全可以反驳:规则改变,等价类也得重新定义。道理确实如此,但是反过来想想,我们写测试的目的不正是构建一张安全网吗?我们信任测试能在代码变动时给予警告,但此处它失信了,这就尴尬了。

如果使用生成式测试,我们规定:

任取一个自然数a,在其上调用f的结果总是a。

1
2
3
4
5
6
7
8
9
10
11
(def test-identity-nat
(prop/for-all [a (s/gen nat-int?)]
(= a (identity-nat a))))


(tc/quick-check 100 test-identity-nat)

-> {:result false, :seed 1477362396044, :failing-size 0, :num-tests 1, :fail [0], :shrunk {:total-nodes-visited 0,
:depth 0,
:result false,
:smallest [0]}}

这个测试尝试对100组生成的自然数(nat-int?)进行测试,但首次运行就发现代码发生过变动。失败的数据是0,而且还给出了最小失败集[0]。拿着这个最小失败集,我们就可以快速地重现失败用例,从而修正。

当然也有可能在一次运行中,我们的测试无法发现失败的用例。但是,如果100个测试用例都通过了,至少表明我们程序对于100个随机的自然数都是正确的。和基于用例的测试相比,这就如同编织出一道更加紧密的安全网。网孔越小,漏掉的情况也越少。

Clojure语言之父Rich Hickey推崇*Simple Made Easy*哲学,所以生成式测试在Clojure.spec中有更为简约的表达。以上述为例:

1
2
3
4
5
6
(s/fdef identity-nat
:args (s/cat :a nat-int?) ;输入参数的规格
:ret nat-int? ;返回结果的规格
:fn #(= (:ret %) (-> % :args :a))) ;入参和出参之间的约束

(stest/check `identity-nat)

fdef宏定义了方法identity-nat的规格,默认情况下会基于参数的规格生成1000组数据进行生成式测试。除了这一好处,它还提供部分类型检查的功能。

再谈TDD

TDD(测试驱动开发)是一种驱动代码实现和设计的过程。我们说要先有测试,再去实现;保证实现功能的前提下,重构代码以达到较好的设计。整个过程就好比演绎推理,测试就是其中的证明步骤,而最终实现的功能则是证明的结果。

对于开发人员而言,基于用例的测试方式是友好的,因为它能简单直接地表达实现的功能并保证其正确性。一旦进入红、绿、重构的节(guai)奏(quan),开发人员根本停不下来,进入一种心流状态。只不过问题是,基于用例驱动出来的实现可能并不是恰好通过的。我们常常会发现,在写完上组测试用例的实现之后,无需任何改动,下组测试照常能运行通过。换句话说,实现代码可能做了多余的事情而我们却浑然不知。在这种情况下,我们可以利用生成式测试准备大量符合规格的数据探测程序,以此检查程序的健壮性,让缺陷无处遁形。

凡是想到的情况都能测试,但是想不到情况也需要测试。这才是生成式测试的价值所在。有人把TDD概念化为“展示你的功能”(Show your work),而把生成式测试概念化为“检查你的功能“(Check your work),我深以为然。

小结

回到我们写单元测试的动机上:

  1. 保证或验证实现功能;
  2. 保护已经实现的功能不被破坏。
    基于用例的单元测试和生成式测试在这两点上是相辅相成的。我们可以借助它们尽可能早地找出缺陷,避免缺陷逃逸到生产环境。

ThoughtWorks 2016年11月份的技术雷达把Clojure.spec移到了工具象限的评估环中,这表明这个工具值得作一番探究。当然,除了Clojure,其它语言都有相应的生成式测试的框架,你不妨在自己的项目中试一试。

起源

TDD讨论组里的申导最近在B站直播了Martin Fowler的经典文章*Refactoring with Loops and Collection Pipelines中谈到的利用集合管道对循环进行函数式重构。视频地址在这里,申导的翻译在这里。组织者小波(Seaborn Lee)趁机出了一道关于集合管道函数题目。我就想啊,论函数式编程,舍Clojure其谁?而且我在Clojure*很少能写出loop... recur这样偏底层的循环代码。话不多说,撸起袖子开工。

题目

一家澡堂有 m 个房间,每个房间有 n 个时段,现在要给用户推荐「最早的可以预约的时段」。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
rooms: [
{
room_id: 1,
periods: [
{
time: '17:00-18:00',
status: 'available'
},
{
time: '18:00-19:00',
status: 'occupied'
}
]
}, {
room_id: 2,
periods: [
{
time: '17:00-18:00',
status: 'occupied'
},
{
time: '18:00-19:00',
status: 'available'
}
]
}
]

期望返回:

1
2
3
4
{
room_id: 1,
time: '17:00-18:00'
}

解析

题目很简单,基本思路:首先过滤出每个房间periodsstatusavailable的时间段,然后取第一个也就是最早的时间段(默认为递增排序的),接着将room_id和这个时间段以期望返回的形式合并。再然后对所有合并的结果依据时间段进行一次排序(sort),最后取第一个结果即可。

1. Clojure 解法

转换数据格式

原题中给的是json的格式,不适合在Clojure中处理,所以我们手工转换成需要的形式,如下:
清单1-1 数据定义

1
2
3
4
5
6
7
8
9
10
11
(def rooms
[{:room-id 1
:periods [{:time "17:00-18:00"
:status :available}
{:time "18:00-19:00"
:status :occupied}]}
{:room-id 2
:periods [{:time "17:00-18:00"
:status :occupied}
{:time "18:00-19:00"
:status :available}]}])

代码

清单1-2 房间最早可预约时间段

1
2
3
4
5
6
7
8
9
10
11
12
13
(defn the-earliest-available-room [rooms]
(->> rooms
(map
(juxt first (fn [{:keys [periods]}]
(->> periods
(filter #(= (:status %) :available))
(ffirst)))))
(map #(into {} %))
(sort-by :time)
(first)))

(the-earliest-available-room rooms)
-> {:room-id 1, :time "17:00-18:00"}

这段代码和上面的解析是一一对应的关系。为了让程序清晰,符合管道的用法,这里使用了thread last宏(->>),它的作用是把前面一个form作为后一个form的最后一个参数。与之呼应的是thread first宏(->),它的作用类似,不过会传成第一个参数。

我们先看(map (juxt ...) ...)这一段代码。juxt是一个非常有意思的函数,而且超级实用。它的文档描述如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
(doc juxt)
->
clojure.core/juxt
[f]
[f g]
[f g h]
[f g h & fs]
Added in 1.1
Takes a set of functions and returns a fn that is the juxtaposition
of those fns. The returned fn takes a variable number of args, and
returns a vector containing the result of applying each fn to the
args (left-to-right).
((juxt a b c) x) => [(a x) (b x) (c x)]

它的神奇之处在于可以对同一个参数应用不同的函数,而且还能将应用的结果全部收集起来。想想题目的解析中提及的以期望返回的形式合并,如果我们应用juxt函数,就能得到[(:room-id 1) (:time "17:00-18:00")]这样的中间结果。

(juxt first (fn ...))first用于提取:room-id,而后面的lambda表达式则用于提取:time。解法很直观,筛选出:status:available的时间段,然后使用(ffirst)取第一个map的首个entry。如:{:time "17:00-18:00" :status :available},那么应用(ffirst)的结果就是[:time "17:00-18:00"]

接下来,又进行了一次map操作,这次的目的是把元组的entries,转换为map。举个例子:[[:room-id 1] [:time "17:00-18:00"]] => {:room-id 1 :time "17:00-18:00"}。转换成map之后,方便以:time对结果进行排序(sort-by :time),最后取出第一个元素(first),即我们期望的返回。

写完之后,我很想再写个TDD版本的。话不多说,继续撸袖子。

2. Clojure TDD 解法

环境准备

  • 生成工程

进入命令行,输入lein new midje the-earliest-available-period-of-bathroom,leiningen会生成基于midje这个测试框架的工程。

  • Git

    1
    2
    3
    4
    5
    6
    7
    8
    git init
    > .gitignore
    .lein*
    .nrep*
    target/
    这里ctrl-c退出
    git add .
    git commit --message "init commit"

    我使用了zshoh-my-zsh,自带了很多git操作的alias,可以通过alias |grep git查看。后续的git操作都会使用alias。

  • 自动测试

输入lein repl,然后(use 'midje.repl),最后输入(autotest)。这样一旦文件修改保存,测试就会自动触发。

  • Emacs

用来写代码的。

Tasking(任务拆分)

先不急着敲代码,我们先从测试的角度看看完成这个需求需要哪几步?

  • 单间澡堂有一个可用时间段
  • 单间澡堂有多个可用时间段
  • 所有澡堂(包含输入为空)没有可用时间段
  • 多间澡堂都有可用时间段
  • 多间澡堂中有的有可用时间段,有的没有可用时间段

第1个任务

  • 单间澡堂有一个可用时间段

1. 写测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
(def room-1 {:room-id 1
:periods [{:time "17:00-18:00"
:status :available}
{:time "18:00-19:00"
:status :occupied}]})

(def room-2 {:room-id 2
:periods [{:time "17:00-18:00"
:status :occupied}
{:time "18:00-19:00"
:status :available}]})

(facts "about `the-earliest-avaible-period-of-bathroom`"
(fact "should recommand if there is only one room with available period"
;; 1号
(the-earliest-available-recommand [room-1]) => {:room-id 1 :time "17:00-18:00"})
;; 2号
(the-earliest-available-recommand [room-2]) => {:room-id 2 :time "18:00-19:00"}))

2. 写实现

1
2
(defn the-earliest-available-recommand [rooms]
{:room-id 1 :time "17:00-18:00"})

针对1号测试,这个实现有点“荒诞”,术语hard code说的就是这个,但是眼下足够了。不过此时,应该再写一个类似的测试来去除hard code,即2号测试。
相应地,我们要修改实现。

1
2
3
4
5
defn the-earliest-available-recommand [rooms]
(let [{:keys [room-id periods]} (first rooms)
available-periods (filter #(#{:available} (:status %)) periods)]
(merge {:room-id room-id}
(select-keys (first available-periods) [:time]))))

3. 关闭并提交

  • 单间澡堂有一个可用时间段
1
2
ga .
gcmsg "one available room"

第2个任务

  • 单间澡堂有多个可用时间段

1. 写测试

1
2
3
4
5
6
7
8
9
10
(def room-3 {:room-id 3
:periods [{:time "17:00-18:00"
:status :occupied}
{:time "18:00-19:00"
:status :available}
{:time "19:00-20:00"
:status :available}]})
...
(fact "should recommand the earliest one if there is only one room with multiple available periods"
(the-earliest-available-recommand [room-3]) => {:room-id 3 :time "18:00-19:00"})

保存,发现测试还是跑过了。原因在于我们默认了period是递增排序的。我们看看有没有重构点?实现太简单了暂时找不到,那就欢欢喜喜地跳过实现步骤。

2. 关闭并提交

  • 单间澡堂有多个可用时间段
    1
    2
    ga .
    gcmsg "one room with multiple available periods"

第3个任务

  • 所有澡堂(包含输入为空)没有可用时间段

1. 写测试

1
2
3
4
5
6
7
8
9
10
11
(def non-available-room {:room-id 4
:periods [{:time "17:00-18:00"
:status :occupied}
{:time "18:00-19:00"
:status :occupied}
{:time "19:00-20:00"
:status :occupied}]})

(fact "should show `:no-available-room` if there is no available room"
(the-earliest-available-recommand []) => :no-available-room
(the-earliest-available-recommand [non-available-room]) => :no-available-room))

这回肯定挂掉。

2. 写实现

1
2
3
4
5
6
7
(defn the-earliest-available-recommand [rooms]
(let [{:keys [room-id periods]} (first rooms)
available-periods (filter #(#{:available} (:status %)) periods)]
(if (seq available-periods)
(merge {:room-id room-id}
(select-keys (first available-periods) [:time]))
:no-available-room)))

这里使用了Clojure中判断集合是否为空较为常用的手法(seq ),如果集合非空,那么返回集合本身;反之,返回nil,nil在逻辑上是false。测试通过。

3. 关闭并提交

  • 所有澡堂(包含输入为空)没有可用时间段
1
2
ga .
gcmsg "no available room"

第4个任务

  • 多间澡堂都有可用时间段

1. 写测试

1
2
3
4
5
(fact "should recommand the earliest if there has more than one room and each has available periods"
(the-earliest-available-recommand [room-1 room-2]) => {:room-id 1 :time "17:00-18:00"}
(the-earliest-available-recommand [room-2 room-1]) => {:room-id 1 :time "17:00-18:00"}
(the-earliest-available-recommand [room-2 room-3]) => {:room-id 2 :time "18:00-19:00"}
(the-earliest-available-recommand [room-1 room-2 room-3]) => {:room-id 1 :time "17:00-18:00"})

2. 写实现

1
2
3
4
5
6
7
8
9
10
11
12
(defn the-earliest-available-recommand [rooms]
(if (seq rooms)
(first (sort-by :time
(map (fn [room]
(let [{:keys [room-id periods]} room
available-periods (filter #(#{:available} (:status %)) periods)]
(if (seq available-periods)
(merge {:room-id room-id}
(select-keys (first available-periods) [:time]))
:no-available-room)))
rooms)))
:no-available-room))

到这里,我们开始使用(map )函数处理多个房间的内容。注意,当输入房间是空集合的时候,这里需要相应地做(seq rooms)判空处理,否则会返回nil,而不是我们想要的:no-available-room

3. 关闭并提交

  • 多间澡堂都有可用时间段
1
2
ga .
gcmsg "more than one room"

4. 重构

代码写到这里,再不重构就说不过去了。另外,管道没看到,倒是看到一堆括号。
我们使用thread last(->> )做一次重构:

1
2
3
4
5
6
7
8
9
10
11
12
13
(defn the-earliest-available-recommand [rooms]
(if (seq rooms)
(->> rooms
(map (fn [room]
(let [{:keys [room-id periods]} room
available-periods (filter #(#{:available} (:status %)) periods)]
(if (seq available-periods)
(merge {:room-id room-id}
(select-keys (first available-periods) [:time]))
:no-available-room))))
(sort-by :time)
first)
:no-available-room))

还行,至少没那么多嵌套了。提交一次。

1
2
ga .
gcmsg "[refactor] use macro thread-last ->> to pipe"

继续重构,使用我们的juxt函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
(defn the-earliest-available-recommand [rooms]
(letfn [(period [{:keys [periods]}]
(->> periods
(filter #(#{:available} (:status %)))
ffirst
(#(or % [:time ::non-available]))))]
(->> rooms
(map (fn [room]
(apply conj {} ((juxt first period) room))))
(remove #(#{::non-available} (:time %)))
(sort-by :time)
first
(#(or % :no-available-room)))))

看上去还行,不过不爽的是(#(or % [:time ::non-available]))。为了迎合(->> )宏,我们给(or )包了一层。原因是(->> )会让前面的结果出现在最后一个参数的位置,而我们需要将结果放到(or )的第一个参数的位置。有没有什么好看的解决方法呢?当然有!我们可以使用(-> )来做到这点。

1
2
3
4
5
6
7
8
9
10
11
12
13
(defn the-earliest-available-recommand [rooms]
(letfn [(period [{:keys [periods]}]
(-> periods
(->> (filter #(#{:available} (:status %)))
ffirst)
(or [:time ::non-available])))]
(-> rooms
(->> (map (fn [room]
(apply conj {} ((juxt first period) room))))
(remove #(#{::non-available} (:time %)))
(sort-by :time)
first)
(or :no-available-room))))

顿时觉得世界干净了不少。再提交一次。

1
2
ga .
gcmsg "[refactor] use juxt to extract needed fields"

第5个任务

  • 多间澡堂中有的有可用时间段,有的没有可用时间段

1. 写测试

1
2
3
(fact "should recommand the earliest available room even if there has non available room"
(the-earliest-available-recommand [room-1 non-available-room]) => {:room-id 1 :time "17:00-18:00"}
(the-earliest-available-recommand [room-2 non-available-room]) => {:room-id 2 :time "18:00-19:00"})

测试直接通过,又可以跳过实现代码了。不过,这也预示着我们的测试是有覆盖的,也需要花时间整理这些测试用例。在那之前,先提交一下。

2. 关闭并提交

  • 多间澡堂中有的有可用时间段,有的没有可用时间段
1
2
ga .
gcmsg "mixed non-available and available rooms"

为第3个任务补上测试用例

  • 所有(包含多个)澡堂(包含输入为空)没有可用时间段
1
2
3
4
(fact "should show `:no-available-room` if there is no available room"
(the-earliest-available-recommand []) => :no-available-room
(the-earliest-available-recommand [non-available-room]) => :no-available-room
(the-earliest-available-recommand [non-available-room non-available-room]) => :no-available-room))

这里的第3个用例包含第2个用例,我们待会整理掉。不过现在先提交一下。

1
2
ga .
gcmsg "multiple non-available rooms"

整理测试

在前面进行的任务当中,我们发现有两次没有写实现测试就通过的情况。这说明测试用例是有覆盖的。

  • 第2个任务的测试用例其实覆盖了第1个任务的测试用例,所以可以直接删去后者;
  • 第5个任务的测试用例覆盖了第4个任务的部分测试用例,所以可以合并到一起。

整理下来,最终的测试变成下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(facts "about `the-earliest-avaible-period-of-bathroom`"
(fact "should recommand the earliest one if there is only one room with multiple available periods"
(the-earliest-available-recommand [room-3]) => {:room-id 3 :time "18:00-19:00"})

(fact "should show `:no-available-room` if there is no available room"
(the-earliest-available-recommand []) => :no-available-room
(the-earliest-available-recommand [non-available-room non-available-room]) => :no-available-room)

(fact "should recommand the earliest if there has more than one room and each may have available periods"
(the-earliest-available-recommand [room-1 room-2]) => {:room-id 1 :time "17:00-18:00"}
(the-earliest-available-recommand [room-2 room-1]) => {:room-id 1 :time "17:00-18:00"}
(the-earliest-available-recommand [room-2 room-3]) => {:room-id 2 :time "18:00-19:00"}
(the-earliest-available-recommand [room-1 room-2 room-3]) => {:room-id 1 :time "17:00-18:00"}
(the-earliest-available-recommand [room-1 non-available-room]) => {:room-id 1 :time "17:00-18:00"}))

文档

The final goal of any engineering activity is some type of documentation.

更新README.md文件,其中描述程序解决的问题以及运行步骤,当然包含设计思路那更好了。提交一下。

1
2
ga .
gcmsg "update readme"

美化代码

代码是诗行 - by lambeta

什么是好看的代码?除了清晰明了,格式也必须产生美感。
1
2
3
4
5
6
7
8
9
10
11
12
13
(defn the-earliest-available-recommand [rooms]
(letfn [(period [{:keys [periods]}]
(-> periods
(->> (filter #(#{:available} (:status %))))
(ffirst) ; 统一套上括号
(or [:time ::non-available])))]
(-> rooms
(->> (map (juxt first period))
(map #(into {} %)) ; 合并单独提出来
(remove #(#{::non-available} (:time %)))
(sort-by :time)
(first)) ; 统一套上括号
(or :no-available-room))))

顺眼不少,最后提交一下。
1
2
ga .
gcmsg "[refactor] beautify pipe format"

这篇文章发出来一天,TDD讨论群的一位麦姓朋友@我道:

core=> (first {:a 1 :b 2})
[:a 1]
core=> (first {:b 2 :a 1})
[:b 2]
@lambeta map的元素应该是无序的,用first来获得key value pair是不可靠的。

看到这个建议的时候,我心里一阵欣喜——又有一员Clojurians,可以切磋技艺了!冷静下来,发现自己确实忽略了map中的entries可能是无序的。所以我做了如下的验证:

1
2
(type {})
-> clojure.lang.PersistentArrayMap

看到PersistentArrayMap的时候,我明白这些entries是保持插入顺序的,也就是说,(first {:a 1 :b 2})的求值结果一定是[:a 1]。照这个思路,在我的程序当中使用(first )取map的第一个元素并不会出错。不过,本着谨慎的心态,我查了一下clojure的array-map,发现一个有趣的例子:

1
2
3
4
5
(defn make-map [count] (zipmap (range count) (range count)))
(type (make-map 9))
;; => clojure.lang.PersistentArrayMap
(type (make-map 10))
;; => clojure.lang.PersistentHashMap

这表明当map中的entries数量超过一定数量(不一定是9,例外见:PersistentArrayMap’s assoc doesn’t respect HASHTABLE_THRESHOLD)时,PersistentArrayMap就变成了PersistentHashMap,那也就意味着,(first )取出来的值可能是随机的。举个例子:

1
2
(first {7 7, 1 1, 4 4, 6 6, 3 3, 2 2, 9 9, 0 0, 8 8, 5 5})
-> [0 0]

返回的结果并不是相当然的[7 7],而是[0 0]。那么(first )到底干了些什么呢?Cognitect公司的alexmiller回答我说:(first )会把它的参数强制转换(coerce)成了一个序列,然后取第一个值。我们试着用(seq )转换一下:

1
2
3
4
(type { 7 7, 1 1, 4 4, 6 6, 3 3, 2 2, 9 9, 0 0, 8 8, 5 5})
-> clojure.lang.PersistentHashMap
(seq { 7 7, 1 1, 4 4, 6 6, 3 3, 2 2, 9 9, 0 0, 8 8, 5 5})
-> ([0 0] [7 7] [1 1] [4 4] [6 6] [3 3] [2 2] [9 9] [8 8])

果然,[0 0]出现在序列的首位。至于为什么是这样的顺序,需要深入Clojure的hash算法和数据结构当中,有时间另起一篇博客解释。我们再试试PersistentArrayMap的情况:

1
2
3
4
(type { 7 7, 1 1, 4 4, 6 6, 3 3, 2 2, 9 9, 0 0})
-> clojure.lang.PersistentArrayMap
(seq { 7 7, 1 1, 4 4, 6 6, 3 3, 2 2, 9 9, 0 0})
-> ([7 7] [1 1] [4 4] [6 6] [3 3] [2 2] [9 9] [0 0])

顺序确实和原来的一致。

我们的程序当中是不应该假设map是有序的,所以需要修改实现代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(defn the-earliest-available-recommand [rooms]
(letfn [(period [{:keys [periods]}]
(-> periods
(->> (filter (comp #{:available} :status)))
(first)
(find :time)))
(room-id [room]
(find room :room-id))]
(-> rooms
(->> (map (comp (partial into {}) (juxt room-id period)))
(filter :time)
(sort-by :time)
(first))
(or :no-available-room))))

(find )函数,用于从map中获取包含该键值的entry,如果找不到,返回nil。这样就避免了潜在无序的entries对程序的干扰。另外,(partial into {})Currying很像,它通过接收into函数及其首个参数,构造出一个接收后续参数的函数。当然也可以直接使用#(into {} %)这样的形式。

下面是麦姓朋友的另一种解法,和我的解法思路不完全一样,值得学习借鉴。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(defn the-earliest-available-recommand [rooms]
(letfn [(earliest-available-time [periods]
(->> periods
(filter (comp #{:available} :status))
(map :time)
(sort)
(first)))]
(rename-keys
(->> rooms
(map #(update-in % [:periods] earliest-available-time))
(filter :periods)
(sort-by :periods)
(first))
{:periods :time})))

真诚欢迎大家继续点评。


本文样例

0%