14 世纪,英格兰的逻辑学家奥卡姆在他的《箴言书注》中说「不要浪费过多的东西,去做那些用较少的东西同样可以做好的事情」。后来这句话被简化为「奥卡姆剃刀原理」,即:如无必要,勿增实体。奥卡姆剃刀在各个领域都有他的运用,他不是一个公理,没有严谨的推导过程,但他却是一个在实践中被证明非常有效的解决问题的手段。

在编程世界里,有太多我们习以为常的东西,我相信存在即合理,同时我也相信存在都有前提,而前提会随着时间变化甚至消失。下面我想跟大家探讨下,我们前端项目中那些应该被剃刀剃掉的东西。

前端项目里的 service 层

在一个前端项目中,一般包含以下文件目录:

  • containers:页面
  • components:组件
  • utils:工具方法
  • routes:路由
  • services:数据服务
  • index.js 入口文件

我们的业务代码基本都在 containers components 里,utils 和 routes 也是必不可少的,但仔细思考我们就会发现,这里有个 services 文件夹,他被称为数据服务层,是我们跟后端打交道的。这一层真的需要吗?
我们来看看大家是怎么使用 service 的。

// services  文件夹下的 accoutService.js

import { post } from '@/utils/request';

// 获取账号列表
export const getAccountsList = params => post('/api/accounts.json', params);
// 新增账号
export const insertAccount = params => post('/api/insertAccount.json', params);
// 更新状态
export const updateAccount = params => post('/api/updateAccount.json', params);
// 校验账号查询
export const checkAccount = params => post('/api/checkAccount.json', params);

-------- 使用 ---------

import { getAccountsList } from '@/services/accountService'

const App = () => {
  const [name, setName] = useState('');
    useEffect(() => {
      getAccountsList().then((res) => {
       setName(res.name);
    });
  }, [])
  return <div>{name}</div>;
};

从上面的代码我们可以看出,services 文件下基本是一些模板代码,偶尔有少见的一些数据转换。这些内容对于我们的业务代码来说,都是非业务相关的,写这些模板性的控制代码真的有必要吗?

service 里包含什么?

  • 数据转换逻辑 converHandler
  • 数据请求工具 request
  • 请求地址定义 url
  • 全局拦截器 interceptor
  • 附加功能 openApi

数据转换逻辑 converHandler:并不通用,有的一个请求在不同的页面需要走不同的转换逻辑,这些转换逻辑一般会写在调用位置的代码里,我也建议这么做,因为数据转换也是这块某个 container 的功能,而且为了方便测试,建议添加 handler.js 将转换逻辑抽离出来。

数据请求工具 request:主要是封装各种请求,这部分需要统一。非业务相关,可以提出来。

请求地址定义 url:这部分是强业务相关的,不应该放到 service 里,而是作为 service 的一个配置,由外部输入。

全局拦截器 interceptor:处理一些通用的业务状态码,比如编辑成功 10001,这部分也是强业务相关的,而且相对比较复杂,但是可以通过配置 schame 来描述,后面再讲。

附加功能 openAPI:如果你系统的接口想让别的系统复用,比如 MTEE 基础平台的接口需要复用给运营平台,那么前端需要提供领域物料,领域物料里会发请求,发请求要解决跨域、登陆、授权的问题,openAPI 应运而生。

综上可以看出,service 层只需要一些统一的逻辑处理和配置文件就能描述清楚,甚至我们可以把 Service 层简化为

$$service = request + config$$

我的 service 包

由此,我希望能设计这样一个 service 包,他需要包含下面的功能:

请求

支持常见的 get post jsonp 请求,以及对于这些请求的附加方法,比如 debounce、throttle、缓存、loading 等功能。也可以提供大家比较喜欢的 hooks API。

接口配置

一个接口包含域名 domain,地址路径 path,请求方法 method,参数 params,一些常见功能的开关,比如开启防抖 { debounce:true } 。参数的配置里,可以添加该参数的基本属性,比如是否必选 { require: true } ,这样包内可以对参数做必要的校验,这样可以保证非法数据传入后台。

环境切换

环境切换是一个非业务相关的功能,他不应该硬编码到代码里,带到线上。他应该只是一个配置,尽量与代码脱离,因此是用浏览器插件来切换,就是一个很好的方法。可以设计 service 包接收一个 domainMap,这个 domainMap 来自 window.GlobalConfig 下的某个变量,浏览器插件可以动态改变这个变量,就可以做到环境的切换了。

网关转发

我们写代码追求复用,从代码块的复用到组件复用,再到业务能力的复用,而业务能力复用的一个载体就是领域物料。一个领域物料里很有多个接口请求,如果我们把原来在业务代码里的组件拆出来作为领域物料的话,就不得不把项目里的 service 层也要打包进去,这样才能发送请求和处理一些统一的异常。上面的我提到的把是service 层做成一个包,别人在使用的时候,只需要传配置进来,也是出于领域物料这个场景。
这之后,我们还要解决一个问题:领域物料在不同站点使用带来的接口跨域问题。我们现在的解决办法是,前端搭建一套基于 node 的网关,用于做接口转发和鉴权。service 包里会集成这个过程,外部使用者只需要配置开不开启网关就可以了。他完全不需要知道网关是如何转发的,就像在自己的站点下写组件一样。

接口文档

我们在接手别的项目的时候,总是不容易找到他的接口文档,因为文档和代码是割裂的,文档的维护也有滞后性,甚至慢慢文档的链接也找不到了。因此,代码和文档应该在一起,最好是代码即文档。大家可能觉得用注释就可以了,但程序员总是要求别人写注释,但自己却不爱写。写注释如果可以像写代码一样,或许能规范这部分的行为。例如:

{
    name: '获取账号',
    domain: DOMAIN.TAOBAO,
    url: '/api/getAccount.json',
    method: METHOD.GET,
    params: {
      userId: {
        name: '策略包id',
        type: PARAM_TYPE.STRING,
        required: true,
      },
    },
    response: {
        name: '账户名字'
    },
  },

这里用配置文件的方式规范了文档的形式,还可以与浏览器插件相结合,通过插件来查看当前用的接口文档。

异常拦截

异常分为服务器异常和业务异常,服务器异常一般是用 http 状态码,400、500等;业务异常则需要是用 body 里的 code 来表示。在真实的业务实践中,我们发现对于服务器异常我们是很容易写出通用的拦截器做一些处理的,但是对于业务异常,就相对比较复杂了,这里面存在几个问题:

  • 很多后端不习惯使用 code 返回相应的业务编码来表示不同的状态。
  • 前端直接使用后端返回的 message 展示给用户,这里有两个问题,① 后端的需要引入第三方库对 message 做国际化 ② 后端定义的 message 不是用户语言,用户一般是看不懂的。因此这里就需要一个第三方系统的参与,他提供业务 code 和前端动作的映射关系表,比如:后端返回 code:10000,前端应该弹窗并展示 message,定义的 json 如下:
{
  code: 10000,
  message: '编辑失败',
  debug: '后端数据库读写异常,堆栈信息:',
  showType: 'openDialog'
}

这里的 message 是可以根据不同语言环境返回不同语言文字的,showType 表示了前端的动作类型,这个是可枚举的,其中肯定有一种动作是,不做动作,直接透传。这个第三方系统,就可以配置不同编码的动作,有利于精细化的管理异常,给用户更好的体验。

落地

实践是检验真理的唯一标准,基于上面的理想,我的 service 包也已经成型,使用他非常简单。只需要两步:
① 配置文件
② 引入包
③ 业务代码里调用

配置

// 配置文件 account.js

import { METHOD, PARAM_TYPE } from '@ali/hulu-service';

export const DOMAIN = {
   TAOBAO: '//taobao.com',
   ALIPAY: '//alipay.com',
};

export default {
  getAccount: {
    name: '获取账号',
    domain: DOMAIN.TAOBAO,
    url: '/api/getAccount.json',
    method: METHOD.GET,
    params: {
      userId: {
        name: '策略包id',
        type: PARAM_TYPE.STRING,
        required: true,
      },
    },
    response: {
        name: '账户名字'
    },
  },
};

引入包

import HService from '@ali/hulu-service';
import account from './account';

// 初始化service
const service = HService.init({
  urls: [
      account,
  ]
});

export default service;

调用 API

import Service from './service';

const App = () => {
  const [name, setName] = useState('');
    useEffect(() => {
      Service.getAccount().then((res) => {
       setName(res.name);
    });
  }, []);
  return <div>{name}</div>;
};

export default App;

同时基于浏览器插件,可以快速的切换环境,查看接口文档等。

想想边界

开头,我们说到奥卡姆剃刀,如无必要,勿增实体,这个的前提是,有清晰独立的实体,如果我们的实体之间相互勾连耦合,那又如何剃掉不必要的实体呢。
其实,无论做任何软件构架,都要分清楚边界,也就是一个模块他的定位是什么,哪些功能是他该做的,哪些不是。这里面一个非常重要的依据就是是否易于变更。哪些是业务的、常变化的,哪些是非业务的、一般不变的。我们的代码常常,坏就坏在边界不清晰,或者是边界原则没有一以贯之。工程代码里耦合了业务,业务代码里掺杂着工程(比如环境判断)。代码的坏味道是一点一点积累而成的,而这个坏的开始,就是初始的架构设计边界不清晰,没有用代码定义规范。
抵抗代码的腐败,这是一个漫漫长路,没有银弹,但确实可以精进一个人的系统思维。

03-05 22:16