从PHP MVC框架到现代分层架构:如何避免代码复杂度失控

源/顶级程序员 文/曹春晖

从PHP MVC框架到现代分层架构:如何避免代码复杂度失控

最近接手了其他人做的项目,这使得之前的一些幻想破灭了。刚工作时做项目用的是 php,php 自身的 web 框架通常只是简单地区分 mvc,稍微复杂一点的会多一个 library 或者 helper 之类的。这样的分层很少有优点,同时也存在缺点。现代的框架通常是支持 namespace 的。你完全能够借鉴其他语言来构建自己的内部框架。这里暂且先不提及这方面的内容。

mvc 的优点在于其简单性。不管一个新人之前是否有过相关工作经验,只要向他简要说明每一层的职责,他就能立刻开始工作。mvc 的缺点也很明显,由于过于简单,当代码累积到一定数量后,就会难以控制其复杂度。并且容易导致没有什么经验的程序员写出难以维护的代码。

之前听闻 java 的框架在这方面表现较好,于是对部门的 java 项目怀有一些期待。然而在实际接手之后,意识到理想与现实之间存在着较大的差距。所以先对共通的初级程序员容易犯的错误进行总结。倘若有一天自己带领团队,在面试他人时,也可以将这些题作为区分人的一种标准。做项目的时候有思考的人和不思考的人还是会有不小的区别的。

1、命名太啰嗦,或者不规范

这个问题较为普遍。即便身为科班出身,工作多年的人,在写程序时也可能出现一堆 var a/b/c 之类的变量。如果你的程序本身较为简洁,或者只是单纯的算法题,那么这种情况或许问题不大,反正在 oj ac 之后,你可能就不会再查看自己的代码了。然而,若将这样的代码置于生产环境中,问题就会变得很大。首先,有些变量自身的生命周期或许会超出一个横着放置的屏幕。这里开个玩笑,不是谁都能买得起戴尔那种可横可竖的屏幕呀。例如,以我的 13 寸笔记本为例,在 vim 的全屏模式下只能显示 33 行(当然啦,是因为我视力不太好,字体比较大)。所以,如果在第一行声明了一个变量 var a,那么顺着读下去,会在 33 行之外……我觉得我肯定不记得这是什么东西了。

如果你想声明一个变量,需让变量名字本身有含义,像 productName、productPrice、userId、userInfo、orderHistory 等。在保证名字有含义的同时,要确保名字与含义相匹配。并且不能把已命名好的变量以优化为由,反复存储各种不同的值。我们这里的代码曾出现过这样的情况,看起来是 userIds,但实际打印出来却是一堆 userInfo 的信息,这着实让人恼火。

业务内部逻辑的变量命名需讲究单词打全且意义明了等。若涉及框架本身的一些内容命名,像 java 的 spring 框架中的 service 命名,反倒应稍简洁些。例如:

KnowledgeService kService;
OrderService oService;
UserMapper uMapper;

在整个 class 内部,service 和 mapper 通常只会有一个唯一的对象。所以即便看不太明白,到整个类的头部也能够找到它们。由于这些对象的使用频率较高,简短一些对于打字是有好处的(实际上可能是想偷懒呢)。当然啦,如果你喜欢把名字完整地打出来,那我觉得也没有什么不可以。

变量命名之外,函数命名也很重要

之前的公司里,有人会将函数称作 dealData 或者 handleData,给人的感觉很是别扭。换成 formatData 之后,是不是就显得很清晰明了了呢?从这些细节之处就能看出真正的功夫。

2、魔法数字

这个问题在各个地方都能见到,比如各种订单的 status 发生跳转这种最简单的情况。你会见到各种 updateStatus(1)这样神奇的代码。倘若恰巧数据库的表定义中没有这个 status 的定义,那肯定会成为维护人员的噩梦。解决办法很简单,在配置文件中集中对这些特殊变量进行维护。

进一步的话,应当引入各类便捷的配置系统。像在 Java 中常用的 disconf,能够在系统无需下线的情况下,于配置系统里查看配置的 key 和 value,同时还可以即时地对其进行修改并下发配置。若其他语言没有类似系统,也可以借助当下流行的 etcd 或者 zookeeper 来进行自主开发,这并不会太复杂。

