分享:后端开发最佳实践

本次的课题让我很头疼, 后端开发最佳实现 — 我觉得我并没有资格来指导别人要怎么实践才是最佳,但好像也只能硬着头皮上了。

什么是最佳实践?

我曾经参与一个系统,有个物品管理功能,产品提出需求:物品名查询不仅仅要支持中文查询,还要要支持拼音查询。当时有个同事想要引入ElasticSearch,用ElasticSearch拼音分词器来实现。这看起来确实是一个很好的实现方式,谷歌上按关键字查询,排在前面的基本上也都是这种方式,那它是不是最佳实践呢?
当时的背景是:这个物品表只有几十条数据,由于业务的局限,未来在可预见的范围内也不会过千。
基于这一点,ElasticSearch方案其实就是“杀鸡用牛刀”,还提高了维护成本。后来采用的方案是增加一个拼音字母字段,保存时由工具类生成值,虽然看起来比较,但是胜在简单快速。

回到什么是最佳实践这个问题:在结合当时的环境下,适合自己的就是最佳的。
比我接手过一个比较老的系统,为了提升性能,在批量查数据时对MySQL有些现在看起来比较奇怪的用法,而那些奇怪(繁琐)的用法,对于现在的我们来说,只是引入redis就可以解决的,但是那种实现方案,在没有(或者还未流行)分布式缓存的当时,可能就是最佳方案了。
系统一直在更新,技术一直改变化,我们的团队/个人技术水平也一直在变化,有时候你认为的最佳的方案,随着时间的推移、团队/个人的的成长、系统的扩大等,最后发现还有更佳的方案。所以我本节课要讲到的一些最佳实践,以后也可能被我自己全部推翻掉。

系统目标

同任何事物一样,软件也是有生命周期的,我们开发的系统,要么跟随业务一起死亡,要么由于无法维护(维护成本过高)而被废弃。当设计一个系统时,我们的目标就是用最小的人力成本来满足构建和维护系统的需求,终极目标就是做到不能比业务先死亡
要构建一个好的软件系统,应该从写整洁的代码开始,代码于系统,就像砖头于房子,砖头质量不好,那房子质量肯定也不会好。软件行业经过了几十年的发展,前辈们根据踩的坑总结了一些经验,现在我们可以站在他们的肩膀上,遵循他们总结的一些最佳实践。

所以本次分享,我也只是总结了一些前辈们的设计原则。

SOLID原则

SOLID是由罗伯特·C·马丁提出,面向对象编程的五个基本原则,可以帮助我们开发一个容易维护和扩展的系统。

SRP:单一职责原则
A module should have one, and only one, reason to change.
一个软件模块都应该有且仅有一个被修改的原因。

这个原则经常被说成是一个方法只能做一件事,但这并不是SRP的全部,SRP应该是一个模块应该只为某一类行为负责,它包含了一个微服务只负责一类服务一个类只负责一类职责一个方法只做一件事。这样做的好处是,我们对一个业务做修改时,确保不会影响到另外一个业务。
比如

public interfacer DemoService {
    void saveUser(User user);
    void saveOrder(Order order);
}

以上DemoService很明显就违反了SRP:当多人为了不同业务(目的)修改同一份源码时,首先代码合并就很容易产生问题。如果我们将DemoService拆分成UserServiceOrderService,那我在修改User相关逻辑时,我可以确保Order业务不会被我影响到。
以上的例子,我现实中很少发现,但如果我把一个类只负责一类职责反过来讲成同一类职责只由同一个类来负责,那以下的例子我就经常见到了。

public class Demo1ServiceImpl implements Demo1Service {
    private UserDao userDao;
    public void demo1() {
        ……
        userDao.save(user);
    }
}
public class Demo2ServiceImpl implements Demo2Service {
    private UserDao userDao;
    public void demo2() {
        ……
        userDao.save(user);
    }
}
public class Demo3Controller {
    private UserDao userDao;
    public DemoResp demo3() {
        ……
        userDao.save(user);
        ……
        return resp;
    }
}

以上例子,应当把Usersave方法统一收拢到UserService里,其他Service不应该直接调用UserDao(特别是Controller层更不应该跨层直接调用Dao)。假如后续我们希望User在保存时,能判断某些字段如果没有值就设成默认值(比如createTime设成当前时间),不收拢的话就要在多个地方修改了。

OCP:开闭原则
A software artifact should be open for extension but closed for modification.
对扩展开放,对修改关闭。

我们还是以代码为例:

class Rectangle extends Shape {
    private int width;
    private int height;
    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }
}
class Circle extends Shape {
    private int radius;
    public Circle(int radius) {
        this.radius = radius;
    }
}
class CostManager {
    public double calculate(Shape shape) {
        double costPerUnit = 1.5;
        double area;
        if (shape instanceof Rectangle) {
            area = shape.getWidth() * shape.getHeight();
        } else {
            area = shape.getRadius() * shape.getRadius() * pi();
        }
        return costPerUnit * area;
    }
}

如果这时候增加一个正方形,那么我们就需要修改calculate方法的代码。这就破坏了开闭原则。
根据OCP原则,我们不能修改原有代码,但是我们可以进行扩展。我们可以把计算面积的方法放到Shape类中,再由每个继承它的子类自己去实现自己的计算方法。这样就不用修改原有的代码了。

public abstract class Shape {
    public abstract double calculateArea();
}
class CostManager {
    public double calculate(Shape shape) {
        double costPerUnit = 1.5;
        return costPerUnit  * shape.calculateArea();
    }
}
我们在阅读 Spring源码时,会发现 BeanPostProcessor无处不在, Spring的很多特性都是基于 BeanPostProcessor扩展的。
LSP:里氏替换原则
Functions that use pointers of references to base classes must be able to use objects of derived classes without knowing it.
子类可以替代父类

根据里氏替换原则,我们可以在接受抽象类(接口)的任何地方用它的子类(实现类)来替代它们。
子类可以替代父类这个规则看起来很简单,但现实中还是经常出现问题。
长方形/正方形问题:

class Rectangle {
    private int width;
    private int height;
    public void setWidth() { …… }
    public void setHeight() { …… }
}
class Square extends Rectangle {
    ……
}
class User  {
    public void operate() {
        Rectangle rectangle = new Square();
        rectangle.setWidth(5);
        rectangle.setHeight(2);
        ……
    }
}

以上例子,Square是不能替代Rectangle,因为Rectangle允许分别修改高和宽,而Square高和宽必须是一样的,而对于User来说,它是不知道Square的规则的,因为它操作的是Rectangle
还有经典的企鹅是鸟的子类问题,Bird类有个fly方法,由于企鹅不会,只能在fly方法里抛出异常。但是对于使用方来说,他们并不知道会有不会飞的鸟,就会出现系统问题。(当然可以在fly接口定义上做注释或者显示throw异常,但是这就增加了使用方的使用成本,并且也让人困惑。)
在系统的设计和编程实现中,我们应该认真地思考系统中各个类之间的继承关系是否合适。

ISP:接口隔离原则
A client should not be forced to implement an interface that it doesn’t use.
不能强制客户端实现它不使用的接口。

以商家接入移动支付API的场景举例,微信支持收费退费;支付宝接口只支持收费

interface PayChannel {
    void charge();
    void refund();
}
class WeChatChannel implements PayChannel {
    public void charge() {
        ……
    }    
    public void refund() {
        ……
    }
}
class AlipayChannel implements PayChannel {
    public void charge() {
        ……
    }    
    public void refund() {
        // 没有任何代码
    }
}

第二种支付渠道,根本没有退款的功能,但是由于实现了PayChannel,又不得不将refund()实现成了空方法。那么,在实际运行中,使用者明明是可以调用这个方法,但调用了却什么都没有做!
可以改成以下方式:

interface PayableChannel {
    void charge();
}
interface RefundableChannel {
    void refund();
}
class WeChatChannel implements PayableChannel, RefundableChannel {
    public void charge() {
        ……
    }    
    public void refund() {
        ……
    }
}
class AlipayChannel implements PayableChannel {
    public void charge() {
        ……
    }    
}

根据不同场合,提供调用者需要的方法,屏蔽不需要的方法。
比如说电子商务的系统,有订单这个领域对象,有三个地方会使用到:

  1. 门户,只能有查询方法。
  2. 外部系统,有添加订单的方法。
  3. 管理后台,添加、删除、修改、查询都要用到。
interface OrderForPortal {
    String getOrder();
}
interface OrderForOtherSys {
    String insertOrder();
    String getOrder();
}
interface OrderForAdmin { 
    String deleteOrder();
    String updateOrder();
    String insertOrder();
    String getOrder();
}

任何层次的软件设计,如果依赖了它不需要的东西,就会带来意料之外的麻烦。
我以前在写项目公共包时,建了个common模块,所以封装的公共类都放进去,比如Redis工具类、Kafka工具类等。到最后我发现我的管理系统(manager)明明没有用到RedisKafka,但因为引入了common包,导致也被动地引入了RedisKafka的依赖包。这样项目发布时打出来的jar包里,就会变得很大,同时如果由于安全等问题要升级第三方包时,manager明明没有用到,却因为引入了相关的jar包而只能跟着重新发布。后来我学apachecommons做法,将common拆分成common-basecommon-kafkacommon-redis等。
分享:后端开发最佳实践_第1张图片

