如何提高代码的扩展性
一、架构的高可用?(不是本文的重点)
(1)【客户端层】到【反向代理层】的高可用,是通过反向代理层的冗余来实现的。以nginx为例:有两台nginx,一台对线上提供服务,另一台冗余以保证高可用,常见的实践是keepalived存活探测,相同virtual IP提供服务。当nginx挂了的时候,keepalived能够探测到,会自动的进行故障转移,将流量自动迁移到shadow-nginx,由于使用的是相同的virtual IP,这个切换过程对调用方是透明的。
(2)【反向代理层】到【站点层】的高可用,是通过站点层的冗余来实现的。假设反向代理层是nginx,nginx.conf里能够配置多个web后端,并且nginx能够探测到多个后端的存活性。当web-server挂了的时候,nginx能够探测到,会自动的进行故障转移,将流量自动迁移到其他的web-server,整个过程由nginx自动完成,对调用方是透明的。
(3)【站点层】到【服务层】的高可用,是通过服务层的冗余来实现的。“服务连接池”会建立与下游服务多个连接,每次请求会“随机”选取连接来访问下游服务。当service挂了的时候,service-connection-pool能够探测到,会自动的进行故障转移,将流量自动迁移到其他的service,整个过程由连接池自动完成,对调用方是透明的(所以说RPC-client中的服务连接池是很重要的基础组件)。
(4)【服务层】到【缓存层】的高可用,是通过缓存数据的冗余来实现的。
缓存层的数据冗余又有几种方式:第一种是利用客户端的封装,service对cache进行双读或者双写。缓存层也可以通过支持主从同步的缓存集群来解决缓存层的高可用问题。当redis主挂了的时候,sentinel能够探测到,会通知调用方访问新的redis,整个过程由sentinel和redis集群配合完成,对调用方是透明的。
(5)【服务层】到【数据库读】的高可用,是通过读库的冗余来实现的。
既然冗余了读库,一般来说就至少有2个从库,“数据库连接池”会建立与读库多个连接,每次请求会路由到这些读库。当读库挂了的时候,db-connection-pool能够探测到,会自动的进行故障转移,将流量自动迁移到其他的读库,整个过程由连接池自动完成,对调用方是透明的(所以说DAO中的数据库连接池是很重要的基础组件)。
(6)【服务层】到【数据库写】的高可用,是通过写库的冗余来实现的。
以mysql为例,可以设置两个mysql双主同步,一台对线上提供服务,另一台冗余以保证高可用,常见的实践是keepalived存活探测,相同virtual IP提供服务。自动故障转移:当写库挂了的时候,keepalived能够探测到,会自动的进行故障转移,将流量自动迁移到shadow-db-master,由于使用的是相同的virtual IP,这个切换过程对调用方是透明的。
X轴和Z轴已经趋于成熟。以后的发展方向必定是业务功能的发展,代码的高可用。
二、软件项目的变化(为什么要提高代码的可扩展性【背景】)
软件设计的唯一产出物---代码
面向对象的目的是模块化
三、什么是高内聚、低耦合
模块就是从系统层次去分成不同的部分,每个部分就是一个模块!分而治之, 将大型系统的复杂问题,分成不同的小模块,去处理问题!
耦合:主要是讲模块与模块之间的联系
例如:如果模块A直接操作了模块B的数据,这种操作模块与模块之间就为强耦合,甚至可以认为这种情况之下基本算没有分模块!如果A只是通过数据与B模块交互,这种我们称之为弱耦合!微服务独立的模块,方便去维护,或者写单元测试等等...如果木块之间的依赖非常严重,将会非常不易于维护。
内聚:主要指的是模块内部【东西聚合在一起形成了一个模块】例如方法,变量,对象,或者是功能模块。
模块内部的代码, 相互之间的联系越强,内聚就越高, 模块的独立性就越好。一个模块应该尽量的独立,去完成独立的功能!如果有代码非得引入到独立的模块,建议拆分成多模块!低内聚的代码,不好维护,代码也不够健壮。
四、软件设计的目的
1、如何评价代码的质量
最重要的是:灵活性;可扩展性;可维护性;可读性。
2、如何实现代码的高质量?
遵循SOLID设计原则:(接口设计原则)参考依据高内聚、低耦合
单一职责原则:一个类值负责一个功能的职责
开闭原则:扩展开放,修改关闭。
里氏代换原则:使用父类的地方都能使用子类对象
依赖倒转原则:针对接口编程,
接口隔离原则:针对不同部分用专门接口,不用总接口,需要哪些接口就用哪些接口
你认为下图的设计违反了哪一种设计原则?
(1)违反了单一职责原则(SRP)
画图和计算面积并不是单一职责,计算几何学应用程序只计算面积不画图,但是还要引入GUI。
应该有且仅有一个原因引起类的变更。简单点说,一个类,最好只负责一件事,只有一个引起它变化的原因。也就是说引起类变化的原因只有一个。高内聚、低耦合是软件设计追求的目标,而单一职责原则可以看做是高内聚、低耦合的引申,将职责定义为引起变化的原因,以提高内聚性,以此来减少引起变化的原因。职责过多,可能引起变化的原因就越多,这将是导致职责依赖,相互之间就产生影响,从而极大的损伤其内聚性和耦合度。单一职责通常意味着单一的功能,因此不要为类实现过多的功能点,以保证实体只有一个引起它变化的原因。
(2)开闭原则
对扩展开放。模块对扩展开放,就意味着需求变化时,可以对模块扩展,使其具有满足那些改变的新行为。换句话说,模块通过扩展的方式去应对需求的变化。
对修改关闭。模块对修改关闭,表示当需求变化时,关闭对模块源代码的修改,当然这里的“关闭”应该是尽可能不修改的意思,也就是说,应该尽量在不修改源代码的基础上面扩展组件。
一个开闭原则的简单实例(懂则不用看)
对拓展开放,对修改关闭:比如当某个业务增加,不是在原类增加方法,而是增加原类的实现类。
下面的例子是一个非常典型的开闭原则及其实现。非常简单,但却能够很好的说明开闭原则。
假设有一个应用程序,能够计算任意形状面积。这是几年前我在明尼苏达州农作物保险公司遇到的一个非常简单问题。app程序必须能够计算出指定区域的农作物总的保险报价。正如你所知道的,农作物有各种形状和大小,有可能是圆的,有可能是三角形的也可能是其他各种多边形。
OK,让我们回到我们之前的例子中....
作为一名优秀的程序员,我们将这个面积计算类命名为 AreaManager。这个 AreaManager是单一职责的类:计算形状的总面积 。
假设我们现在有一块矩形的农作物,我omen用一个Rectangle类来表示。相关类代码如下:
public class Rectangle {
private double length;
private double height;
// getters/setters ...
}
public class AreaManager {
public double calculateArea(ArrayList<Rectangle>... shapes) {
double area = 0;
for (Rectangle rect : shapes) {
area += (rect.getLength() * rect.getHeight());
}
return area;
}
}
AreaManager类现在运行良好,直到几周之后,我们又有一种新的形状——圆形:
public class Circle {
private double radius;
// getters/setters ...
}
由于有新的形状需要考虑,我们必须修改我们的AreaManager类:
public class AreaManager {
public double calculateArea(ArrayList<Object>... shapes) {
double area = 0;
for (Object shape : shapes) {
if (shape instanceof Rectangle) {
Rectangle rect = (Rectangle)shape;
area += (rect.getLength() * rect.getHeight());
} else if (shape instanceof Circle) {
Circle circle = (Circle)shape;
area += (circle.getRadius() * cirlce.getRadius() * Math.PI;
} else {
throw new RuntimeException("Shape not supported");
}
}
return area;
}
}
从这段代码开始,我们察觉到了问题。
如果我们遇到一个三角形,或者其他形状呢,这时候我们就必须一次又一次的修改AreaManager类。
这个类的设计就违背了开闭原则,没有做到对修改的封闭性以及对扩展的开放性。我们必须避免这种事情的发生~
基于继承的开闭原则的实现
AreaManager类的职责是计算各种形状的面积,而每一种形状都有其独特的计算面积的方法,因此将面积的计算放入到各个形状类中是特别合理的。
AreaManager类仍然需要知道所有的形状,否则它就无法判断所有的形状类是否都包含了计算面积的方法。当然了,我们可以通过反射来实现。其实有一种更简单的方式也可以实现——让所有的形状类都继承一个接口:Shape(也可以是抽象类)
public interface Shape {
double getArea();
}
每一个形状类都实现这个接口(如果接口无法满足你的需求,也可以通过继承某个抽象类):
public class Rectangle implements Shape {
private double length;
private double height;
// getters/setters ...
@Override
public double getArea() {
return (length * height);
}
}
public class Circle implements Shape {
private double radius;
// getters/setters ...
@Override
public double getArea() {
return (radius * radius * Math.PI);
}
}
现在,我们可以通过这个抽象方法将AreaManager构造成一个符合开闭原则的类。
public class AreaManager {
public double calculateArea(ArrayList<Shape> shapes) {
double area = 0;
for (Shape shape : shapes) {
area += shape.getArea();
}
return area;
}
}
通过这种方式, AreaManager类符合了对修改关闭,对扩展开放的要求。如果我们需要增加一种新形状,比如:八边形。新的类只需要继承Shape接口即可,AreaManager根本不需要做任何的修改。
作者:4d3bf4cac28c
链接:https://www.jianshu.com/p/6c8a9611b38b
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
通过扩展去应对需求变化,就要求我们必须要面向接口编程,或者说面向抽象编程。所有参数类型、引用传递的对象必须使用抽象(接口或者抽象类)的方式定义,不能使用实现类的方式定义;通过抽象去界定扩展,比如我们定义了一个接口A的参数,那么我们的扩展只能是接口A的实现类。总的来说,开闭原则提高系统的可维护性和代码的重用性。
指导建议:使用者先定义接口
(3)里氏替换原则
如上图释义,一个软件实体如果使用的是一个基类的话,那么一定适用于其子类,而且它根本不能察觉出基类对象和子类对象的区别。
比如,假设有两个类,一个是Base类,另一个是Child类,并且Child类是Base的子类。那么一个方法如果可以接受一个基类对象b的话:method1(Base b)那么它必然可以接受一个子类的对象method1(Child c).
里氏替换原则是继承复用的基石。只有当衍生类可以替换掉基类,软件单位的功能不会受到影响时,基类才能真正的被复用,而衍生类也才能够在基类的基础上增加新的行为。
问题由来:有一功能P1,由类A完成。现需要将功能P1进行扩展,扩展后的功能为P,其中P由原有功能P1与新功能P2组成。新功能P由类A的子类B来完成,则子类B在完成新功能P2的同时,有可能会导致原有功能P1发生故障。
解决方案:当使用继承时,遵循里氏替换原则。类B继承类A时,除添加新的方法完成新增功能P2外,尽量不要重写父类A的方法,也尽量不要重载父类A的方法。
里氏替换原则通俗的来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能。它包含以下4层含义:
子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法。
子类中可以增加自己特有的方法。
当子类的方法重载父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松。
当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。
(4)依赖倒置原则
官方释义:高层模块不应该依赖低层模块,两者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象。
上面的定义主要包含两次意思:
1)高层模块不应该直接依赖于底层模块的具体实现,而应该依赖于底层的抽象。换言之,模块间的依赖是通过抽象发生,实现类之间不发生直接的依赖关系,其依赖关系是通过接口或抽象类产生的。
2)接口和抽象类不应该依赖于实现类,而实现类依赖接口或抽象类。这一点其实不用多说,很好理解,“面向接口编程”思想正是这点的最好体现。
相比传统的软件设计架构,比如我们常说的经典的三层架构,UI层依赖于BLL层,BLL层依赖于DAL层。由于每一层都是依赖于下层的实现,这样当某一层的结构发生变化时,它的上层就不得不也要发生改变,比如我们DAL里面逻辑发生了变化,可能会导致BLL和UI层都随之发生变化,这种架构是非常荒谬的!好,这个时候如果我们换一种设计思路,高层模块不直接依赖低层的实现,而是依赖于低层模块的抽象,具体表现为我们增加一个IBLL层,里面定义业务逻辑的接口,UI层依赖于IBLL层,BLL层实现IBLL里面的接口,所以具体的业务逻辑则定义在BLL里面,这个时候如果我们BLL里面的逻辑发生变化,只要接口的行为不变,上层UI里面就不用发生任何变化。
在三层架构里面增加一个接口层能实现依赖倒置,它的目的就是降低层与层之间的耦合,使得设计更加灵活。从这点上来说,依赖倒置原则也是“松耦合”设计的很好体现。
依赖倒置原则(DIP)例子:
未进行依赖倒置设计:司机只开一种车
遵循依赖倒置原则设计:司机可以开多种车
https://blog.csdn.net/yabay2208/article/details/73826719
(5)接口隔离原则
ISP:其一是不应该强行要求客户端依赖于它们不用的接口;其二是类之间的依赖应该建立在最小的接口上面。简单点说,客户端需要什么功能,就提供什么接口,对于客户端不需要的接口不应该强行要求其依赖;类之间的依赖应该建立在最小的接口上面,这里最小的粒度取决于单一职责原则的划分。
如果客户端依赖了它们不需要的接口,那么这些客户端程序就面临不需要的接口变更引起的客户端变更的风险,这样就会增加客户端和接口之间的耦合程度,显然与“高内聚、低耦合”的思想相矛盾。
类之间的依赖应该建立在最小的接口上面。何为最小的接口,即能够满足项目需求的相似功能作为一个接口,这样设计主要就是为了“高内聚”。那么我们如何设计最小的接口呢?那就要说说粒度的划分了,粒度细化的程度取决于我们上一章讲的的单一职责原则里面接口划分的粒度。从这一点来说,接口隔离和单一职责两个原则有一定的相似性。
不同:
(1)单一职责原则更加偏向对业务的约束,接口隔离原则更加偏向设计架构的约束。
(2)从接口的细化程度来说,单一职责原则对接口的划分更加精细,而接口隔离原则注重的是相同功能的接口的隔离。接口隔离里面的最小接口有时可以是多个单一职责的公共接口。
(3)从原则约束的侧重点来说,接口隔离原则更关注的是接口依赖程度的隔离,更加关注接口的“高内聚”;而单一职责原则更加注重的是接口职责的划分。
未遵循接口隔离原则的设计
遵循接口隔离原则的设计
1、场景举例分析
二、场景示例
下面就以我们传统行业的订单操作为例来说明下接口隔离的必要性。
1、胖接口
软件设计最初,我们的想法是相同功能的方法放在同一个接口里面,如下,所有订单的操作都放在订单接口IOrder里面。理论上来说,这貌似没错。我们来看看如何设计。
public interface IOrder
{
//订单申请操作
void Apply(object order);
//订单审核操作
void Approve(object order);
//订单结束操作
void End(object order);
}
刚开始只有销售订单,我们只需要实现这个接口就好了。
public class SaleOrder:IOrder
{
public void Apply(object order)
{
throw new NotImplementedException();
}
public void Approve(object order)
{
throw new NotImplementedException();
}
public void End(object order)
{
throw new NotImplementedException();
}
}
后来,随着系统的不断扩展,我们需要加入生产订单,生产订单也有一些单独的接口方法,比如:排产、冻结、导入、导出等操作。于是我们向订单的接口里面继续加入这些方法。于是订单的接口变成这样:
public interface IOrder
{
//订单申请操作
void Apply(object order);
//订单审核操作
void Approve(object order);
//订单结束操作
void End(object order);
//订单下发操作
void PlantProduct(object order);
//订单冻结操作
void Hold(object order);
//订单删除操作
void Delete(object order);
//订单导入操作
void Import();
//订单导出操作
void Export();
}
我们生产订单的实现类如下
//生产订单实现类
public class ProduceOrder : IOrder
{
/// <summary>
/// 对于生产订单来说无用的接口
/// </summary>
/// <param name="order"></param>
public void Apply(object order)
{
throw new NotImplementedException();
}
/// <summary>
/// 对于生产订单来说无用的接口
/// </summary>
/// <param name="order"></param>
public void Approve(object order)
{
throw new NotImplementedException();
}
/// <summary>
/// 对于生产订单来说无用的接口
/// </summary>
/// <param name="order"></param>
public void End(object order)
{
throw new NotImplementedException();
}
public void PlantProduct(object order)
{
Console.WriteLine("订单下发排产");
}
public void Hold(object order)
{
Console.WriteLine("订单冻结");
}
public void Delete(object order)
{
Console.WriteLine("订单删除");
}
public void Import()
{
Console.WriteLine("订单导入");
}
public void Export()
{
Console.WriteLine("订单导出");
}
}
销售订单的实现类也要相应做修改
//销售订单实现类
public class SaleOrder:IOrder
{
public void Apply(object order)
{
Console.WriteLine("订单申请");
}
public void Approve(object order)
{
Console.WriteLine("订单审核处理");
}
public void End(object order)
{
Console.WriteLine("订单结束");
}
#region 对于销售订单无用的接口方法
public void PlantProduct(object order)
{
throw new NotImplementedException();
}
public void Hold(object order)
{
throw new NotImplementedException();
}
public void Delete(object order)
{
throw new NotImplementedException();
}
public void Import()
{
throw new NotImplementedException();
}
public void Export()
{
throw new NotImplementedException();
}
#endregion
}
需求做完了,上线正常运行。貌似问题也不大。系统运行一段时间之后,新的需求变更来了,要求生成订单需要一个订单撤销排产的功能,那么我们的接口是不是就得增加一个订单撤排的接口方法CancelProduct。于是乎接口变成这样:
public interface IOrder
{
//订单申请操作
void Apply(object order);
//订单审核操作
void Approve(object order);
//订单结束操作
void End(object order);
//订单下发操作
void PlantProduct(object order);
//订单撤排操作
void CancelProduct(object order);
//订单冻结操作
void Hold(object order);
//订单删除操作
void Delete(object order);
//订单导入操作
void Import();
//订单导出操作
void Export();
}
这个时候问题就来了,我们的生产订单只要实现这个撤销的接口貌似就OK了,但是我们的销售订单呢,本来销售订单这一块我们不想做任何的变更,可是由于我们IOrder接口里面增加了一个方法,销售订单的实现类是不是也必须要实现一个无效的接口方法?这就是我们常说的“胖接口”导致的问题。由于接口过“胖”,每一个实现类依赖了它们不需要的接口,使得层与层之间的耦合度增加,结果导致了不需要的接口发生变化时,实现类也不得不相应的发生改变。这里就凸显了我们接口隔离原则的必要性,下面我们就来看看如何通过接口隔离来解决上述问题。
2、接口隔离
我们将IOrder接口分成两个接口来设计
//删除订单接口
public interface IProductOrder
{
//订单下发操作
void PlantProduct(object order);
//订单撤排操作
void CancelProduct(object order);
//订单冻结操作
void Hold(object order);
//订单删除操作
void Delete(object order);
//订单导入操作
void Import();
//订单导出操作
void Export();
}
//销售订单接口
public interface ISaleOrder
{
//订单申请操作
void Apply(object order);
//订单审核操作
void Approve(object order);
//订单结束操作
void End(object order);
}
对应的实现类只需要实现自己需要的接口即可
//生产订单实现类
public class ProduceOrder : IProductOrder
{
public void PlantProduct(object order)
{
Console.WriteLine("订单下发排产");
}
public void CancelProduct(object order)
{
Console.WriteLine("订单撤排");
}
public void Hold(object order)
{
Console.WriteLine("订单冻结");
}
public void Delete(object order)
{
Console.WriteLine("订单删除");
}
public void Import()
{
Console.WriteLine("订单导入");
}
public void Export()
{
Console.WriteLine("订单导出");
}
}
//销售订单实现类
public class SaleOrder : ISaleOrder
{
public void Apply(object order)
{
Console.WriteLine("订单申请");
}
public void Approve(object order)
{
Console.WriteLine("订单审核处理");
}
public void End(object order)
{
Console.WriteLine("订单结束");
}
}
这样设计就能完美解决上述“胖接口”导致的问题,如果需要增加订单操作,只需要在对应的接口和实现类上面修改即可,这样就不存在依赖不需要接口的情况。通过这种设计,降低了单个接口的复杂度,使得接口的“内聚性”更高,“耦合性”更低。由此可以看出接口隔离原则的必要性。
另:
有称六大设计原则:+迪米特法则。
迪米特法则的定义是:只与你的直接朋友交谈,不跟“陌生人”说话(Talk only to your immediate friends and not to strangers)。其含义是:如果两个软件实体无须直接通信,那么就不应当发生直接的相互调用,可以通过第三方转发该调用。其目的是降低类之间的耦合度,提高模块的相对独立性。
举例:明星由于全身心投入艺术,所以许多日常事务由经纪人负责处理,如与粉丝的见面会,与媒体公司的业务洽淡等。这里的经纪人是明星的朋友,而粉丝和媒体公司是陌生人,所以适合使用迪米特法则,其类图如图 所示。
往期推荐
低代码框架X-series应用案例
十四五”规划纲要全文发布!这些内容与金融科技领域密切相关
微信红包技术揭秘
200张表,单表记录过亿,10多年核心老系统的重构之旅
从十四五规划看企业数字化转型(PPT分享)
技术琐话
以分布式设计、架构、体系思想为基础,兼论研发相关的点点滴滴,不限于代码、质量体系和研发管理。本号由坐馆老司机技术团队维护。