如果你发现自己的系统里有各种不明白其含义的数值,那么就该考虑引入配置来进行管理了。(实际上从最开始就应该做这些事)

3、解决了魔法数字的问题,但不对配置文件进行分类

有了配置系统之后,存在一个麻烦的问题。即所有配置都被放置在一个文件中。像之前所做的系统那样,将所有的 key 以及对应的 sql 都放在一起,这就使得配置文件变得很大且很难看。如果想要修改一个简单的 sql,甚至都找不到它在哪里。

改进方法较为简单。其一,要对配置文件进行业务逻辑的分类,像与 Order 相关的配置项就放置在 Order 的下方。倘若配置内容依然较多,那么就可以进一步对粒度进行细分,比如 OrderHistory、OrderType 等这类。实际上,通常的系统也不会如此复杂。

4、分类粒度问题

对配置进行了分类,然而有时配置的粒度会特别细,这就使得文件数量增多,找起来就不是很便捷。

这怎么办呢?

第一,要把粒度划分掌握好合适的程度;第二,相关的配置最好有能用于简单检索的对应的 comment;第三,可能就是对系统的模块进行拆分,不同模块管理自己的配置文件,这样就眼不见心不烦了。

划分后的模块使用哪种通信方式,取决于自身的业务量以及延迟要求。是使用 json http 还是 rpc 通信,需根据具体情况来确定。

5、函数写的太长

这是一个常见的问题。在业务开发堆代码的阶段,这种问题特别容易出现。比如上一家公司的 createOrder 逻辑,起初它只是一个 80 行的小函数。后来陆续加入了商品内容的校验工作,包括对用户购买商品合法性的校验,比如用户是否跨越了购买区域,或者购买了本没有资格购买的 VIP 商品等;还有价格的校验,像是否有优惠,优惠了多少,前端价格是否存在问题等。接着这些校验工作使得事情愈发难以处理,原本 80 行的开头最终演变成了 1000 行的庞然大物。

这个又怎么解决呢?

首先是要根据功能进行简单的小函数块划分。

func ABCDE() {
  A
  B
  C
  D
  E
}

=>

func main() {
  A()
  B()
  C()
  D()
  E()
}

再具体一些

函数创建订单(userInfo 为用户信息,products 为产品列表)。
  checkUserValid(userInfo);
  checkProducts(products);
检查用户信息和产品;对用户信息与产品进行检查;检查关于用户信息以及产品的情况;查看用户信息以及产品的状况;审视用户信息和产品的相关事宜;核查用户信息与产品的情况;查验用户信息以及产品的状况;检验用户信息和产品的相关内容;审察用户信息与产品的情况;查看并检验用户信息和产品;审视并查验用户信息和产品;核查并检验用户信息与产品;查验并检验用户信息和产品的相关情况;检查用户信息以及相关产品;核查用户信息以及相关产品;审视用户信息以及相关产品;查看用户信息以及与之相关的产品;核查用户信息以及与之相关的产品;审视用户信息以及与之相关的产品的情况;检查用户信息以及与之对应的产品;核查用户信息以及与之对应的产品;审视用户信息以及与之对应的产品的状况;查看用户信息以及与之对应的产品的相关事宜;核查用户信息以及与之对应的产品的相关内容;审察用户信息以及与之对应的产品的情况;查看并核查用户信息以及相关的产品;审视并核查用户信息以及相关的产品;核查并审视用户信息以及相关的产品;查验并审视用户信息以及相关的产品;检查用户信息以及与之有联系的产品;核查用户信息以及与之有联系的产品;审视用户信息以及与之有联系的产品的状况;查看用户信息以及与之有联系的产品的相关事宜;核查用户信息以及与之有联系的产品的相关内容;审察用户信息以及与之有联系的产品的情况
创建一个名为 order 的变量,它是通过调用 insertOrder 函数并传入 userInfo 和 products 这两个参数而得到的结果。
创建标志(createFlag)是通过创建订单详情(createOrderDetail)函数并传入订单(order)和产品(products)来生成的。
  return createFlag;
}

看起来清晰了许多。如果原本有上千行,在进行划分之后,每个函数都不会太长。

当然,有人会认为,我的 api 逻辑不会如此简单。有诸多其他系统依赖于我当前系统的数据,我创建订单之后需要对它们的接口进行回调。这部分代码难以节省下来吧?

其实还是可以的。

