软件设计:设计抉择的关键因素

80 阅读12分钟

欢迎来到雲闪世界。在开始讨论初始化我们做出的设计决策时,先说明初始化SDK之前的使用场景(不能透漏之前工作的细节,因此是修改过的版本),SDK的主要用户是使用下橘色的第三方服务的服务器端应用程序,通过 SDK 与我们的服务沟通(下图中紫色的主机服务器),类似 OAuth,第三方服务的 Web 客户端端可以获取代表我们服务的用户的令牌,第三方服务的服务器端使用此令牌换发真正与我们服务沟通的凭证,我们的服务可以使用凭证得知哪个第三方服务代表谁发出请求。

添加图片注释,不超过 140 字(可选)

SDK修改一个功能是访问服务提供的某种容器,就暂且称为Box吧,每个Box都有一个唯一的ID,第三方服务可以用ID访问SDK操作Box,如名称、说明、图标,上传文件到这个容器中容器,并设置容器的管理员以及那些人可以看到这个里面的东西。

当时团队讨论时,我曾问要不要把管理员设定的函式放在Box对象里?就类似于《Yet Another Evil Suffix for Object Names: Client》所说的,将Box设计成一个代理,实际上可以作备份在Box建立时,就将远端资讯获取回的远程代理,或者Box建立时不取回最后的资讯,直到真正要获取资讯时再请求资讯的虚拟代理。其次Box有清晰的设计方式:AWS的客户端、远程代理和虚拟代理。

要求數量

说明完成背景之后,回到设计抉择,由于SDK主要的使用方式在第三方App服务器上,从响应时间的考量上会希望减少第三方App服务器发向主机服务器的请求数量。所以比较在不同下,明确方式所需要发出的请求数量。

姿势一:取出盒子的基本资讯

从列表1可以看出,在取得基本资讯的场景下,透明设计都可用1次请求就完成任务,在虚拟代理的情况下,boxManager.getBox("box-id")并不会真正发出请求,会等到info()被呼叫时才真正发出请求。


// client & remote proxy
Box box = boxManager.getBox("box-id"); // 1 request

// virtual proxy
BoxInfo box = boxManager.getBox("box-id").info(); // 1 request

清单 1 获取 Box 资本的资讯

集合二:加入一个用户作为成员

从列表2可以看到,远程代理较其他两种设计多了一次请求,因为不管有没有使用到,boxManager.getBox("box-id")立即发出请求取回Box的基本,然后addMember("user-id")再次发出请求真正完成任务。

// client
boxManager.addMember("box-id", "user-id"); // 1 request

// remote proxy
boxManager.getBox("box-id").addMember("user-id"); // 2 requests

// virtual proxy
boxManager.getBox("box-id").addMember("user-id"); // 1 request

列表 2 加入一个用户作为成员

其中,虚拟代理似乎是个不错的选择,请求数量和客户端方式一样,设计也比较OO,远程代理虽然希望会多次请求,但若将第一次取得的Box实体放在记忆体上,后续的操作也不会再发出请求,看起来也是不错的方式。

證明的处理

接下来看依赖的处理,如果SDK最初设计的使用场景是像图2一样,直接让用户的移动客户端直接访问Host服务器的话,我应该会选择远程代理的设计,因为不用考虑太多依赖的问题,每个移动客户端只有一个代表该移动客户端和正在使用的用户的代理。远程代理对象放在记忆体中也没有问题,由BoxManager管理曾经获取的代理对象的所有,代理对象的生命周期和移动应用程序的生命周期一致,可以大幅减少需要再次发出请求的情况。

添加图片注释,不超过 140 字(可选)

但在服务器端的方案就不是这样了,将Box实体放置在记忆体中,需要把相关的信息封装在Box实体中,如果想要在发出请求时使用正确的图形,还需要建立一个用户与Box实体对照表中,每当第三方App服务器要利用Box发出请求时,从对照表中调用该用户的Box实体,然后调用对应的函式才会使用到正确的资源。

对照表很难由 SDK 来维护,使用上反而因为对照表的关系变得复杂。而且若考量记忆体的使用量,早已没有接触的Box实体,也没有必要一直放在记忆体上,只是这还要 SDK 的用户维护这种类似快取管理的机制,并没有太多好处。当然,如果不在记忆体中存储远程代理,依赖的处理就没有什么问题,只是会有请求数量的问题。

这样一来,每当第三方App服务器需要对外部的Box操作时,用用户的资源建立客户端对象( BoxManager)然后调用函式,省事很多。虚拟代理因为建立成本很低,所以也不是需要放置记忆体,因此和客户端一样,要使用的时候重新获取一个代理对象,然后调用函式,多解决没有繁琐的问题。

其中,客户端和虚拟代理都不错的选择。

例外

再思考一下异常,这其实是最容易被遗忘的一个部分,如果事情不会出错,自然不用考虑异常,但是第三方App服务器和Host服务器之间透过网路沟通,很难保证不出错的。问题是类图通常不会标注方法会抛出类似的异常,设计时就不会想到这件事,但这是一个Java SDK,而Java就是一个决定(checked)异常声明在方法上的一个语言,这时候会发现例外让抽象渗漏法则更加明显:

某种程序上的所有重大抽象机制都存在漏洞。

是否有人注意到上面的虚拟代理在获取Box基本信息是远程呼叫info()的通信方式,为什么是这样的设计呢?先看看具体的info()版本:

public interface BoxInfo {

  String getName();

  String getDescription();
}

public interface Box {

  BoxInfo info() throws IOException, BoxNotFoundException;

  void addMember(String userId) throws IOException, BoxNotFoundException, UserNotFoundException;
}

public interface BoxManager {

  Box getBox(String boxId);
}

列表 4 虚拟代理以 info() 的版本

再看看沒有info()的版本:

public interface Box {

  String getName() throws IOException, BoxNotFoundException;

  String getDescription() throws IOException, BoxNotFoundException;

  void addMember(String userId) throws IOException, BoxNotFoundException, UserNotFoundException;
}

public interface BoxManager {

  Box getBox(String boxId);
}

List 5 虚拟代理没有 info() 的版本

很明显,List 5 比 List 4 一点,连都getName()可能会抛出异常,这里有将请求回传的错误码中具有意义的转成域层异常,很及BoxNotFoundException,UserNotFoundException其他低阶的错误则以IOException发送那这和抽象渗漏有什么关系呢?代理设计的目的就是想提高抽象程度,让使用的开发者觉得Box这个物体就在他们的环境中,但IOException暴露出来其实取得的效果Box都是Box需要网路的沟通的,BoxNotFoundException则发现Box实体是一个可能不存在的代理。

那很简单的远程代理呢?就异常声明的位置来看,远程代理比虚拟代理稍微好一些,至少getName()也getDescription()不会触发BoxNotFoundException,毕竟已经取得Box实体时这些资讯已经一并取回了,但还是有一个函件式addMember(userId)会发送,这等等再谈为什么。

public interface Box {

  String getName();

  String getDescription();

  void addMember(String userId) throws IOException, BoxNotFoundException, UserNotFoundException;
}

public interface BoxManager {

  Box getBox(String boxId) throws IOException, BoxNotFoundException;
}

List 6 远程代理的异常声明

最后是客户端的版本,可以看异常出来都集中在BoxManager身上,而Box实体则没有任何会抛出异常的函式。

public interface Box {

  String getName();

  String getDescription();
}

public interface BoxManager {

  Box getBox(String boxId) throws IOException, BoxNotFoundException;

  void addMember(String boxId, String userId) throws IOException, BoxNotFoundException, UserNotFoundException;
}

List 6 Client方式的异常声明

不管哪一种方式,都免不了抽象渗漏,因为有了怎样的设计,都还是会投放IOException、BoxNotFoundException或者UserNotFoundException,只是看哪种方式让用户觉得比较自然,异常处理方便比较,以这样的角度来看,我个人觉得客户端的方式优于远程代理,远程代理优于虚拟代理。

异步

开始开发 SDK 之前,团队先订下几个意见,希望 (1) 最低相容的 Java 版本 (Java 6)、(2) 全部都是同步 API、(3) 解决问题相依的第三方函式库并提供集成的单一 JAR 文件(带有所有依赖项的 JAR),以及 (4) 至少所有公共 API 都有 JavaDoc 说明并提供使用文件。既然是全同步 API,那标题为什么是异步呢?

首先考量到 Servlet 虽然从 3.0 开始便支持异步请求,但使用独立框架(相当于 Struts 2、J2EE、Spring 框架等)编写的应用程序,主要是进行同步的编写,要在同步的程序中取得异步API的回传值,或者捕捉错误(基本上要自己重新发送,否则无法用try-catch处理),那是一件很麻烦的事情。反之,异步程序(例如用未来的编写方式)中假设同步 API 串在一起相对比较容易,Java 提供的工具类别可以简单封装成未来的串。

那么跟上面三种设计方式有什么关系呢?主要是哪种设计方式让SDK的用户更容易知道那些方法的关系调用是会发出请求的,有必要的话,可以用异步封装成未来的作法的方式进行呼叫,让服务器端的处理效率更高。事实上,SDK的主要使用方式是在服务器端,但是SDK要在Android上使用也是可以的,我相信应该有很多开发者,在呼叫某有些心中没有注明会发出请求的方法时遇到NetworkOnMainThreadException,然后在黑暗三字经的经验吧!

在开发 SDK 之前,有一点研究一下 Google、Amazon 和 Microsoft 的几个 SDK 的原始码(开源就是方便研究),注意到@WebMethod这类注释,标注在会发出请求的方法上,这似乎是个解法(我离开之前也替公司的SDK加入类似的注释),但我个人觉得即使没有注释,所有发出请求的API都在客户端一起比较,很容易让SDK用户意会到的BoxManager所有函式都会发出请求的,反之,远程代理和虚拟代理则没那么明显。

同步

这里的同步不是指会阻塞的方法,而是指数据的同步,回顾整个使用对象,对象Box都是一个代表终端的容器,而这个对象可能由有授权的用户通过主机服务器提供的其他服务进行修改或删除,在这种情况下,无论是哪一种方式设计,一个Box对象都可能指向一个已被删除的容器,因此,刚才在例外中前面讨论虚拟代理时,对一个代表已删除容器的Box对象调用addMember(userId)时,主机服务器会发现该请求想要对一个不存在的容器进行操作,最后会发送BoxNotFoundException,原因就是数据的不同步。

首先不考虑零时差同步,要做到一定期限,资料完全同步也需要不小的努力,相当于每个SDK实体维持一条固定连线,让主机服务器能够推播变更给SDK实体,能够更新所有一般来讲,代理除了可能代表不存在的资源,其内部的状态也可能会过渡,例如:远程代理的Box实体中会有名称与描述的信息,随时都可能会过渡;虚拟代理的Box实体中,若info()函式若会保存已取得的资讯,也会有过渡的可能;客户端的方式,Box是一个DTO(数据传输对象)也会有过渡的可能。

就过了这一点,不是很清楚吗?一般来说,比较把客户端的Box物品当成用过即丢掉该物品,额外的操作也不依赖于Box物品上,而代理底部是持有一批子的物体,那么就会有明显有一个Box物体,调用它的物体却会抛出BoxNotFoundException诡异的异象。

序列化

文章已经有点长了,最后讲一个概念,考虑到第三方App服务器的节点可能不只一个,例如图3有两个节点,前面的分散负载均衡器将Web客户端的请求平均分散到不同的节点,节点之间也可以互相沟通。如果Box对象都是用完即丢的情况,那也许不用考虑指定一个Box对象从一个节点转移到另一个节点的问题,但如果需要转移,Box对象必须支持序列化(serialization) ) 和反序列化(deserialization),发出能力在跨网路传递,

由于封装的关系,不管用什么方式,序列化和反序列化的实作肯定是由SDK负责的,这也是一个努力。客户端BoxPOJO,序列化上还是很容易的,但是代理,不管是远程代理也好虚拟代理要在另一个节点将与主机服务器沟通的对象也全部还原,相对麻烦很多。

添加图片注释,不超过 140 字(可选)

说了这么多,好像客户端就是唯一解了?其实不然,在写这篇文章的时候,内部也是虚拟代理和客户端两者中支撑,只是最后选了客户端,可能是因为相对简单吧!但如果还有更多其他因素加入思考,也许还会有不同的结果,我相信还有更多可以考虑的因素,不知道大家在做设计决策的时候,还有考虑那些因素呢? 或者,看完这个分析后,有不一样的决定呢?甚至是上述三者以外不一样的设计呢?欢迎大家讨论。

感谢关注雲闪世界。(Aws解决方案架构师vs开发人员&GCP解决方案架构师vs开发人员)

订阅频道(t.me/awsgoogvps_…) TG交流群(t.me/awsgoogvpsHost)