设计原则——面向对象的 SOLID 原则(python)

设计原则——面向对象的 SOLID 原则(python)

在软件开发中,如何做到代码的可读,可复用,可扩展,稳定是需要一些编程原则和编程思维。故开始对设计原则进行理解和学习,并结合具体案例分析。本文采用 python3 进行代码编写。

职责单一原则- SRP

Single Responsibility Principle

A class or module should have a single responsibility

一个类或者模块只负责完成一个职责或者说功能

一个类只负责完成一个职责或者功能。也就是说,不要设计大而全的类,要设计粒度小、功能单一的类。

在我的理解中,对于日常开发而言,对于一个产品可能有很多需求原型,有很多模块和零散的模块。对于每个模块的功能点,可以进行归纳总结,业务抽象,具体到设计类和方法。

那么问题来了?如何判断该是否需要用到职责单一的原则?

我这里举个例子:用户模块和订单模块就是两个独立的模块。这里只举例用户模块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# 用户模块


class UserMeta(object):
"""
用户信息
"""
def __init__(self, user_id: str, name: str, age: str, address: str, cellphone: str):
self.user_id = user_id
self.name = name
self.age = age
self.address = address
self.cellphone = cellphone

def to_dict(self):
pass


class UserCtrl(object):
"""
用户操作
"""
def login(self):
pass

def logout(self):
pass

def register(self):
pass

def get_user_info(self):
pass

在上述的代码中,我们创建了一个用户模块,其中包含 UserMeta 用户信息类和 UserCtrl用户操作类。这种类设计的模式在 MVC 架构的设计中很常见。

当然,随着业务的变化和公司产品的升级,需要不断的扩展和设计类,那么就需要结合实际的情况来进行扩展了。

例如,当公司的社交产品比较多的时候,需要一个统一的用户登陆认证中心,那么就需要从顶层开始设计登陆模块,这个时候就不单单是代码的重构,可能也包含服务的重构,对于多来源,多用户,需要对认证相关的信息(例如邮件、手机号、用户名)拆分开来。

以下是我的理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
from abc import ABCMeta, abstractmethod


class UserAuthMeta(object):
"""
用户认证信息
"""
def __init__(self, user_id, name, password, cellphone, email, third_id, login_type):
self.user_id = user_id
self.name = name
self.password = password
self.cellphone = cellphone
self.email = email
self.third_id = third_id
self.login_type = login_type


class LoginInterface(metaclass=ABCMeta):
@abstractmethod
def login(self, userinfo: dict):
pass


class CellphoneLogin(LoginInterface):
def login(self, userinfo: dict):
pass


class EmailLogin(LoginInterface):
def login(self, userinfo: dict):
pass


class WechatLogin(LoginInterface):
def login(self, userinfo: dict):
pass

在上述代码中,我从顶层抽象了一个登陆功能的接口,然后支持不同方式的登陆,对于用户认证,每种方式需要有不同的处理逻辑,故封装了 login 方法来处理。

综上,在实际的代码开发中,一个业务总是从简单到复杂,那么对业务的发展也非常考验开发人员的重构能力,并没有一个非常明确的、可以量化的标准。我们也没有必要过度设计,需要结合场景具体分析。在设计初期,可以先写一个粗粒度的类,拆分成几个更细粒度的类,然后随着业务的设计迭代不断重构。

对扩展开放、修改关闭 - OCP

Open Closed Principle

software entities should be open for extension, but closed for modification.

简单表述一下就是,添加一个新的功能应该是,在已有代码的基础上扩展代码(新增模块、类、方法等),而非修改已有的代码(修改模块、类、方法)。

对于这条原则,是一个难以应用和最有用的一个原则,这是因为,扩展性是代码质量最重要的衡量标准之一,大部分设计模式都是为了解决代码的扩展性问题而存在的,主要遵循的设计原则就是开闭原则。

这里为了更好地理解这个原则,举一个 API 接口监控告警的代码:

1
2
3
4
5
6
7
8
9
10
11
12
class Alert(object):
def __init__(self, alert_rule, notification):
self.alert_rule = alert_rule
self.notification = notification

def check(self, api, request_count, error_count, duration_of_seconds):
tps = request_count / duration_of_seconds
if tps > self.alert_rule.get_matched_rule(api).get_max_tps:
self.notification.notify(notificationEmergencyLevel.URgency, "...")

if error_count > rule.get_matched_rule(api).get_max_errorcount():
notification.notify(notificationEmergencyLevel.URgency, "...")

上面这段代码中,当接口的 TPS 超过某个预先设置的最大值时,以及当前接口请求出错数大于某个最大允许值时,就会触发告警,通知接口的相关负责人或者团队。

现在有一个新的需求是:当接口的超时时间超过我们设计的阈值时,也要触发告警,应该如何设计?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Alert(object):
def __init__(self, alert_rule, notification):
self.alert_rule = alert_rule
self.notification = notification

def check(self, api, request_count, error_count, duration_of_seconds, timeout_count):
tps = request_count / duration_of_seconds
if tps > self.alert_rule.get_matched_rule(api).get_max_tps:
self.notification.notify(notificationEmergencyLevel.URgency, "...")

if error_count > rule.get_matched_rule(api).get_max_errorcount():
notification.notify(notificationEmergencyLevel.URgency, "...")

if (timeout_count / duration_of_seconds) > self.rule.get_matchedRule(api).get_max_tps():
notification.notify(notificationEmergencyLevel.URgency, "...")

在上述的代码中,我们新增了 timeout_count 和 判断是否告警的逻辑,这样就违背了开闭原则

那么应该如何设计呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class APIStatInfo(object):
def __init__(self, api: str, requestCount: int, errorCount: int, durationOfSeconds: int, timeout_count):
self.api = api
self.requestCount = requestCount
self.errorCount = errorCount
self.timeoutCount = timeout_count
self.durationOfSeconds = durationOfSeconds


class Alert(object):
def __init__(self):
self.handlers = []

def add_handler(self, handler):
pass

def check(self, api_stat_info: APIStatInfo):
for handler in self.handlers:
handler.check(api_stat_info)


from abc import ABC, abstractmethod


class AlertHandler(ABC):
def __init__(self, rule, notification):
self.rule = rule
self.notification = notification

@abstractmethod
def check(self):
pass


class TPSAlertHandler(AlertHandler):
def __init__(self, rule, notification):
super().__init__(rule, notification)

def check(self):
if self.rule > "设定的阈值":
print("监控告警逻辑")


class TimeoutAlertHandler(AlertHandler):
def __init__(self, rule, notification):
super().__init__(rule, notification)

def check(self):
if self.rule > "设定的阈值":
print("监控告警逻辑")

在上述代码中,我对原有的类进行了扩展,对于每个 API 的状态信息进行了封装:APIStatInfo 类,然后对于每种监控抽象出来了一个接口类用于扩展。

当新增一个 timeout 的需求时,我只需要在 APIStatInfo 类中添加 timeout_count 信息和扩展一个 TimeoutAlertHandler 类就可以做到满足开闭原则了。

综上所述:在上述例子中,演示了如何设计一个可扩展的类,用于后续的扩展。那么有人就要问了,不是对修改关闭吗,为什么 APIStatInfo 中的代码修改了,对于修改这个词,我们要正确的认识,在本人的开发经历中,对于新需求的添加,很少有做到完全对原有代码不修改的,我们所谓的对修改关闭,实则是要做到让修改更集中,更少,更上层,尽量让最核心、最复杂的部分逻辑满足开闭原则。

里氏替换——LSP

Liskov Substitution Principle, 缩写为 LSP。这个原则最早是在 1986 年由 Barbara Liskov 提出:

if S is a subtype of T, then objects of type T may be replaced with objects of type S, without breaking the program。

在 1996 年,Robert Martin 在他的 SOLID 原则中,重新描述了这个原则:

Functions that use pointers of references to base classes must be able to use objects of derived classes without konwing it。

用中文结合描述就是:子类对象(object of subtype/derived class) 能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。

具体定义可以分为:

  • 子类必须实现父类的抽象方法,但不得重写(覆盖)父类的非抽象(已实现)方法。
  • 子类中可以增加自己特有的方法。
  • 当子类覆盖或实现父类的方法时,方法的前置条件(方法的形参)要比父类的输入参数更宽松。
  • 当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。

我们用一个鸟类的继承来说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
from abc import ABC, abstractmethod

class Bird(ABC):
def __init__(self, name):
self.name = name
self.birds = []

@abstractmethod
def fly(self):
pass

def collect_bird(self, name):
self.birds.append(name)


class Swallow(Bird):
def __init__(self, name):
super().__init__(name)

# 子类必须实现父类的抽象方法
def fly(self):
print("swallow flying")

# 子类不能覆盖父类的非抽象方法
def collect_bird(self, name):
print("")

# 子类可以实现自己的方法
def sing(self):
print("swallow singing")

对于为什么子类必须实现父类的抽象方法,个人的理解是这样的,在面向对象中的继承中,父类的抽象方法对于子类而言是一种协议,是必须实现的,是用来约束的。

而对于父类中非抽象的方法,多数是一些常用方法,对于子类或者父类都是通用的逻辑,如果改变,将导致一些问题。

接口隔离原则——ISP

Interface Segregation Principle, 缩写为 ISP. Robert Martin 在 SOLID 原则中是这样定义的: Client should not be forced to depend upon interfaces that they donot use.

即客户端不应该被强迫依赖它不需要的接口。

对于接口隔离而言,首先我们需要理解接口二字,有以下常规理解:

  • 一组 API 接口集合
  • 单个 API 接口或函数
  • OOP 中的接口概念

这里结合一个例子来理解:在一个微服务用户中心中,有一组跟用户相关的 API 给其他系统使用,比如注册、登陆、获取用户信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from abc import ABC, abstractmethod


class UserService(ABC):
@abstractmethod
def register(self):
pass

@abstractmethod
def login(self):
pass

@abstractmethod
def get_user_info(self):
pass

现在对于后台系统而言,对于一些违规用户,我们是需要对其进行拉黑处理或者封禁处理的。那么应该如何操作,常规的思路就是在该接口下定义一个 unbidden() 方法。但是这样设计的话,会有一些操作隐患,因为这个接口只限于后台管理系统,对于前端系统而言是不可调用的,如果不加限制的被其他业务系统调用,就会导致一些安全问题。

一组 API 接口集合

最好的办法就是从架构设计的层面,通过接口鉴权的方式来限制接口的调用。再者可以参考接口隔离原则,调用者不应该强迫依赖它不需要的接口,将删除接口单独放置另外一个接口。

于是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from abc import ABC, abstractmethod


class UserService(ABC):
@abstractmethod
def register(self):
pass

@abstractmethod
def login(self):
pass

@abstractmethod
def get_user_info(self):
pass


class RestrictUserInterface(ABC):
@abstractmethod
def unbidden_user(self):
pass


class UnbiddenUser(RestrictUserInterface):
def unbidden_user(self):
print("限制用户的一些操作")

接口理解为单个 API 或者函数

在这部分我们可以理解为:函数的设计要功能单一,不要将多个不同的功能逻辑在一个函数中实现。

1
2
3
4
5
6
7
8
9
10
class Statistics(object):
def __init__(self, max, min, average, sum):
self.max = max
self.min = min
self.average = average
self.sum = sum


def statistics(self):
print("计算的具体逻辑")

在上述的代码中,对于统计而言,有很多个统计项,按照接口隔离而言,应该将不同的统计拆分。

1
2
3
4
5
6
7
def max():
pass

def min():
pass

...

当然,在这方面,接口隔离和职责单一而言比较类似,但是单一职责针对的是模块、类、接口的设计。接口隔离更多指的是每种接口应该实现其特有的方法。

将接口理解为 OOP 中的接口概念

这里是将接口理解为 interface,例如 go/java 中的 interface

例如在我们的项目中,有三个外部系统:Redis, mysql, kafka。每个系统都对应一系列的配置信息,比如地址、端口、访问超时时间等。为了在内存中存储这些配置信息,分别设计了三个 Config 类: RedisConfig, MysqlConfig, KafkaConfig.

like this:

1
2
3
4
5
6
7
8
9
10
class RedisConfig(object):
def __init__(self, configSource, address, timeout, maxTotal,):
self.configSource = configSource
self.address = address
self.timeout = timeout
self.maxTotal = maxTotal

def update(self):
# 从 configsource 中加载配置
print("更新的具体逻辑")

但是现在有一个需求,就是以固定时间的方式更新配置,且在不重启服务的情况下,这就是热更新;

为了实现这个需求,我们需要设计一个调度类SchedulerUpdater 来支持热更新,

于是,为了使得接口隔离,抽象出 UpdaterViewer 但是来实现,前者更新,后者显示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class Updater(ABC):
@abstractmethod
def update(self):
pass


class Viewer(ABC):
@abstractmethod
def view(self):
pass


class RedisConfig(Updater):
def __init__(self, configSource, address, timeout, maxTotal,):
self.configSource = configSource
self.address = address
self.timeout = timeout
self.maxTotal = maxTotal

def update(self):
# 从 configsource 中加载配置
print("更新的具体逻辑")


class SchedulerUpdater(object):
def __init__(self, schedulerservice, per_seconds, updater):
self.scheulserservice = schedulerservice
self.per_seconds = per_seconds
self.updater = updater


def run(self):
self.scheulserservice(self.per_seconds, self.per_seconds)

在这样的扩展下,就实现了接口隔离,每个接口对应不同的功能。

依赖反转原则——DIP

High-level modules shouldn’t depend on low-level modules. Both modules should depend on abstractions. In addition, abstractions shouldn’t depend on details. Details depend on abstractions.

高层模块(high-level modules )不要依赖底层模块(low-level)。高层模块和低层模块应该通过抽象(abstractions)来互相依赖。除此之外,抽象不要依赖具体的实现细节,具体的实现细节依赖抽象。

所谓高层模块和低层模块,简单来说就是,在调用链上,调用者属于高层,被调用者属于低层。

在 python 中,实现依赖倒置原则通常使用接口抽象和依赖注入技术。具体来说,我们可以将代码组织为两个层次,即高层代码和低层模块。高层模块负责处理业务逻辑,低层模块负责提供基础服务。二者之间通过抽象接口进行通信,从而实现了解耦合。

同样,我们通过一个例子来学习:

当我们需要一份数据时,这份数据可能有很多个来源,例如文件或者数据库,当对文件进行解析时,需要用到不同的解析器,我们可以抽象出一个 Reader 接口管理低层解析类,在 DataView 使用时,不用关心 reader 的具体实现,只需要知道调用其中的 read() 方法即可,这样就可以满足 DIP 原则了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
from abc import ABC, abstractmethod


class Reader(ABC):
@abstractmethod
def read(self):
pass


class FileReader(Reader):
def read(self):
print("Reading from file")

class DatabaseReader(Reader):
def read(self):
print("reading from database")


# 高层模块
class DataView(object):
def __init__(self, reader):
self.reader = reader

def show_data(self):
self.reader.read()


if __name__ == '__main__':
# 低层模块
file_reader = FileReader()
database_reader = DatabaseReader()

# 依赖注入
processor1 = DataView(file_reader)
processor2 = DataView(DatabaseReader)

# 调用高层模块
processor1.show_data()
processor2.show_data()

总结

今天总结学习了面向对象的五大原则:

  • 职责单一
  • 开闭原则
  • 接口隔离
  • 里氏代换
  • 依赖反转

其中职责单一原则是为了对模块,类进行划分,每个类,每个模块都只做一件事。

开闭原则更多的是关注代码的可扩展性,在软件开发中,业务需求是不断迭代的,对于如何写出可扩展的代码,需要我们使用开闭原则进行规范,即对组合开放,对修改关闭。

接口隔离适用于比较复杂的业务场景,和职责单一原则息息相关,对于每种接口而言,是隔离的,这样在使用和扩展时,就会比较清晰。

里氏代换原则更多的考虑是代码的健壮性,即在继承和多态时,对于抽象方法和非抽象方法的约束,保证继承链条的健壮,保证调用时不会出错。

依赖反转描述的是高层和低层的关系,二者通过接口抽象协议进行通信,低层负责干活,高层只负责到指定的地方去拿数据,不需要考虑低层的实现方式,高层是不依赖于低层的具体实现的。

以上就是全部内容了,上述原则都是为了写出可读,可扩展,可复用,稳定的代码。代码设计讲究均衡和适合,在了解这些原则的时候也要清楚一点,就是不要强搬硬套,避免过度设计

-------------THANKS FOR READING-------------