工程师实战方法论核心 12 讲

专栏亮点

  • 授人以渔。对工程师而言,「鱼」即知识点,「渔」则为实战方法论。技术类文章通常聚焦于知识点,而本专栏则侧重于实战方法论,力求授读者以渔。

  • 服务于实践。本专栏内容源于实践,是阿里系 4 位资深工程师、高级专家多年实战经验的总结,内容涵盖架构设计、模型设计、性能优化、异常排查、职业发展、学习成长等多个方面。

  • 工程师进阶之选。在进阶之路上,仅仅掌握领域知识点并不足以应对激烈的职场竞争。掌握实战方法论可以让你事半功倍,同时扩展视野、提升水平。

  • 面试必备。在高级工程师的面试中,领域知识不再是绝对的重点,面试官更关注的是应聘者的思考和沉淀,一套可复用的方法论或最佳实践是一名优秀工程师的必备技能。

  • 适用面广。本专栏有 3 篇文章使用 Java 语言描述,但关于架构设计、模型抽象、稳定性保障、学习成长等方面的内容与编程语言和技术栈无关,感兴趣的读者均可作为参考。

  • 一图胜千言。为了帮助读者更好地理解工程项目中复杂的关系和概念,作者为每篇文章都制作了生动而准确的示意图,让你在图文并茂的内容中更快掌握知识。

为什么要学习方实战法论?

做一个有「沉淀」的工程师

工程师在经历一系列项目实践的洗礼后,在业务建模、系统设计、性能优化、异常排查、稳定性设计等方面会形成一套可复用的方法论或最佳实践,这是工作经验的核心价值所在,也是普通工程师和高阶工程师的分水岭

然而,在大、中型项目中,一名工程师通常只负责某个模块的设计和实现,这样的分工协作模式在提升效率的同时,也容易让人产生惰性,逐渐退化成「螺丝钉」—— 只关注自己的「一亩三分地」,囿于局部,缺乏对项目全局的洞察。随着时间的推移,这样的工程师只会做自己擅长的,或者只被安排做自己擅长的。缘于这类因素,很多工程师虽然参与过很多项目,但趋于同质化,技术视野局限,缺少「沉淀」

进阶之路不可或缺的能力模型

笔者在阿里长期负责技术面试,在过往的面试中,很多工作 3~5 年的应聘者虽然领域知识熟练,但是,当问及方法论相关的话题,如:过往项目中你是如何建立业务模型的?你做系统设计的路线(套路)是怎样的?你是怎么做系统性能优化的?你是怎么做稳定性设计的?你排查系统异常的思路是怎样的?到新公司如何熟悉一个陌生的系统?你觉得一个好的 API 应该怎样设计?应聘者大都只能给出一些缺乏条理的零碎点。

由于这类原因,应聘者的能力模型往往会被面试官减分。实战方法论与单纯的知识点不同,知识点可以通过阅读和实践快速积累,但要上升到方法论却需要辅以思考和总结,这正是很多工程师所欠缺的。在职业进阶之路上,从技术熟练者到思考者的转变是无法跳过的一步。

方法论不是成功学,它很实在

工程方法论源自实践,是经验的归纳总结,并不虚幻。比如工程师熟悉的 23 种设计模式,就是一种设计方法论,它可以指导开发者设计出优雅的程序。再比如阿里的 Java 开发规范,是众多阿里工程师经年累月踩坑经验的集合,借鉴后可避开很多坑。

本专栏是 4 位阿里系资深工程师和专家多年工作经验和日常思考的总结。源自实践,服务于实践

专栏大纲

