# 设计模式实践(OJ判题和聚合搜索)
作者:南侠 (opens new window),编程导航星球 (opens new window) 编号 29240
介绍星球OJ和聚合搜索项目中使用的常用设计模式
在近期的开发中,笔者主要参与了星球OJ(OJ 判题系统(亮点多) - 飞书云文档 (feishu.cn) (opens new window))和聚合搜索(聚合搜索平台(中台设计) - 飞书云文档 (feishu.cn) (opens new window))两个项目。在这两个项目中,均使用了设计模式来使代码变得“优雅”。直观来说,让代码的简洁度、可读性和拓展性都得到了一定程度的提升。
正所谓“学以致用”,那么就跟着笔者的节奏,来从以上两个项目的实际场景中来学习和体会设计模式吧~
# 1. OJ项目
主要使用的设计模式有:
- 代理模式
- 工厂模式
- 策略模式
- 模板方法模式
# 1.1. 代码沙箱的开发:模板方法模式
(引自菜鸟教程)
在模板方法模式(Template Pattern)中,一个抽象类公开定义了执行它的方法的方式/模板。它的子类可以按需要重写方法实现,但调用将以抽象类中定义的方式进行。这种类型的设计模式属于行为型模式。
**意图:**定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。模板方法使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤。
**主要解决:**一些方法通用,却在每一个子类都重新写了这一方法。
优点: 1、封装不变部分,扩展可变部分。 2、提取公共代码,便于维护。 3、行为由父类控制,子类实现。
**缺点:**每一个不同的实现都需要一个子类来实现,导致类的个数增加,使得系统更加庞大。
使用场景: 1、有多个子类共有的方法,且逻辑相同。 2、重要的、复杂的方法,可以考虑作为模板方法。
在代码沙箱的开发中,针对不同代码语言的代码执行,逻辑都是相同的,只是在具体实现的方法细节存在差异。因此,我们可以通过提取“执行代码”共有的逻辑和方法,封装至模板类中,然后创建不同语言的“执行代码子类”交由“客户端”来调用和处理对应的代码语言,得到执行结果。
**首先,**我们提取并创建“执行代码”抽象类角色
**然后,**我们针对不同的语言,继承公共类,得到对应的子类(以处理Java语言代码的子类为例):
**而且,**在子类中,我们除了可以使用父类已有的方法,也可以实现抽象方法,也可以自行添加对应的方法,比如,Java需要编译代码,那么我们就可以添加“编译代码”方法,Python无需编译,则无需添加此方法。
# 1.2. 代码沙箱对象的生成:工厂模式
(引自菜鸟教程)
工厂模式(Factory Pattern)是 Java 中最常用的设计模式之一,这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。
工厂模式提供了一种创建对象的方式,而无需指定要创建的具体类。
工厂模式属于创建型模式,它在创建对象时提供了一种封装机制,将实际创建对象的代码与使用代码分离。
**意图:**定义一个创建对象的接口,让其子类自己决定实例化哪一个工厂类,工厂模式使其创建过程延迟到子类进行。
**何时使用:**我们明确地计划不同条件下创建不同实例时。
优点: 1、一个调用者想创建一个对象,只要知道其名称就可以了。 2、扩展性高,如果想增加一个产品,只要扩展一个工厂类就可以。 3、屏蔽产品的具体实现,调用者只关心产品的接口。
**缺点:**每次增加一个产品时,都需要增加一个具体类和对象实现工厂,使得系统中类的个数成倍增加,在一定程度上增加了系统的复杂度,同时也增加了系统具体类的依赖。这并不是什么好事。
**注意事项:**作为一种创建类模式,在任何需要生成复杂对象的地方,都可以使用工厂方法模式。有一点需要注意的地方就是复杂对象适合使用工厂模式,而简单对象,特别是只需要通过 new 就可以完成创建的对象,无需使用工厂模式。如果使用工厂模式,就需要引入一个工厂类,会增加系统的复杂度。
工厂模式包含以下几个核心角色:
- 抽象产品(Abstract Product):定义了产品的共同接口或抽象类。它可以是具体产品类的父类或接口,规定了产品对象的共同方法。
- 具体产品(Concrete Product):实现了抽象产品接口,定义了具体产品的特定行为和属性。
- 抽象工厂(Abstract Factory):声明了创建产品的抽象方法,可以是接口或抽象类。它可以有多个方法用于创建不同类型的产品。
- 具体工厂(Concrete Factory):实现了抽象工厂接口,负责实际创建具体产品的对象。
在OJ系统中,我们需要再判题时调用“代码沙箱”服务,而代码沙箱的种类通常不止一种,比如:远程第三方代码沙箱、本地代码沙箱、其他厂商代码沙箱等,对此我们首先会将其交由不同的子类来处理(实现同一代码沙箱接口),然后针对不同的条件,来创建不同的子类沙箱对象来使用,这时,就可以用到工厂模式。
**首先,**我们拥有若干种类的“代码沙箱”子类:
**然后,**我们创建代码沙箱工厂类来根据选择的条件来生成相应的子类对象:
**最后,**我们在使用时,只需指定对应的“代码沙箱type”,即可得到对应的代码沙箱使用对象:
# 1.3. 代码沙箱对象方法的调用:代理模式
(引自菜鸟教程)
在代理模式(Proxy Pattern)中,一个类代表另一个类的功能。这种类型的设计模式属于结构型模式。
在代理模式中,我们创建具有现有对象的对象,以便向外界提供功能接口。
**意图:**为其他对象提供一种代理以控制对这个对象的访问。
**主要解决:**在直接访问对象时带来的问题,比如说:要访问的对象在远程的机器上。在面向对象系统中,有些对象由于某些原因(比如对象创建开销很大,或者某些操作需要安全控制,或者需要进程外的访问),直接访问会给使用者或者系统结构带来很多麻烦,我们可以在访问此对象时加上一个对此对象的访问层。
**如何解决:**增加中间层,实现与被代理类组合。
优点: 1、职责清晰。 2、高扩展性。 3、智能化。
缺点: 1、由于在客户端和真实主题之间增加了代理对象,因此有些类型的代理模式可能会造成请求的处理速度变慢。 2、实现代理模式需要额外的工作,有些代理模式的实现非常复杂。
主要涉及到以下几个核心角色:
- 抽象主题(Subject):
- 定义了真实主题和代理主题的共同接口,这样在任何使用真实主题的地方都可以使用代理主题。
- 真实主题(Real Subject):
- 实现了抽象主题接口,是代理对象所代表的真实对象。客户端直接访问真实主题,但在某些情况下,可以通过代理主题来间接访问。
- 代理(Proxy):
- 实现了抽象主题接口,并持有对真实主题的引用。代理主题通常在真实主题的基础上提供一些额外的功能,例如延迟加载、权限控制、日志记录等。
- 客户端(Client):
- 使用抽象主题接口来操作真实主题或代理主题,不需要知道具体是哪一个实现类。
承接1.2,在得到“代码沙箱子类对象”后,我们要调用其“执行代码”的方法,但我们希望能够在执行原方法的同时添加一些额外的操作(如,打印方法日志),同时不影响原方法,那么就可以创建一个代理类,来代理原代码沙箱子类对象。
**首先,**创建代理类:
**然后,**就可以使用代理类去调用“执行代码(新)”方法:
# 1.4. 多语言代码的判题:策略模式
(引自菜鸟教程)
在策略模式(Strategy Pattern)中一个类的行为或其算法可以在运行时更改。这种类型的设计模式属于行为型模式。
在策略模式定义了一系列算法或策略,并将每个算法封装在独立的类中,使得它们可以互相替换。通过使用策略模式,可以在运行时根据需要选择不同的算法,而不需要修改客户端代码。
在策略模式中,我们创建表示各种策略的对象和一个行为随着策略对象改变而改变的 context 对象。策略对象改变 context 对象的执行算法。
**意图:**定义一系列的算法,把它们一个个封装起来, 并且使它们可相互替换。
**主要解决:**在有多种算法相似的情况下,使用 if...else 所带来的复杂和难以维护。
**何时使用:**一个系统有许多许多类,而区分它们的只是他们直接的行为。
**如何解决:**将这些算法封装成一个一个的类,任意地替换。
**关键代码:**实现同一个接口。
优点: 1、算法可以自由切换。 2、避免使用多重条件判断。 3、扩展性良好。
缺点: 1、策略类会增多。 2、所有策略类都需要对外暴露。
使用场景: 1、如果在一个系统里面有许多类,它们之间的区别仅在于它们的行为,那么使用策略模式可以动态地让一个对象在许多行为中选择一种行为。 2、一个系统需要动态地在几种算法中选择一种。 3、如果一个对象有很多的行为,如果不用恰当的模式,这些行为就只好使用多重的条件选择语句来实现。
策略模式包含以下几个核心角色:
- 环境(Context):维护一个对策略对象的引用,负责将客户端请求委派给具体的策略对象执行。环境类可以通过依赖注入、简单工厂等方式来获取具体策略对象。
- 抽象策略(Abstract Strategy):定义了策略对象的公共接口或抽象类,规定了具体策略类必须实现的方法。
- 具体策略(Concrete Strategy):实现了抽象策略定义的接口或抽象类,包含了具体的算法实现。
策略模式通过将算法与使用算法的代码解耦,提供了一种动态选择不同算法的方法。客户端代码不需要知道具体的算法细节,而是通过调用环境类来使用所选择的策略。
在OJ系统中,不同语言代码的题目判题,方法相同,但方法的具体实现策略有所不同,因此可以使用策略模式来根据代码语言“环境”的不同选择不同的算法策略。
**首先,**创建不同语言的策略子类(实现统一抽象判题接口):
**然后,**创建“上下文对象”来统一“环境”类的参数:
**最后,**就可以创建“环境”类,来实现动态切换策略:
# 2. 聚合搜索项目
主要使用的设计模式有:
- 适配器模式
- 注册器模式
# 2.1. 多数据源搜索接口调用:适配器模式
(引自菜鸟教程)
适配器模式(Adapter Pattern)是作为两个不兼容的接口之间的桥梁。这种类型的设计模式属于结构型模式,它结合了两个独立接口的功能。
这种模式涉及到一个单一的类,该类负责加入独立的或不兼容的接口功能。举个真实的例子,读卡器是作为内存卡和笔记本之间的适配器。您将内存卡插入读卡器,再将读卡器插入笔记本,这样就可以通过笔记本来读取内存卡。
优点: 1、可以让任何两个没有关联的类一起运行。 2、提高了类的复用。 3、增加了类的透明度。 4、灵活性好。
缺点: 1、过多地使用适配器,会让系统非常零乱,不易整体进行把握。比如,明明看到调用的是 A 接口,其实内部被适配成了 B 接口的实现,一个系统如果太多出现这种情况,无异于一场灾难。因此如果不是很有必要,可以不使用适配器,而是直接对系统进行重构。 2.由于 JAVA 至多继承一个类,所以至多只能适配一个适配者类,而且目标类必须是抽象类。
**使用场景:**有动机地修改一个正常运行的系统的接口,这时应该考虑使用适配器模式。
**注意事项:**适配器不是在详细设计时添加的,而是解决正在服役的项目的问题。
在聚合搜索项目中,我们需要从不同数据源中检索数据,那么就需要调用不同数据源接口的方法,而这些方法一般方法参数都存在差异,我们无法直接方便的统一调用,只能使用分支结构做复杂的代码逻辑,此时我们就可以引入适配器模式,将分支结构的代码从客户端调用方拆分后移至抽象适配器接口的各个子类中,统一适配器类的参数,从而方便客户端便捷、统一的使用。
**首先,**创建适配器接口,制定统一的规范,即统一方法名和参数格式:
**然后,**实现不同数据源的实现子类(实现适配器接口):
**最后,**客户端即可通过适配器接口,来统一获取不同数据源的查询结果:
# 2.2. 特定搜索接口子类对象使用:注册器模式
注册器模式是一种基础常见的设计模式,它的主要意思是把多个类的实例注册到一个注册器类中去,然后需要哪个类,由这个注册器类统一调取。
(引自ChatGPT)
- 优点:
- 提供了一种统一的注册和查找机制,使得系统更加灵活,易于维护和扩展。
- 可以动态地向注册器中注册和移除对象,实现了松耦合。
- 缺点:
- 可能会导致注册器对象过于庞大,难以维护和理解。
- 对象的生命周期由注册器管理,可能会导致内存泄漏或者对象过早被销毁的问题。
实现起来也很简单,在聚合搜索项目中,可以将“适配器接口”的各个子类加入同一注册器,便于统一调取,从而减少了客户端调用方法的代码量: