草庐IT

编码中的Adapter,不仅是一种设计模式,更是一种架构理念与解决方案

架构悟道 2023-03-28 原文

大家好,又见面了。

不知道下面这玩意大家有没有见过或者使用过?这是一个插座转换器。我们都知道日常使用的是220v的交流电,而国外不同国家使用的电流电压是不一样的(比如日本使用的是110v)、且插座的接口样式也是各不相同的(比如欧洲国家使用的是两个小圆柱状的插头接口),如果我们到别的国家去旅行的时候,借助这个插座转换器,就可以让我们的手机充电器在国外也能正常使用了。

当然,除了使用插座转换器,还有个方法也可以让我们出国之后正常的使用各种电子产品,那就是在当地重新买一套!显然,这样的成本就会非常巨大,明显不符合我们 勤(nang)俭(zhong)持(xiu)家(se) 的特征。

看过我前面的文章的小伙伴应该知道,我的文章中一直反复的在阐述自己的一个观念,即“编码源于生活” ,这里依旧不例外。现实生活中的朴素哲学思维,在代码世界中其实也无时无刻不在体现着。上面举的例子,在我们的项目中又何尝不是在频繁上演此类情况呢?

我们先按照原有的业务逻辑实现了一套代码,后来又来了个新的需求,如果重新开发一套需要投入大量的人力物力,所以首选方案就是去思考如何去复用已有的逻辑,以最小的代价将业务对接适配使用现有的逻辑去实现。

本篇文章中,我们就从这个“插座转换器”来作为切入点,聊一聊在软件系统中无处不在的“插座转换器” —— 编码中的适配器(Adapter)。选定以Adapter为题材进行阐述,并非是因为Adapter在技术实现上有多复杂,其实Adapter真正实现起来是非常简单的,而且很多人有意或无意中其实也都在使用。更多的是想一起探讨这种借助Adapter来复用与兼容已有逻辑的思路,以及如何利用Adapter来践行OCP(开闭原则)的系统架构设计理念

Adapter的百媚千姿

新瓶旧酒:复用现成的实现逻辑

新瓶装旧酒,在我们的系统里面是一个很“节省”的操作,可以让我们基于一个现有的能力快速的封装提供出一个全新的业务功能,当然有的时候,系统现有的能力可能会某些方面无法完全满足新业务的需求,需要做一些转换适配处理。

举个例子:

一个视频网站,原先已有一个评论能力,用户可以在视频下方发表评论,然后评论内容以列表的形式展示在视频下方页面上。现在需要开发一个新功能,支持视频发送弹幕能力,并将弹幕显示在视频播放画面上。

从需求功能上来说,评论弹幕有很多相似之处。对后端而言,其处理逻辑与存储数据结构几乎都是相同的,只是在数据列表API实现的时候,需要过滤出评论信息展示到评论区、或者过滤出弹幕信息显示到视频画面上。但是由于弹幕信息有一些特殊的属性,又没法直接完全使用现有的评论接口,比如弹幕可能会设置显示在屏幕的位置、弹幕的字体颜色等等。这种情况下,我们可以通过构造个Adapter适配器的方式,在复用已有评论能力的基础上,顺便扩展实现需要的弹幕新特性。

如上图所示,我们可以在Adapter中封装扩展弹幕需要的新特性,然后对于数据存储等逻辑则直接复用已有的评论功能处理逻辑,这样就可以大大减少我们的开发工作量、后续也只需要维护一套主体代码即可。

负重前行:兼容历史版本

和上面讨论的场景相反,实际开发中还有一种非常常见的情况,就是原先的时候实现了一套业务逻辑,然后因为业务变化或者系统重构,需要对底层具体实现逻辑进行大改。这种情况下,为了保证此前调用该API的业务可以正常使用,通常有两种思路:

  1. 保持原先的内容不动,完全另起炉灶全新实现一套,然后两套逻辑并存,同时维护;

  2. 按照新的逻辑去实现,并将原先的对外API适配转换对接使用新逻辑实现。

