文章目录
移动互联网时代,OIDC变得越来越流行,如果你在使用Android,AWS,Microsoft Azure,Salesforce或Google,那么你很可能已经在使用OIDC协议和JWT令牌。本文是深入理解JSON Web Token系列第一部分,追本溯源,JWT是移动互联网时代手机app在身份验证 (Authentication)和授权 (Authorization) 中使用最广泛的令牌规范,而在JWT被发明前,SAML Assertion令牌曾一度风靡Web浏览器主导的时代。为了解JWT的历史,本文以身份验证中,如何避免对用户重复进行验证为线索,依次介绍了服务端Session,服务端Session的改进方法,SAML协议以及现在使用最广泛的的OIDC协议。其中OIDC协议使用了本文的主角JWT。
在日常生活中,有许多需要身份验证的场景。比如进入公司的大楼时,需要携带工牌;打卡上班时,需要指纹识别;打开工作电脑时,需要输入密码。
身份验证(Authentication)的目的是确认当前所声称为某种身份的用户,确实是所声称的用户。在计算机、通信等领域,我们一般有三种方法来确认用户的身份(Authenticate),基于用户知道的东西,基于用户拥有的东西,以及基于用户的生物特征的方法
Web服务一般通过前两种方式来验证用户的身份。最常见的就是通过用户名和密码来确认用户的身份,确认过身份的用户称为为授权用户(Authenticated user)。
比如下图所示的例子,Web服务器通过HTTP协议向授权用户提供对于资源A的访问。Web服务器是向用户提供Web服务的机器。在这里,你可以想象下图中的Web服务器是一个视频网站的后端,资源A可以是注册用户才能观看的视频,客户端可以是手机app或者是浏览器。
在这里,用户通过客户端向Web服务器提供用户名和密码,Web服务器则在身份验证数据库中检查用户的真实性(authenticity)。这里对Web服务器的逻辑有很多省略,比如,用户注册后,身份验证数据库就保存了用户的用户名和密码,所以,验证用户名和密码就是在身份验证数据库中找到该用户,并且检查用户提供的密码是否与数据库中的密码匹配。
如果同一个用户想要访问资源B,那么就如同访问资源A一样。用户需要通过客户端再次向Web服务器提供用户名和密码。你可能也发现了,这里的用户体验是很不好的,因为用户每访问一个资源,就需要再次输入用户名和密码。没有一个现代Web服务会要求用户这么做。
早期的Web服务器会通过保存用户Sessions (会话信息) [1]的方式避免用户重复输入用户名密码。在用户第一次向Web服务器请求资源时,Web服务器在身份验证数据库中检查用户的真实性,如果用户身份验证成功,Web服务器则生成一个键值对(Key-Value Pair)保存在Web服务器的内存中。其中键为Session ID(会话ID),值为与身份验证有关的信息,比如用户名,身份有效期等。接下来,Web服务器把资源A和Session ID一并返回给客户端,客户端会替用户保存Session ID。
当用户想要访问资源B时,客户端会把Session ID直接发送给Web服务器,不需要用户再次输入用户名和密码。Web服务器收到请求后,在它的内存中验证与Session ID对应的Session,如果验证成功,则返回资源B。为了安全起见,Session ID一般都存在有效期,在有效期内,用户访问Web服务器上的资源都不需要再次输入用户名和密。
服务端Sessions曾一度是避免重复身份验证的方法,直到使用Web服务的用户越来越多,多到一个Web服务器已经无法满足用户请求了。为了满足日益增长的用户请求,一个常见的作法是对Web服务的架构进行水平拓展 (Horizontal Scaling) [2]。然而在水平拓展后,服务端Sessions避免重复身份验证的方法就失效了。
Web服务的架构进行水平拓展后,客户端不再直接与Web服务器进行交流。取而代之是负载均衡服务器 (Load Balancer) [3],负载均衡服务器就像是包工头,它把来自客户端的请求路由给不同的Web服务器。如下图所示,客户端通过负载均衡服务器访问Web服务器X上的资源,服务器X保存了用户的Session。当客户再次访问服务器X上的资源时,服由于服务器X保存了用户的Session,用户的身份验证成功。如果客户端访问了服务器Y,由于服务器Y没有保存用户的Session,用户的身份验证失败。所以服务端Sessions的方法在服务器水平拓展的情况下是有缺陷的。
服务端Sessions方法在水平拓展的架构下是有缺陷的,有没有什么办法可以改进该方法,使其在水平拓展的架构下能够继续适用呢?方法是有的,下面便依次介绍三种方法,需要注意的是,下面三种方法也有各自的缺陷。
负载均衡服务器一般不会保证同一用户的请求会被路由到同一个服务器,这是造成服务端Sessions方法失效的直接原因。如果对负载均衡服务器加以改造,让同一用户的请求路由到同一个服务器,那么服务端Sessions方法就可以继续使用下去了。一般负载均衡服务器可以通过客户端Cookie,或者客户端IP来标识和追踪每位用户,为不同用户分配标识ID。根据标识ID,负载均衡服务器可以把某用户的所有请求路由到特定服务器上。这种方法叫做粘性会话 (Sticky Session),形象地说就是会话粘到了特定的服务器上。
负载均衡服务器的主要目的是均衡不同服务器上的负载,所以当新的请求到来时,一般要把新请求路由到负载最轻的服务器上,以便让不同的服务器有差不多负载。而采用了粘性会话的负载均衡服务器,由于要保证来自相同的用户的请求被路由到特定的服务器,会导致不同服务器上的负载不均衡。造成有些Web服务器负载过重,而有些Web服务器负载过轻的问题。
之前的方法需要把特定的用户的Session保存在特定的服务器上,这样用户需要访问特定服务器才能取得之前的Session。有没有什么方法能放宽这种限制,让用户不需要访问特定的服务器呢?
一种按照这种思路解决问题的方法是内存同步 (Memory Replication),当Web服务器X生成用户Session时,不仅在自己的内存中保存一份,同时也向服务器Y发送请求,让服务器Y也保存一份用户的Session。这样不论用户访问哪个服务器,都可以通过Session ID取得相应的Session。
然而在不同的服务器上进行内存同步不是一件简单的事情,这里会涉及到分布式系统中的许多问题。比如,用户在服务器Y还没来的及保存Session时就访问了服务器Y,那么服务器Y会要求用户输入用户名和密码,生成该用户的Session并请求服务器X也保存该信息,那么服务器X上的Session还没来及使用就已经作废了。
不在特定Web服务器上保存Session,这样用户就不需要访问特定的Web服务器来获取上面存放的Session了。另外一种按照这种思路解决问题的方法是使用数据库,这时Web服务器不在自己的内存中保存Session,而是在一个所有服务器都可以存取的数据库中保存Session。通常,为了减少时延,会采用下图所示的内存数据库 (In-memory database)。因为,内存数据库的数据完全存放在内存中,相比传统数据库有更高的存取速度。
如下图所示,不论用户的请求是被负载均衡服务器路由到了哪个Web服务器,用户的Session都可以在内存数据库中根据Session ID找到。
内存数据库虽然可以解决内存同步的问题,但是额外的数据库会带来架构复杂性的提升:内存数据库一旦失效,整个系统的会话就没法运作,这是典型的单点故障 (Single ponit of failure) [4],会严重影响Web服务的可用性 (Availability) [5]。为了去除单点故障,我们可以把内存数据库放到另外一个负载均衡服务器后面,并增加一个内存数据库的热备份,但是,这样的架构太复杂了。此外,额外的数据库会带来额外的时间开销。即便内存数据库的存取速度很快,但是因为有额外的网络时延,内存数据库还是要慢上许多。
服务端Session的弊端让人们思考避免重复身份验证的其他方法。大约在2001年,为了解决网页浏览器SSO (单点登录) [6] 的需求,SAML (安全主张标记语言) [7] 被发明出来。SSO是一个比较常见的需求,比如手机登陆微信账号后,访问CSDN,图灵教育,InfoQ等网站时可以选择微信免密码登陆,这就是一种SSO。
SAML规范定义了三个角色:委托人(通常为一名用户)、身份提供者(IdP),服务提供者(SP)。
如下图,在用SAML解决的使用案例中,身份验证流程如下
SAML是一种**基于令牌的身份验证 (Token based Authentication)和授权 (Token based Authorization)**的协议。其协议背后的一般概念可以总结如下。 用户向IdP输入他们的用户名和密码,以获得一个令牌,该令牌允许他们获取特定资源时无需使用用户名和密码。 一旦获得了令牌,用户就可以向SP提供令牌并在一段时间内可以访问特定资源。
虽然SAML是为了解决SSO需求而发明的,但是它连带着解决了用户身份重复验证的问题。作为对比,服务端Session方法需要在服务端保存用户的身份信息,也就是说,服务器需要保存用户的状态。在有多个服务器的情况下,服务器之间需要同步用户的状态,实现起来不简单而且易出错;而基于令牌的方法不需要在服务端保存用户的身份信息,也就是说,服务器只需要验证令牌的有效性就可以了。验证令牌涉及到对称以及非对称加密算法,在单个或者多个服务器的情况下,验证令牌的算法没有区别。
SAML被发明的时代Web浏览器占据主导地位,SOAP [8]和XML分别是Web协议和Web数据交换格式的代表。所以SAML协议与SOAP和XML有着紧密地联系。SAML通过SOAP在用户,IdP和SP间使用XML传输信息,并且SAML定义的令牌,SAML Assertion,就使用了XML格式。
屏幕截图中的网站是 https://samltool.io/,展示了SAML定义的令牌 SAML Assertion
屏幕截图中的网站是 https://jwt.io/,展示了具有JSON格式的令牌JWT
然而随着移动互联网时代到来,SOAP和XML分别被REST [9]和JSON代替,这时与SOAP和XML紧密联系的SAML协议的弊端也逐渐显现。其中最重要是基于XML格式的令牌体积巨大,定义复杂,不易在移动互联网中传输和使用。因为这些缺点,SAML定义的令牌,SAML Assertion近年来又被JSON的令牌 JWT (JSON Web Token) RFC-7519 所代替的趋势。
XML格式的令牌不易传输是相较于JSON格式的令牌而言的。首先XML格式冗长,不仅包含数据,而且包含数据定义,虽然具有自解释性,但是相比于只包含数据的JSON格式,XML格式保存令牌的体积比JSON大。
XML格式的令牌不易使用是相较于JSON格式的令牌而言的。JSON是JavaScript编程语言的一等公民,JavaScript可以直接操作JSON格式的数据。所以JavaScript应用之间几乎都是用JSON进行数据交换,比如REST,JSON-和GraphQL。虽然JavaScript也可以使用XML,但是相比JSON,使用XML的过程更复杂,比如需要解析和序列化的工具。
因为SAML Assertion的缺陷,不仅是SAML Assertion,SAML本身也岌岌可危。诞生于2014年的OIDC (Open ID Connnect) 协议在设计时主要考虑了移动互联网和手机app的需求。所以OIDC设计的主要原则是易于传输和使用。OIDC的用户授权 (Auhorization) 基于OAuth2,并在OAuth2的基础上提供了用户验证 (Authentication) ,成为集用户验证和用户授权于一体的协议,就和SAML一样。于SAML不同的是,OIDC在令牌上,不仅支持 SAML Assertion,更是支持具有JSON格式的JWT。有了JWT加持,OIDC协议比SAML更易于传输和使用。
由于OIDC协议支持REST和JSON之类的现代技术,移动互联网时代的Web应用以及手机app可以更方便的实现OIDC协议。OIDC协议非常适合与SPA (Single-Page Application) 和手机app和一起使用,而在这些应用程序中,使用SAML会很困难。所以在移动互联网时代,OIDC变得越来越流行,如果你在使用Android,AWS,Microsoft Azure,Salesforce或Google,那么你很可能已经在使用OIDC协议和JWT令牌 [10]。
SAML为解决了企业SSO的需求而诞生,虽然仍然被广泛地应用于企业身份验证中 [11],但是由于其协议过于学术化(复杂),而且支持它library太少了。所以现在也有被OICD协议的替代的趋势。
因为JWT的体积很小,可以在HTTP协议中作为URL的一部分被传输,如下图所示
身份验证的目的是确认用户的真实性 (Authenticity),验证用户 (Authenticate) 的方法可以大致归为以下三种,基于用户知道的东西,基于用户拥有的东西,基于用户的生物特征
https://stackoverflow.com/questions/6922145/what-is-the-difference-between-server-side-cookie-and-client-side-cookie
客户端Seesion https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Cookies
以下内容节选自 https://security.stackexchange.com/questions/82587/what-are-the-differences-between-json-web-tokens-saml-and-oauth-2
1 | +----------+----------------+-------------------------------+ |
以下内容节选自 https://tools.ietf.org/html/rfc7519#appendix-B
1 | Appendix B. Relationship of JWTs to SAML Assertions |
以下内容节选自 https://tools.ietf.org/html/rfc7519#appendix-C
1 | Appendix C. Relationship of JWTs to Simple Web Tokens (SWTs) |
Sessions, 也被称为服务器端Cookie (Server side cookies)。Sessions被保存在服务器上,客户端,也即浏览器仅Session ID。Session ID是Session的唯一标识 ,服务器用Session ID把客户端的请求与存储在服务器上的Sessions进行匹配。Sessions一般与用户的身份验证有关,有时也会包含用户偏好设置有关的信息,比如用户的语言偏好。于服务器端Cookie相对的是客户端Cookie,客户端Cookie也成为HTTP Cookie。服务端Cookie于客户端Cookie’的对比请参看 https://stackoverflow.com/questions/6922145/what-is-the-difference-between-server-side-cookie-and-client-side-cookie ↩︎
水平拓展,通过增加廉价服务器的方式增加Web服务器能够承担的负载,使其能够服务更多的用户,比如在1个Web服务器不能满足用户需求时,用2个Web服务器一起满足用户的需求。与之相对的是垂直拓展 (Vertical Scaling),通过增加已有服务器的配置来Web服务器能够承担的负载,比如在1个Web服务器不能满足用户需求时,用一个配置更高的Web服务器,比如CPU更快,内存更大的Web服务器替代原来的服务器。 ↩︎
负载均衡服务器,简称负载均衡器。是一种特殊的服务器,主要功能是负载分配给服务器。负载均衡器的类型很多,有的工作在OSI网络模型的传输层,具有很高的吞吐量。有的工作在应用层,可以根据HTTP的请求更智能的分配请求,比如把某个用户的请求总是分配给一个特定的服务器。 ↩︎
单点故障,是指系统中一旦失效,就会让整个系统无法运作的部件。https://en.wikipedia.org/wiki/Single_point_of_failure ↩︎
可用性:Availability。简单的说,可用性就是一个系统处在可工作状态的时间的比例。例如,服务A在一年时间里(8760小时)有8751小时可用。其可用性则为8751/8760 = 0.999,或以百分比表示99.9%。详情参见 https://en.wikipedia.org/wiki/Availability ↩︎
SSO,Single sign-on的缩写。又译为单一签入,一种对于许多相互关连,但是又是各自独立的软件系统,提供访问控制的属性。当拥有这项属性时,当用户登录时,就可以获取所有系统的访问权限,不用对每个单一系统都逐一登录。SSO的需求最先出现在对企业内网中不同服务的访问,这种功能通常是以轻型目录访问协议(LDAP)来实现,在服务器上会将用户信息存储到LDAP数据库中。后来这种需求广拓展到了对互联网上不同服务的访问,主要目的是让用户只需一个账号即可访问所有互相信任的应用系统。详情参见 https://en.wikipedia.org/wiki/Single_sign-on ↩︎
SAML,Security Assertion Markup Language的缩写。是一个基于XML的开源标准数据格式,它在当事方之间交换**身份验证 (Authentication)和授权 (Auhorization)**数据,尤其是在身份提供者 (IdP) 和服务提供者 (SP)之间交换。SAML是OASIS安全服务技术委员会的一个产品,始于2001年。详情参见 https://en.wikipedia.org/wiki/Security_Assertion_Markup_Language ↩︎
SOAP(原为Simple Object Access Protocol的首字母缩写,即简单对象访问协议)是交换数据的一种协议规范,使用在计算机网络Web服务(web service)中,交换带结构的信息。SOAP为了简化网页服务器(Web Server)从XML数据库中提取数据时,节省去格式化页面时间,以及不同应用程序之间按照HTTP通信协议,遵从XML格式执行资料互换,使其抽象于语言实现、平台和硬件。详情参见 https://zh.wikipedia.org/wiki/简单对象访问协议 ↩︎
表现层状态转换(英语:Representational State Transfer,缩写:REST)是Roy Thomas Fielding博士于2000年在他的博士论文[1]中提出来的一种万维网软件架构风格,目的是便于不同软件/程序在网络(例如互联网)中互相传递信息。表现层状态转换是根基于超文本传输协议(HTTP)之上而确定的一组约束和属性,是一种设计提供万维网络服务的软件构建风格。详情参见 https://zh.wikipedia.org/wiki/表现层状态转换 ↩︎
这句话翻译自"If you use Android, AWS, Microsoft Azure, Salesforce, or Google then chances are that you are already using JWT.",来源是 https://auth0.com/blog/jwt-json-webtoken-logo/ ↩︎
这一段话的来源参见 Application and Use Cases of OIDC and SAML https://auth0.com/intro-to-iam/saml-vs-openid-connect-oidc/ ↩︎
文章目录
亚马逊作为一家科技公司,在软件工程上有许多值得借鉴学习的地方。其中COE作为质量管理的重要一环,十分独特。本文浅略介绍COE的实践方法,所有参考文献均来自互联网公开资料。
COE属于软件工程领域中,质量管理流程中的错误管理。所以本文的目标读者是具有一定软件工程背景的软件开发工程师,软件工程管理人员,项目管理人员以及信息技术行业的从业者。
读完本文你将对COE流程有个大致的了解,理解为什么需要它,什么时候使用它,以及使用COE流程的好处。
COE[1]是一种通过记录和处理问题来提升软件工程质量的流程[2]。所谓记录问题就是通过标准化的文档来记录造成问题的根本原因,这一部分也叫根本原因分析[3];所谓处理问题,就是通过标准化的流程来逐一消除造成问题的根本原因,顺藤摸瓜,釜底抽薪,从根本上解决问题。
需要指出的是,COE即是一种流程,也是一种文档的类型。在没有特指的情况下,需要根据上下文来判断其具体含义。
COE一般用来对错误进行书面分析,然后留档保存。对于公司业务影响较小的问题,根本原因分析起来一般来说比较简单,不需要书面分析。即便根本原因分析起来复杂,因为对业务影响小,分析也没什么价值。而对公司业务影响较大的问题,则需要书面分析。书面分析至少有以下两个好处:
如何定义业务影响的大小?
业务影响的定义对于不同行业的公司,对于不同大小的公司都是不同的。比如对于一家提供视频网站的公司,如果有10%的用户无法观看视频的插片广告,那么这是一个对业务影响较大的问题。如果有1个用户无法观看视频的插片广告,那么这是一个对业务影响较小的问题。此时可以根据公司的体量,设计双重指标,如果有超过x%的用户,或者超过y个用户无法观看视频的插片广告,那么这就是一个对对业务影响较大的问题。
COE不仅对根本原因分析进行书面记录,同时以规范的格式进行书面记录。规范的格式也有至少有以下两个好处:
便于阅读,和提取关键信息:想象一下,如果你的直属领导要求你从100篇错误分析文档中找出一共有多少文档是部门A发布的,一种情况是100篇文档格式不尽相同,发布部门信息散落各数,另一种情况是格式一致,发布部门信息都在前言里。必然是格式一致的文档能方便你提取你需要的关键信息。
便于自动化提取关键指标:文档规范化后,可以交由软件自动读取,分析出关键指标。比如从1000篇COE中发现123篇的根本原因都是X,那么管理层可以得知解决X的重要性,并且可以用这些指标来进行科学决策。
其实根本原因分析普遍存在于软件工程的生产实践中,根据问题的影响范围,软件工程师会采用合适的方法来分析问题,以在电商工作的张三为例
No | 问题类型 | 问题造成的后果 | 问题的影响范围 | 问题对公司造成的损失 | 如何应对问题 |
---|---|---|---|---|---|
1 | 张三电脑系统出现问题,没法开机 | 张三工作进度收到影响 | 张三一个人 | 微小 | 当天找IT部门修复电脑 |
2 | 张三的公司内部聊天系统出现问题,员工没办法新增好友 | 使用该内部聊天软件的员工没法加新的好友 | 公司内部员工 | 有一定影响 | 在几天的时间内紧急修复问题,或者回滚造成错误的代码 |
3 | 张三的公司网站用户购物车出现问题,没法把商品放入购物车 | 用户没法购物 | 100%的用户 | 重大影响,生意没法做了 | 在几分钟的时间内紧急修复问题,或者回滚造成错误的代码 |
对于第一个例子,问题的影响的范围小,解决问题的方法也直截了当。根本原因分析心算即可完成,此时没有必要进行书面的根本原因分析。
对于第二个例子,问题有一定的影响范围,而且不注意的话,错误有可能再次发生,为了避免类似的错误,应该进行书面的根本原因分析,记录问题的根本原因,并且在紧急修复问题后,应该提出避免类似错误的措施,并且和相关团队讨论,审核,施行该措施。
对于第三个例子,问题的影响范围重大,不但要进行书面的根本原因分析。错误分析还要印发给各个部门,组织全公司的各个团队从错误中学习经验。
对于第二个和第三个例子,COE可以标准化根本原因分析的流程,帮助张三快速,准确地分析出问题的根本原因。不仅如此,COE作为标准化的文档,可以帮助相关团队高效的审核,也可以帮助其他团队快速的提取出关键信息来学习。此外,由于文档使标准化的,张三的公司可以通过统计方法得到丰富的指标,比如有多少错误的根本原因是由于“证书过期”引起的,还有张三坐在部门一年的COE数量相比去年的变化,是多了还是少了。有了这些指标,项目经理和领导层可以根据数据来做出决策。
在软件系统错误发生时,一般由当事人起草COE。当事人可以是导致错误发生的软件开发工程师,可以是在代码审核过程中遗漏该错误的工程师,也可以是该软件系统的负责人。选择他们是因为他们对出错误的系统最为熟悉。在亚马逊的企业文化里,由某一个人导致的软件系统错误也会同样发生在其他任何人上,所以要改善流程而不是批评指责某个具体的人。
COE编写完后,相关团队会一起开诚布公的审核COE文档,不仅审核根本问题分析,而且要审核为了消除根本问题所制定的纠正措施[4]。有些纠正措施致力解决眼前的问题,比如紧急修复一个错误。有些纠正措施则目标长远,目的是改善相关流程。解决短期和长期的纠正措施所需要的时间不同,所以纠正措施的预计交付日期也不尽相同,审核也包括评估纠正措施的预计交付日期。比如预计的交付日期是否合理。
COE的终极目标是避免重蹈覆辙,而不是把错误归咎于某个人,因此对造成错误的软件开发工程师不会,也不应该有任何的责罚。COE不是软件开发工程师的"检讨书",而是软件开发工程师对抗来自项目经理不合理要求的“武器”,同时也是保护软件工程师的“防弹衣”。
在COE审核结束后,COE里的纠正措施通常比实现软件的某个新功能有更高的优先级,如果此时有项目经理提出与COE相悖的优先级排期,比如得把COE的纠正措施搁置一旁去实现某个新功能。此时软件开发工程师可以援引COE的纠正措施来质疑不合理的要求。
如果领导介入,得把COE的纠正措施搁置一旁。那么领导就需要承担COE中错误再次发生的后果。
一般来说,起草COE的作者是对出错误的系统最为熟悉的工程师,并不一定是直接导致问题发生的工程师。就算是直接导致问题的工程师,他或许会对造成问题心存内疚,但是写COE却不会。不仅如此,写COE也是工程师的某种“荣誉”。软件系统不可能不出错,世界上最稳定的服务也无法达到100%的可用性[5]。但是一篇一篇的COE,是亚马逊工程师们学习的源泉,成年累月地从错误中汲取经验,使得亚马逊服务的稳定性能向99.9%的小数点后再增加一位。
以下模板所有内容纯属杜撰,如有雷同,请勿对号入座。
简述错误,并配以时间线
2007年1月1日,时间下午14:00,公司A电商网站预计举行抽奖活动B。当日下午13:00。负责为抽奖活动提供技术支持的团队C的某软件工程师(注意不要出现姓名)发现一个会妨碍抽奖活动的bug,在巨大的时间压力下,修复该bug的代码未经测试便直接上线。导致公司网站的购物车功能从14:00瘫痪,直到14:30,该问题才由运营技术团队D发现并紧急回滚包含错误的代码。
错误对于用户,业务造成的影响,最好列举相关的指标
该错误导致抽奖活动B取消,此外,由于购物车功能收到影响,45分钟内,网站的所有用户均无法把商品放入购物车中。根据历史数据,此时用户数量为X,活动B预计增加Q%的客流量,购物车转化率为Y%,平均每单收入为W元人民币。这次事故对于公司A的收益影响是
1 | X*(1+Q/100)*Y*W 元 |
刨根问底,顺藤摸瓜,造成错误的最根本原因是什么
吃一堑长一智,从错误中学习到的宝贵经验
为了避免重蹈覆辙,都有那些短期或者长期的纠正措施
对外公开的COE其格式有所变化,其侧重点在于错误分析,但是也可以看到具体的纠正措施
COE:Correction Of Error,也即纠正错误。 ↩︎
流程:软件工程流程的简称。流程,就是为了完成某一目标而进行的一系列,有序的步骤。就如同生活中,为了做菜这个目标,流程可以是就是先买菜,然后洗菜,最后炒菜。 ↩︎
根本原因分析:Root Cause Analysis。顾名思义,沿着因果关系组成的链条,找到问题的根本原因。来源自管理学概念,详情参见https://en.wikipedia.org/wiki/Root_cause_analysis ↩︎
纠正措施: Corrective Action的直译。 ↩︎
可用性:Availability。简单的说,可用性就是一个系统处在可工作状态的时间的比例。例如,服务A在一年时间里(8760小时)有8751小时可用。其可用性则为8751/8760 = 0.999,或以百分比表示99.9%。详情参见https://en.wikipedia.org/wiki/Availability ↩︎
目录
JVM(Java Virtual Machine) 既可以指Java虚拟机规范,也可以指具体的某个实现。当JVM指Java虚拟机规范时,我们要注意虚拟机规范的版本。比如是JVM 1.8 还是 JVM 1.9。Java有很好的向后兼容性,使用高版本的Java编译器也可以顺利编译针对低版本JVM开发的代码。也正是由于Java的向后兼容,很多过时的语言规范成了新的语言规范的包袱,很多过时的标准类库依然遗留在新版本的JDK中。
过时的java.util.Date包
在很大程度上,java.time.*已经取代java.util.Date包。
类型擦除
泛型只在编译时存在,运行时泛型信息会被清除。Java的向后兼容性允许我们初始化类似ArrayList
的原始类型(Raw type),但是通常不建议这么做。
const和goto两个关键字被保留
但并未实现。
基本类型的封装类
比如int和java.lang.Integer,boolean和java.lang.Boolean,等等。
过时的集合实现
例如Hashtable和Vector诞生于Java 2 plateform时代(Java 1.2)之前,所以Hashtable都没有实现Map接口,Vector也没有实现List接口。在Java 1.2发布时,这些老旧集合都被改进以适应新的集合接口规范。但是使用它们的场景也跟着改变了。如果不需要线程安全,建议使用HashMap和ArrayList,如果需要高并发,建议从ava.util.concurrent包中找到合适的实现。
当JVM指某个实现时,可不要被它的名字欺骗(Java Virtual Machine)。它并不是指能够运行Java代码的虚拟机,而是指可以运行Java字节码(Java bytecode)的虚拟机。在Java编程语言设计之初,其设计者就考虑到了跨平台特性。所以Java语言不直接编译为在具体操作系统上运行的机器码,而是编译为Java字节码,从而与具体的操作系统解耦。Java字节码则可以在不同操作系统上被某个JVM实现解释运行。
由于Java语言相比于其他语言,比如C语言多了JVM这一层抽象。Java语言编译器的实现不需要依赖于特定操作系统。这就好比是OSI模型 的7层网络协议一样,抽象能帮助我们专注于真正重要的事情。比如网站的开发者不需要考虑用户的家里的的网卡,路由是什么规格的。甚至在一定程度上都不要关系用户使用的是什么操作系统或是浏览器。
Java语言的这种设计,使其在早期就可以在各种操作系统上运行。这也是为什么Java语言在早期有“一次编写,到处运行”(Write once, run everywhere)的口号。由于JVM的规范是免费且公开的,任何人也都可以根据JVM的规范来自己实现自己的语言。只要能编译成Java字节码,这种语言也能依赖JVM到处运行。实际上,这些语言还不少,比如可作为脚本语言的Groovy,整合面向对象和函数式编程的Scala,甚至IDE开发商Jetbrains也推出了Kotlin语言来推动IntelliJ IDEA的销售(当然由于Kotlin本身的优秀也使其获得了Google的大力支持)。
如果你平时在电脑上运行过Java应用程序(Java Application),那么你需要安装一个叫JRE的东西而不是JVM。JRE(Java Runtime Environment)是一个可以让电脑运行Java应用程序的软件。它的内部包括一个JVM的实现,一些标准的类别函数库(Bootstrap classes和Extension Classes)以及一些配置文件(Property Files)。
如果要开发Java应用程序,JRE是不够的。需要JDK(Java Development Kit),其中包括JRE和一些开发工具。最有名的JDK莫过于Oracle公司的Oracle JDK和开源的Open JDK。
Open JDK是Sun公司于2006年开始开发,2007年5月发布的。市面上的各种JVM实现几乎都是从Open JDK中的JVM演化而来,其中也包括Oracle JDK。因为Oracle公司在Open JDK发布后的两年,也就是2007年收购了Sun公司。所以现在Open JDK和Oracle JDK的开发实际上都是由Oracle公司主导的(当然,Open JDK作为开源项目,Red Hat,Azul Systems,IBM,Apple和SAP以及Java社区也都参与了开发)。
虽然JVM的实现很多,但是这些不同的JVM实现在功能上区别不大(几乎都是基于Open JDK的JVM实现)但是有些JVM可以对特定平台进行优化,以实现在特定平台上性能优化。此外,这些JVM实现的许可限制可能有所不同。比如Oracle 从Oracle JDK11开始,使用它的LTS(Long time support,长期支持版本)需要商业许可。这也是为什么一些企业为了避免高昂的Oracle税,自己开发实现JVM,比如Amazon的Java Corretto。
理解构建工具需要理解什么是构建周期,一般认为软件的构建周期由以下部分组成
# | 阶段 | 说明 |
---|---|---|
1 | 获取依赖 | 现代软件开发一般都不会从零开始写代码,所以通常软件开发中都会由很多依赖,在软件开始编译前,取得所有依赖包 |
2 | 编译代码 | 编译源代码,链接依赖,将计算机源代码编译成二进制码 |
3 | 测试 | 运行自动化测试,通常是单元测试 |
4 | 打包 | 将源代码按照可发布的格式打包(发布包),例如jar,war或者ear文件 |
5 | 安装 | 安装本地仓库可用的发布包,可作为本地其他软件项目的依赖 |
6 | 部署文件 | 将发布包上传到远端仓库(公司内部仓库或是Maven Central一样的公有仓库),可作为他人软件项目的依赖 |
现实世界的仓库是用来存放商品的,软件开发领域里的仓库是存放代码的。代码被整齐的归置在一个个软件包(Package)里。使用仓库里的软件包,让我们专注于业务逻辑,避免重复造轮子。在软件开发领域有很多仓库,比如有针对不同软件开发语言的
也有针对不同操作系统的
构建工具横向对比
构建工具 | 构建文件 | 插件 | 依赖管理 | 构建速度 |
---|---|---|---|---|
Ant | build.xml 强制标准不多,不依赖特定项目目录结构 | 不支持 | 不支持,需要第三方工具,比如Apache Ivy | 慢 |
Maven | pom.xml, 标准严格,依赖特定项目目录结构 | 原生支持 | 原生支持 | 慢 |
Gradle | build.gradle, Gradle DSL基于Groovy编程语言,有无限可能 | 原生支持 | 依靠现有的Maven和Ivy依赖体系 | 快 |
文章目录
今天中午我偶然路过公司二楼的厨房,听到同事们边吃午饭边争论着什么。我好奇的前去观摩,他们原来是在代码评审上有了分歧。
第一个同事说这个代码虽然能满足现在的需求,但是却不易于向某个他描述的方向拓展,他言之凿凿,认为我们的软件不久就会有他描述的需求。相比于被动等待,不如主动出击,现在就考虑将来的需求,多花些时间设计这个模块,使其变得易于拓展以适应将来的需求。
而另一位同事则不为所动,觉得YAGNI[1]。就算我们真的要满足第一个同事所描述的需求,但是根据他的判断那也是很长时间(至少一年)之后的事了。如果现在就把模块变得易于拓展,那么无疑会增加代码的抽象层次,本来一个类[2]就可以实现的功能,现在要使用好几个类。
第三位同事也加入了讨论。把软件设计的灵活以适应将来的变化是一件好事,但是这么做也是有代价的。灵活意味着这个模块需要更高的抽象层次,在新的需求到来前,这个模块都需要保持它本不需要的抽象层次。况且现在我们假设对于将来需求的预测是正确的,如果我们预测错了,那么现在的努力不就白费了吗?
第一位同事似乎没有被说服,另外两位同事也各执已见。午饭结束,大家各自回到工位上继续搬砖了。
虽然这次争论还没有结果,也还不知道谁对谁错。那么我们在实现某个模块时,究竟是只实现现有需求还是要考虑到将来的需求而将模块实现的灵活一点呢?
如果我们从1970年的Smalltalk语言开始算起,面向对象程序设计至今已至少经存在了50年了(如果从1960年的Simula 67语言开始算起,那么时间会更长)。我们现在遇到的各种问题,前人几乎都遇到过了。这也是为什么面向对象程序设计会有总结出如此多的模式(Pattern),甚至是名言警句(Maxim)。
“们在实现某个模块时,究竟是只实现现有需求还是要考虑到将来的需求而将模块实现的灵活一点呢?”
对于这个问题,如果问一位极限编程[3]的专家,他会说:
YAGNI,一个程序员不应该为软件增加不必要的功能。
对于这个问题,如果问一位SOLID主义者[4],他会说:
我们应该遵守开闭原则[5],软件中的对象(类,模块,函数等等)应该对于扩展是开放的,但是对于修改是封闭的。
对于这个问题,其实不久前Java大师Bruce Eckel恰巧在他的博客中写道
质疑抽象的合理性,在不使用教条的情况下如果不能解释抽象的合理性,就不要抽象[6]
但是不论是YAGNI还是SOLID,这些经验法则都是有适用条件的。在不同情况下,教条地套用这些法则还不如对这些法则一无所知。在1988年Bertrand Meyer提出开闭法则32年后的今天,我们真的了解什么是开闭原则吗?
软件中的对象(类,模块,函数等等)应该对于扩展是开放的,但是对于修改是封闭的。
开闭原则说了两件事
软件中的对象对于扩展应该是开放的
对象的行为可以被拓展,也就是说新的需求来临时,我们可以改变对象的行为以满足新的需求
软件中的对象对于修改应该是封闭的
实现原有需求的的源代码不应该被修改
难道这两句话不矛盾吗?如果我们不修改源代码怎么拓展对象原有的行为?难道有不写代码就满足需求的魔法吗?当然,不修改代码是不可能的。但是不修改原先的代码,而只在软件的某一处增加代码来拓展对象的行为确实有可能的。
抽象是解决问题的关键
Robert C. Martin 拿了C++写了一个关于在GUI打印形状的例子。比如我们现在有在GUI上打印方块和圆的需求,熟悉过程式编程人会实现类似于下面的程序。
1 | public class OpenClosedPrincipal { |
由于我拿了Java重写了Robert C. Martin 当年拿C++语言写的例子。所以上面有很多地方看起来都很奇怪,但是暴露的问题是一样的。假如不久之后我们有了新的需求,需要打印菱形,那么我们不可避免的需要修改DrawAllShapes
方法。但是假如我们利用接口进行抽象
1 | public class OpenClosedPrincipal { |
这里抽象出Shape
接口并定义draw()
方法,让不同的具体类去实现draw()
方法。这样当打印新的形状的需求出现时我们就不需要修改 DrawAllShapes
方法了。这样就符合了开闭原则
drawAllShapes()
没有改变但是就好像老鼠团大会,谁去给猫挂铃铛的故事一样。开闭法则听起来诱人,可是又有谁知道一个软件里哪个模块,类应该关闭,哪个模块,类应该开放呢?假如新的需求不是打印新的形状而是先打印圆形再打印方形呢?那么上面的例子就不再试用了。可能会有人说,那么我在实现 drawAllShapes()
就考虑到打印顺序的需求,再去要求每个形状实现 Comparable<Shape>
接口就好了。哈哈,,今天有打印顺序的需求,明天又你想象不到的需求呢?只可惜软件开发工程师并不是预言家,要是软件开放工程师能准确地预测客户的需求,那么现在软件工程领域也不会有那么多问题了。
这就是开闭原则的陷阱,它要求使用者对特定领域的知识有非比寻常的见解。但是没有个十年软件开放经验,又怎么能获得非比寻常的见解呢?
YAGANI:You aren’t gonna need it的缩写,直译为你不会需要它。详情参见马丁·福勒的博客https://martinfowler.com/bliki/Yagni.html ↩︎
类:class,此处特指面向对象编程语言中的类。 ↩︎
极限编程:Extreme Programming,简称XP。它是一种软件工程方法学,是敏捷软件开发的一种方式。如同其他敏捷方法学,极限编程和传统方法学的本质不同在于它更强调可适应性而不是可预测性。详情参见https://en.wikipedia.org/wiki/Extreme_programming ↩︎
SOLID主义者:那些把所谓软件工程中设计模式的六大原则奉为圭臬的人。 ↩︎
开闭原则:The Open-Closed Principle,软件工程中设计模式的六大原则之一。 ↩︎
质疑抽象的合理性,在不使用教条的情况下如果不能解释抽象的合理性,就不要抽象。原文是Question your abstractions. If you can’t justify it (without quoting a maxim), take it out. 详情参见https://www.bruceeckel.com/blog/2019-12-24-unspoken-assumptions-underlying-oo-design-maxims ↩︎
在Java里,如果我们创建了Integer
类和Number
类,并且Integer
是Number
的子类。那么List<Integer>
类也是List<Number>
类的子类吗?
我们可以写一段代码来找到这个问题的答案。现在我们有一个求List<Number>
类最大值的方法,它的输入是List<Number>
类的实例。若List<Integer>
类也是List<Number>
类的子类,那么下面的代码应该是合法的。
1 | public class InvariantExample { |
但是编译器告诉我们incompatible types
错误,这里max
方法期待一个List<Number>
类作为输入,但是实际上获得的是List<Integer>
类。这就告诉我们,即便Integer
是Number
的子类,编译器也认为List<Integer>
类和List<Number>
类是没有关系的。
结论:对于任意两个不同的类型Type1
和 Type2
,不论它们之间具有什么关系,List<Type1>
和 List<Type2>
都是没有关系的。
如果我们把上述结论推广,可以得到更一般性的结论
对于任意两个不同的类型Type1
和 Type2
,不论它们之间具有什么关系,给定泛型类 G<T>
, G<Type1>
和 G<Type2>
都是没有关系的。
上面的结论可以用一句话概括:
Java的泛型是不可变的(Invariant)
在一门程序设计语言的类型系统中,给定
<=
和>=
, 表示类型排序关系Type1<=Type2
表示Type1
是 Type2
的子类型Type1>=Type2
表示Type1
是 Type2
的超类型f()
表示一个类型的构造函数NewType = f(Type)
NewType = f(Type1, Type2)
那么,对于任意两个不同的类型Type1
和 Type2
, 这个类型的构造函数f()
可以是
变型(Variance) | 定义 | 例子 |
---|---|---|
协变的(covariant) | 当且仅当它保持了类型排序关系 | 如果Type1<=Type2 ,那么f(Type1)<=f(Type2) |
逆变的(contravariant) | 当且仅当它逆转了类型排序关系 | 如果Type1<=Type2 ,那么f(Type1)>=f(Type2) |
不变的(invariant or nonvariant) | 既不保持也不逆转类型排序关系 | 不论Type1 和 Type2 的关系,f(Type1) 和f(Type2) 没有关系 |
以上定义来自 Covariance and contravariance, Wikipedia,其中还有双变的(bivariant),但是不在本文的讨论范围,顾略去
在 C#中,子类型也称为派生类(Derived class),超类型也称为基类(Base class)
不同的程序设计语言,需要在类型推断的安全性和语法的易用性上做出权衡,对不同的类型构造函数进行规约,以使其符合某一种变型(Variance)。
不同的程序设计语言,在支持变型时,也有不同的处理方式。例如C#只允许在接口类型上标记变型,,而Java则在通过通配符来支持变型。
继续最初的例子,Integer
是Number
的子类,怎么样才能让Number max(List<Number> list)max
方法也接受List<Integer>
呢?(这里List<T>
是类型构造函数,List<Integer>
和 List<Number>
两个新的类型)虽然在Java中,泛型是不可变的(List<Integer>
和 List<Number>
没有关系),但是Java可以通过使用上限通配符来实现协变。
修改后的max
方法签名如下
1 | public static Number max(List<? extends Number> list) |
? extends Number
表示接受Number
类型和Number
类型的子类型。这样max
方法能够接受比原始指定的类型(这里是Number
类型)更具体的类型。
思考下面的例子,Integer
是Number
的子类,方法generateIntegers
生成List<Integer>
并放入到输入参数output
中
1 | public class ContravariantExample { |
由于Java泛型的不可变性,generateIntegers()
生成的List<Integer>
不能放到输入参数output
指定的List<Number>
中。那么有没有办法把 List<Integer>
放到List<Number>
中呢?
这里使用下限通配符来实现逆变。
修改后的generateIntegers
函数签名如下
1 | public static void generateIntegers(List<? super Integer> output) |
<? super Integer
表示接受Integer
类型和Integer
类型的超类型。这样generateIntegers
方法就能接受比原始指定类型(这里是Integer
类型)更泛化(更不具体)的类型。
如果Integer
类是Number
类的子类型
变型(Variance) | 通配符 | 例子 |
---|---|---|
协变的(covariant) | 上限通配符 ? extends T | List<? extends Number> 接受比原始指定类型Number 更具体的类型 |
逆变的(contravariant) | 下限通配符 ? super T | List<? super Integer> 接受比原始指定类型Integer 更泛化的类型 |
不变的(invariant or nonvariant) | 泛型默认不可变 | List<Integer> 和List<Number> 没关系 |
自从Java SE 1.5引入泛型(Generics) 之前,Java程序员想要写出通用的代码有点难度。比如想要得到Java某个集合(Collection
)的最大值,在没有泛型的情况下,我们需要针对每个特定类型去写特定的求最大值方法。
比如针对Number
集合我们需要实现
1 | public static Number max(NumberCollection coll, NumberComparator comp) |
针对Integer
集合我们需要实现
1 | public static Integer max(IntegerCollection coll, IntegerComparator comp) |
显而易见,这样实现起来是非常没有效率的。我们需要为每个不同的类型实现重复的逻辑,重复在编程中是非常罪恶的。当然,为了减少重复,我们也可以有这样的实现
1 | public static Object max(ObjectCollection coll, ObjectComparator comp) |
因为Java所有的类都是Object
的子类。当然这样实现的坏处就是需要做对象转型(Casting)
1 | Integer maxInterger = (Integer) max(coll, comp) |
然而对象转型也是非常罪恶的,因为一旦错误地使用了对象转型,代码只有到运行阶段(runtime) 才会报错。所以我们要尽可能的避免对象转型。
而有了泛型以后,我们只需要实现
1 | public static <T> T max(Collection<T> coll, Comparator<T> comp) |
其中T
叫做类型参数(Type paramter),如果一个类(Class),一个接口(Interface) 或者一个方法(Method) 在定义时(declaration)有一个或者多个类型参数,那么我们就叫他们泛型类(Generic class),泛型接口(Generic interface) 和泛型方法(Generic method)。而泛型类,泛型接口和泛型方法就被统称为泛型(Generic types, Generics)。
定义时,泛型是由类,接口和方法名跟着一个由尖括号包围的参数化类型(Parameterized types) 组成的。例如
ArrayList<E>
ArrayList类有一个类型参数,E,它表示元素类型。读作元素E的ArrayList。Map<K, V>
Map接口有两个类型参数,K,V,分别表示键和值的类型,读作K到V的映射。T max(Collection<T> coll)
max方法有一个类型参数,T,表示对象类型,这个不太好读。使用时,我们用实际类型参数(Actual type paramter) 替换类型参数,比如ArrayList<String>
就代表一个元素为String
的ArrayList
。其中类型参数E
被实际类型参数String
替代了。
在英语里Generic有通用的含义,这也揭示了Java泛型的本质:让类,接口和方法变得更加通用
有限通配符(Bounded Wildcards) 是Java泛型(Java Generics) 里的概念,这里的有限不是和无限对应的,而是有上限和下限的意思。所以有限通配符又分为下限通配符
和上限通配符
。在一些翻译中,Bounded Wildcards也被翻译为有界通配符,相应的,有界通配符又分为下界通配符
和上界通配符
。
上面说到有了泛型以后,我们只需要实现
1 | public static <T> T max(Collection<T> coll, Comparator<T> comp) |
便可以对任意类型的数据求最大值,但是上面的方法签名(method signature) 也有一些限制。
1 | Collection<Integer> intergerColl = ...; |
我们知道Number
是Integer
是的超类(Super type),每一个Intger
类也是Number
类,所以Number
类的比较器应该可以用于比较Integer
类。
然而上面的代码会在编译阶段(Compile time)出错,这是因为类型参数T
限制了我们只能使用Integer
类的比较器。在这里,限制比较器的类型和集合类型完全一样是没有必要的。其实我们可以放宽限制,只要比较器类型是集合类型T的超类型就可以了。这样,我们可以让求最大值方法变得更通用。修改后的函数签名如下
1 | public static <T> T max(Collection<T> coll, Comparator<? super T> comp) |
这里?
就是通配符(Wildcard),? super T
就是下限通配符(Lower Bounded Wildcards)。它表示某个类?
是T
的超类。
这样我们在这里使用Number类的比较器来比较Integer集合了。
下限通配符? super T
中,T
是表示下限类型,它既可以是一个类型参数,也可以是一个实际类型参数。
Comparator<? super T>
类型参数Comparator<? super Integer>
实际类型参数至于为什么叫做下限,我们可以这么类比。族谱里爸爸在上面,儿子在下面。下限通配符以某个类型的子类型为下限,它匹配包括这个子类型的所有超类型。
通过上限通配符,我们把求最大值方法变得通用了。试想限制我们想要实现一个Number集合累加,可以有如下的函数签名
1 | public static Number sum(Collection<Number> inputs) |
但是它只能用于累加Number,如果我们也想累加Number的子类Double,Integer呢,可以使用具有实际类型的上限通配符
1 | public static Number sum(Collection<? extends Number> inputs) |
这样我们就可以累加Double,Intger类型的集合了。
这里? extends T
就是上限通配符(Upper Bounded Wildcards)。它表示某个类?
是T
的子类。
无限通配符(Unbounded Wildcards)里的无限不是和有限对应的无限,而没有上下限的意思。有时,无限通配符(Unbounded Wildcards)也会被翻译为无界通配符。他们指代的都是同一个概念。
无限通配符记作?
, 表示未知类型(unknown type)。 比如List<?>
, 表示类型未知的List。
试想我们实现了如下在List中交换元素的方法
1 | public static <E> void swap(List<E> list, int i, int j) { |
由于使用了泛型,上面的方法可以应用于不同类型的List。但是我们也可以看到,在实现这个方法的过程中,我们没有使用基于类型参数E的任何信息。在这种情况下,我们可以用无限通配符代替类型参数E
1 | public static void swap(List<?> list, int i, int j) { |
我们可以总结出这样的经验法则,如何类型参数只在方法声明中出现,我们就可以用通配符来代替。
Java通过让类,接口和方法在定义时有一个类型参数的形式让代码变得更加通用(泛化),这个就叫做泛型。由于Java泛型也有一些限制,这里没有阐明技术原因(技术原因请参考Java的协变,逆变与不变),仅从几个把代码变得更通用得需求出发,引入通配符?
的概念。
通配符在Java里三种形式,分别是
<? super LowerBoundedClass>
,通配LowerBoundedClass
和它的超类。也可以用类型参数代替实际类型,<? super T>
<? extends UpperBoundedClass>
统配UpperBoundedClass
和它的子类。也可以用类型参数代替实际类型,<? extends T>
?
通配任意类型关于通配符上下限的说法,来自于ORACLE关于泛型的官方文档,以及Gilad Bracha关于泛型的教程。如果觉得难以理解,可以参考在《On Java 8》里,Bruce Eckel的超类通配符
和子类通配符
的说法。
<? super LowerBoundedClass>
,通配LowerBoundedClass
和它的超类。也可以用类型参数代替实际类型,<? super T>
<? extends UpperBoundedClass>
统配UpperBoundedClass
和它的子类。也可以用类型参数代替实际类型,<? extends T>
文章目录
几年前在实习时,我完全没有接触过设计评审。当时的团队采用Scrum开发过程。开发团队采取一个架构师(Architect)和三到四个开发者(Developers)的配置。为了保证开发速度,冲刺订单(Spring backlog)里的大多数任务(Ticket)都有架构师写的详细开发步骤。架构师在团队里负责了大多数的软件设计任务,开发者则专注于将架构师的想法实现。
参加工作后,由于开发领域,开发过程,甚至是公司文化都与我实习过的公司有很多不同。在此期间,我也接触到了相当多的设计文档和设计评审。
2019年,我在开发团队里承担的设计任务也越来越多,写过的设计文档有二三十个。参加的设计评审则是更多。一年下来,我对设计评审,这个软件开发中的一个流程,有了自己的一些看法,下面则是我对设计评审的一个小总结。
设计评审,需要首先有设计。这里说的设计就是设计文档(全称软件开发设计文档)。这是在软件开发之前,一种把软件设计思路结构化记录下来的资料。我第一次接触设计文档是在大学的软件工程课上。当时我也在课程项目中写过设计文档,但是却对为什么需要设计文档体会不深。毕竟当时的项目都很小,难度也不大。写设计文档无非是根据模板填充文字的游戏罢了。
但是当项目参与人员众多,开发难度大时,软件设计,作为软件开发的一个流程,被重视起来。我们在实践过程中,尤其是在一些重要的工作上 (比如时间跨度长,需要几个Sprint周期才能完成的工作,或者涉及多个团队,需要跨团队分工协作时),需要先把思路写下来,通过设计评审分享出来,得到相关人员的批准或者承诺后,再进行实现。
也许短期看,写设计文档,走完设计评审流程比直接蒙头实现软件要慢的多。但是一个好的设计可以在长期为产品的开发节省大量的时间。试想一下在有其他团队对你的软件库(Library)或者Web Service产生依赖时你才发现你的代码需要重新设计的情况,这时不仅是自己负责的项目会受到影响,使用你的代码的其他团队的项目也都会受到影响。
设计评审是软件开发的一个重要环节,写好设计文档则是让设计评审成功的第一步。在我看来,一个好的设计文档应至少包括如下几项:
更加详细的设计文档除了上面几项外还会有:
一个成功的设计评审需要以下要素。
request-pull
和 Pull Request
的名称如此相似,但是他们的功能是完全不同的。
request-pull
是git的命令,它用于生成发送到邮件列表的待处理的更改的摘要。默认情况下,GitHub没有集成这个功能。
Pull Request
是GitHub特有的一项功能(简称PR)。它用于向Github托管的某一项目中的某一分支提出合并请求。项目管理者则可以在github的web界面上合并来自不同分支的代码,解决合并冲突,做代码审查或对代码进行评论。
Generates a summary of pending changes
生成待处理的更改的摘要
1 | git request-pull [-p] <start> <url> [<end>] |
新增修改是基于从远端pull
下来的master
分支上的,为此建立了一个分支叫add-android-cours
, 首先把自己的分支push
到自己的仓库里
1 | git push https://github.com/JinhaiZ/TB-F2B-205.git |
运行git request-pull
命令
1 | git request-pull master https://github.com/JinhaiZ/TB-F2B-205.git add-android-cours |
它会根据远端master分支和刚刚push到仓库里的add-android-cours
分支的不同做一个摘要。你可以选择把这份摘要email给远端仓库的管理者,这样,管理者可以从这份摘要中快速看出你想对master分支做出什么更改
生成摘要如下所示
1 | The following changes since commit 5a992617e38665e73ca5ec5e5c10b78373c88938: |
“Pull Request 是一种通知机制。你修改了他人的代码,将你的修改通知原来的作者,希望他合并你的修改,这就是 Pull Request。”
以下借用阮一峰老师的讲解
首先安装Hub,以下步骤参考Hub官方指南
第一步,把别人在Github上的项目克隆到本地
1 | `hub clone https://github.com/SOME_ONE/xxx.git` |
第二步,创建分支,并在分支上进行修改
1 | git checkout -b my-feature-branch |
第三步,把本地项目fork到Github上
1 | hub fork |
第四步,把分支push
到新远端(自己fork的远端)
1 | git push YOUR_USER feature |
第五步,为自己的分支创建一个PR
1 | git pull-request |
翻译自:Latency Numbers Every Programmer Should Know
操作 | 延迟(纳秒) | 延迟(微秒) | 延迟(毫秒) | 参考 |
---|---|---|---|---|
CPU L1 级缓存访问 | 0.5 ns | |||
分支预测错误* | 5 ns | |||
CPU L2 级缓存访问 | 7 ns | 14x L1 cache | ||
互斥体Mutex 加锁/解锁 | 25 ns | |||
内存访问 | 100 ns | 20x L2 cache, 200x L1 cache | ||
用Zippy压缩1K字节 | 3,000 ns | 3 us | ||
在1 Gbps速率的网络上发送1K字节 over | 10,000 ns | 10 us | ||
从SSD读取4K长度的随机数据 | 150,000 ns | 150 us | ~1GB/sec SSD | |
从内存读取连续的1 MB长度数据 | 250,000 ns | 250 us | ||
在同一数据中心内的来回通讯延迟* | 500,000 ns | 500 us | ||
从SSD读取连续的1 MB长度数据 | 1,000,000 ns | 1,000 us | 1 ms | ~1GB/sec SSD, 4X memory |
磁盘寻址 | 10,000,000 ns | 10,000 us | 10 ms | 20x datacenter roundtrip |
从磁盘读取连续的1 MB长度数据 | 20,000,000 ns | 20,000 us | 20 ms | 80x memory, 20X SSD |
发送数据包 California->Netherlands->California | 150,000,000 ns | 150,000 us | 150 ms |
分支预测错误(Branch misprediction): 在包含了分支指令(if-then-else)的程序的执行过程中,其执行流程根据判定条件的真/假的不同,有可能会产生跳转,而这会打断流水线中指令的处理,因为CPU无法确定该指令的下一条指令,直到分支指令执行完毕。流水线越长,CPU等待的时间便越长,因为它必须等待分支指令处理完毕,才能确定下一条进入流水线的指令。为了解决这一问题,分支预测器(Branch predictor)在分支指令执行结束之前猜测哪一路分支将会被运行,以提高处理器的指令流水线的性能在分支执行完毕前先进行分支预测,分支预测器有很多种实现。其中最简单的是静态分支预测,方法是任选一条分支进入流水线,但是有50%的概率会预测错误,这时候就会出现分支预测错误
来回通讯延迟(Round-trip delay time),在通讯(Communication)、电脑网络(Computer network)领域中,意指:在双方通讯中,发讯方的讯号(Signal)传播(Propagation)到收讯方的时间(意即:传播延迟(Propagation delay)),加上收讯方回传讯息到发讯方的时间(如果没有造成双向传播速率差异的因素,此时间与发讯方将讯号传播到收讯方的时间一样久)
最后附上一张形象的图片
Credit: https://imgur.com/k0t1e
参考:
大约在20天前,我在亚马逊上冲动的购买了这个东西
树莓派3的官方Desktop Starter Kit,其实也就是树莓派3开发板+16GB miroSD装机卡+壳子+充电器套装。不过这样一套买下来,上手把玩树莓派要方便许多,机子到手第一天我就按照说明书安装了树莓派定制Debian的发型版Raspbian系统。第一感觉还挺好用,通过HDMI连接上显示器,再用4个USB接口中的两个连接键盘和鼠标就可以当做桌面电脑使用了。如果要通过个人电脑远程连接树莓派的话,ssh和VNC Viewer都很好用。
注意,以下步骤均在个人电脑上完成,不是在树莓派上
首先通过Vim 修改本地hosts文件vim /etc/hosts
加入raspberrypi的ip地址,以后就可以通过ssh pi@raspberrypi
的方式ssh到树莓派中,如果嫌每次连接都要输入密码麻烦,可以把公钥导入到树莓派中,具体方法如下
1 | ssh-keygen -t rsa |
1 | ssh-copy-id pi@raspberrypi |
ssh pi@raspberrypi
远程登录树莓派就不需要输入密码了1 | sudo nano /etc/ssh/sshd_config |
找到PermitRootLogin
这一行,并将它改成PermitRootLogin without-password
,如果这一行是被注释的,记得取消注释以此覆盖默认配置。修改并保存,最后运行下面命令使修改生效
1 | sudo service ssh restart |
1 | sudo apt-get update |
在树莓派3上做全栈开发并不是很理想,因为部分软件对32bit ARM架构处理器的兼容性不是很好,查看树莓派3的CPU架构可以使用如下命令,结果如下
1 | $ lscpu |
可以看到树莓派3采用一块ARMv7架构的CPU,通过查阅维基百科,得知ARMv1到ARMv7架构都是32 bits寻址,直到ARMv8架构才出现64 bits寻址。
故事起源于我想在树莓派上安装wekan,wekan是类似于Trello的Kanban类项目管理软件,奈何wekan是基于Meteor建立的,而Meteor对安装环境有x86_64的假设,即使修改源代码去掉该假设也不能顺利安装,参见meteor/issues/442。好消息是2017年6月份,Meteor团队开始着手与增加Meteor对ARM架构CPU的支持,参见meteor-feature-requests/issues/130。
不能在树莓派上安装wekan,让我有点小小的伤心,但是接下来发生的事情就让我对在树莓派上搞全栈开发暂时失去了兴趣。故事是这样的,为了学习网爬虫,我做了一个每天计算出Stackoverflow最火热问题的项目来练手,该项目的的爬虫部分基于Scapy框架,数据库我选用了最新版本的MongoDB (v3.4.9) 来存储爬取的网页内容。开发完成后我利用crontab在自己的电脑上让它每天晚上10点5分定时在这里update最火热问题排行。
问题是我的电脑并不是每天晚上10点5分都处于运行状态,有好几次因为我没有开机导致当天我写的程序没有update。于是乎我有了把MongoDB和网页爬取程序部署在树莓派上的想法。说干就干,在树莓派上陆续安装了相应的开发环境,主要是python的开发环境,比如安装Scapy框架可以通过下面的命令
1 | # install Scrapy |
然而在安装最新版的MongoDB时我遇到了无法解决的问题,实际上,通过下面命令可以安装MongoDB
1 | # install MongoDB |
但是版本号停留在了v2.4.14,经过查询得知v2.4.14版本后MongoDB就放弃了对32 bit ARM架构CPU的支持,导致树莓派3无法安装最新的MongoDB。奈何我的程序用了大量新版本的特性,比如$slice
选择符等,最后在树莓派上部署爬虫的想法只好作罢。
第一次接触"单板电脑"(SBC, Single Board Computer)还是很有新鲜感的,低廉的售价,和正常PC几乎一样的功能以及丰富的DIY选项让人很难拒绝。不过作为一个全栈开发人员,想要在树莓派上部署应用,不能安装最新的MongoDB意味着几年前比较流行的MEAN框架在树莓派上可能会行不通,部分框架,例如于ARM处理器有兼容问题的Meteor,会导致部分软件例如wekan在树莓派上无法应用,这些问题在购买前还是要考虑的。
参考
从字面上来说,机器学习就是让机器通过模仿人学习的过程学习。也就是说让机器有能力学习,而不是通过准确的程序而实现。我们生活中无处不在存在着机器学习的应用,例如垃圾邮件分类系统,通过图片识别物体等。
机器学习分为两大类:
有监督学习(supervised learning):给定输入数据和正确的输出结果,通过一系列“训练模型”使得输出结果和正确输出结果无限接近的过程。其中又可以分为两大类 回归(regression) 和 分类(classification) 问题。例如:给定某地区房屋的情况(房间个数,地理位置,周围环境…)及价格,来预测这个地区某个房子的价格。这就是一个回归问题。再比如通过一个人的照片来预测这个人的年龄。 而分类问题则是输出结果只有两种情况的问题。比如:预测一个病人的肿瘤是良性还是恶性;预测一封邮件是否是垃圾邮件等等。这种问题的结果只有两种情况。
无监督学习(unsupervised learning):没有给出正确的输出结果,让机器自己发现输入数据的结构。无监督学习最常见的应用就是分类。比如:给定用户数据,自动划分用户类比从而划分市场;给定一些文章报道,分类文章的类别,政治新闻,经济新闻,或文化新闻等等。
通过训练数据集(training set)学习得到一个函数h(x),使得输入数据X通过h函数映射的预测结果Y最接近正确值。线性回归的目标就是寻找到h函数使得其完美拟合输入X和输出Y的关系。
(Figure 1 https://www.coursera.org/learn/machine-learning/supplement/cRa2m/model-representation)
既然已经有了这个模型我们下一个问题就是如何才能找到这样的一个h函数?
在线性回归里用来判断拟合函数h好坏的一个标准就是代价函数(Cost funtion)。这个函数的数学表示如下,其中用均方误差来表示预测结果与真实结果的误差。
这时,机器学习过程的目标就是要找到一个h函数使得误差J最小。
求解最小代价函数的方法之一就是用梯度下降(Gradient descent)的方法。
数学上,梯度方向是函数值下降最为剧烈的方向。那么,沿着梯度方向走,我们就能接近其最小值,或者极小值,从而接近更高的预测精度。其数学表达如下:
其中学习率ɑ表示的是下降的速率,当这个值过大时会导致错过最小值(步子跨的太大);当这个值过小时会导致下降的速度很慢(步子跨的太小)。
用梯度下降的方法就是不断的沿着梯度方向更新值,直到找到最小值。
今天在看一本基于python2.2的Design Pattern书,其中有一段关于单例的代码我很不解,因为用到了反引号`,也就是reverse quotes。书中代码如下
1 | class OnlyOne: |
console输出结果是
1 | <__builtin__.__OnlyOne instance at 0x7ff4670b0200>sausage |
反引号`到底有什么用呢?经过查询,反引号在python2中是repr()函数的别名(alias),但是反引号别名表示已经在pyhton3.0中取消了。至于为何取消,Guido van Rossum他本人的解释是反引号`和正常引号‘的相似性实在是太令人误解了,此外反引号在印刷字体以及输入方面都有不少问题。
知道了python中反引号`其实是repr()函数的别名,那么python中repr函数本身又是干什么的呢?
repr(object)
Return a string containing a printable representation of an object. This is the same value yielded by conversions (reverse quotes). It is sometimes useful to be able to access this operation as an ordinary function. For many types, this function makes an attempt to return a string that would yield an object with the same value when passed to eval(), otherwise the representation is a string enclosed in angle brackets that contains the name of the type of the object together with additional information often including the name and address of the object. A class can control what this function returns for its instances by defining a repr() method.
根据上述节选自python官方文档的解释,可以知道repr函数主要功能是返回object的一种可打印字符串表示。一般来说,repr函数被设计成其输出可以被eval函数生成值相同的object,或者,被设计成返一个包含在尖头括号中的字符串,其中包含对象类型名称和一些附加信息。而附加信息通常包括这个对象的名字和物理地址。
在Desgin Patter作者给出的单例代码中,repr函数便输出了对象的名字和物理地址。
一般讲Desgin Pattern的书几乎一开始都会讲单例,毕竟单例是最容易理解的Desgin Pattern。作者在实现单例时用了nested class方法,内部class是一个私有class,这样外部无法直接访问。那么print函数又是如何访问到私有class的__str__
函数的呢?这就要靠wrapper class的__getattr__
函数了,在这里作者巧妙实现了一个delegation。这也是为什么print OnlyOne class的instance时,console输出都是私有class的__str__
函数定义的结果。
参考
要想理解python中的生成器,得先从可迭代对象Iterable说起。什么是可迭代对象呢?
定义了可以返回一个迭代器的__iter__方法的对象,或者定义了可以支持下标索引的__getitem__方法的对象,就是一个可迭代对象
所以我们可以用如下方法来判断一个实例是否为可迭代对象
1 | hasattr((1,2,3), '__iter__') # True |
知道了可迭代对象的定义,那么可迭代对象又有什么用呢,如下列所示,我们经常在python中使用for…in循环,那么for…in循环能在所有对象上使用吗?
1 | my_list = [1, 2, 3] |
其实不然,只有在可迭代对象上,for…in循环才能使用,比如列表,元组,字典以及字符串。这就是可迭代对象的最大用途。然而问题有来了,这些可迭代对象虽然好用,但是它们却又一大弊端。可迭代对象在使用时,比如my_list
,它会把列表[1, 2, 3]
中所有元素都存贮在内存中,当列表长度非常可观时,python会遇到性能方面的问题(python2.7中range函数返回可迭代对象,xrange函数返回生成器,所以可以通过比较这两个函数的执行相同任务时间来直观的了解性能方面的问题,stackoverflow上便有回答来比较这两个函数的执行时间,问题链接点此)。
对于上述列表对象遇到的性能问题,我们自然会问到有没有方法来解决这样的问题。比如,有没有一种类似列表的对象,但是它不会一下子把它所有的元素都放到内存里的对象?
有,它就是迭代器,还是先说迭代器Iterator的定义
定义了返回迭代器对象本身__iter()__(Python2)方法的对象,或者定义了方法返回容器的下一个元素__next__方法的对象,就是一个迭代器。
同时我们也说,实现了上述两个方法的对象是遵守了迭代器协议(iterator protocol)的对象。
判断一个对象我们除了可以检查它有没有__iter()__
和__next__
方法外,我们也可以用isinstance()方法。如下所示
1 | from collections import Iterator |
这下我们发现,元组,列表,字典,字符串都是可迭代对象,但同时它们也都不是迭代器。谁是迭代器呢?从上面例子可看到(x for x in [1,2,3])
是迭代器对象的实例。
首先,为什么元组,列表,字典,字符串都不是迭代器Iterator呢?
这是因为Python的Iterator对象表示的是一个数据流,Iterator对象可以被next()函数调用并不断返回下一个数据,直到没有数据时抛出StopIteration错误。可以把这个数据流看做是一个有序序列,但我们却不能提前知道序列的长度,只能不断通过next()函数实现按需计算下一个数据,所以Iterator的计算是惰性的,只有在需要返回下一个数据时它才会计算。
从以上摘自廖雪峰官网上的解答中我们可以看出,迭代器表示一个数据流,它不会把这个数据流中每一个元素都存贮在内存中,而列表则不同,它会把列表中所有元素都存贮在内存中。
对于第二个问题,为什么(x for x in [1,2,3])
是迭代器对象的实例呢?
1 | from collections import Iterator |
从以上例子可以看出,虽然元组,列表,字典,字符串都不是迭代器,但是通过iter()函数,可以获得一个Iterator对象。
所以Python的for循环本质上可以看做先通过iter()函数,获得一个迭代器Iterator对象,再在这个迭代器对象上通过不断调用next()函数实现的。
在了解了可迭代对象Iterable和迭代器Iterator的概念后,我们终于可以开始探讨生成器的概念了。
先放概念
生成器是一种迭代器,但是只能对其迭代一次。因为它们并没有把所有的值存在内存中,而是在运行时生成值。生成器可通过遍历来使用它们,要么用一个“for”循环,要么将它传递给任意可以进行迭代的函数和结构。
1 | my_generator = (x for x in [1,2,3]) |
上面是生成器的一个简单的例子,这个例子有点像for…in循环输出my_list
的例子,除了这里用了一个迭代器(x for x in [1,2,3])
替换了可迭代对象[1,2,3]
。它们有什么不同吗?仔细观察上例,当我们再次使用for…in循环输出my_generator时,什么都没有发生,这也就是定义中说的
生成器是一种迭代器,但是只能对其迭代一次。
因为生成器首先生成1,接着从内存中清空1,接下来生成2,接着从内存中清空2,最后生成3,接着从内存中清空3。
我们知道了一种由迭代器来获得生成器的方式,那么还有其他的方式来获得生成器吗?用Python关键词yield就可以。
1 | def createGenerator(): |
虽然上面的例子没有人在实际中会使用,但是用它来了解yield的用法还是很通俗易懂的。关于yield的难点,首先我们可以看到,通过createGenerator()
函数来获得生成器my_generator
时,函数createGenerator()
并没有被执行,它只是返回了一个位于内存中处于某个位置上的该生成器对象。直到我们用for…in循环作用于这个对象上时,函数createGenerator()
才被真正的执行,我们可以把它当做是一种惰性求值。
那么生成器my_generator
是如何求值的呢?当for…in循环第一次调用生成器时,createGenerator()
被执行,直到被执行到yield关键词这里,返回循环的第一个值。余下的循环在调用生成器时,会继续上次的循环,再次遇到yield关键词时返回这一次循环的值,直到循环结束,再也遇不到yield关键词为止。
生成器的一个典型应用场景是:你不想把同一时间将所有计算出来的大量结果贮存到内存中,因为这样做会消耗大量资源,所以使用生成器来进行惰性求值,只有在需要某个结果时,再计算该结果。
举一个生成斐波那契数列的例子
1 | def listFibonacci(n): |
当输入参数很大时,内存资源会被严重消耗。
下面是该函数的生成器版本
1 | def genFibonacci(n): |
由于惰性求值的缘故,即便参数很大时,我们也不用担心内存资源的消耗。
我们刚刚谈到了内存消耗情况,有什么工具能帮助我们直观的感受到内存的消耗呢? python有个叫memory_profiler
的工具可以来帮我们进行内存消耗情况的测试。为了对比上述两个方法的内存消耗,我们让这两个方法同时生100000个斐波那契数列,并观察内存消耗情况。
测试listFibonacci()
代码如下
1 | from memory_profiler import profile |
测试genFibonacci()
代码如下
1 | from memory_profiler import profile |
使用listFibonacci()
测试结果如下
1 | Filename: listFib.py |
使用genFibonacci()
测试结果如下
1 | Filename: genFib.py |
从上面的测试结果可以看出,在生成大于100000个斐波那契数列的任务中,listFibonacci()
消耗了460.5 MiB内存,而genFibonacci()
只消耗了14.4 MiB内存。其实,如果我们绘制一个内存消耗随生成斐波那契数列长度变化的折线图,我们可以发现,随着生成斐波那契数列长度的增加,listFibonacci()
的内存消耗情况是指数递增的,而genFibonacci()
的内存消耗情况则是不变的,总是维持在消耗14.4 MiB 左右。通过这个测试,我们可以直观的感受到,在计算大量不需要被保存结果且只需计算一次的任务下,我们为什么不用担心生成器对内存资源的消耗。
如果从Design Pattern的角度来看生成器,生成器就是一个无参数版本的工厂模式。通常工厂模式需要通过参数来确定生成什么对象以及如何生成该对象,但是生成器则不需要参数,它通过内部算法来确定生成什么对象以及如何生成该对象。
参考
最近在看一本基于pyhton的Design Pattern方面的书,其中涉及到了python的多态,也就是polymorphism。用python来解释,到底什么是多态(polymorphism)呢?StackOverflow上有一个很好的答案,下面便是答案中举的例子
1 | class Animal: |
从这个例子我们可以看出,不同的动物都可以“talk”,但它们“talk”的实现方式不同。 因此,“talk”行为是多态的,它根据动物的不同而有所不同。 我们可以看到,抽象的Animal类实际上并不能“talk”,而具体的动物类(如Dog和Cat)则分别实现了“talk”的动作。
类似地,加法操作符+
在许多数学运算中有定义,在特定情况下,多态性允许我们根据具体规则定义加法操作符,比如在实数集下:1 + 1 = 2
,但包含复数的情况下(1 + 2i)+(2-9i)=(3-7i)
。
总结来说,多态允许我们在抽象类中指定常用方法,并在特定子类中实现它们。
参考
作为一个游戏发烧友,同时也是小白全栈开发程序员我买了Alienware却只用来打游戏是在是太可惜了。为了搞搞开发,昨天配置了一天WSL (Windows Subsystems for Linxu),用了Hyper+zsh的终端组合。其中在zsh中输入atom能启动windows的Atom也着实把我惊艳了一把,但其实也就算仅限于打开而已。事实是Windows中的文本编辑器并不能直接编辑其子系统中的文件。所以这个子系统在某些方便我觉得还没有Vagrant方便,没有图形界面,意味着不能使用Atom,Visual Studio Code这样代表先进生产力的工具,于是配置WSL搞搞开发的想法就此作罢。
子系统搞不成,于是有了直接在Ubuntu上工作的想法。其实Alienware上安装Ubuntu不是很难,除了一些蓝牙和Wi-Fi方面的兼容问题,安装过程非常顺利。接下来step by step记录安装过程。
在Ubuntu官方网站下载16.04 LTS系统镜像,并将其制作成启动U盘。
制作启动U盘的工具有很多,Rufus 是我用的一款,界面简单清爽无广告不收费。
Rufus界面如下,制作启动U盘时,注意Partition schemem and target system type选择同时支持UEFI和BIOS的格式。
重新启动Windows,启动黑屏时按F11进入UEFI界面,有两个配置需要更改
插入Ubuntu启动U盘符,再次重新启动,按下F11,选择从U盘启动,接下来的步骤和在普通电脑上安装双系统无异。
安装完成后,再次启动,boot loader已经从windows boot manager变成了GRUB(GRand Unified Bootloader)。以后在便可以在GRUB界面中选择进入的系统是Ubuntu还是Windows。
进入安装完毕的Ubuntu,我遇到的第一个问题就是无法连接Wi-Fi。安装驱动程序有一个一般性的方法,先通过下面的命令查看自己使用的设备是什么型号,然后在网上搜索该型号设备的驱动程序。
lsusb
查看usb接口的设备
lspci
查看pci接口的设备
如下图所示,lspci | grep -i net
命令显示我使用的网卡型号是Qualcomm Atheros Killer E2400,通过搜索,发现Alienware使用的killer网卡的官方网站提供了相关驱动的下载。
如上图所示,可通过如下命令安装killer网卡驱动
1 | wget http://mirrors.kernel.org/ubuntu/pool/main/l/linux-firmware/linux-firmware_1.164_all.deb |
首先通过下面的命令卸载已安装的蓝牙驱动并且安装新的蓝牙驱动
1 | sudo apt-get purge blueman bluez-utils bluez bluetooth |
然后再启动蓝牙服务
sudo /etc/init.d/bluetooth start
如下图所示,接下来就可以通过Blueman管理系统蓝牙连接了
Alienware的一大特色就是它有狂野炫酷的灯,更换Ubuntu平台后,一个大问题就是戴尔官方没有Ubuntu平台的AlienFx。
通过查询,发现有人写了开源版本的AlienFx,只可惜项目已经有好几年无人维护。下载最新版本试用后,不出以外的,该软件并不支持最新的Alienware,该软件支持的Alienware系列可以从该网站中找到。
参考资料
目录
最近又重新安装了Ubuntu系统。因为要写中文博客,没有中文输入法实在是麻烦。在Ubuntu上安装中文输入法确实没有Windows上显而易见,所以写下步骤方便自己将来参考。
首先进入系统设置选择language support,如果没有完整安装该功能,先根据系统提示完成安装
点击install/remove language 来安装中文支持
接下来在Terminal中手动安装下面任何一个输入法
sudo apt-get install ibus-pinyin
sudo apt-get install ibus-sunpinyin
重启IBus daemon
ibus restart
再进入系统设置选择Text Entry,点击左下角的小加号并在add input source界面中搜索Chinese,选择一款拼音输入法
使用了几天IBus的中文输入发,使用体验简直弱爆了。首先4k屏适配有问题,待选框时刻游离在屏幕之外,然而最致命的是,IBus拼音输入法竟然有切换到双拼就无法再切换回来的bug。早年间我在ubuntu 14上使用过搜狗输入法,用户体验良好,于是试着在ubuntu 16.04上安装。虽然安装步骤比在ubuntu 14上稍稍复杂一些,但是搜狗输入法完美适配4k屏幕而且一如既往的好用。安装步骤如下:
搜狗舒服法依赖Fcitx键盘输入法系统而不是IBus,所以安装搜狗输入法前需要确认Fcitx已经安装,若没有安装Fcitx,可以通过terminal
sudo apt-get install fcitx
如果安装过程中需要额外依赖,可以通过以下命令安装
apt-get install -f
点击这里下载搜狗中文输入法,有32bit和64bit安装包可选,ubuntu用户可以选择deb格式安装包用Ubuntu Software Center来安装,当然可以到下载路径下通过以下命令安装
sudo dpkg -i sogoupinyin_2.1.0.0086_amd64.deb
安装完毕后,在从当前系统设置/语言支持里选择Fcitx做为键盘输入法系统
退出登录,再重新登录
现在在系统右上角应该能看到键盘图标,选择设置,添加搜狗中文书法
开心的使用吧
参考资料
情况 | 时间复杂度 | 空间复杂度 |
---|---|---|
最优 | O(N) | O(1) |
平均 | O(N) | O(1) |
最差 | O(N) | O(1) |
设输入数组长度为N,最优情况在输入数组的最大值不等于N时达成
permutation 被定义为长为N的数组,包含整数1到N,且每个整数包含有且仅有一次。根据定义,我们必须遍历完输入数组才能知道是否1到N的每个整数包含有且仅有一次,所以算法复杂度为O(N)且无法继续优化。如何检测输入数组中1到N的每个整数出现有且仅有一次呢?一个比较直观的方法是用一个长度为N的数组作标记,遍历输入数组并在标记数组的对应项作标记,一旦该标记数组所有项均被标记,则说明输入数组满足permutation定义。但是该方法有一个缺点,就是需要成比例于输入数组长度N的存储空间。有没有不需要额外存储空间的算法呢?答案当然是有的,技巧在于利用输入数组,在遍历输入数组的同时,利用输入数组做标记。由于输入数组各项均为正整数,我们可以将某一项置为负值来标记该项索引+1对应的正整数出现过一次。
1 | def solution(A): |
情况 | 时间复杂度 | 空间复杂度 |
---|---|---|
最优 | O(N) | O(1) |
平均 | O(N) | O(1) |
最差 | O(N) | O(1) |
创建检查数组check,遍历数组A的项,若某项的值为整数x,则以整数x作为索引,将check数组对应的项置1。在遍历check数组,寻找值为0时索引的最小值。
1 | def solution(A): |
情况 | 时间复杂度 | 空间复杂度 |
---|---|---|
最优 | O(N+M) | O(N) |
平均 | O(N+M) | O(N) |
最差 | O(N+M) | O(N) |
若要达到最差情况下的时间复杂度为O(N+M),那就意味着不能在A[K] = N + 1
时依次将每个counter
的值置为当前counters
数组的最大值。因为max counter操作的复杂度为O(N)且需要嵌套在复杂度为O(M)的循环中,这样算法的复杂度至少为O(NM)。也就以为着当A[K] = N + 1
时,我们不能立即执行max counter操作。借用下惰性求值的概念,只有在真的需要某个counter
值的情况的下才执行计算。这就需要建立两个变量,max_A
记录当前counters
的最大值,lastUpdate
记录上一次执行max counter操作时max_A
的值。这样只有当A[K] = N + 1
时,才把max_A
的值赋值给lastUpdate
。而当A[i] < N+1
时,则要根据lastUpdate
的值来更新counter
和max_A
的值。
最后在返回所counters
数组值之前,需要根据lastUpdate
的值对每个counter的值进行计算
1 | def solution(N, A): |
情况 | 时间复杂度 | 空间复杂度 |
---|---|---|
最优 | O(1) | O(1) |
平均 | O(N) | O(X) |
最差 | O(N) | O(X) |
最优情况在输入数组长度小于河宽X下达成
依次读取输入数组的项,当读取过的项的集合包含1到X在内的所有整数时,输出当前项的数组索引。对于记录读取过的项的集合,可以用一个长度为X+1的check
数组来记录。若读取了整数z,就把整数z作为索引,将对应的项置1。对于判断读取过的项的集合是否包含1到X在内的所有整数,我们都可以遍历check
数组,检查索引1到X对应的项是否全部为1,如果不是,则说明我们还没有包含1到X在内的所有整数。但是这样,该判断算法需要遍历check
数组,复杂度为O(X)。不过有一个小技巧可以将该判断算法降为O(1),那就是在check
数组的基础上,再使用step_left变量来记录还需要读取多少个不同的整数后读取过的项的集合才能包含1到X在内的所有整数。若读取了整数z,除了把整数z作为索引,将对应的项置1外,还要根据条件来更新step_left
的值。此时,检查算法只需要判断step_left
是否为0即可得知读取过的项的集合是否包含1到X在内的所有整数了。
1 | def solution(X, A): |