本专栏共十二个主题,每个主题 1~ 4 篇文章,内容包括架构设计、模型设计、性能优化、异常排查、职业发展、学习成长等方面。

  • 主题 1: 如何设计一个好的 API

    良好的 API 设计可以让使用者高效使用一个系统的能力,同时也可以极大减轻技术支持的工作量。文章将结合实践为读者呈现怎样才能设计出良好的 API 。

  • 主题 2:如何设计预案

    预案的本质是为系统稳定性服务的。在互联网领域,从系统设计、编码实现到后期维护,预案几乎贯穿全程。对于工程师,特别是大型应用的工程师,预案(稳定性)设计能力尤为重要。

  • 主题 3:如何设计模型

    模型设计是一个从整体到局部、从具体到抽象,自上而下设计、自下而上验证并不断迭代完善的过程。模型设计是软件系统设计中极为关键的环节,因此,欲进阶必须掌握模型设计方法。

  • 主题 4:如何设计一个复杂的系统

    系统设计是高阶工程师的必备能力,设计出完备、健壮、优雅、前瞻的系统是工程师永恒的追求。本主题分为上、下两篇,由两位资深工程师合作完成,将带领读者掌握系统设计的思考框架和设计套路。

  • 主题 5:如何评估软件系统的吞吐量

    在实践中经常会遇到需要进行系统性能优化、提高系统吞吐量的场景。那么,系统的吞吐量与哪些因素有关呢?如何评估?是否有方法论可循?如果在面试中遇到这样的提问,你是否能从容应对?

  • 主题 6:如何高效地排查 Java 系统异常

    衡量系统性能的常用指标有哪些?当系统运行中出现问题时,如何通过这些指标量化地评价系统的状态并定位问题?理解系统性能指标、掌握排查系统问题的方法,是软件工程师进阶的必备技能。

  • 主题 7:如何进行 Java 系统性能优化

    系统性能优化涉及面非常广,涵盖方案优化、编码优化、并发优化、JVM 调优等诸多方面的知识。虽然不同系统的优化策略存在差异,但从全局来看,它们的共性仍是主要的。本主题将分为上、下两篇。

  • 主题 8:如何熟悉一个完全陌生的系统

    在职业生涯中难免遇到以下场景:入职新公司,如何有条不紊地熟悉已有系统?支援陌生系统开发,如何快速上手?同事离职,如何快速接手相关系统?面对这些情况,梳理一套方法论,从而有序、高效地应对。

  • 主题 9:如何画好系统设计图

    系统设计图是一种整体视图,目的是具象呈现软件系统的整体轮廓、各个组件之间的相互关系和约束边界,以及物理部署和演进方向。一图胜千言,在项目评审、晋升答辩、内部培训中,画好系统设计图都大有裨益。

  • 主题 10:如何将工作中的创新点转化为专利

    阿里、腾讯、华为等知名企业都很注重专利并设置了专利奖励,但在专利挖掘方面,技术人常常陷入误区:技术不够牛不能申请专利?尚未实现的技术方案不能申请专利?本主题将结合案例详解专利的挖掘方法和误区。

  • 主题 11:如何打造能力护城河

    近些年,中年危机话题甚嚣尘上,互联网领域的工程师焦虑尤甚。对于工程师,为长久计,必须打造自己的能力护城河。本主题由一位正值中年的资深技术专家撰稿,分享其在过往职业生涯中构建、完善自身能力体系的经验。

  • 主题 12:实践案例集锦

    本专栏最后一个主题由 4 篇文章构成,内容取材于阿里的典型应用实践,涉及系统架构、业务模型抽象、稳定性保障、推荐等方面,将以真实的案例诠释实战方法论。

作者介绍

工程师实战方法论核心 12 讲_第1张图片

本专栏由 4 位作者共同完成,应书澜作为主要贡献者,负责全文统稿。

你的收获

  • 掌握系统设计、模型设计、API 设计、预案设计等方面的实战方法论,快速提升设计能力;
  • 多位阿里系资深工程师和专家的实践经验总结;
  • 在工作、面试、晋升答辩中赢得优势;
  • 可现学现用的实战案例。

适宜人群

  • 有计划换工作或正在准备面试的互联网行业工程师
  • 计算机、软件、信电等相关专业有意向从事软件开发的在校生
  • 希望提升系统设计、模型设计、稳定性设计等软件工程能力的工程师
  • 尤其适合 Java 技术栈工程师

购买须知

  • 本专栏为图文内容,共计 17 篇。每周更新 1 篇,预计 4 月底更新完毕。
  • 付费用户可享受文章永久阅读权限。
  • 本专栏为虚拟产品,一经付费概不退款,敬请谅解。
  • 本专栏可在 GitChat 服务号、App 及网页端 gitbook.cn 上购买,一端购买,多端阅读。

订阅福利

  • 订购本专栏可获得专属海报(在 GitChat 服务号领取),分享专属海报每成功邀请一位好友购买,即可获得 25% 的返现奖励,多邀多得,上不封顶,立即提现。

  • 提现流程:在 GitChat 服务号中点击「我-我的邀请-提现」。

  • 订阅本专栏后,服务号会自动弹出入群二维码和暗号。如果你没有收到那就先关注微信服务号「GitChat」,或者加我们的小助手「GitChatty6」咨询。(入群方式可查看第 3 篇文末说明)。

课程内容

主题 01:如何设计一个好的 API

1. 引言

如果说好的 UI 设计可以让用户更容易地使用一款产品,那么,好的 API 设计则可以让其他开发者更高效地使用一个系统的能力。良好的 API 可以很大程度上减轻使用者的负担,同时也可以极大地减轻技术支持的工作量,尤其是对那些使用者众多的 API 来说。

在实践中,一个较复杂的系统通常由多位开发者共同开发。往往由于缺乏统一的规范,开发者各自为政,导致同一个系统提供的 API 风格相差甚远,增加使用者的学习成本和后期维护成本。此外,有些时候由于开发资源紧张,可能无法投入足够的资源到 API 的设计、完善和相关文档上,进而导致产出的 API 质量差,难以使用。如是种种,无论对使用者还是维护者都将是一场噩梦。那么,怎样才能设计出良好的 API 呢?