显然,从成本与可维护性层面考虑,思路2更为可行、更加经济

对比我们文首举的那个“插头转换器”的例子,我们可以把图中V1版本业务逻辑当做我们国内的手机充电插头,而图中绿色部分的V2新版本依赖逻辑,则是欧洲地区的圆孔墙面插座,那么如何让国标的扁口插头能用上欧标的圆孔插座呢?关键就是那个插头转换器(Adapter)。

另类心机:屏蔽开源协议传染

大家可以回想下,曾经是否也有过从github上“借鉴”一些代码放进了自己的项目中,然后简单修改为符合自己诉求的逻辑,便当做是自研代码去正常使用了?不知道你是否有关注过你所拷贝的代码所对应的开源协议呢?要小心啦、这个看似平常的操作,也许会给项目埋下致命隐患

为什么说的这么危言耸听呢?因为有一些不太友好的开源协议(比如GPL协议),会要求使用了其代码的项目如果商用就必须要开源其全部源码!而对于很多软件公司而言,源码便是公司的核心资产,是公司最为核心的竞争力,将源码开源无异于是要了老板和公司的命。也许有人会对此很不屑,大家都这么干,似乎并没有发现有人来追责呢?有个词叫做“树大招风”,只要你的产品做的够大,就一定会被盯上 —— 你品,你细品。在当前知识版权保护越来越强的情况下,我们还是应该关注并提前做好应对这种危机出现的可能,避免埋下隐患。

这种情况下,可以基于Adapter的机制,实现弃卒保车的效果。即构建一个适配层,然后仅将适配层进行开源,而核心的模块代码中,则通过接口调用的方式使用适配层即可,这样避免了核心模块代码被开源协议传染。由于核心模块中并没有集成被二次改动后的开源源码,所以也不具有开放源码的义务、而Adapter层没有任何核心业务逻辑,即使开源对公司、对项目也没有影响。

基于Adapter适配层的方式来切断开源协议传染的成功实践,最典型的莫过于Android项目(AOSP)了。因为AOSP是基于Linux kernel内核进行构建的,而Linux Kernel使用的是GPL协议,那么按照要求,AOSP也需要开源其源码。但是问题来了,如果AOSP开源源码了,势必导致所有基于Android定制的各个硬件厂商底层的设备驱动相关的代码也都要全部开源,显然不会有公司愿意这么干。

为了让各个公司可以放心的基于Android去开发自己的产品,AOSP将自己的协议搞成了Apache开源协议,这样对产商而言就非常友好了,无需将自己的核心源码开源。那么Google是如何做到将本来需要以GPL协议开源的AOSP给变为使用Apache协议开源的呢?其实就是做了一个Adapter —— 也即HALHardware Abstract Layer,硬件抽象层)。

Adapter是一种理念

关于编码中的Adapter,常规的文档或者资料中,往往都是指的狭义上的适配器,也就是代码class类维度的Adapter

我们跳出纯粹的编码层面,站到全局系统架构视角去审视的时候,其实Adapter在系统架构与编码设计中是一个比较宽泛的概念。我个人更愿意Adapter看做是一种问题解决的思想、一种方案设计的理念

根据要解决的问题level范围的不同,Adapter对应的粒度与呈现形态也会有差异。

服务型Adapter

如果是在一个分布式微服务系统中,消息推送能力可以预见的会提供给很多不同的服务节点去调用,则可以将消息推送能力也封装为一个对外微服务,业务通过RPC或者HTTP等方式进行远程调用。

这种是一种相对High Level的Adapter抽象使用(但抽象为服务独立部署后,其实也不仅仅是个Adapter了),广泛的应用于系统架构层面,是解决系统功能复用、业务解耦的一种有效手段。