这一“点”就是消息队列存在的意义。在后面关于滥用回调的一节里会详细说明。

6、滥用回调,增加系统复杂性

滥用回调这种情况其实挺常见的,尤其在现在的这个公司。很多时候,那些有数据依赖的项目会在彼此之间设置很多错综复杂的回调接口。在初期开发阶段,由于调用是同步操作,所以从表面上看,项目显得很简单。并且,因为是同步调用,随时都可以打日志,一旦出现问题,也便于进行排查。

但依赖项多了以后就会变成维护和重构的噩梦。

举个实际场景例子:

这里存在一个用户的评论系统,该评论系统会针对为你服务的商家进行一些特定的 tag 勾选操作以及内容的填充。

评论系统本身只需将被打 tag 的对象以及被评论的对象简单记录到 mysql 中。

但后面的信用系统、用户画像系统、客服系统依赖这些评论的数据,所以要把这些评论的 tag 以及内容同步给其他几个系统,甚至是跨部门的系统。

目前是怎么做的呢?通过回调。

一个接口里的回调逻辑超过五个后,程序员就难以知晓自己的代码用途了。在如今互联网公司每年离职率较高的情形下,若导致 pm 和 rd 都离职,那么后来的新人根本不敢触碰这些回调问题。原本很简单的接口,用 50 行就实现并完成了自身的业务逻辑,然而却有几百行是在回调其他系统,着实令人作呕。

这个问题解决起来并不难,它根本就不应该被解决,这种问题一开始就应该被极力避免。我们可以利用消息队列来避免这个问题,回想一下人们常说的,消息队列可以用来解耦,所谓解耦,指的就是上面这种场景。你的系统发生了一个事件,其他系统依赖这个事件的数据,那就让他订阅你系统产生的消息,此消息放在队列里就行。你可以使用 kafka 或者其他消息队列。

这样,变化是下面这样的:

event A happend
then {
    call sys A1();
    call sys A2();
    call sys A3();
    call sys A4();
    call sys A5();
    call sys A6();
    call sys A7();
    call sys A8();
    ...
}

=>

event A happend
then {
    push msg to msg queue
}

A1~AN subscribe topic A in msg queue

复杂性被分散去了各自的系统中。看起来是不是明朗了很多。

这种方案当然不是毫无问题。引入消息队列后,整个系统对消息队列自身的可靠性会面临一定的考验。其次,原来的同步回调转变为基于消息队列的异步分布式系统。提及这种系统,大多数人必然会想到令人头痛的分布式事务。一般来说,分布式事务在数据库领域提及较多,而涉及到消息队列的分布式事务相对会更加复杂。现在有一些解决这种问题的思路,这是值得庆幸的。不过相关资料并不多,对此感兴趣的读者可以去搜索一下 saga pattern。实际上,即便使用 saga pattern 也无法完全解决这种问题。

实际项目中出错的概率其实是比较低的。如果 push 失败了,打失败日志并报警,并且及时解决,那么大多数系统是不会有这么大成本的。只要在失败时准备好恢复的预案就可以了。比如一些两边系统进行时间区间内数据同步的工具。要确保一定程度的幂等性。

7、虽然有分层,但每层的结果返回没有明确界线和定义

这个在啥语言的项目里都比较多。

最近接触的是一个 java 项目,这个项目划分了 entity 层、mapper 层、provider 层、service 层和 controller 层。

其实分层是很科学的。但实际上,在 service 这一层的使用方面,由于缺乏使用约束和规范,导致乱用的结果是返回结果很混乱。比如,有些函数只是简单地返回了查询到的对象或对象数组,而另一些函数可能直接将最终呈现给用户的 business status code 都进行了返回。

后来的维护者接到需求时,难以凭借直觉判断要组合哪些小函数才能实现新功能,只能逐个去查看具体的函数实现。

这里似乎体现出了设计模式的重要性,也体现出了规范作为程序员共同语言的重要性。哈哈。

8、明明提供的是公共接口,但是其本身却只能使用一次

这个错误其实很容易理解,就是常提及的可重入与不可重入的概念。当年刚毕业时,我曾被很多人问到这个问题,但那时确实难以理解。其实很简单,如果你的函数在被调用时无法保证返回结果的正确性,像使用了全局变量且未加锁,或者使用了静态局部变量,那么这个函数就是不可重入的。如果你的环境是 php,那么这里会有一些不同。因为 php 本身是单线程运行的,所以所谓的不可重入就是不要用全局变量来存储函数计算的结果。

