设计模式在Go语言中的应用

Mew151
Mar 24, 2022

--

很多同学都或多或少看过一些设计模式的内容,但是却苦于在日常的CRUD式的业务开发中应用不到这些“高大尚”的知识。其实并不然,即使是在平时的业务开发过程中,只要你善于发现,仍然有一些实际的场景会用到设计模式,本文基于笔者经历的实际业务场景,使用Go语言来说明如何将设计模式应用到日常开发中。

场景一

假设你的部门正在创建一个爬虫系统,这个系统对支持的平台提供统一对外的获取单篇内容数据的API接口,开发人员则需要在这个系统中不断的进行各平台的适配开发,使系统的API接口能支持越来越多的平台。

经过调研后,发现获取各平台单篇内容数据的方式,大体可分为三类:
1. 通过解析html页面
2. 调用平台开放的API接口
3. 从App抓包得到接口

接口的主干代码最直观的写法会是下面这种形式:

当开发人员需要新增一个平台的适配,就需要在上面的代码中新加一条else if,那么随着系统支持的平台越来越多,主干代码就充斥着大量的分支判断。😅

那怎么用设计模式来解决这事儿呢?其实这是一个很典型的工厂模式的应用场景。基于开闭原则(Open Closed Principle),我们希望在新增一个平台的适配时,主干代码要保持稳定,只要在某个扩展点将新增的平台注入进去即可。于是改动后的代码如下:

这样,每当新增一个平台的适配,在适配逻辑类开发完成之后,只需要在NewSingleArticleGrabService函数中新增一行映射关系即可完成快速接入。主干代码完全不用改动,去除冗长的if else分支同时,也避免在改动主干代码时不小心引入bug的情况。

按理说使用工厂模式将该业务场景的代码重构到此就基本结束了,但实际上你会发现上面的代码还有些地方有点儿啰嗦,比如SingleArticleGrabService需要持有三种方式的map,而这些map的定义都一样;再比如在Grab方法中,遍历三个map的代码几乎是一模一样。因此我们再来看看,怎样优化一波代码,让写法更优雅一些。

首先,抽象出一个接口,来定义在每个for循环中共同的行为:一个是判断某个map中是否存在某个平台,另外就是当存在时,取出该平台的实现类。该接口的定义如下:

再定义一个通用的结构体来实现该接口,这样,对于三种方式的map,就可以复用这个通用的结构体:

接下来我们来看看SingleArticleGrabService及Grab方法如何来使用上面的定义:

当然,上面的Grab方法仍然存在switch case,但相较于三次的for循环,写法上更优雅一些。或者如果你想到了更好的实现方式,欢迎在评论区给我留言一起交流讨论~😄

总结:当我们需要基于同一种行为来做各自的实现时,首先应该把这种行为抽象成接口,然后为了适应不断添加的新实现,应该遵循开闭原则,使用工厂模式将创建实现的类封装起来,并使用map消除if else分支逻辑,以此来达到稳定主干代码的目的。

场景二

假设你开发的系统中,有很多需要调用第三方系统的接口(通过http协议),一般情况下,比较直观和常见的方式会写成如下形式:

如果再新写一个三方系统的接口调用,会把上面的代码拷贝一份,改改formData(比如改为传urlQuery参数),或许会把header加上,Method由Post改为Get,再或许会修改解析返回值的逻辑等等。当系统中要调用的第三方接口越来越多时,这种copy-paste-modify的方式就有些别扭了,不仅写的人感觉不清爽,同时还会造成类似这种问题:如果想对一些共同的部分加一些逻辑,比如监控http的请求耗时,当系统中有几十处这种接口调用,那就要把所有的地方都改一遍,而且很容易漏掉某些地方。

其实细分析这些接口调用的代码,它们是有一些共通的流程的,无论调用什么样的接口,都可概括为以下三步:
1. 构造请求参数,包括header、urlQuery/body/form等
2. 发送请求,以Post/Get的方式
3. 解析请求的返回结果

梳理到这里,很自然的想到,这其实就是模板模式的一种使用场景。为此,我们需要先定义一个通用的请求流接口:

在流程中的第一步BuildRequest的入参中,同样定义了一个接口RequestParams,用来抽象不同的调用接口需要构造不同的参数这件事:

然后定义两个结构体,一个用以实现RequestFlow接口,另一个实现RequestParams接口,完成通用的处理流程:

最后,创建一个模板函数来作为调用各三方系统接口的通用核心代码:

这样,所有的前置工作都完成了,我们来看一下,如果要新写一个三方系统调用的接口,该如何来实现:

稍微解释一下上面的代码,分为这么两步:
1. 创建一个BusinessXParams结构体,其持有一个requestParams匿名属性,并覆写相应的Headers()/QueryParams()/Body()/FormData()方法(比如例子中的BusinessX这个接口是以form的方式传参的,所以就覆写FormData()方法。
2. 创建一个BusinessXFlow结构体,其持有一个requestFlow匿名属性,并覆写HandleResponse()方法,在其中做解析接口返回数据的逻辑。

最终,调用三方系统接口代码的入口方法就可以变得很简洁了:

这样,当需要新增一个三方系统接口调用时,只需要按照上面的方式即可以扩展而非copy-paste-modify的方式来实现了。另外,本节开头提到的监控http的请求耗时这种通用功能,只需要在Send()方法这一处加就可以了。🍺

总结:当我们在系统中多处重复实现一些流程的时候,就需要考虑这些流程之间是否有一些共通的部分,如果有,可以尝试使用模板模式将流程抽离出来,每处在使用流程的基础上,针对模板函数提供的扩展点实现自己特有的逻辑。如此,整个系统在新增三方系统接口调用时,遵循了开闭原则,使得代码具有较好的可维护性和可扩展性。

场景三

假设你所在的团队开发的App有这样一个页面,这个页面的内容最多由10张卡片以上下滑动的方式循环展示,你在做的是提供这个页面所需要的数据接口。这10张卡片用10个位置做为标记,第一张卡片对应位置1,第二张卡片对应位置2,以此类推,多个位置对应于同一种类型的数据。如下图:

接口的请求参数中带有要请求的内容类型有哪些,比如只请求科技类内容和时尚类内容,那对于上图来讲,要返回位置1、2、3、4、6、7、8的数据;如果请求的是所有分类的内容数据,要返回位置1~10的数据。返回的数据格式如下:

对于这个需求,直线式的编码方式像下面这样:

假如现在新版本需要对这块功能做改动,把位置1的数据变成新版本主打功能推荐,位置5的数据变为配置的运营活动,按照上面的写法,则需要做如下改动:

这样,每当需求有变,要新增或者去掉内容类型,核心方法GetPageCards的逻辑都要有改动,同时也增加了引入bug的风险。那如何做可以提高代码的可维护性以及在写法上变得优雅一些呢?

我们来分析一下上面的代码,它是面向实现的编程方式。因此代码会随着需求的变动而变动。于是自然而然的想到,我们要基于开闭原则以及使用平常总提及的面向接口编程方式,把变化的部分和不变的部分拨离开来,在需求有变动的时候,保持主干代码的稳定,只需要在预先提供的扩展点上插入变动部分的代码即可。

通过观察接口的返回数据,发现在cards数组中,每一个json object都有contentType字段来标明这条数据的内容类型,其他的字段则表示这种数据类型的业务属性。我们将json object和每种内容类型获取数据的方法抽象出两个接口:

每种内容类型的返回数据都将持有ConcreteCard,同时每种内容类型获取数据的方法会持有AbstractPositionDatasGenerator并覆写相应的方法。接下来,核心方法GetPageCards的逻辑可以这样来写:

那对于每种内容类型获取数据的方法,按照以上两个接口的定义来实现自己的业务逻辑:

这样,对于需求变动(新增或者去掉内容类型)来说,只需要增加ContentTypeDatasGenerator,并且调整NewService方法初始化Generators的地方,即可满足需求的变动,而核心方法GetPageCards则完全不需要修改。

总结:本节并不是某一个设计模式的应用场景,但却能说明基于一些设计原则和思想,可以写出扩展性更好的业务代码。因此,当我们在日常的业务开发中,发现重复在写相似的业务逻辑时,就要停下来思考一下,是否能够运用学习到的这些设计原则及理念,将业务代码写的更具扩展性?以此来更有效率的支持需求的变动。

结尾

本文结合笔者真实经历过的业务场景,讲解了如何将设计模式以及相关的设计思想应用到日常的业务开发中。也想借此跟大家说一句,即使我们在CRUD式的工作中,只要善于思考,也是可以将学到的这些知识应用上的。好了,如果对本文有任何疑问和想要和笔者讨论的,可以在评论区留言,欢迎一起交流共进。☕️

--

--

Mew151

十年经验的coolder,记录和分享自己对软件开发的一些心得和感悟