在我此前的一篇文章中,介绍了一个构建通用在线文档预览服务的实际案例,里面对“预览编辑服务”的定位就是一个典型的服务型Adapter,如下图所示。通过预览编辑服务这个Adapter,将文档预览能力所涉及的后端对接OnlyOffice或者对接kkFileView等细节逻辑给屏蔽掉,业务服务通过Adapter进行调用,大大简化了业务的使用复杂度,也保持了业务模块与文档预览服务内部模块之间的耦合。

服务型Adapter着眼解决的是系统进程层面的适配与统一封装,自身既是一个Adapter,又是一个独立的服务,封装内部细节差异化的实现,保证其它进程服务相对简单的调用逻辑。

依赖库型Adapter

在一些中小型项目中,会有若干个业务模块中会用到消息发送的能力,但是整体体量与业务规划层面而言,却也无需单独部署一个专门的消息推送服务进程,这种情况下,可以将其封装为一个依赖库,比如JAVA中的一个jar包,或者C++中的一个so库文件,亦或是C#中的dll库文件。这样各个业务模块可以集成此库文件,直接进行API调用即可。

此种类型的Adapter实现,在很多的框架中非常常见。比如在JAVA中的SpringBoot中的日志框架,底层可以选择是使用logback,也可以选择切换到log4j

代码类Adapter

在单个项目模块中,我们为了保持业务逻辑的清晰与独立,也会通过Adapter类的方式,来解耦具体的业务逻辑。比如这里的消息推送服务,如果仅当前模块需要使用,则可以创建一个独立的Adapter类,提供接口供其他类调用,在Adapter类中完成具体逻辑的封装实现。

还是以前面举的告警通知消息发送的例子来说明,使用Adapter方式隔离消息通道与业务逻辑的实现UML图如下:

代码类的Adapter在实际项目中使用的场景非常的广泛,是用于屏蔽代码底层差异化逻辑的不二选择。在总结各种实际使用场景与优秀实践的基础上,演进为23种设计模式之一的适配器模式

下面我们一起聊一聊适配器模式。

Adapter是一种设计模式

所谓设计模式,便是将常规代码编码中常遇到的一些场景的处理方式进行了总结与抽象,固化成一个优秀实践范例模板,使其整体实现更符合设计原则的要求。也就是说:设计模式并非是凭空捏造的,其实就是来源于常规的编码实践总结

按照通俗意义上对代码设计模式的理解,适配器模式也可以分为2种形式,即类适配器模式对象适配器模式

下面分别阐述下。

类适配器模式

类适配器模式整体非常的简单,涉及的角色也很少。类适配器模式中,Adapter与被适配的Adaptee之间,通过继承的方式来实现,其UML图如下所示。

主要角色说明如下:

  • Adaptee:原始被适配的类,即不符合诉求需要由Adapter进行适配的原始接口

  • Adapter:适配器本身,也是类适配器模式的核心,用于将Adaptee适配为目标的Target。

  • Target:期待获取到的目标结果。也即Adaptee经由Adapter适配后得到的统一的目标接口

还是以前面的告警通知发送的场景为例,我们按照聚合的方式,演示下对应的Adapter实现逻辑。

@Service
public class MsgSendAdapter extends SmsSender implements IMsgSender {
    @Override
    public void send(AlarmDetail detail) {
        // detail转SMS请求体的逻辑
        SmsContent sms = convertToSmsContent(detail);
        super.sendSms(sms);
    }
}

上述代码中,MsgSendAdapter继承了SmsSender类并且实现了IMsgSender接口,将父类SmsSender中的sendSms接口转换为了IMsgSender接口提供的目标接口send(),业务可以调用IMsgSender.send()接口,实现对SmsSender.sendSms()逻辑的调用。

对象适配器模式

对象适配器模式与类适配器模式类似,区别点在于Adapter与被适配的Adaptee之间非继承关系,而是对象组合关系。其UML图如下:

按照对象适配器的设计思路,其代码可以如下方式来实现:

@Service
public class MsgSendAdapter implements IMsgSender {
    @Autowired
    private SmsSender smsSender;

    @Override
    public void send(AlarmDetail detail) {
        // detail转SMS请求体的逻辑
        SmsContent sms = convertToSmsContent(detail);
        smsSender.sendSms(sms);
    }
}

上述代码中,MsgSendAdapter类中以组合的方式持有SmsSender对象(Adaptee),相比较类适配器的继承逻辑,灵活性更高,所以对象适配器要更加的灵活与实用(其实在架构设计领域也一直有一种观点叫“组合优于继承”,因为继承打破了面向对象的封装)。

总结回顾

好啦,关于Adapter相关的讨论与个人的理解,这里就给大家分享到这里。Adapter不仅是一个简单的具体实现类,也不仅仅是23种设计模式之一,更是一种问题解决的思想、一种方案设计的理念。

关于本篇文档中的内容,不知道屏幕前的各位小伙伴是否在项目中有使用过Adapter或者Adapter模式来帮助自己实现某些功能呢?是否对Adapter还有一些别的独到见解呢?欢迎评论区留言一起交流下。

我是悟道,聊技术、又不仅仅聊技术~

如果觉得有用,请点赞 + 关注让我感受到您的支持。也可以关注下我的公众号【架构悟道】,获取更及时的更新。

期待与你一起探讨,一起成长为更好的自己。

