本次的课题让我很头疼,
后端开发最佳实现
— 我觉得我并没有资格来指导别人要怎么实践才是最佳,但好像也只能硬着头皮上了。
什么是最佳实践?
我曾经参与一个系统,有个物品管理功能,产品提出需求:物品名查询不仅仅要支持中文查询,还要要支持拼音查询。当时有个同事想要引入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
拆分成UserService
和OrderService
,那我在修改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;
}
}
以上例子,应当把User
的save
方法统一收拢到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() {
……
}
}
根据不同场合,提供调用者需要的方法,屏蔽不需要的方法。
比如说电子商务的系统,有订单
这个领域对象
,有三个地方会使用到:
- 门户,只能有查询方法。
- 外部系统,有添加订单的方法。
- 管理后台,添加、删除、修改、查询都要用到。
interface OrderForPortal {
String getOrder();
}
interface OrderForOtherSys {
String insertOrder();
String getOrder();
}
interface OrderForAdmin {
String deleteOrder();
String updateOrder();
String insertOrder();
String getOrder();
}
任何层次的软件设计,如果依赖了它不需要的东西,就会带来意料之外的麻烦。
我以前在写项目公共包时,建了个common
模块,所以封装的公共类都放进去,比如Redis
工具类、Kafka
工具类等。到最后我发现我的管理系统(manager
)明明没有用到Redis
、Kafka
,但因为引入了common
包,导致也被动地引入了Redis
、Kafka
的依赖包。这样项目发布时打出来的jar包里,就会变得很大,同时如果由于安全等问题要升级第三方包时,manager
明明没有用到,却因为引入了相关的jar
包而只能跟着重新发布。后来我学apache
的commons
做法,将common
拆分成common-base
、common-kafka
、common-redis
等。
如果升级到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
服务。
改为这种模式之后,不管是开发(不用写服务接口对接)、问题排查还是运维实施,都提升了效率。
单体应用的优势:
- 开发简单。
- 测试简单。
- 快:只有进程内的延迟。
- 部署简单。
现在当我在拆分系统的时候,我都会想下“你真的需要微服务吗?”。当业务比较小、或者已经拆的足够细的情况下,不一定非要用微服务模式
。甚至可以说,在中小企业,大多数应用程序采用单体架构就足够了
本原则举的例子,两个方案哪个好,其实是
仁者见仁智者见智
,只是从我个人的实践结果上,我更喜欢后面那种方案。
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
查看当前测试覆盖率。
对质量负责
认真对待自己的代码/架构。
了解自己所使用的框架的原理:只有了解自己所使用的框架/工具,才能更好的使用它。
- 我见过一个系统,由于开发者对
redis
性能的低估,导致本来一个redis
就能搞定的事情,他用了4
个redis
来分担压力。 - 最好的学习方式是分享,分享过程中,可能会面对各种提问,这种压力迫使你必须清楚各个细节,当你能给别人讲清楚的知识点时,才是真正的掌握了。
- 我见过一个系统,由于开发者对
- 重视监控:我刚进上家公司接手的APP推送服务,由于一些特殊原因,这个项目之前在海外有个云服务器节点,云服务器上部署了
Tomcat
、数据库
并独立运行服务。这台云服务没有任何监控,我们当时把“对这海外服务节点的改造”排进了计划,但是因为这个节点已经跑了很久都没有出问题,所以优先级设的并不高。突然有一天,部分用户的APP不停的收到弹窗消息,排查发现是由于磁盘空间满了,导致mysql无法修改数据状态(要改成已推送状态)(磁盘空间满导致mysql无法写入binlog),推送任务扫描未推送的消息一直重复推送。当时影响到了10W用户,最后公司给了这些用户一些补偿。 心态很重要:开发排期压力、无休止的做重复工作、不停歇加班、需求频繁修改等,都会影响我们的心态,必须要注意的是负面心态很可能影响我们的开发质量。
- 我在厌倦的时候,会自己写一些与详细业务无关的代码,比如通用工具类,来调整自己的心态。
- 提交代码时,我会重新过一遍自己修改的地方,修改不干净的代码。提交代码就表示已经完成了某一阶段的开发,这时候心态是不一样的,会比较有耐心地走查、优化代码。
不要让交接代码的人吐槽你的代码。
参考:
【译】浅谈SOLID原则
SOLID Principles-simple and easy explanation
面向对象的SOLID原则白话篇
单体到微服务是一个演化过程,别在一开始就过度设计
测试驱动开发
测试驱动开发的概述
测试驱动开发是否已死