如果换到其他语言,本质上就是尽量去写可重入的函数。虽然在性能方面可能会稍微存在一些问题,但是对于大多数的 Web 程序来说,你所说的那些性能问题根本就是无稽之谈。

这里为了不扯jb蛋,我们举个例子:

在这个部门的XX系统里有这么一段php代码:
global $validUserList = array();

获取有效的用户列表的方式如下:首先进行相关的条件判断和数据筛选等操作,以确定哪些用户是符合要求的,然后将这些符合要求的用户整理成一个列表并返回。$users) {
  global $validUserList;
  for($users as $user) {
    $validUserList[] = $user;
  }
}

代码的作者使用了一个全局的 validUserList 变量,且自认为很聪明。接着在 getValidUserList 中对这个全局变量进行了修改。

关键问题在于每次调用时未进行初始化,这使得该函数只能被调用一次。若先后多次调用,必然会得到令人匪夷所思的结果。

关键的是,此类代码出现在了线上运行的业务系统中。你们真的不是外包团队吗?

这种问题改起来很简单,犯错的人只能说也比较弱智了。。

public static getValidUserList($users) {
  $validUserList = array();
  for($users as $user) {
    $validUserList[] = $user;
  }
  return $validUserList;
}

不要认为每次都声明新的数组会引发性能方面的问题。你的 web 程序对于那一点性能并不在意。

9、设计模式滥用

这一点或许不太容易界定,设计模式本身的作用是将重复的工作一次性完成。然而,很多时候问题并非在于使用了何种设计模式,而是在于编写这段代码的人是谁。有的人明明使用的是策略模式,然而在他的代码中,从字里行间都无法察觉到这是策略模式,仅仅能看到 getOm(实际上是莫名其妙的 getObjectModel 的缩写),接着通过反射去找到一个名字被写死了的 xxxxfunction,然后直接通过字符串来进行函数调用(只有 php 可以这样),让人读得一头雾水。你把他的代码全部扫过一遍之后才发现,虽然采用了策略模式,然而这段代码仅有一种策略,其功能仅仅是把数据库里某一个表的一个字段修改为一个固定的状态值。

实际上只需要三行代码就可以解决的问题。

实际上这位程序员写了多少代码呢?

1000行。。。

你是用代码量来衡量工作量的公司的员工吗?

10、访问数据库不做批量

比较典型的场景是,如今大多数的 web 程序能够被分为列表页和详情页。说批量这一情况,实际上主要指的就是列表页方面的问题。

一个商品列表页中包含五十种商品,每种商品都有一个分类。商品表中仅存储了分类的 id,然而在展示时,需要将分类的路径以及分类的名字都展示出来。

func getProductList(xxxx) {
   var products []Product
   for product := range products {
获取产品的类别 ID 后,将其用于获取类别名称,即 categoryName := getCategoryName(product.getCategoryId()) 。具体来说,先获取到产品的类别 ID,然后利用这个 ID 去获取对应的类别名称,最终将获取到的类别名称赋值给 categoryName 。
调用 product 的 setCategoryName 方法。
   }
}

这个问题看起来好像没什么大问题?实际上问题很严重。你想想,原本查询两次就能解决的问题,面对 50 种商品,却至少需要查询 51 次数据库。像 php/py 这类语言,如果没有数据库连接池,每次查询都得去与数据库建立连接,这使得问题更加严峻,原本只需几毫秒的接口,变成了几秒甚至几十秒。

有连接池可复用连接的语言,你查询 50 次的性能和查询 2 次的性能会一样吗?

11、树形结构的表结构设计问题

公司里有人会把存储树形结构的表设计成这样:

字段 | 类型 | 是否为空 | 键 | 默认值 | 额外信息 |
请提供需要改写的句子呀,你只给了“+------------------+--------------+------+-----+---------------------+-----------------------------+”,没有具体的句子内容呢。
id 是整型且长度为 10,为否(NO),为主键(PRI),值可为空(NULL),具有自动递增的属性(auto_increment)。
name 是 varchar(64)类型,它是 NO 且没有其他特别说明,也没有其他与之相关的额外信息。
parent_id 是一个整数类型,长度为 10,它的值为 0,没有其他特殊情况或额外信息。
status 为 tinyint(3)类型,NO 为其属性,值为 0 ,无其他相关内容。
createtime 对应的 datetime 为 0000-00-00 00:00:00 ,NO 处为空,没有其他内容。
|modifytime|timestamp|NO| |当前时间戳|更新时为当前时间戳|
category_id_path 是 varchar(256)类型的,它不是必填字段,没有默认值,其值为 0,没有其他相关内容。

先查看表结构,看是否存在问题。确实,该表没有存储 level 信息。在这种情况下,如果要找出三/四级分类的所有分类 id,就会变得极为麻烦、恶心且几乎不可能。实在没有必要这样做。

看起来除了 level 之外没什么问题了,那我们来看看表里面存储的具体数据吧。

              id: 3459
            name: fasdfasf
       parent_id: 0
          status: 0
创建时间为 2016 年 10 月 20 日 17 时 11 分 12 秒。
修改时间为 2016 年 10 月 20 日 17 时 11 分 13 秒。
category_id_path: 1,345,2359
1 row in set (0.01 sec)

发现什么问题了么。。

这个 category_id_path 字段,设计者很巧妙地存储了一个分类从根到该 category 的完整路径,然而却出现了一个错误。暂且先不说,在这样的表中,倘若你要查询 345 结点(包含 345 结点)的所有子结点,该怎么做呢?

从 category 表中选择所有数据,条件是 category_id_path 类似于 '1,345%'

这样显然存在问题。倘若一个结点的路径是 1,3456,那么很不幸,你的查询会将这个结点选中。

那么我是不是可以:

从 category 表中选择所有数据,条件是 category_id_path 类似于 '1,345,'

看起来似乎是没有问题了。那么,对于上面要求的 1,345 这个结点该怎么办呢?只能……

从 category 表中选择所有数据,条件是 category_id_path 类似于 '1,345,' 或者 category_id_path 等于 '1,345'

问题不大。为什么不在 path 的两边再加一个逗号呢?好处留给读者去思考吧。

12、if/else嵌套层次

从例子开始吧:

创建用户的函数为 createUser,该函数接收一个 UserInfo 类型的参数 user。
    if user.Age <= 0 {
        inValid = true;
    } else {
        if user.Name == "" {
            inValid = true
        } else {
            if user.History.Length == 0 {
            } else {
                //500行
                //500行
                //500行
                //500行
                //500行
                //500行
                //500行
            }
       }
   }
   return inValid;
}

将逻辑正常的业务流程放置于一个庞大的 else 之中,或许是许多人的喜好。在此情形下,倘若前面的 if else 有十个之多,那么你或许翻到后面,连 else 里面究竟是什么都无法看到了(已超出 ide 的 80 字红线)。

改起来很简单:

func createUser(user UserInfo) {
  if user.Age <= 0 {
    inValid = true;
    return inValid
  }
  if user.Name == "" {
    inValid = true
    return inValid
  }

  if user.History.Length == 0 {
    inValid = true;
    return inValid;
  }

  //500行
  return inValid;
}

这样可以让你的代码神清气爽。

13、同一张数据库表的查询,每换一种查询方式就写一个函数

还是我们的线上系统,dao层有这么个mapper:

公共接口 CategoryMapper 具有以下功能:xxxx 。
    @Select(从 category 表中选择所有列,条件是 name 等于传入的 #{name})
    public List findByName(@Param("name") String name);
    
    @Select(从 category 表中选择所有列,条件是 id 等于传入的 #{id}。)
    public List findById(@Param("id") Long id);

    @Select(从 category 表中选择所有列,条件是 parentId 等于传入的参数 #{parentId} 。)
    public List findByParentId(@Param("parentId") Long parentId);

    @Select(从 category 表中选择所有列,条件是 status 等于传入的 status 值。)
    public List findByStatus(@Param("status") Integer status);

    //以下略
}

在 php 的系统当中存在类似的情况,model 层的一张表中写了数十个函数,这是因为我们的表通常会有数十个字段。

写出了这些代码的员工都是我们部门的优秀员工哦~

现在做开发应尽量避开这种重复性劳动。如今不论何种语言,通常都存在开源的 sql builder 工具,能够直接拿来使用。这些 sql builder 工具通常还具备统一的查询接口,可支持传入 table name、order by、limit、where 的 map(java)或 array(php)。

这种东西即便你不采用开源的方式,自己去开发的话,实际上也不会花费太长时间。我们之前所在的那家公司的工具是由我们的同事独自完成的,其代码量大概只有两百行左右。

另外可以考虑一些代码生成器。如果是 dao 层,在不是做事务以及表关联查询的情况下,很多时候几乎不需要自己去编写这方面的代码了。

如果你不这样做,那么在 Java 中会给你的 dao 层带来困扰。会有大量重复工作的工作量。不过也许老板喜欢那种能够狠劲修改代码的员工呢。

呵呵。

14、工作流系统update不判断修改前的状态

工作流系统以及订单状态流系统等都会存在状态的流转,这与编译原理中的状态机的含义较为相近。不同状态之间的跳转需要有一个基本的前置条件,即从某一个特定的状态出发,满足某些特定条件后,便可跳转至下一个状态。然而在实际的业务系统中,人们发现很多系统在进行状态变更时,根本不会考虑前置状态。例如以下这段代码:

update xxx set status = yyy where id = zzz;

这样的话,有很多 sql 根本不判断前置状态。那么实际运转着的系统肯定不会是你想象中的状态流,要是出现莫名其妙的跳转,你会非常难过。

很多系统为了解决这种问题,将校验放到前端去完成,这样做风险很大。因为这里做的都是内部系统,且没有人搞破坏,所以程序员才不重视这些问题。如果你的订单/工单状态流与你公司的奖励、损失紧密相关,那么我认为你不会觉得这些不是问题。

解决起来较为简单,其原理与常被提及的高并发时的乐观锁概念相近,具体细节留给读者去思考吧。

15、非单线程系统不考虑线程安全问题

这个问题实际上较为复杂。在多线程程序中,难以排查的大多是这类问题。因此,现在一般在构建非性能要求极高的系统时,都会尽量避免使用多线程并发。或者在需要并发的时候,再通过并发执行特定任务来减少串行执行任务的等待时间,例如在 Java 中使用 future 或者 ExecutorService 等方式进行数据库并发查询。

现在 golang 成为了搅局者,并发很可能会成为未来的常态。幸运的是,golang 提供了 race 检测工具,能够让你在程序出现 race 情况时方便地使其主动崩溃(233333)。这样比 C 的黑暗时代进步了很多。

在做开发时应当有这样的概念。若存在全局的 map 之类的变量,在进行访问时需考虑加锁。加锁之后还要对性能进行评估,若性能太差,还需考虑是否参考一些 concurrentMap 的实现。

是个蛮复杂的话题。

16、open的资源不关闭,造成句柄泄露

这个错误常常是由从 PHP 转成其他语言的程序员犯下的。我们 PHP 程序员所开启的东西从来不会关闭(这是错误的)。

如果记性不佳,那么像数据库连接池以及 redis 连接池这类的资源,都会迅速被消耗殆尽。接着你便会明白。

17、抱怨接口性能是语言问题

有一个程序员,他做了一个接口,这个接口需要 3 秒才返回。之后他说这是贵司 php 版本 5.3 导致的。他还表示,如果让他换成 php 7,速度肯定能快十倍。

然后被秒打脸。就是前面说的没有做好批量查询的问题。

这事情有时候确实是。。态度问题。

在大多数情况下,语言对具体接口的性能影响不会很大。因此,在你向别人说之前,最好先简单地用日志记录下你的程序每一步所花费的时间。

做一个聪明的程序员~

嗯,其实我也是初级程序员。

—完—

一键三连「分享」、「点赞」和「在看」

技术干货与你天天见~

你可能想看:
« 上一篇
下一篇 »

相关推荐

php实时推送,php实地培训

2026年10月05日

53阅读

php乱码出现问号的原因,php乱码出现问号的原因有哪些

2026年10月05日

44阅读

php一句话木马,一句话木马ctf

2026年10月05日

48阅读

php获取数组长度,php中如何获取数组的长度

2026年10月05日

50阅读

php面试题库及答案,php程序员面试真题库

2026年10月05日

47阅读

phpcontent-type修改,php源码二次修改需要多少钱

2026年10月05日

42阅读