有关编码中的Adapter,不仅是一种设计模式,更是一种架构理念与解决方案的更多相关文章

  1. ruby - 如何从 ruby​​ 中的字符串运行任意对象方法? - 2

    总的来说,我对ruby​​还比较陌生,我正在为我正在创建的对象编写一些rspec测试用例。许多测试用例都非常基础,我只是想确保正确填充和返回值。我想知道是否有办法使用循环结构来执行此操作。不必为我要测试的每个方法都设置一个assertEquals。例如:describeitem,"TestingtheItem"doit"willhaveanullvaluetostart"doitem=Item.new#HereIcoulddotheitem.name.shouldbe_nil#thenIcoulddoitem.category.shouldbe_nilendend但我想要一些方法来使用

  2. ruby - 其他文件中的 Rake 任务 - 2

    我试图在一个项目中使用rake,如果我把所有东西都放到Rakefile中,它会很大并且很难读取/找到东西,所以我试着将每个命名空间放在lib/rake中它自己的文件中,我添加了这个到我的rake文件的顶部:Dir['#{File.dirname(__FILE__)}/lib/rake/*.rake'].map{|f|requiref}它加载文件没问题,但没有任务。我现在只有一个.rake文件作为测试,名为“servers.rake”,它看起来像这样:namespace:serverdotask:testdoputs"test"endend所以当我运行rakeserver:testid时

  3. ruby-on-rails - Ruby net/ldap 模块中的内存泄漏 - 2

    作为我的Rails应用程序的一部分,我编写了一个小导入程序,它从我们的LDAP系统中吸取数据并将其塞入一个用户表中。不幸的是,与LDAP相关的代码在遍历我们的32K用户时泄漏了大量内存,我一直无法弄清楚如何解决这个问题。这个问题似乎在某种程度上与LDAP库有关,因为当我删除对LDAP内容的调用时,内存使用情况会很好地稳定下来。此外,不断增加的对象是Net::BER::BerIdentifiedString和Net::BER::BerIdentifiedArray,它们都是LDAP库的一部分。当我运行导入时,内存使用量最终达到超过1GB的峰值。如果问题存在,我需要找到一些方法来更正我的代

  4. ruby-on-rails - Rails 3 中的多个路由文件 - 2

    Rails2.3可以选择随时使用RouteSet#add_configuration_file添加更多路由。是否可以在Rails3项目中做同样的事情? 最佳答案 在config/application.rb中:config.paths.config.routes在Rails3.2(也可能是Rails3.1)中,使用:config.paths["config/routes"] 关于ruby-on-rails-Rails3中的多个路由文件,我们在StackOverflow上找到一个类似的问题

  5. ruby-on-rails - Rails - 子类化模型的设计模式是什么? - 2

    我有一个模型:classItem项目有一个属性“商店”基于存储的值,我希望Item对象对特定方法具有不同的行为。Rails中是否有针对此的通用设计模式?如果方法中没有大的if-else语句,这是如何干净利落地完成的? 最佳答案 通常通过Single-TableInheritance. 关于ruby-on-rails-Rails-子类化模型的设计模式是什么?,我们在StackOverflow上找到一个类似的问题: https://stackoverflow.co

  6. ruby - 什么是填充的 Base64 编码字符串以及如何在 ruby​​ 中生成它们? - 2

    我正在使用的第三方API的文档状态:"[O]urAPIonlyacceptspaddedBase64encodedstrings."什么是“填充的Base64编码字符串”以及如何在Ruby中生成它们。下面的代码是我第一次尝试创建转换为Base64的JSON格式数据。xa=Base64.encode64(a.to_json) 最佳答案 他们说的padding其实就是Base64本身的一部分。它是末尾的“=”和“==”。Base64将3个字节的数据包编码为4个编码字符。所以如果你的输入数据有长度n和n%3=1=>"=="末尾用于填充n%

  7. ruby - 解析 RDFa、微数据等的最佳方式是什么,使用统一的模式/词汇(例如 schema.org)存储和显示信息 - 2

    我主要使用Ruby来执行此操作,但到目前为止我的攻击计划如下:使用gemsrdf、rdf-rdfa和rdf-microdata或mida来解析给定任何URI的数据。我认为最好映射到像schema.org这样的统一模式,例如使用这个yaml文件,它试图描述数据词汇表和opengraph到schema.org之间的转换:#SchemaXtoschema.orgconversion#data-vocabularyDV:name:namestreet-address:streetAddressregion:addressRegionlocality:addressLocalityphoto:i

  8. ruby-on-rails - Rails - 一个 View 中的多个模型 - 2

    我需要从一个View访问多个模型。以前,我的links_controller仅用于提供以不同方式排序的链接资源。现在我想包括一个部分(我假设)显示按分数排序的顶级用户(@users=User.all.sort_by(&:score))我知道我可以将此代码插入每个链接操作并从View访问它,但这似乎不是“ruby方式”,我将需要在不久的将来访问更多模型。这可能会变得很脏,是否有针对这种情况的任何技术?注意事项:我认为我的应用程序正朝着单一格式和动态页面内容的方向发展,本质上是一个典型的网络应用程序。我知道before_filter但考虑到我希望应用程序进入的方向,这似乎很麻烦。最终从任何

  9. ruby - 用逗号、双引号和编码解析 csv - 2

    我正在使用ruby​​1.9解析以下带有MacRoman字符的csv文件#encoding:ISO-8859-1#csv_parse.csvName,main-dialogue"Marceu","Giveittohimóhe,hiswife."我做了以下解析。require'csv'input_string=File.read("../csv_parse.rb").force_encoding("ISO-8859-1").encode("UTF-8")#=>"Name,main-dialogue\r\n\"Marceu\",\"Giveittohim\x97he,hiswife.\"\

  10. ruby-on-rails - Rails 3.2.1 中 ActionMailer 中的未定义方法 'default_content_type=' - 2

    我在我的项目中添加了一个系统来重置用户密码并通过电子邮件将密码发送给他,以防他忘记密码。昨天它运行良好(当我实现它时)。当我今天尝试启动服务器时,出现以下错误。=>BootingWEBrick=>Rails3.2.1applicationstartingindevelopmentonhttp://0.0.0.0:3000=>Callwith-dtodetach=>Ctrl-CtoshutdownserverExiting/Users/vinayshenoy/.rvm/gems/ruby-1.9.3-p0/gems/actionmailer-3.2.1/lib/action_mailer

随机推荐