单页应用的HATEOAS实战

要点

  • HATEOAS是Hypertext As The Engine Of Application State的缩写。在 Richardson Maturity Model中, 它是REST的最高级形态
  • 单页应用正越来越受到欢迎,前后端分离的开发模式进一步细化了分工,但同时也引入了不少重复的工作,例如一些业务规则在后端必须实现的情况下,前端也需要再实现一遍以获得更好的用户体验。HATEOAS虽然不是唯一消除这些重复的方法,但作为一种架构原则,它更容易让团队找到消除重复的“套路”

什么是HATOEAS

HATEOAS是Hypertext As The Engine Of Application State的缩写。采用Hypermedia的API在响应(response)中除了返回资源(resource)本身外,还会额外返回一组Link。 这组Link描述了对于该资源,消费者(consumer)接下来可以做什么以及怎么做。

举例来说,假设向API发起一次get请求,获取指定订单的资源表述(representation),那么它应该长得像这样:

HTTP/1.1 200 OK
Server: Apache-Coyote/1.1
Content-Type: application/hal+json;charset=UTF-8
Transfer-Encoding: chunked
Date: Fri, 05 Jun 2015 02:54:57 GMT

{
    "tracking_id": "123456",
    "status": "WAIT_PAYMENT",
    "items": [
        {
            "name": "potato",
            "quantity": 1
        }
    ],
    "_Links": {
        "self": {
            "href": "http://localhost:57900/orders/123456"
        },
        "cancel": {
            "href": "http://localhost:57900/orders/123456"
        },
        "payment": {
            "href": "http://localhost:57900/orders/123456/payments"
        }
    }
}
  • 理解Link中的“self”的消费者知道使用get方法访问其“href”的uri可以查看该订单的详细信息
  • 理解Link中的“cancel”的消费者知道使用delete方法访问其“href”的uri可以取消该订单
  • 理解Link中的“payment”的消费者知道使用post方法访问其“href”的uri可以为该订单付款

REST是目前业界相当火热的术语,似乎发布的API不带个REST前缀,你都不好意思和别人打招呼了。 然而大部分号称REST的API实际上并没有达到Richardson成熟度模型的第三个级别:Hypermedia。 而REST的发明者Roy Fielding博士更是直言HATEOAS是REST的前提, 这不是一个可选项,如果没有Hypermedia,那就不是REST。(摘自Infoq对Fielding博士的第二段访谈)

那么HATOEAS带来了什么优势?

一个显而易见的好处是,只要客户端总是使用Link Rel来获取URI,那么服务端可以在不破坏客户端实现的情况下实现URI的修改,从而进一步解耦客户端和服务端。

另一个容易被忽视的优势是它可以帮助客户端开发者探索API,Links实际上提示了开发者接下来可以进行何种业务操作,开发者虽然精通技术,但往往对于业务不甚了解,这些提示可以帮助他们理解业务,至少是一个查询API文档的好起点。想象一下,如果某个API的响应中多了一个新的Link,敏感的开发者可能就会询问这个Link是用来做什么的,是一个新的特性吗?虽然看起不起眼,但这往往使两个团队的成员沟通起来更容易。

单页应用和HATEOAS

在过去的几年里,WEB开发技术发生了很多重大的变革,其中之一就是单页应用,它们往往能带来更平滑的用户体验。在这一领域,分工进一步细化,前端工程师专精客户端程序构建和HTML、CSS等效果的开发,后端工程师则更偏重高并发、DevOps等技能,大部分特性需要前后端工程师配合完成。或许有人会质疑,为什么不是全栈工程师?诚然,如果一个人就能端到端的交付特性,那自然会减少沟通成本,但全栈工程师可不好找,细化分工才能适应规模化的开发模式。继Ajax之后,单页应用和前后端分离架构进一步催生了大量的API,我们急需一些方法来管理这些API的开发和演进,而HATEOAS应该在此占有一席之地。

在摸索中前进,自由地重命名你的资源

我们常说在敏捷开发中,应该拥抱变化。所以敏捷开发中推崇重构、单元测试、持续集成等技术,因为它们可以使变化更容易、更安全。HATOEAS也是这样一种技术。想象一下,在项目初始阶段,团队对业务的理解还不深入,很有可能会得出错误的业务术语命名,或者业务对象的建模也不完全合适。反映在API上,可能你希望能够修正API的URI,在非HATOEAS的项目中,由于URI是在客户端硬编码的,即使你把它们设计的非常漂亮(准确的HTTP动词,以复数命名的资源,禁止使用动词等等),也不能帮助你更容易地修改它们,因为你的重构需要前端开发者的配合,而他/她不得不停下手头的其他工作。但在采用了HATEOAS的项目中,这很容易,因为客户端是通过Link来查找API的URI,所以你可以在不破坏API Scheme的情况下修改它的URI。当然,你不可能保证所有API的URI都是通过Link来获取的,你需要安排一些Root Resource,例如 /api/currentLoggedInUser,否则客户端没有办法发起第一次请求。