Google 的首席 Java 架构师 Joshua Bloch 在一个演讲中分享了关于 API 设计的理念,他认为一个好的 API 应该具备以下特点:

  • Easy to learn and memorize(易学易记)
  • Easy to use, even without documentation(易用)
  • Hard to misuse(不容易用错)
  • Easy to evolve(容易扩展迭代)
  • Sufficiently powerful to satisfy requirements(足以满足需求)

2. 设计一个好的 API 需要注意的点

本文末尾有 Joshua Bloch 的演讲 PPT 和视频链接。Joshua Bloch 分享的关于 API 的设计理念令人印象深刻,那么,如何在实践中将这些优秀的理念“落地”呢?在我看来有以下需要注意的点。

2.1 明确边界

在写文章的时候,通常需要首先确定一个主题,然后再围绕主题展开。有了主题的指引,在行文时有利于理清思路:哪些内容与主题相关?哪些内容可以升华主题?既定内容是否跑题?与之类似,设计 API 的时候,我们需要首先明确边界(boundary),聚焦 API 需要提供的本质能力,避免陷入具体场景而沦为某个业务的专属 API。

工程师实战方法论核心 12 讲_第2张图片

上图是一个简要的系统边界示意图,关于边界,在设计 API 时需要注意以下事项:

  1. 只有绿色的部分才是设计 API 所需要考虑的,它是软件系统具体可提供的服务或者能力。API 是系统和外部交互的接口,至于外部如何使用这个接口、通过什么途径使用不应该在我们的考虑范畴。
  2. 设计 API 时不应该陷于具体的通信协议。通信协议只是一种信息交换的渠道,而随着技术的发展,这些协议的变动性很大,而 API 的外观相对要稳定得多。
  3. 设计 API 不应陷于 UI 交互的相关细节。交互属于客户端的范畴,随着终端设备的多样性,客户端的交互也是趋于多样性和不稳定的。

举例解读

在超市结账的时候,当收银员扫描商品的二维码时,POS 终端上就会显示这个产品的价格和名称,那么这个 API 应该如何设计呢?如果一开始选择 REST 架构来做项目,那么很可能会出现上面注意事项 2 所描述的问题——API 和具体的通信协议层代码捆绑。

