如何进行代码结构的规划

Index

从MVC谈起

从最开始接触到web编程时,MVC是使用的最多的模型,在当时的一众CMS中,大多都会打着MVC、OOP等旗号推向市场。MVC从何而起,不必深究,只需要知道,他是一种可以指导我们对代码进行规划的模型。如果你的项目中有太多无处安放的代码,那么,用MVC去盘他,总能找到他们合适的位置。

再后来接触到ThinkPHP的时候,又了解到了AOP面向切面编程,“切面”又成了一种可选的组织代码的模型。如果把MVC看做是纵向的拆分方式,那么切面就是横向的拆分方式。这两种模型,都可以在目前的框架中找到其实现,可以作为我们自身代码结构规划的指导思想。

MVC的变种

然而,凡事没有银弹,不存在一个方法可以完美解决世界上的所有问题,MVC与AOP也不例外。由于当前web开发中,后台的业务变得更为复杂,不再只是以前简单的增删改查,一味往MVC上靠,已经变得不可取了。再加上前后端分离的趋势,后端实际只剩MC两层,将复杂的业务逻辑编码到MC两层中,必然导致某一层变得臃肿庞大,日渐难以维护。由此,有一种思路是将MC继续拆分,引入Service,Repository两层,分别与Controller和Model进行对接,使项目代码分为四层:

1
Controller -> Service -> Repositroy -> Model

还可以根据项目自身情况来决定,只引人Service或Repository中的一层,从而解决某一层过大的问题。依据这一思路,其实可以规划出更多的层级来组织代码,不怕你业务逻辑多复杂,就怕你层级分的不够多。

分层带来的问题

将MVC继续细分,固然是解决方案之一,但同时也带来了一些问题,比如:

  • 层级与层级之间的划分依据是什么?
  • 如果某个操作逻辑比较简单,可否跨级调用?

这些问题可能不会有一个标准的答案,而是需要在决定如此分层时,进行统一约束与定义。即使是进行了定义,也会有:如果层级之间的划分依据不明确,会导致层级与层级之间的边界变得模糊;如果可以进行跨级调用,除了导致层级之边界模糊外,还会使人重新思考,层级划分的意义何在?但是反过来,如果层级的划分依据过于明确,是否可以适用于大多数场景?禁止跨级调用,是否会导致无用代码的增加?等等系列问题。

AOP与MVC

上文我们提到过,如果把MVC看做是纵向的拆分方式,那么切面就是横向的拆分方式。就像是代码执行到某个分叉口时,既要横向执行,又要纵向执行,但众所周知,PHP是单线程的,串行执行,不存在分叉一说,所谓切面,实际上就是插队,将“切面”上编码的内容,插入到钩子设置的切面点上执行,然后继续MVC这条线的执行。

如果我们将“切面”看做是我们分的一个层的话,那么,这一层在执行中到底存不存在,实际上由是否在代码中设置了钩子来决定的。如此看来,刚才我们讨论的分层带来的问题,仿佛有了解决办法,至少,不存在跨级调用的这个问题。

“切面”是层的另一种形态,是某一层中的一个细节。当某一层中的“切面”大量出现时,就应该考虑将切面进行归纳总结,是否来划分成一个“层”了。

至此我们可以总结:

  • 在使用MVC这种模型规划代码时,初期可以不必划分过多的层级,通过引入切面这一模型,来进行调节,然后在合适的时机划分出下一个层级。
  • 通过“层”的维度对代码整体进行划分,是个不错的选择。除此之外,还可以通过模块的方式,对代码进行划分。

一个例子

我以做过的一个项目,美术数据驾驶舱,来举例,简单说明一下我是如何进行分层及依据的。 文档

从Controller到Model,这个项目分了这些层次:

  • Controller: 接收请求的参数;确定用于筛选数据的“应用数据”对象
  • Charts/Exports/TableViewers: 应用数据层。根据上层传递的参数及数据展现型式,查询并组合出指定型式的数据,如:图表型数据、表格型数据、Excel文件数据。对于图表型数据,需要将数据转化成适配EChart的数据格式,如果是表格型数据、Excel文件数据,需要对表格单元格进行合并。
  • TablePrototypes: 表格原型数据层。如果应用数据层是Exports/TableViewers,需要经过这一层。根据上层传递的参数,查询数据并组合成原始表格格式的数据
  • Data: 基础数据层。根据上层传递的参数,查询所需要的数据,这一层的数据都是简单的键->值单元,一个上层的调用,在这一层可能会查询出很多组相同主键的键->值单元,然后上层会根据主键,组合成表一样的数据。
  • Model: 数据库层。

模块

模块是区别于层的,从另外一个维度划分代码时,提出的一个概念,与之平行的还有库、包等概念。在说起模块时,可以暂时忘掉我们上文对于层的讨论。

在使用php的过程中,如果说有什么能帮助你理解模块的概念的话,那莫过于composer了。通常我们使用composer来为项目安装依赖后,可能会在任何地方使用到这些依赖。他们大多都是,为了解决某一范围内的问题,提炼出的一套接口。

然而,使用composer安装的依赖都集中在vendor目录中,日常开发中很少会去注意到这里面都有些什么,似乎并不会对我们自己组织代码时起到什么指导作用。对此,我们来说道说道。

如何构建一个模块

要解决的问题

一个模块,必须要有他的边界,有他要着重解决的问题。如果你想把所有问题都放到一个模块中,那么,这个模块实际就是你正在开发的项目了。所以,在构建一个模块时,首先要考虑的是:你打算用他来解决项目中的哪个问题?

  • 由于需要调用其他服务的接口,所以我们划分出了HttpClient模块,来专门处理http请求发送与响应的问题。
  • 由于需要生成图片的缩略图,所以我们划分出了Image模块,来专门处理缩略图生成的问题,后来我们又需要给图片加水印、需要获取图片宽高等信息等等

提供的接口

在明确模块需要解决的问题后,下一步就要定义为了解决这些问题、模块所提供的接口。在解决这一问题时,所需要的上下文信息,都应该通过接口的参数传递过来,尽可能避免在处理问题的过程中,再次向外部获取参数。与之对应,接口的返回也是要明确定义的,如果返回的是一个对象,那么应该始终返回一个对象,不会因为参数传递的区别,导致返回值类型的差异。

其实,这一条可以作为大多数函数参数与返回值的标准,虽然不是必须遵守的,但是如果你的模块,有可能被抽象出来,广泛运用到其他项目中,最好严格遵守这一条。因为你不知道别人调用你的接口之后,到底会不会对返回值进行检查,如果没有而你又恰巧返回了其他类型的值,就可能导致代码运行出错。

模块的引用方式

一般来说,一个模块有一个公共的出口对象,所有的接口都由这个公共对象来调用,是一个比较好的模式。这样在引用模块时,只需要实例化该对象即可。

必要的说明文档

主要介绍模块的引用方式以及接口使用说明

内部模块与外部模块

使用composer安装的依赖,我们可以称之为外部模块,与之对应的内部模块,就是我们项目中封装的模块了。内部模块公用同一个命名空间即可。

其他

代码结构的规划,说到底其实就是封装的过程。无论是层级的划分,还是模块的划分,都会遇到边界的问题,封装的过程,就是要明确问题的边界。这需要一些设计模式相关知识的储备,也需要进行大量编码的实践,没法一蹴而就,如果从现在开始,试着将函数与方法尽量进行拆分,坚持一段时间,或多或少都会提高自己对代码封装的理解。