HTTP/1.1 200 OK
Path: /api/currentLoggedInser (1)
{

​    …… //omitted content

​    "_Links": {  (2)
        "searchUserStories": {
            "href": "http://localhost:8080/userStories/search{?page, size}"
        },

​        "searchUsers": {
            "href": "http://localhost:8080/users/search{?page, size, username}"
        },

​        "logout": {
            "href": "http://localhost:8080/logout"
        }

​    }
}
  1. Root Resource,它们是API的入口,客户端通过他们浏览当前用户有哪些资源可以访问,你可以定义多个Root Resource,并确保它们的URI不会改变
  2. Link引入的URI可以自由地变化,可能是因为需要重命名资源,也可能是需要抽取出新的服务(域名变化)

消除重复的业务规则校验实现,更容易得适应变化

经验告诉我们,不能相信客户端的请求,所以在服务端我们需要根据业务规则校验当前的请求是否合法。这样确保了业务正确,但当用户发起了请求后才告诉他们请求失败,有时候是一件令人沮丧的事情。为了用户体验,可能会要求某些组件根据业务规则展示。例如,对于某个业务对象,要求编辑按钮只在当前用户可以编辑的情况下才展示。在传统的服务端渲染架构下,一般都可以复用校验的代码,而在单页应用中,往往由于技术栈不同,代码无法直接共用,业务规则在前后端都分别实现了一次。例如,在我们最近的一次项目中,前后端分别实现了如下规则:

  • 给定一个用户故事
  • 只有它的作者才能编辑它

服务端通过在用户故事的API中暴露作者帮助前端完成编辑按钮的有条件渲染。

HTTP/1.1 200 OK
Path: /api/userStories/123
{

​    "author": "john.doe@gmail.com"  (1)

}

1. 与当前用户比较判断是否渲染编辑按钮

但如果规则发生变化,前后端都需要适应这一改变,所以我们用HATEOAS重构了一下:

HTTP/1.1 200 OK
Path: /api/userStories/123
{

​    "author": "john.doe@gmail.com",
    "_links": {

        ​"updateUserStory": { 
        ​    "href": "http://localhost:8080/api/userStories/123"  (1)
        ​}

​    } 

}

2. 现在前端会根据 updateUserStorylink是否出现来验证当前用户是否具有编辑用户故事的能力

后来业务规则变为除了作者之外,系统管理员也可以编辑用户故事,这时候只需要后端去响应这个变化就行了。你可能会质疑,通过为用户故事暴露一个 isCurrentLoggedInUserAvailableToUpdate的计算属性也可以做到。没错,HATOEAS并不是唯一的办法,但作为一种架构约束,团队会自然而然地想到它,而计算属性则要求团队成员有更强的抽象技能。

总结

HATEOAS提倡在响应返回Link来提示对该资源接下来的操作。这种方式解耦了服务端URI,也可以让客户端开发者更容易地探索API。最后,通过Link来判断业务状态,还能有效地消除单页应用中的业务规则重复实现。


更多精彩洞见,请关注微信公众号:思特沃克

Share

讲真,别再使用JWT了

摘要:

  • 在Web应用中,使用JWT替代session并不是个好主意。
  • 适合JWT的使用场景。

抱歉,当了回标题党。我并不否认JWT的价值,只是它经常被误用。

什么是JWT

根据维基百科的定义JSON WEB TokenJWT,读作 [/dʒɒt/]),是一种基于JSON的、用于在网络上声明某种主张的令牌(token)。JWT通常由三部分组成: 头信息(header), 消息体(payload)和签名(signature)。

头信息指定了该JWT使用的签名算法:

header = '{"alg":"HS256","typ":"JWT"}'

HS256 表示使用了 HMAC-SHA256 来生成签名。

消息体包含了JWT的意图:

payload = '{"loggedInAs":"admin","iat":1422779638}'//iat表示令牌生成的时间

未签名的令牌由base64url编码的头信息和消息体拼接而成(使用”.”分隔),签名则通过私有的key计算而成:

key = 'secretkey'  
unsignedToken = encodeBase64(header) + '.' + encodeBase64(payload)  
signature = HMAC-SHA256(key, unsignedToken)

最后在未签名的令牌尾部拼接上base64url编码的签名(同样使用”.”分隔)就是JWT了:

token = encodeBase64(header) + '.' + encodeBase64(payload) + '.' + encodeBase64(signature) 

# token看起来像这样: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJsb2dnZWRJbkFzIjoiYWRtaW4iLCJpYXQiOjE0MjI3Nzk2Mzh9.gzSraSYS8EXBxLN_oWnFSRgCzcmJmMjLiuyu5CSpyHI

