全栈系列后端项目结构的思考,不夸夸其谈,只想写好代码。

这是 后端开发者从零做一个移动应用 的后端部分第二篇。介绍下一个新项目,后端该如何从零去搭建。我们先假设这个项目由两部组成

  • 提供给wap站点、app使用的api;
  • 提供给运营人员使用的管理后台。

整个项目采用 Phalcon,项目的demo可以 点这里 参阅

备注:跟随文章进度,项目持续更新,最后会与配套的wap app形成一个整体

项目最终至少会包含以下内容:

  • 小米消息推送
  • 支付集成(支付宝、招商、微信)
  • 基于 Codeception 的api测试
  • 登陆api(这部分采用oauth2,会基于 ‘bshaffer/oauth2-server-php’ 做)

项目结构回顾

后端系统一般都是采用 MVC 结构(这里均以PHP为例),M 代表模型,V 代表视图,C 代表控制器。我在啰嗦几句

Model指的是数据模型,这个数据模型包括你的Mysql中的表结构,或者redis的缓存对象结构都可以。它代表一个数据操作单元。

View指的展示给用户浏览、直接操作的界面,这个大家都懂,不多说

Controller 控制器,主要是为了隔离 View 与 Model 直接打交道,他做为一个中间人,两头传递小纸条。

在我过往的项目中,我主要的困惑在于,业务逻辑是放在 C 还是放在 M。

从对象角度出发,业务逻辑无非就是操作数据,要么读取,要么修改,那么应该放在M层,因为一个对象应该有自己的属性与方法。

业务放在M中

实际工作中我们常常有这样的场景,比如:读取一个游戏列表数据,数据包括游戏的详情以及游戏的版本信息以及下载信息。因为游戏app会存在升级,因此一个游戏会对应多个包。那么这里至少存在两个model

  • 游戏详情model,包括游戏的名称,logo等基本信息
  • 游戏的包信息model,包括包所属平台,大小,下载地址,版本信息等

那么这个动作的方法应该封装在哪里呢?以前的做法是,分别封装对应的操作到对应的model,然后在控制器中分别调用。说回到这里,游戏model封装了查询游戏列表的method,然后包model封装了根据游戏id查询包信息的method。

然后我们在控制器中分别调用这个两个方法,然后再进行组装,把游戏对应的包设置到对应的游戏中。

那么有一个问题,假设我们在游戏详情这个控制器方法中,需要返回一个相关游戏的集合,难道又重复一次上面的操作?
有人会说把处理游戏部分抽离成一个公共方法,那么假设是要在新闻详情里边调用呢?这根本不该在同一个控制器里边啊!

业务放在C中

上面我们把方法放在model中遇到了复用的小麻烦,那么继续看看放到controller中会怎样?

这个时候的一个好处是:我们可以使用连接查询,将刚刚的2次查询,通过连接查询1次完成,对于mysql的时间减少了,程序性能提升,然后对查询结果啪啪啪处理完成。

好吧,不往后面说了,相信大家已经发现了,这个查询过程还是不可复用。自然而然的,我们这里应该想到,将它提炼成一个方法,无法满足其他控制器使用(一个控制器调用另外一个控制器的想法想都别想啊)。那么只能提炼成一个类了,这个类来封装所有的业务。

这样之后,任何需要游戏列表数据的地方,直接调用这个GameServer(假设封装的业务逻辑都放在xxxServer中)就可以获得相同的数据,然后如果业务变动,我们也只需要改动这一处,所有地方得到的数据也将会是一致的。

因此通过回顾,我们得出我们的后端项目需要一个server的层次,来存放业务逻辑。

Server层存在的意义

分离出来的这一层,集中涵盖了所有的业务功能,极大的提高了代码的复用性,除了不同控制器不同方法的直接使用,还包括了不同模块之间的复用。