如果升级到java11,也可以用模块化来处理。
DIP:依赖倒置原则
High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.
高层模块不应该依赖于低层的模块,它们都应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象。

DIP原则告诉我们:如果想要设计一个灵活的系统,依赖关系就应该多引用抽象类型,而非具体实现。

public class MySQLConnection {
    public void connect() {
        System.out.println("MYSQL Connection");
    }
}
public class PasswordReminder {
    private MySQLConnection mySQLConnection;
    public PasswordReminder(MySQLConnection mySQLConnection) {
        this.mySQLConnection = mySQLConnection;
    }
}

上面的例子里,高层模块PasswordReminder依赖于低层模块MySQLConnection的,这不符合依赖倒置原则。如果想要把MySQLConnection改成MongoConnection,那就要在PasswordReminder中更改硬编码的构造函数注入。应改成以下方式:

public interface Connection {
    void connect();
}
public class MySQLConnection implements Connection {
    public void connect() {
        System.out.println("MYSQL Connection");
    }
}
public class PasswordReminder {
    private Connection connection;
    public PasswordReminder(Connection connection) {
        this.connection = connection;
    }
}

KISS 原则

Keep It Simple, Stupid.
系统的设计应保持简洁和简单,而不掺入非必要的复杂性。

方案和架构的原则,就是尽量简单,越简单出问题的概率越低,也越好维护,但是也不能为了简单而抛弃了扩展性等,这要根据实际情况找到平衡点。
我发现很多人,包括我,要搭建一套系统的时候,总是习惯性地按微服务思想拆分出几个模块,但实际上很多系统已经没必要再拆了。每多拆出一个服务,请求就会多转发一次请次,服务节点多维护一个。系统/流程越复杂,出问题的概率越大。

当你做的是管理系统简单的用户管理功能时,就别想着搞什么复杂的设计了。

我以前参与前公司推送服务系统开发,我们按微服务思想将系统根据业务拆分成APP推送短信推送邮件推送等服务。
每个业务下面又会按职能拆分成多个模块,以APP推送为例,我们按对接公司内部业务(主动推送)、对接互联网APP用户(主动拉取)、管理系统(manager)定时任务(job)拆分模块,这几个模块肯定都会有相同的逻辑,比如获取消息内容推送黑/白名单等,所以就抽出一个common-service公共服务模块,我当时拆的更细,把调用最频繁的消息相关的操作再独立拆出一个message-service服务。
分享:后端开发最佳实践_第2张图片
改为这种模式之后,不管是开发(不用写服务接口对接)、问题排查还是运维实施,都提升了效率。
单体应用的优势:

  1. 开发简单。
  2. 测试简单。
  3. 快:只有进程内的延迟。
  4. 部署简单。

现在当我在拆分系统的时候,我都会想下“你真的需要微服务吗?”。当业务比较小、或者已经拆的足够细的情况下,不一定非要用微服务模式。甚至可以说,在中小企业,大多数应用程序采用单体架构就足够了

本原则举的例子,两个方案哪个好,其实是 仁者见仁智者见智,只是从我个人的实践结果上,我更喜欢后面那种方案。

YAGNI原则

You aren’t gonna need it.
不要过度的设计。

程序开发的前期阶段为了程序更好的扩展性,有时候会做一些超越目前需求的设计,但是臆想中的需求事实上往往是不存在的。
我以前做一个广告投放系统,系统有:广告计划广告策略组。他们之间的关系是:一个广告计划下选择配置一个对应的广告策略组,一个广告策略组可以被多个广告计划配置,也就是广告计划广告策略组的关系是多对一(n:1)。我问我们的产品,一个广告计划以后是不是可以选择多个广告策略组,产品的回答是“短期内没有这规划,以后不敢保证”。我当时考虑到这个可能,代码上设计成广告计划广告策略组的关系是多对多(n:m包含了n:1)。由于复杂的投放逻辑,实际开发中多对多的复杂度比多对一高了好几倍,后续需求迭代时我都会想:如果当时做成一对多我应该很快就能完成了。再后来我终于受不了了,花了两个礼拜时间把系统重构了一遍。直到我把项目交接给别人,多对一的关系一直都没变。
但是这个事情一直困扰着我:我这种做法到底是不是正确的,如果后面产品真的提出多对多呢。只是从结果上看,我后面改回多对一的做法是正确的,这也是YAGNI原则的一个核心思想:大部分的超前设计,最后都不会被用到,反而会迷惑其他的开发者。