JWT常常被用作保护服务端的资源(resource),客户端通常将JWT通过HTTP的Authorization header发送给服务端,服务端使用自己保存的key计算、验证签名以判断该JWT是否可信:

Authorization: Bearer eyJhbGci*...<snip>...*yu5CSpyHI

那怎么就误用了呢

近年来RESTful API开始风靡,使用HTTP header来传递认证令牌似乎变得理所应当,而单页应用(SPA)、前后端分离架构似乎正在促成越来越多的WEB应用放弃历史悠久的cookie-session认证机制,转而使用JWT来管理用户session。支持该方案的人认为:

1.该方案更易于水平扩展

在cookie-session方案中,cookie内仅包含一个session标识符,而诸如用户信息、授权列表等都保存在服务端的session中。如果把session中的认证信息都保存在JWT中,在服务端就没有session存在的必要了。当服务端水平扩展的时候,就不用处理session复制(session replication)/ session黏连(sticky session)或是引入外部session存储了。

从这个角度来说,这个优点确实存在,但实际上外部session存储方案已经非常成熟了(比如Redis),在一些Framework的帮助下(比如spring-sessionhazelcast),session复制也并没有想象中的麻烦。所以除非你的应用访问量非常非常非常(此处省略N个非常)大,使用cookie-session配合外部session存储完全够用了。

2.该方案可防护CSRF攻击

跨站请求伪造Cross-site request forgery(简称CSRF, 读作 [sea-surf])是一种典型的利用cookie-session漏洞的攻击,这里借用spring-security的一个例子来解释CSRF:

假设你经常使用bank.example.com进行网上转账,在你提交转账请求时bank.example.com的前端代码会提交一个HTTP请求:

POST /transfer HTTP/1.1
Host: bank.example.com
cookie: JsessionID=randomid; Domain=bank.example.com; Secure; HttpOnly
Content-Type: application/x-www-form-urlencoded

amount=100.00&routingNumber=1234&account=9876

你图方便没有登出bank.example.com,随后又访问了一个恶意网站,该网站的HTML页面包含了这样一个表单:

<form action="https://bank.example.com/transfer" method="post">
    <input type="hidden" name="amount" value="100.00"/>
    <input type="hidden" name="routingNumber" value="evilsRoutingNumber"/>
    <input type="hidden" name="account" value="evilsAccountNumber"/>
    <input type="submit" value="点击就送!"/>
</form>

你被“点击就送”吸引了,当你点了提交按钮时你已经向攻击者的账号转了100元。现实中的攻击可能更隐蔽,恶意网站的页面可能使用Javascript自动完成提交。尽管恶意网站没有办法盗取你的session cookie(从而假冒你的身份),但恶意网站向bank.example.com发起请求时,你的cookie会被自动发送过去。

因此,有些人认为前端代码将JWT通过HTTP header发送给服务端(而不是通过cookie自动发送)可以有效防护CSRF。在这种方案中,服务端代码在完成认证后,会在HTTP response的header中返回JWT,前端代码将该JWT存放到Local Storage里待用,或是服务端直接在cookie中保存HttpOnly=false的JWT。

在向服务端发起请求时,用Javascript取出JWT(否则前端Javascript代码无权从cookie中获取数据),再通过header发送回服务端通过认证。由于恶意网站的代码无法获取bank.example.com的cookie/Local Storage中的JWT,这种方式确实能防护CSRF,但将JWT保存在cookie/Local Storage中可能会给另一种攻击可乘之机,我们一会详细讨论它:跨站脚本攻击——XSS。

3.该方案更安全

由于JWT要求有一个秘钥,还有一个算法,生成的令牌看上去不可读,不少人误认为该令牌是被加密的。但实际上秘钥和算法是用来生成签名的,令牌本身不可读仅是因为base64url编码,可以直接解码,所以如果JWT中如果保存了敏感的信息,相对cookie-session将数据放在服务端来说,更不安全。

除了以上这些误解外,使用JWT管理session还有如下缺点:

  1. 更多的空间占用。如果将原存在服务端session中的各类信息都放在JWT中保存在客户端,可能造成JWT占用的空间变大,需要考虑cookie的空间限制等因素,如果放在Local Storage,则可能受到XSS攻击。
  2. 更不安全。这里是特指将JWT保存在Local Storage中,然后使用Javascript取出后作为HTTP header发送给服务端的方案。在Local Storage中保存敏感信息并不安全,容易受到跨站脚本攻击,跨站脚本(Cross site script,简称xss)是一种“HTML注入”,由于攻击的脚本多数时候是跨域的,所以称之为“跨域脚本”,这些脚本代码可以盗取cookie或是Local Storage中的数据。可以从这篇文章查看XSS攻击的原理解释。
  3. 无法作废已颁布的令牌。所有的认证信息都在JWT中,由于在服务端没有状态,即使你知道了某个JWT被盗取了,你也没有办法将其作废。在JWT过期之前(你绝对应该设置过期时间),你无能为力。
  4. 不易应对数据过期。与上一条类似,JWT有点类似缓存,由于无法作废已颁布的令牌,在其过期前,你只能忍受“过期”的数据。