@Path("/items")public class ItemResource {   @RequestMapping("/checkout")   public ItemCheckoutResult checkoutItem(@RequestParam(value="Barcode") String barcode) {       // 具体实现代码...   }}

某种程度上,上面的这种设计在最初并没有什么问题,但随着系统的不断迭代,可能需要支持不同的通信协议,比如 WebSocket、RPC;同时需要支持的终端设备也在增加,比如需要支持手机 App,那么上面的设计会让边界变得越来越模糊,最终可能导致 API 的实现逻辑代码被 copy/paste 得到处都是——repeat yourself everywhere。为了避免上面的情况出现,设计 API 时应明确边界,保证 API 具有良好的独立性,示例代码如下所示:

public interface StoreService {   ItemCheckoutResult checkoutItem(String barcode);}

然后在协议层的代码中进行调用。

@Path("/items")public class ItemResource {   @RequestMapping("/checkout")   public ItemCheckoutResult checkoutItem(@RequestParam(value="Barcode") String barcode) {       return storeService.checkoutItem(barcode);   }}

2.2 Tell, Don't Ask

Tell-don't-ask 原则最早是在 IEEE 软件专栏的一篇文章中提出的,某种程度上,它反映了面向过程编程与面向对象编程的本质区别。其核心思想为:在面向对象编程时,应该根据对象的行为来封装具体的业务逻辑,调用方应该直接告诉(tell)对象需要做什么,而不是通过询问(ask)对象的每一个状态然后再告诉对象需要做什么。两种方式的区别如下图所示:

工程师实战方法论核心 12 讲_第3张图片

举例解读

按照这个原则来设计 API 可以更好地体现软件的系统能力,而避免沦为简单的增、减、改、查操作。为了让读者更好地理解,这里举一个银行取款的例子。

方案一:按照 ask 模式来设计 API

step-1,需要创建一个账户对象,如 AskAccountDTO:

public class AskAccountDTO {    private int id;    private long balance;    private long credit;    private long debt;    public int getId() {return id;}    public long getBalance() {return balance;}    public void setBalance(long balance) {this.balance = balance;}    public long getCredit() {return credit;}    public void setCredit(long credit) {this.credit = credit;}    public long getDebt() {return debt;}    public void setDebt(long debt) {this.debt = debt;}}

step-2,创建两个 API,分别用于读取和更新这个账户对象。

public interface BankService {    AskAccountDTO getAccountById(int id);    void updateAccount(AskAccountDTO account);}

step-3,调用方来实现取钱逻辑。

    // 用户账户 ID 和取款数    int id = 20881234;    long amount = 500;    AskAccountDTO account = bankService.getAccountById(id);    if (account.getBalance() >= amount) {        account.setBalance(account.getBalance() - amount);        bankService.updateAccount(account);        return;    }    long total = account.getBalance() + account.getCredit();    if (total >= amount) {        long restAmount = amount - account.getBalance();        account.setBalance(0);        account.setDebt(account.getDebt() + restAmount);        bankService.updateAccount(account);        return;    }    throw new InsufficientBalanceException("您的账户资金不足");

方案二:按照 tell 模式来设计 API

step-1,只需要一个API——“withdraw”,在这个 API 内部实现所有的取款逻辑(同方案一 step-3 中调用方实现的代码逻辑)。

public interface BankService {    TellAccountDTO withdraw(int id, long amount);}

step-2,方案一中的账户对象简化后作为这个 API 的出参,用以承载取钱操作的返回信息。

public class TellAccountDTO {    private int id;    private long balance;    private long credit;    private long debt;    public int getId() {return id;}    public long getBalance() {return balance;}    public long getCredit() {return credit;}    public long getDebt() {return debt;}}

step-3,调用方只需要 “tell” 这个 API 需要取款,API 内部完成所有的计算和判断。

TellAccountDTO account = bankService.withdraw(20881234, 500);

2.3 Do One Thing

“Do One Thing”—— 即单一职责。在设计 API 的时候,力求一个 API 只做一件事情,单一职责不但可以让 API 的外观更稳定、没有歧义(side effects)、简单易用,而且可以提高 API 的可重用性。在设计 API 的时候,如果符合以下条件,可以考虑拆分:

  1. 一个 API 可以完成多个功能。例如,一个 API 既可以用于修改商品的价格,又可以修改商品标题、描述、库存等,通常这些功能并不需要在一次调用里完成,修改价格的时候通常不会去修改标题和描述,合并在一起会使得接口过于复杂,不易使用。
  2. 一个 API 用于处理不同类型的对象。例如,发布不同类型的商品可以拆成多个 API,这样可以简化数据结构,发布服装类商品为什么要关心卡券类商品的属性(如有效期)呢。

举例解读

通过用户名和密码进行登录是一个很常见的功能,一般通过设计一个 login 方法来实现,示例代码如下。

接口示例:

public interface SomeService {  String login((String username, String password);}

实现示例:

public class SomeServiceImpl implements SomeService{  @Override   public String login(String username, String password) {        User user = userRepository.findByUsername(username);        if (null == user) {            // 略        }        if (!user.verifyPassword(password)) {            // 略        }        Session session = sessionFactory.generate(user);        return session.getKey();    }}

看上去这个 API 没有什么问题,而且,也满足“tell-dont-ask”原则。但是这个方法内部其实做了两件事情:

  1. 检验用户名和密码的正确性,并且返回相应结果;
  2. 如果用户名和密码验证成功,则创建一个用户 session。按照“do one thing”的原则来设计这个功能,应该把这两件事情变成两个 API,示例代码如下。

接口示例:

public interface SomeService {  boolean verifyUserCredential(String username, String password);  String createUserSession(String username);}

实现示例:

public class SomeServiceImpl implements SomeService{    @Override    public boolean verifyUserCredential(String username, String password) {        User user = userRepository.findByUsername(username);        if (null == user) {            return false;        }        if (!user.verifyPassword(password)) {            return false;        }        return true;    }    @Override    public String createUserSession(String username) {        User user = userRepository.findByUsername(username);        if (null == user) {            //抛出用户未找到异常        }        Session session = sessionFactory.generate(user);        return session.getKey();    }}

使用示例:

if (someService.verifyUserCredential("zhangSan", "2088124567")) {    String sessionKey = someService.createUserSession("zhangSan");}

上述设计的好处是 verifyCredential 和 createUserSession 可以被分别独立使用,在某些场景下也许我们只需要为用户创建一个新的 session 而不一定需要再次输入用户名和密码,反之亦然。

2.4 不要基于实现设计 API

在设计 API 的时候,要避免陷入实现细节,API 应该与实现无关,它不能泄露实现相关的信息,以免误导用户。什么是实现细节呢?如过多地透露 API 的行为,以常见的 hash 方法为例,其实现方式很多(直接定址法、除留余数法、平方取中法、折叠法等),设计 API 时不应透露 hash 方法的实现方式。

bad:

public interface HashService {    int hashBasedOnDirectAddr(Object key);}

good:

public interface HashService {    int hash(Object key);}

2.5 Exception Or Error Code?

系统运行过程中难免出现异常,那么就 API 的设计而言,是抛出异常还是返回错误码呢?关于这个问题,业内争议不断,在我看来,两种方式并没有绝对的高下之分。不论是 exception 还是 error code,核心点在于当 API 产生错误的时候,API 的调用方是否可以清晰地理解错误信息,并据此做出正确的处理。

在复杂的系统中,error code 有一定优势。API 调用具有复杂的多层级调用关系——一个系统的调用者还会被其他系统调用,要一层层的抛出错误。如果采用 exception,调用层次太多时将难以分类,如果下游系统不能分类,上游也将无法为调用者分类,到终端调用者时,已经不知道该如何处理这个错误了,这种情况通常只能找维护人员解决。

当然,复杂系统采用 error code 的前提是错误处理需要有统一的规范,以下是几种常见的形式:

  1. {"message": "xxx", "code": "200", "success": true}
  2. {"message": "xxx", "code": "XXX_EXCEPTION_ERROR", "success": false}
  3. {"code": 500, "error": "msg xxx"}

使用 Exception

如下例子,API 的设计中使用了 unchecked exception。

接口示例:

public interface SomeService {    String createUserSession(String username) ;}

实现示例:

public class SomeServiceImpl implements SomeService{    @Override    public String createUserSession(String username) {        User user = userRepository.findByUsername(username);        if (null == user) {            throw new BusinessLogicException(40018, "no user found with given username");        }        Session session = sessionFactory.generate(user);        return session.getKey();    }}

异常 BusinessLogicException 定义。

public class BusinessLogicException extends RuntimeException {    private int errorCode;    public BusinessLogicException(int errorCode, String msg) {        super(msg);        this.errorCode = errorCode;    }    public int getErrorCode() {return errorCode;}}

使用示例:

String sessionKey = someService.createUserSession("testUser");

使用 Error Code

接口定义:

public interface SomeService {    SessionResult createUserSession(String username) ;}

实现示例:

public class SomeServiceImpl implements SomeService {    @Override    public SessionResult createUserSession(String username) {        SessionResult result = new SessionResult();        User user = userRepository.findByUsername(username);        if (null == user) {            result.setSuccess(false);            result.setErrorCode("NO_USER_FOUND");            result.setErrorDesc("no user found with given username");            return result;        }        Session session = sessionFactory.generate(user);        result.setSessionKey(session.getKey());        return result;    }}

SessionResult 定义:

public class SessionResult extends CommonResult {    private String sessionKey;    public String getSessionKey() {        return sessionKey;    }    public void setSessionKey(String sessionKey) {        this.sessionKey = sessionKey;    }

CommonResult 的定义:

public class CommonResult implements Serializable{    // 序列化相关省略    private boolean  success = true;    private String  errorCode;    private String  errorDesc;    // getter、setter 省略

2.6 避免 Flag 效果的参数

在设计 API 时,为了兼容不同的逻辑分支,有时会通过增加一个参数来实现不同分支的切换。如下示例:读取学生信息的 API 设计。

public interface SchoolService {    PaginatedResult> listStudents(boolean isGraduated);}

上面的设计并没有太大的问题,只是对 API 的调用方并不十分友好,在使用 API 的时候,参数 isGraduated 的作用可能会让调用方疑惑。其实,我们完全可以将上面的 API 设计成如下形式,清晰明了:

public interface SchoolService {    PaginatedResult> listInSchoolStudents();    PaginatedResult> listGraduatedStudents();}

2.7 名字很重要,API 即语言

在设计 API 的时候,还应给它取一个合适的名字,这样调用方在使用的时候会更容易。关于 API 命名,通常需要注意以下几个方面:

  1. API 的名字应该能自解释,即 API 的名字本身就可以很好地描述 API 的能力;
  2. 保持一致性,如 callback 应该在同系统所有的 API 中表示一样的意思;
  3. 保持对称性,如 set/get、read/write;
  4. 拼写准确,API 发布之后便无法更改,只能增加新的 API,直到旧 API 无人使用了才能废弃,因此发布的时候要注意检查拼写。如将 capacity 错写成 capicity。

good:

public interface SchoolService {    boolean addStudentToCourse(long studentId, String courseCode);}

better:

public interface SchoolService {    boolean enrollCourse(long studentId, String courseCode);}

2.8 使用尽量精确的数据结构

在设计 API 的时候,应尽量使用精确的数据结构,避免为复用而复用(其实是为偷懒),复用的一些数据结构可能与 API 本身并不十分匹配,甚至存在一些对使用者毫无意义的字段,导致使用者难以理解。

举个例子:

编辑单个商品 SKU 的库存和上线商品共用一个返回结果模型,但前者是单体操作,后者是批量操作,为了兼容这两种操作,返回对象里既包含单个的商品 id,又包含商品 id 列表;与此同时,错误信息里既包含单个错误信息,又包含一个错误信息列表。如此设计,无形中增加了调用方的学习成本,降低了效率。

2.9 给 API 建立文档

好的 API 也需要好的文档,否则也有可能“收获”骂声一片。同时,要站在用户的角度去写文档,而不是开发者的视角——“这个接口很简单,说明略”。API 的文档如同一份”合约“,不只是让 API 的使用方更容易使用和理解,更重要的是,让 API 的提供方按照这份”合约“来保证 API 的实现是对的。通常 API 的文档应包含以下内容:

  1. Maven 依赖
  2. 类、方法、参数、异常、错误码详细说明
  3. 使用范例(针对不同场景分别举例)
  4. 历史版本
  5. FAQ
  6. 注意及时更新

2.10 统一的规范

同一个系统提供的不同 API 之间应该遵循统一的规范,保持一致的风格,这样不仅有助于降低使用者的学习成本,而且可为后续迭代开发提供可遵循的范式。

在实践中,同一个系统往往由众多的开发者共同开发,如果没有统一的规范,开发者都按照自己的习惯设计、开发,那么,这样的系统无论对使用者还是维护者都将是一场噩梦。举个反例,数年前,在笔者参与开发的一个系统中,由于事先没有约定规范(统一的 API 模板),不同开发者对 API 的返回结果中错误码字段的定义完全不同,有的 API 用 errorCode,有的用 resultCode,有的用 code,有的甚至没有错误码字段。系统交付后,“收获”抱怨声一片。

3. 总结

关于如何设计一个好的 API,业界大牛们提出了很多优秀的设计理念,但是在实践中将这些优秀的理念“落地”却是相对困难的。就本文提及的“do one thing”和“tell-don't-ask”原则,两者之间就存在矛盾,对于经验不够丰富的工程师,如何在二者之间取得平衡是一个难题。

事实上,“do one thing”和“tell-don't-ask” 的侧重点是不一样的。“tell-don't-ask” 的关注点在服务层(按照 DDD 的说法就是应用层)的接口设计粒度应该做到“tell”,如上文中银行取钱(widthdraw 方法)的示例。而“do one thing”的侧重则在于保持代码的可维护性、可重用性、可测试性,在“widthdraw 方法”内部实现的时候,再按照“do one thing”原则把代码划分为独立的 method(getAccountById 和 updateAccount)进行组织。

不同的设计原则侧重会有不同,但并不是绝对的隔离,而是相辅相成的。

4. 参考文献

  • How to Design a Good API and Why it Matters
  • 视频:How To Design A Good API and Why it Matters
主题 02:如何设计系统预案(Preplan)?

1. 引言

所谓预案,是指根据评估分析或经验,对潜在的或可能发生的突发事件的类别和影响程度而事先制定的应急处置方案。预案并不是个新鲜词,《尚书·说命中》:“惟事事,乃其有备,有备无患。”其大意为:事先有防备便可以避免灾祸,强调了做预案的重要性。

在互联网领域,从系统设计、编码实现到后期维护,预案几乎贯穿全程。以下场景读者应该不陌生:

  • 淘宝首页“千人千面”推荐服务,读者应该都体验过。在接入推荐系统时,有一个必须考虑的问题——推荐服务不可用的场景下如何兜底,以最大限度保障用户体验,这就要求系统设计必须有一个 plan-B,详情下文将介绍。
  • 2019“双十一”天猫交易峰值达 54.4 万笔/秒,为了保障交易核心链路的稳定性,做了大量预案,如对非核心业务(如积分奖励)都做了提前降级预案。
  • 支付宝为了提高容灾能力,于 2018 年建成了目前全国唯一的“三地五中心”服务器网络,即在三座城市部署五个机房,一旦其中一个或两个机房发生故障,底层系统可快速识别并将故障机房的流量切换到运行正常的机房,并保障数据一致性和零丢失。

以上这些场景本质上都是为了保障系统稳定性而做的广义预案。在实践中,很多工程师倾向于系统的功能性建设,而忽视那些影响系统稳定性的非功能性建设,而这往往是导致系统故障的深层原因。作为一名工程师,特别是大型应用的工程师,稳定性设计能力尤为重要,鉴于此,笔者将从“预案”的角度切入,与读者一起探讨系统稳定性建设。

本文将围绕以下三个问题展开:

  • 什么是预案?
  • 为什么要做预案?
  • 如何做预案?

2. 为什么要做预案?

2.1 风险如影随形

在互联网领域,风险总是如影随形,没有什么系统是绝对稳定的。按照产生的原因,风险可以大致作如下划分:

  1. 自然风险,即自然因素所造成的风险,如设备老化、地震灾害、天气异常等。例如某个城市发生自然灾害,而造成公司部署在该城市的服务器机房不可用。
  2. 社会风险,即个人或团体在社会上的行为导致的风险,如战争、各地区法律变更、邮政罢工、网络攻击等。例如 2013 年 7 月,微信因上海某施工队挖断通信光缆而导致服务中断 7 小时;支付宝每天受到的网络攻击次数以亿计。
  3. 内部风险,即公司内部人员在设计、开发、变更、发布、运维等环节中引入的缺陷或错误的操作所造成的风险。在互联网领域,这类风险最为常见,如应用发布、调整 DB 结构、发布前端内容、调整网络、修改元数据配置、变更中间件、变更安全配置等等。

2.2 风险可导致严重后果

风险一旦发生,有可能导致一系列事故,引发难以接受的严重后果。

案例 1:2015 年 5 月 27 日,支付宝大规模宕机事故导致大量用户无法登陆或支付,故障持续长达两小时。这起事故给支付宝造成了严重的负面影响,导致用户满意度降低、品牌价值下滑、增长放缓以及经济损失等。

案例 2:2017 年“双十一”,某电商平台大促活动预热启动后不久,App 首页第二屏出现活动楼层“开天窗”的问题,经过定位并重新发布相关接口方才解决,给相关商家带来了巨大经济损失。

2.3 设计预案以应对风险

既然风险是客观存在的,同时又不能对其视而不见,那么,最好的处理方式便是积极地应对。在实践中,应对风险的时间阶段通常划分为事前预防、事中救援、事后处理三个阶段,与之对应的预案分别称为提前预案、应急预案、恢复预案。为了保障稳定性,通常需要在这三个阶段都有针对性地制定有效预案,但是,对于一个具体的系统,可能并不同时具备上述三个风险应对阶段,因此,风险处理预案的制定应结合实际情况决策。

3. 如何设计预案?

3.1 预案的生命周期

预案的生命周期一般可以分为规划阶段、实施阶段、演练阶段、生效阶段及失效阶段。

工程师实战方法论核心 12 讲_第4张图片

在实践中,预案设计在系统设计阶段就开始了,这一步是难点,因为需要识别业务场景的核心链路,以便分析各个环节所存在的风险。如果存在风险,则需要设计对应的预案策略;系统开发完成后,还需进一步通过预案演练来验证预案的有效性,以确保风险发生时预案可以随时生效。此外,考虑到系统变更(如各种优化、迭代),可能导致预案失效,因此需定期验证预案。

3.2 预案规划 & 实施

1. 识别待实施点

针对具体的业务,理清业务的核心链路,整体分析链路中潜在的风险点,这些点便是预案的待实施点。以淘宝 App 首页商品数据投放为例,商品数据来源主要有两个:运营定向投放和算法推荐投放。数据投放的基本链路如下图所示:

工程师实战方法论核心 12 讲_第5张图片

2. 充分分析风险

接续上面的例子,在此单就服务端进行分析,如果算法平台、运营平台或数据平台因故障而导致服务不可用,在没有预案的情况下,商品数据投放将无法正常进行,进而导致客户端异常,影响用户体验。

工程师实战方法论核心 12 讲_第6张图片

3. 思考预案策略

在充分分析风险点之后,下一步便是针对这些风险点制定应对预案(策略)。接续上面的例子,针对算法投放服务和运营投放服务因平台故障而导致不可用的场景,需要制定一个预案来保障商品数据投放的可用性。在此列举几种可行的方案。

  • 策略一

运营定投与算法投放互为灾备方案,即当运营投放服务不可用时,商品数据投放全部依赖算法推荐;当算法推荐服务不可用时,全部采用运营投放服务提供的数据。如此,虽然可以提高服务的可用性,但是,若两个服务同时不可用则仍然存在可用性风险,当然,这种情况概率极低——假设两个服务不可用的概率均为 1%,则同时不可用的概率为 1%*1% = 1/10000。

工程师实战方法论核心 12 讲_第7张图片

  • 策略二

针对首页商品投放位的数据以配置推送的形式兜底,即针对首页商品投放位,预先由运营人员准备好合适的数据(比如普适性商品),当运营定投服务或算法推荐服务不可用时,根据投放位从兜底数据中直接获取商品数据进行兜底,从而保障可用性。

工程师实战方法论核心 12 讲_第8张图片

  • 策略三

针对运营定投服务和算法推荐服务分别设计预案。考虑到运营定投服务数据相对固定,个性化程度低,因此仍采用方案二所述方式进行兜底;相较而言,算法推荐是一种个性化的服务,号称千人千面,因此,宜采用基于用户人群的兜底方案——根据用户的特征数据集将用户划分为不同的人群,相同的人群具有相似的购买倾向,然后针对每个人群分别缓存推荐商品数据并实时更新,当推荐算法服务不可用时,可根据用户所属人群获取相应的数据兜底,从而保障用户体验。相较于方案二,此方案更复杂,但体验更好。

工程师实战方法论核心 12 讲_第9张图片

在实践中,上述三种策略常常组合使用,以求最大限度地保障可用性和用户体验。

4. 预案落地

针对同一个风险点,通常有多种可行的应对预案,对于这些预案应从落地成本、负面影响、可维护性等多个维度考量,确定最合适的预案(最好的预案未必是最合适的)。确定预案之后,下一步便是将其落地,不同的预案涉及的技术细节和要点大相径庭,本文不做展开。

3.3 预案演练

预案规划并实施完成后,为了确保预案的有效性,必须进行预案演练。演练需要注意以下事项:

  • 预案演练之前应通知上下游,知会其预案演练过程中可能对其造成的影响;
  • 预案相关的业务应配置监控,以便实时监控预案的执行效果;
  • 预案演练应准备对应的回滚预案,一旦预案演练出现问题,可以及时回滚止血;
  • 演练应在线上环境进行,以确保场景真实;
  • 对于会影响用户体验的预案,演练应在流量低谷进行,如凌晨三点至六点,持续时间不宜过长,以尽量降低对用户的影响;
  • 预案演练完成后,应注意收集、记录演练数据,并充分评估预案的有效性;
  • 如果预案演练结果不符合预期,在修复问题后应重新演练,直到符合预期。

3.4 预案生效 & 预案失效

预案演练完成并通过有效性验证后,预案就正式生效了。在大型互联网企业,通常有专门的预案平台负责管理预案,预案可以由预设的条件触发执行,也可以人工手动执行。

预案不是一次性工作,生效后需要定期维护和更新。在实践中,由于业务逻辑的变更、技术方案的更迭,有可能导致现有预案失效而需要我们替换预案或者对原有预案作优化,甚至重新设计预案。

3.5 补充说明

预案的类型和处理方式有很多,上文所举例子中仅仅涉及“灾备预案”、“兜底预案”,更常见的如“多级缓存预案”、“限流降级预案”等,读者可以根据自身所负责业务的特点,针对性地分析和处理。

须知,方式和工具不是预案的重点,重点在于稳定性思维的培养,所谓预案,实则为保障系统稳定性的 Plan-B。

4. 扩展:“三维”开发

预案本质上是为保障系统稳定性服务的,在阿里、腾讯、华为等一线企业核心部门,有一个普遍共识:稳定压倒一切,是第一原则。这不难理解,试想一个不稳定的系统,无论提供的服务多么亮眼,你敢把“后背”交给它吗?

作为一名工程师,在职业生涯中会经历下图所示的三个阶段:

  • 初级阶段,仅考虑如何实现需求,聚焦于功能层面,缺乏设计能力和抽象能力。
  • 中级阶段,开始考虑系统扩展性,在开发中注重模型抽象和接口设计,善于运用一些设计模式优化业务实现逻辑。
  • 高级阶段,在抽象和实现的基础上,注重系统稳定性考量,能敏锐洞悉系统潜在的风险点,同时将应对策略融入系统设计中。

工程师实战方法论核心 12 讲_第10张图片

阅读本文的读者,相信大都已经历过前两个阶段,而第三个阶段作为进阶之路必须具备的能力却往往被忽视。在此,特别强调,在开发中须养成“三维开发”的意识,实现、抽象、稳定,全面考量。

5. 总结

本文以三个 W(什么是预案?为什么要做预案?如何设计预案?)为主线展开,介绍了系统预案相关的内容,其中,结合案例重点介绍了系统预案的设计方法。

文中曾提及:预案的本质是为系统稳定性服务的。换句话说,设计预案的前提一定是系统的稳定性存在风险,且一旦风险发生,所导致的结果将难以接受。在设计系统的时候,稳定性是必须充分考虑的,但是,预案却并非必须。

预案通常是从系统层面考虑,但落实到具体的开发实践中则不然,比如代码容错设计本身也可划归预案的范畴。预案可大可小,可上可下,在实践中,读者不必拘泥、纠结,一切为保障系统稳定性而做的设计都可归属于广义的预案。

主题 03:如何设计模型
主题 04:如何设计一个复杂的系统(上)
主题 04:如何设计一个复杂的系统(下)
主题 05:如何评估软件系统的吞吐量
主题 06:如何高效地排查 Java 系统异常
主题 07:如何进行 Java 系统性能优化(上)
主题 07:如何进行 Java 系统性能优化(下)
主题 08:如何熟悉一个完全陌生的系统
主题 09:如何画好系统设计图
主题 10:如何将工作中的创新点转化为专利
主题 11:如何打造能力护城河
主题 12:案例 (1) 系统架构实践
主题 12:案例 (2) 业务模型抽象实践
主题 12:案例 (3) 稳定性保障实践
主题 12:案例 (4) 推荐实践

阅读全文: http://gitbook.cn/gitchat/column/5e61b62ed0cbbb4557e7c257

你可能感兴趣的:(工程师实战方法论核心 12 讲)