但是在不同模块之前服用,server层也需要考虑一些额外的东西,比如我们有一个app api模块,有一个后台管理模块。那么都是获取列表数据,可能给app api模块可能不需要某些字段,但是后台管理需要知悉全部内容,以及后台用户权限上的一些问题。这些部分可以继续进行拆分,与server组合。需要结合自己的业务来进行管理。

我个人实践过程中代码的另外一个好处是,server层从某种层度上让C层变得简单,这让团队中的新人能够快速上手接触代码。比如小明是团队新人,那么在他熟悉所使用框架的前提下,他可以立刻在C层开始做事情,因为这里没有业务,有的只是验证客户端传过来的数据,以及对server层的调用返回。通过这个过程可以加速其融入团队的进程。

统一的返回格式

约定api返回的数据格式,这基本上是系统开发开始的第一步,原先常用的方式就是在每个控制器中通过

1
2
3
4
5
6
7
return json_encode([
'msg' => 'ok',// 携带的信息,可以用来前端 alert 提示用户
'data' => [// 具体数据
... ...
],
'code' => '0', // 0表示成功,其他表示对应错误
])

那么这里首先遇到的第一个问题,为了简化前端对类型的判断,基本上所有的字段值,都是返回字符串形式。那么 data 里边的内容就需要在每个控制器中进行处理字符串、utf-8编码等问题。要重复代码,就算你抽离成一个方法,也需要面对该问题。好点的解决方案是在返回数据的拦截器(每一个框架都有类似的概念)内进行统一的处理。

像上面这样的代码写法,带来的额外问题可能有,字段名称打错,比如: code 写成 cdoe ,data 写成 date。为程序代码额外的风险(尤其是bug修复时最容易出现该情况)

那么一种解决办法就该由此想到,采用对象的方式来规范化返回的数据结构。比如我们定义一个类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
class ResultData {

/**
* 返回的信息提示
* @var string $msg
*/
private $msg;

/**
* 返回的数据结构
* @var array|object|string
*/
private $data;

/**
* api 状态码
* @var int $apiCode
* @see ApiCode
*/
private $apiCode;

public function __construct(int $apiCode, string $msg = 'ok', $data = null)
{
$this->apiCode = strval($apiCode);
$this->msg = trim(strval($msg));
$this->data = $data;
}

/**
* 获取数据结果
* @return array
*/
public function getRetData()
{
if (! is_array($this->data) && is_object($this->data) && method_exists($this->data, 'toArray')) {
$this->data = $this->data->toArray();
}

// valueToString 将data的value转化为 string 并且做utf-8转码
$result = [
'code' => $this->apiCode,
'msg' => $this->msg,
'data' => $this->data ? ArrayUtil::valueToString($this->data) : [],
];

if (! APP_ENV_PROD) {// 测试环境显示 api 的处理时间信息 方便优化
$result['use_time'] = microtime(true) - $_SERVER['REQUEST_TIME_FLOAT'];
}

return $result;
}
}

有了上面这个类,我们所有的服务层或者controller都应该用它作为返回值。然后在拦截器中统一进行json encode即可。这样子即减少了犯错的可能性,同时对统一处理数据的地方做了统一管理集中到 ResultData 中,那么以后有什么特殊变动,调整一处,处处生效。

其它问题

另外还有关于 oauth2 如何集成到项目中等等问题,这部分均放到 x-api 项目中进行说明,纸上说来终觉浅嘛。

日志的记录也是系统开发非常重要的部分,这部分没什么太多说的,用规范的格式,存储指定的数据(介质可以是:db、file)。

系统开发中应该拒绝使用 var_dumpecho 这些方式进行调试,另外建议采用:PhpStorm IDE来进行系统开发。

后续分享

接下来会完善一个 x-api 的基本结构,以及php自动化测试部分文档教程,然后后端部分就告一段落。(本系列的分享主要集中在代码层面,不涉及相关系统部署问题)


如果你对我的内容感兴趣,请关注我的微信公众号:

公众号:icanfo

image

个人博客:https://helei112g.github.io/