看到这里后,你可能发现,将JWT保存在Local Storage中,并使用JWT来管理session并不是一个好主意,那有没有可能“正确”地使用JWT来管理session呢?比如:

  • 不再使用Local Storage存储JWT,使用cookie,并且设置HttpOnly=true,这意味着只能由服务端保存以及通过自动回传的cookie取得JWT,以便防御XSS攻击
  • 在JWT的内容中加入一个随机值作为CSRF令牌,由服务端将该CSRF令牌也保存在cookie中,但设置HttpOnly=false,这样前端Javascript代码就可以取得该CSRF令牌,并在请求API时作为HTTP header传回。服务端在认证时,从JWT中取出CSRF令牌与header中获得CSRF令牌比较,从而实现对CSRF攻击的防护
  • 考虑到cookie的空间限制(大约4k左右),在JWT中尽可能只放“够用”的认证信息,其他信息放在数据库,需要时再获取,同时也解决之前提到的数据过期问题

这个方案看上去是挺不错的,恭喜你,你重新发明了cookie-session,可能实现还不一定有现有的好。

 

那究竟JWT可以用来做什么

我的同事做过一个形象的解释:

JWT(其实还有SAML)最适合的应用场景就是“开票”,或者“签字”。

在有纸化办公时代,多部门、多组织之间的协同工作往往会需要拿着A部门领导的“签字”或者“盖章”去B部门“使用”或者“访问”对应的资源,其实这种“领导签字/盖章”就是JWT,都是一种由具有一定权力的实体“签发”并“授权”的“票据”。一般的,这种票据具有可验证性(领导签名/盖章可以被验证,且难于模仿),不可篡改性(涂改过的文件不被接受,除非在涂改处再次签字确认);并且这种票据一般都是“一次性”使用的,在访问到对应的资源后,该票据一般会被资源持有方收回留底,用于后续的审计、追溯等用途。

举两个例子:

  1. 员工李雷需要请假一天,于是填写请假申请单,李雷在获得其主管部门领导签字后,将请假单交给HR部门韩梅梅,韩梅梅确认领导签字无误后,将请假单收回,并在公司考勤表中做相应记录。
  2. 员工李雷和韩梅梅因工外出需要使用公司汽车一天,于是填写用车申请单,签字后李雷将申请单交给车队司机老王,乘坐老王驾驶的车辆外出办事,同时老王将用车申请单收回并存档。

在以上的两个例子中,“请假申请单”和“用车申请单”就是JWT中的payload,领导签字就是base64后的数字签名,领导是issuer,“HR部门的韩梅梅”和“司机老王”即为JWT的audience,audience需要验证领导签名是否合法,验证合法后根据payload中请求的资源给予相应的权限,同时将JWT收回。

放到系统集成的场景中,JWT更适合一次性操作的认证:

服务B你好, 服务A告诉我,我可以操作<JWT内容>, 这是我的凭证(即JWT)

在这里,服务A负责认证用户身份(相当于上例中领导批准请假),并颁布一个很短过期时间的JWT给浏览器(相当于上例中的请假单),浏览器(相当于上例中的请假员工)在向服务B的请求中带上该JWT,则服务B(相当于上例中的HR员工)可以通过验证该JWT来判断用户是否有权执行该操作。这样,服务B就成为一个安全的无状态的服务了。

总结

  1. 在Web应用中,别再把JWT当做session使用,绝大多数情况下,传统的cookie-session机制工作得更好
  2. JWT适合一次性的命令认证,颁发一个有效期极短的JWT,即使暴露了危险也很小,由于每次操作都会生成新的JWT,因此也没必要保存JWT,真正实现无状态。

参考

https://auth0.com/blog/ten-things-you-should-know-about-tokens-and-cookies/

https://auth0.com/docs/security/store-tokens#where-to-store-your-jwts

http://cryto.net/~joepie91/blog/2016/06/13/stop-using-jwt-for-sessions/

更多精彩洞见,请关注微信公众号:思特沃克


Share

拯救Java Code Style强迫症

这篇文章缘起于上一个持续交付的咨询项目,当时正在指导客户团队的Java工程师做Code Review,发现一个很有意思的现象:有一位工程师对Code Style特别在意,所以在Code Review的大部分时间中都是该工程师在指出哪里哪里的格式不对,但是团队并没有找到改进方法,每次的结论都是“下次我注意一点。”我挺欣赏这位工程师对Code Style的认真态度,所以就萌生了“怎么拯救Code Style强迫症”的想法。

要点

  • Code Style不是个人喜好问题,它会影响工作效率,团队应将其当做工程实践予以重视。
  • Code Style需要端到端的工具支持,尽早解决问题,避免技术债。
  • 以Checkstyle作为核心工具支撑Java项目的Code Style实施方案。

Code Style是一项工程实践

我是右侧风格的忠实拥趸,如果让我在工作的项目中看到左侧风格的代码,你猜猜我的反应是什么。

嗯,可能我对代码风格确实有些强迫症,但事实上,Code Style并不仅仅是代码是否好看那么简单,如果没有按照惯例来编写代码,甚至会让阅读者产生疑惑。

private Listener listener = new Listener() // So Listener looks like a class?  
{}; // Oops, it is an interface

如果代码可读性还不足以打动你,那么想象一下这个场景,你的同事说他修复了两个空指针问题,请你帮忙Code Review,你查看了这个文件的修订历史,乍看之下有许多改动,看来是个大动作。然而事实上,绝大部分改动是代码格式调整,只有两处改动与需要Review的问题相关。

(看来这位同事的IDE使用了不同的自动缩进设置,导致所有行都产生了缩进)

之所以会产生以上这些影响工作效率的问题,是因为团队没有重视Code Style,没有把它当做一项工程实践,既没有对其达成一致,也没有正确地使用工具帮助实施。

那就按照工程实践的标准来实施Code Style

本文将重点介绍Java项目中Code Style的工具支持,但在此之前,你的团队需要一起做一些决定:

  1. 使用哪种Code Style?
    每个人可能都有偏好的style,但在团队协作面前,需要一定的妥协。有些公司或组织有着统一的Code Style指导标准,萧规曹随是个不错的选择(但是要确保这类统一指导标准在制定时参考了开发人员的意见,是切实可行的),你的团队也可以自己裁剪,但至少要保证项目(Repository)级别上使用同一种Style。
  2. 如何处理不符合Code Style的提交?
    大家往往懈怠于事后补救的方式,我的建议是不要让不符合约定的代码流入代码库。对于遗留项目,尤其是大型项目,可以选择一部分代码作为实施范围,集中修复Style问题后严格实施,切忌操之过急,最后团队疲惫不堪只得放弃。

我们都知道人工监督检查的方式是不可持续和不可靠的,来看看有哪些工具可以提供帮助吧。

懒惰是第一生产力

工程实践不能没有自动化工具支持,在Java生态圈中,Code Style工具最出名的应该是Checkstyle了,它可以通过XML形式的外部DSL来定义Code Style的检查风格,比如你可以从这里找到Google的Java Checkstyle配置文件。这里我不会详细介绍Checkstyle本身,相反,我会更多地探讨如何工程化地使用Checkstyle,在交付代码的各个活动中,我们都可以用到Checkstyle,进行360°无死角的检查。

(和Code Style相关的代码交付生命周期)

守住提交的质量关口

为了贯彻不让不符合约定的代码流入代码库的决定,可以优先在服务端设置Code Style的检查关卡。

(优先守住代码提交时的服务端检查,可以考虑使用CI服务器来实现)

从实现层面上说,有两种方式:

一是在SCM(Source Control Management,例如Git/SVN)服务端设置检查项,如果不达标则拒绝提交,但这种方式相对不容易实现,而且一般SCM服务端也不由开发团队管理,设置起来不灵活也不方便。

二是利用持续集成服务器,开发团队的每一次提交都会触发一次构建,我们可以在构建脚本中加入Checkstyle检查,如果有不达标的代码则让构建失败,以便告诉提交者立即修复Style问题。我更推荐这个方案,因为相关的工具支持都很成熟,实现简单,而且构建过程可以在开发者的本地环境复制,以便在后续改进中将Checkstyle检查前移,提供更快的反馈。如果团队使用Maven/Gradle等构建工具,可以用插件的方式实现Checkstyle检查并嵌入到整个构建过程中。这样CI服务器只要调用构建脚本就行了。

在开发者本地验证Style

(在开发者本地实现验证,反馈关口前移)

在实现了CI验证后,就可以着手实现开发者本地验证了,这样开发者就不用等到提交代码到服务端后才会获得反馈了。由于之前采用的是构建工具的插件方案,所以开发者在本地运行构建就能实现验证了。比如Gradle提供了Checkstyle插件支持,你可以在这里找到Gradle Checkstyle Plugin的详细配置文档,如果你使用Maven,则可以参考这里。现在只需要一条命令,开发者久能在本地验证Code style了。

# build.gradle 
# omitted plugins 
apply plugin: 'checkstyle'  
checkstyle {     
    configFile = file("config/checkstyle.xml") //指定checkstyle配置文件     
    toolVersion = "7.4" //指定checkstyle工具的版本,部分style规则有版本要求 
}  
checkstyleTest.exclude "**/ContractVerifierTest**" // 忽略检查生成代码,这个锅我们不背  

// 如果出现checkstyle warning也使构建失败,插件默认只支持checkstyle error失败 
// Fail build on Checkstyle Warning Violation · Issue #881 
tasks.withType(Checkstyle).each { checkstyleTask ->     
    checkstyleTask.doLast {         
        reports.all { report ->             
            def outputFile = report.destination             
            if (outputFile.exists() && outputFile.text.contains("<error ")) {    
                throw new GradleException("There were checkstyle warnings! For more info check $outputFile")             
            }         
        }     
    }
}

现在只需要一条命令,每个开发者就能在本地验证Code Style了。你可以在这里找到Gradle Checkstyle Plugin的详细配置文档,如果你使用Maven,则可以参考这里

➜  court-booking-backend (master) ✗ ./gradlew check 
Starting a Gradle Daemon (subsequent builds will be faster) 
:compileJava 
:processResources UP-TO-DATE 
:classes 
:checkstyleMain [ant:checkstyle] 
    [WARN] /Users/twer/Workspace/restbucks/court-booking-backend/src/main/java/com/restbucks/courtbooking/http/CourtRestController.java:16: 
    'method def' child have incorrect indentation level 4, expected level should be 8. [Indentation] :checkstyleMain FAILED  
FAILURE: Build failed with an exception.

本地验证很不错,但我有时候会忘记执行

(让机器代劳琐事)

有时候,开发者修改了代码后会忘记执行本地检查就提交代码了,最好能够在提交代码前自动执行检查。如果你使用Git的话,可能会想到Git commit hook,比如这是我常用的pre-commit hook

#!/bin/sh
# From gist at https://gist.github.com/chadmaughan/5889802

# stash any unstaged changes
git stash -q --keep-index

# run the tests with the gradle wrapper
./gradlew clean build

# store the last exit code in a variable
RESULT=$?

# unstash the unstashed changes
git stash pop -q

# return the './gradlew build' exit code
exit $RESULT

将该脚本拷贝到.git/hooks/下,在执行git commit的时候就会自动触发检查了,如果检查失败则提交失败。但问题是.git并不能提交到远程代码仓库,那么除了人工分发和拷贝外,有没有更好的方式在团队中共享这个机制呢?

可以曲线救国!把pre-commit纳入版本控制(如下面的config/pre-commit),再使用构建工具的扩展机制来自动完成拷贝工作,这样可以间接实现git hooks的团队间共享。

# build.gradle

task installGitHooks(type: Copy) { //将pre-commit拷贝到指定位置
    from new File(rootProject.rootDir, 'config/pre-commit')
    into {
        new File(rootProject.rootDir, '.git/hooks')
    }
    fileMode 0755
}

build.dependsOn installGitHooks //设置执行build任务时会自动触发installGitHooks任务

关闭包围圈,编辑时反馈

(实时反馈)

之前基于构建工具的方案都很好,但是对于开发者来说,最好能将反馈前移到编辑时,并且可视化。所幸的是,Checkstyle的生态系统非常成熟,各主流IDE都有插件支持,以Intellij Idea为例,可以使用checkstyle-idea插件,让团队成员手工设置插件,使用项目的checkstyle配置文件即可(我目前还没有找到自动化配置的方式,或许gradle idea插件可以?)

(checkstyle-idea插件配置和效果)

有了自动实时检查,最好还能将IDE的自动格式化与Checkstyle配置文件挂钩,否则自动格式化反倒给你添麻烦了。

(为IDE导入checkstyle配置文件作为自动格式化的依据)

如果你连自动格式化都懒得按,那可以试试Save Actions插件,它可以在Intellij保存文件时自动执行代码格式化等动作。

(这个插件目前对部分文件有些问题,可以通过File path exclusion忽略)

总结

  1. Code Style影响工作效率,团队应将其当做工程实践予以重视。
  2. Code Style不能靠人工监督和检查,应该提供端到端的工具支持
    • 服务端检查(推荐集成到CI的构建步骤中)
    • 开发环境检查(使用各构建工具的Checkstyle插件)
    • 自动提交检查(git pre-commit hook与共享)
    • IDE增强(checkstyle插件实时可视化反馈/自动的自动格式化!)
    • 以上的工具都要依据为同一份Checkstyle配置文件,并纳入版本控制

希望以上这些招数可以解救Java Code Style强迫症 :)


更多精彩洞见,请关注微信公众号:思特沃克

Share

GoCD的正确打开方式

事件:ThoughtWorks在2016年11月发布的技术雷达中将“Jenkins as a deployment pipeline”列为了“暂缓”。

Jenkins以“持续集成”闻名,进入持续交付时代后,常被人们用来尝试搭建deployment pipeline,我以前也乐此不疲。遗憾的是Jenkins的设计是以单个job为核心,deployment pipeline的实现需要靠官方或社区插件来支持,看上去都能实现,但在实际使用中总让我觉得差了点什么。最近的一则广告可以完美表达这种体验:

(图片来自:http://t.cn/RX7EvfT)

不拼凑,纯pipeline?

ThoughtWorks坦言将“Jenkins as a deployment pipeline”列为了“暂缓”是相当冒险的,因为ThoughtWorks在此领域有一款竞争产品:GoCD。GoCD曾是一款商业产品,现已追随大潮进行了开源,身边一些朋友尝试之后褒贬不一,有人反馈它忠实还原了《持续交付》中提到的pipeline,也有人反馈较难上手。我想结合技术雷达分享一些GoCD的使用经验,希望对正在尝试GoCD的同学有所帮助。

建议一 :如果你不需要deployment pipeline,不要使用GoCD

用惯jenkins job做持续集成的同学,往往一上来就被GoCD的配置界面搞得晕头转向:

“我只是想运行一下mvn clean package怎么有这么多东西要设置,stage、job、task都是什么鬼?”

(信息量好大)

GoCD在设计之初就将deployment pipeline作为“一等公民”,实现复杂交付流程是其强项,但如果你需要的只是持续集成,就有点杀鸡用牛刀了。因此,重要的话说三遍:

  • 如果你不需要deployment pipeline,不要使用GoCD。
  • 如果你不需要deployment pipeline,不要使用GoCD。
  • 如果你不需要deployment pipeline,不要使用GoCD。

如果看到这里,你还没有关闭页面,那让我们来看一下GoCD的pipeline元素吧。

P代表pipeline,S代表stage,J代表job,T代表task

Pipeline可由若干个stage组成,stage之间可以设置依赖关系,默认上游stage失败的时候不会触发下游stage。stage可由多个job组成,但多个job一般用在并行任务的用例中(例如并行构建多个模块),它们之间是没有依赖关系的,所以如果你希望某个stage执行一系列有依赖关系的动作,应该使用单个job并为其设置多个task,而不是多个job。这里比较容易产生误会的是job,因为它和jenkins job同名。一个典型的pipeline可能会按如下设计:

每个stage代表一个阶段,build&test负责构建和单元测试,Int_Deploy负责自动化端到端测试,UAT_Deploy负责手工测试,Prd_Deploy则负责部署生产环境,每个stage最简可由一个job组成,job中的task依次完成自动化任务。

Pipeline、stage、job、task使得GoCD可以组合串行、并行执行,实现复杂、精巧的工作流。但就像硬币的另一面,这些概念也提高了入门门槛,再加上原有的UI交互设计得比较繁琐,往往需要来回地切换编辑页面才能完成整个pipeline的设置,也难怪用户抱怨了。

改善

不过好消息是,“Have a better getting-started experience”已列入GoCD roadmap,一个全新的”quick edit”功能也已经发布(需要16.9.0以上)。

全新的”quick edit”功能,让你可以在单个页面即完成pipeline的配置,操作更简便。 而对于熟手来说,本文后面提到的“实现pipeline as code”更合胃口。

建议二:必备插件——script-executor-task

曾经有朋友向我吐槽GoCD的task太难用了,每个task只能执行一条命令,导致每个job都有十几个task。

(琐碎的tasks,使用前)

其实,你需要的是一款叫做“script-executor-task”的GoCD插件。是的,你没有看错,GoCD也是有插件的!有了这款插件后,你就可以像shell脚本一样编排指令,从而愉快地合并臃肿的task了。

(使用后)

不可矫枉过正

值得一提的是,这个插件的初衷是简化task中命令的书写和排序,而不提倡滥用它编排大量琐碎的指令。不管是用jenkins还是GoCD,最佳实践是将指令放到脚本文件中,并纳入代码版本仓库(SCM)。可以签出的脚本方便团队所有人查看,更改也有迹可循,便于协作;另一方面脚本与工具的耦合也最小(往往就是一行命令),我们将在“实现Pipeline as Code”一节中继续讨论这个话题。

建议三:你使用Artifact Repository了吗?

Pipeline的各个环节本质上是在验证构建出的artifact(以下翻译为二进制包)是否符合质量标准,这就要求pipeline能够正确识别和传递artifact。“只生成一次二进制包”是pipeline设计中的一条重要原则,下游步骤应该重用上游步骤生成的二进制包。 相比每次从源代码构建二进制包,这节约了宝贵的反馈时间,更重要的是它实现了“你所测试的二进制包就是将要发布的二进制包”的配置管理需求。

Gocd对此提供内建支持:publish artifacts和fetch artifact task(相比jenkins需要copy artifact plugin并且需要细心选择上游job,详见基于Jenkins实现的部署流水线中共享二进制包。)

(上游构建stage将artifact到gocd自带的artefact repository)

(下游部署stage从构建stage抓取artifact)

一个容易出现误解的地方是,在没有使用publish/fetch artifact功能的情况下,试图在同一个pipeline的stage间共享artifacts,这很可能造成artifact传递错误,严重的时候可能造成向生产环境发布未经测试的二进制包。

如上例中,分别在commit-stage和acceptance-stage中取消publish/fetch build/version,只要这两个stage都分配在同一个go-agent上执行,也不会报错。假设现在pipeline build number为892,运行pipeline build number为890的acceptance-stage也会取得一份build/version文件,但这份文件的来源是该go-agent上最近一次commit-stage运行后生成的(很可能是由pipeline build number892),未必是pipeline build number为890的commit-stage生成的,这样就出现了artifact版本错位。

(隐蔽的artifact版本错误)

重视artifact repository并且正确实现artifact共享是一条合格deployment pipeline的重要标志,只有这样artifact的来源才能够回溯,才能检查它是否符合了发布的标准,才有信心真正实现一键发布到生产环境。

应用publish/fetch artifact是生成正确Value Stream Map的前提,通过Value Stream Map可以直观地观测artifact经历的质量检查步骤和结果,作为是否发布此artifact的前置条件。

专用artifact repository

最后多嘴一句,虽然Gocd提供了内置的artifact repository,但我强烈推荐使用专用的artifact repository产品(例如java常见的sonatype nexus或者私有Docker Registry)。这些产品往往有更好的GUI和周边工具支持,可以帮助你更好地管理artifacts。在这种方案中,我建议使用Gocd的artifact repository来作为publish/fetch artifact的唯一标识符(通常以文件形式),在各pipeline及其stage之间共享这个唯一标识符,而artifact本身的publish/fetch则交给专用的artifact repository,并通过唯一标识符来识别。

(专用artifact repository方案)

建议四:实现Pipeline as Code

严格来说,不管是GoCD还是jenkins,早就可以通过编辑config文件或使用API来实现pipeline as code了,但它们都不易使用。前者因为config文件掌管着全局配置,粒度太粗,实际上只可能由专人维护,成为瓶颈。后者则往往需要开发客户端程序。随着infrastructure as code概念的流行,开发团队希望更灵活且更可靠地管理自己的pipeline(如果你用过Travis CI,会对这种方式很熟悉)。从16.7开始,GoCD提供了更友好的pipeline as code支持,可以通过yaml或json定义pipeline,并将配置文件放到SCM(git或其他)中,GoCD会自动获取定义文件并生成pipeline。

(GoCD可以兼容手工配置和文件配置,所以你可以在部分pipeline上尝试这种技术)

configuration deployment

那么pipeline定义文件是放在应用源代码仓库还是单独放在独立代码仓库呢?我的建议是:都可以。但是如果deployment-pipeline含有部署环节,且部署不同环境需要不同的环境变量时,我建议把流水线本身拆开:

  1. 构建环节作为一条单独的pipeline,这条pipeline由自动触发的stage组成,目标是构建artifact,如果有条件的话还进行一些端到端的自动化验证。这条pipeline的定义文件可以和项目源码仓库放在一起,因为pipeline的改变常常也影响了artifact本身的构建,它们的变化节奏应该是一致的。
  2. 部署环节作为一条(或多条,视环境数量决定)单独的pipeline,这条pipeline由fetch artifact开头,其定义文件可以和部署脚本及环境变量放在一起,它们的变化节奏应该是一致的。与负责构建的pipeline分开的原因是,当你想为QA环境部署一次配置变更时(如果你使用了特性开关,这种情况很常见),往往并不希望等待pipeline重新再构建一次artifact。

(部署pipeline与构建pipeline分离,可以实现 configuration deployment,单独部署环境变量变更)

期待:缺乏统计报表类功能/插件

deployment-pipeline不是设计出来的,而是演化来的。

deployment-pipeline的初衷是希望能够通过自动化和可视化来消除交付活动中的瓶颈,但如果不精心维护,pipeline自身可能也会出现瓶颈。例如随着自动化测试用例逐渐增多,反馈周期也会随之变长,这时需要重构pipeline以便消除瓶颈,但如何重构,重构的效果是需要用数据来度量的。jenkins有一些插件可以统计job的平均执行时间,job失败后的平均恢复时间等指标,可以用来指导团队重构pipeline。遗憾的是,GoCD对此没有内建的功能支持,而plugin还不够丰富,暂时存在空白。让我们期待官方和社区在这方面有所作为吧。

写在最后

感谢你耐心看完本文,最后把重要的话再说三遍:

如果你不需要deployment pipeline,不要使用Gocd。

如果你不需要deployment pipeline,不要使用Gocd。

如果你不需要deployment pipeline,不要使用Gocd。

但是这年头,应用软件交付怎么会不需要deployment pipeline呢?


更多精彩洞见,请关注微信公众号:思特沃克

Share