TDD:测试驱动开发

测试也是系统的一部分,无论怎么强调测试的重要性都不为过。
龟兔赛跑的故事告诉我们:稳才是最重要的。

TDD的基本思路就是通过测试来推动整个开发的进行。原理是在开发功能代码之前,先编写单元测试用例代码,测试代码确定需要编写什么产品代码。

  • 先写测试,并执行,得到失败的结果。测试先行并不是说不需要思考,直接开始写代码,而在开始写代码之前要进行需求分析,测试代码其实是产品代码的“用户”,在写测试代码时你就要考虑如何“使用”产品代码
// step1
@Controller
public class DemoController {
    public boolean greaterEqualThan0(int value) {
        return false;
    }
}
@Test
public void greaterEqualThan0() {
    Assert.assertTrue(demoController.greaterEqualThan0(0));
}
  • 快速实现代码,让测试通过。以最快的速度让测试变绿,意味着我们通常用最直接但可能并不优雅的方式,比如复制代码。
// step2
@Controller
public class DemoController {
    public boolean greaterEqualThan0(int value) {
        return true;
    }
}
  • 添加新的测试用例,并重复“开发-测试”直到所有的场景都测试通过。
// step3
@Test
public void greaterEqualThan0Negative() {
    Assert.assertFalse(demoController.greaterEqualThan0(-1));
}
@Controller
public class DemoController {
    public boolean greaterEqualThan0(int value) {
        if(value >= 0) {
            return true;
        }
        return false;
    }
}
  • 重构代码,去掉坏代码,并保证测试通过。
// step4
@Controller
public class DemoController {
    public boolean greaterEqualThan0(int value) {
        return value >= 0;
    }
}
  • 重复上述以推动功能实现。每个周期的修改部分应该尽可能小,软件开发是复杂性非常高的工作,小步前进是降低复杂性的好办法。

TDD的优点:在任意一个开发节点都可以拿出一个可以使用,含少量bug并具一定功能和能够发布的产品。
TDD的缺点:增加代码量。测试代码是系统代码的两倍或更多,但是同时节省了调试程序及挑错时间。

不同代码的测试应该相互独立,比如Controller类里的方法虽然依赖了Service,但是它测试代码不应该去测试用到的Service方法,可以通过 Mock来摆脱依赖。
可以借助 IDEA查看当前测试覆盖率。
分享:后端开发最佳实践_第3张图片
分享:后端开发最佳实践_第4张图片

对质量负责

认真对待自己的代码/架构。
  • 了解自己所使用的框架的原理:只有了解自己所使用的框架/工具,才能更好的使用它。

    • 我见过一个系统,由于开发者对redis性能的低估,导致本来一个redis就能搞定的事情,他用了4redis来分担压力。
    • 最好的学习方式是分享,分享过程中,可能会面对各种提问,这种压力迫使你必须清楚各个细节,当你能给别人讲清楚的知识点时,才是真正的掌握了。
  • 重视监控:我刚进上家公司接手的APP推送服务,由于一些特殊原因,这个项目之前在海外有个云服务器节点,云服务器上部署了Tomcat数据库并独立运行服务。这台云服务没有任何监控,我们当时把“对这海外服务节点的改造”排进了计划,但是因为这个节点已经跑了很久都没有出问题,所以优先级设的并不高。突然有一天,部分用户的APP不停的收到弹窗消息,排查发现是由于磁盘空间满了,导致mysql无法修改数据状态(要改成已推送状态)(磁盘空间满导致mysql无法写入binlog),推送任务扫描未推送的消息一直重复推送。当时影响到了10W用户,最后公司给了这些用户一些补偿。
  • 心态很重要:开发排期压力、无休止的做重复工作、不停歇加班、需求频繁修改等,都会影响我们的心态,必须要注意的是负面心态很可能影响我们的开发质量。

    • 我在厌倦的时候,会自己写一些与详细业务无关的代码,比如通用工具类,来调整自己的心态。
    • 提交代码时,我会重新过一遍自己修改的地方,修改不干净的代码。提交代码就表示已经完成了某一阶段的开发,这时候心态是不一样的,会比较有耐心地走查、优化代码。
不要让交接代码的人吐槽你的代码。

参考:

【译】浅谈SOLID原则
SOLID Principles-simple and easy explanation
面向对象的SOLID原则白话篇
单体到微服务是一个演化过程,别在一开始就过度设计
测试驱动开发
测试驱动开发的概述
测试驱动开发是否已死

你可能感兴趣的:(后端,java)