Web开发框架推导
本文欲回答这样一个问题:在 「特定环境 」下,如何规划Web开发框架,使其能满足 「期望 」?
假设我们的「特定环境 」如下:
- 技术层面
- 使用Java语言进行开发
- 通过Maven构建
- 基于SpringBoot
- 使用IntellijIDEA作为IDE
- 使用Mybatis作为持久层框架
- 前后端分离
- 非技术层面
- 新项目,变化较频繁
- 快速迭代
- 开发人员资历较浅
- 人员流动性较大
我们的 「期望 」是:
- 快速上手:鉴于人员流动性较大、开发人员的资历较浅和项目的快速迭代需求,期望开发框架易于开发人员开发。易于入门,易于部署。
- 符合行业规约:尽量不定义私有规范,使用行业标准,进一步降低学习难度
- 快速开发:尽可能复用代码,尽可能自动化生成模板代码
- 独立性:应用能独立运行,不过多的依赖其它应用或中间件。边界清晰,有利于理解、开发、测试和部署。反例:就是没有规划的RPC调用。
- 易于测试:能方便的进行单元/集成测试,不影响真实数据
- 易于部署:能方便的进行部署,便于快速的扩容
- 异常可追踪:对异常,可快速定位到具体是哪个应用,哪个类,哪行代码的问题
本文从一个空框架开始,逐步加入上面的约束,最终推导出符合期望的Web框架!
本文提供的是一种思路!如有纰漏、或不同意见,欢迎讨论指正!
从「空框架」开始
我们从一个「空框架」开始我们的框架推导!所谓「空框架」是一个没有任何约束的接收HTTP的可运行代码,比如对任何请求都只返回Hello World的servlet!
这里我们基于Maven和SpringBoot快速搭建一个「空框架」!
代码结构如下(Maven构建约束):
intellijweb2
src/main
java
com.ivaneye.intellijweb2
TestController
resources
application.properties
logback-spring.xml
代码如下:
package com.ivaneye.intellijweb2;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller
@EnableAutoConfiguration
public class TestController {
@RequestMapping("/")
@ResponseBody
public String home() {
return "Hello World!";
}
public static void main(String[] args) throws Exception {
SpringApplication.run(Main.class, args);
}
}
启动后,当访问http://localhost:8080时,页面上将显示Hello world!字样!
我们完全可以基于这个「空框架」进行开发,但是这个「空框架」离我们的期望还很远。我们来一步步的改造!
分层架构
分层架构可以说是Web项目的默认架构风格,可以说是行业标准!所以我们首先引入分层架构这个约束!
分层架构有其优势和劣势:
- 优势:通过将组件对系统的知识限制在单一层内,为整个系统的复杂性设置了边界,并且提高了底层独立性。使用层来封装遗留的服务,使新的服务免受遗留客户端的影响;通过将不常用的功能转移到一个共享的中间组件中,从而简化组件的实现。中间组件还能够通过支持跨多个网络和处理器的负载均衡,来改善系统的可伸缩性。
- 劣势:增加了数据处理的开销和延迟,因此降低了用户可觉察的性能。可以通过在中间层使用共享缓存来弥补这一缺点。
Web里最常用的切分方式就是MVC模式!我们对我们的「空框架」引入MVC模式!
那我们这里是切分包?还是切分模块呢?考虑到最小影响原则,这里先切分包。如果有后续约束,再做进一步调整。
引入MVC模式后的代码结构:
intellijweb2
src/main
java
com.ivaneye.intellijweb2
controller
TestController
model
respository
service
Main
resources
application.properties
logback-spring.xml
引入MVC模式后的代码:
package com.ivaneye.intellijweb2;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
@EnableAutoConfiguration
@ComponentScan({"com.ivaneye.intellijweb2"})
public class Main {
public static void main(String[] args) throws Exception {
SpringApplication.run(Main.class, args);
}
}
package com.ivaneye.intellijweb2.controller;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller
public class TestController {
@RequestMapping("/")
@ResponseBody
public String home() {
return "Hello World!";
}
}
这里暂时切分了Controller,Service,Model,Respository四个包,职责如下:
- Controller:接收前台的请求,验证数据,组装需要的数据,委托Service执行具体业务逻辑,并将结果组装返回给前台
- Service:处理核心业务逻辑,包含事务
- Model:数据模型,与数据库表的对应类
- Respository:数据操作类包,操作Model中的类,进行基本的CRUD操作
分层后的框架逻辑清晰,且切分方式符合行业规约,更易于上手。
前后端分离
考虑到,目前Web开发流行前后端分离,为了适应潮流,引入前后端分离的约束。
为了适应前后端分离,后端不负责页面的渲染,只接收和返回JSON数据。SpringBoot对此有直接的支持,直接将@Controller改为@RestController即可!
相关代码:
package com.ivaneye.intellijweb2.controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class TestController {
@RequestMapping("/")
public String home() {
return "Hello World!";
}
}
整个URL符合RESTful,即符合行业规约!至于REST相关内容另行讨论。
实际上完整的RESTful应用不只是URL符合RESTful,需要符合四个核心的约束:
- 资源的识别(identification of resources)
- 通过表述操作资源(manipulation of resources through representations)
- 自描述的消息(self-descriptive messages)
- 超媒体作为应用状态引擎(hypermedia as the engine of application state)
绝大部分声称符合RESTful的应用都不是百分百符合这四个约束,特别是超媒体作为应用状态引擎(hypermedia as the engine of application state)这个约束。
基于注解的数据处理
确定了以JSON的方式进行参数的传递后,就需要确定如何来处理参数和返回结果?这涉及到几个问题:
- Controller如何接收参数?
- Controller如何返回结果?
- Controller如何将数据传递给Respository进行持久化处理?
- Respository又如何将数据从数据库中查出来返回给Controller?
这里选择了Mybatis作为持久化框架,我们先从Mybatis的角度来回答上面的几个问题!
首先Mybatis作为框架,会生成几个文件:Model.java,Mapper.java和Mapper.xml!(这里不做过多解释!对Mybatis不熟悉的朋友请自行google!)这几个文件可以自动生成,也可以手写!
不论是自动生成还是手写都有其优缺点:
- 先说自动生成的优缺点:
- 优点就是在修改表结构以后,直接一条命令就可以自动生成新文件。
- 缺点就是这三个文件不能修改,如果修改了就不能再次自动生成了,否则会被覆盖。 - 手动编写的优缺点:
- 优点是完全自主控制,可复用Model,在里面添加注解,实现数据验证、主键加解密、字典自动查询等逻辑。
- 缺点就是表结构调整后,需要手动修改需要调整的文件。一是繁琐,二是没有编译期校验,如果手误写错了,直到运行期才可能发现
一种优化方案是,第一次使用自动生成,后续手动修改。
但是结合前面的约束:
- 新项目,变化较频繁
- 快速迭代
- 开发人员资历较浅
此方法并不适用。 此方法只对于改动不太频繁的项目还算适用,但是如果表结构改动较频繁,后续的每次修改还是要手动修改,非常的麻烦(无法适应频繁的变更,快速迭代)。且只能第一次使用自动生成这个规定并没法强制实施,你没法保证谁不会误操作了自动生成(考虑开发人员资历较浅),导致手写的代码被覆盖了!
结合以上约束,为了尽量避免错误,优先选择自动生成!再来尝试解决其短板,即生成的三个文件无法进行修改。是否有可行方案呢?
我们先考虑几个问题:
- Controller需要对页面传过来的参数做哪些操作?
- 页面传来的参数和Model是一个什么关系?
- 从Controller返回给页面的数据又和Model是什么关系?
- Controller对返回给页面的数据又要做哪些操作?
为方便起见,我们把入参称为Param,返回结果称为Result。我们先回答第一个和第四个问题!
-
Controller需要对Param做哪些操作?
- 把从页面传递过来的flat数据transform为对象(这是面向对象语言的一种典型做法,我目前更偏向函数式做法,另开一篇讨论)
- 对数据做校验:类型对不对、格式对不对、是否为空等等等等
- 解密:有些字段数据可能是加过密的,比如主键,在transform的过程中需要对这些字段进行解密处理 -
Controller需要对Result做哪些操作?
- 加密:对需要加密的字段进行加密操作,比如主键
- 字典转换:有些字段是code码,页面需要code码对应的值,方便人类阅读。这里需要根据这些code码从字典中获取对应的值(你可以在数据库查询的时候,直接关联字典表查询,但是这样会带来两个麻烦,一个是model中需要包含字典value字段,就没法自动生成了。第二个就是,一般字典会放在内存中,关联表查询相对内存取数据,性能上会有劣势)
- 字典列表:和字典转换类似,有些页面需要字典列表数据,需要获取这些数据到前台供用户选择
这些操作都可以方便的处理:
- SpringMVC已经提供了数据绑定功能,将数据绑定到对象上
- JSR303基于注解进行校验
- 加解密、字典都可以通过自定义注解处理(扩展Jackson的注解处理即可。Jackson的注解只在方法上生效,本以为是个问题,却助我构思了一个方案:一个结合了自动生成的方便性和手写的灵活性的方案!!!!)
这些都是规约!
针对第二个和第三个问题,我们先看Param、Result和Model之间的关系:
从上图可以看出,除了第一种情况(且这种情况很少),其它四种情况Param和Model实际是一个包含的关系。既然是一种包含的情况,那这种包含关系,在Java里我们可以使用继承来实现。也就是说可以使Param extends Model,以这样的方式来复用Model的内容!
我们来看以这种方式来实现Param和Result,如何来解决上面的问题!
- 首先,因为Param和Result都继承了Model,所以Model是不需要做任何改动的,就可以无限次的自动生成
- 其次,数据验证、加解密的注解是可以添加到方法上的。我们对需要这些注解的字段,在Param/Result里覆盖Model里的get/set方法,在其上添加注解,就可以使用基于注解的数据验证和加解密。
- 假设数据字段有了修改,重新生成后,由于有@Override注解,在编译期就可以定位到需要修改的get/set方法,结合IDE可以快速修复
- 如果是新增字段,则直接重新生成Mybatis的三个文件即可,原有代码不受任何影响
尽量以扩展规约的方式来处理问题,在不增加理解难度的情况下提高易用性和开发效率!
数据返回
在RESTful约束中,推荐使用HTTP的标准响应来处理返回数据。SpringMVC中也提供了标准响应的支持。
ResponseEntity.ok("body");
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("");
但是由于HTTP的标准状态码太少了,见下表:
代码 | 消息 | 描述 |
---|---|---|
100 | Continue | 只有请求的一部分已经被服务器接收,但只要它没有被拒绝,客户端应继续该请求。 |
101 | Switching Protocols | 服务器切换协议。 |
200 | OK | 请求成功。 |
201 | Created | 该请求是完整的,并创建一个新的资源。 |
202 | Accepted | 该请求被接受处理,但是该处理是不完整的。 |
203 | Non-authoritative Information | |
204 | No Content | |
205 | Reset Content | |
206 | Partial Content | |
300 | Multiple Choices | 链接列表。用户可以选择一个链接,进入到该位置。最多五个地址 |
301 | Moved Permanently | 所请求的页面已经转移到一个新的 URL。 |
302 | Found | 所请求的页面已经临时转移到一个新的 URL。 |
303 | See Other | 所请求的页面可以在另一个不同的 URL 下被找到。 |
304 | Not Modified | |
305 | Use Proxy | |
306 | Unused | 在以前的版本中使用该代码。现在已不再使用它,但代码仍被保留。 |
307 | Temporary Redirect | 所请求的页面已经临时转移到一个新的 URL。 |
400 | Bad Request | 服务器不理解请求。 |
401 | Unauthorized | 所请求的页面需要用户名和密码。 |
402 | Payment Required | 你还不能使用该代码。 |
403 | Forbidden | 禁止访问所请求的页面。 |
404 | Not Found | 服务器无法找到所请求的页面。 |
405 | Method Not Allowed | 在请求中指定的方法是不允许的。 |
406 | Not Acceptable | 服务器只生成一个不被客户端接受的响应。 |
407 | Proxy Authentication Required | 在请求送达之前,您必须使用代理服务器的验证。 |
408 | Request Timeout | 请求需要的时间比服务器能够等待的时间长,超时。 |
409 | Conflict | 请求因为冲突无法完成。 |
410 | Gone | 所请求的页面不再可用。 |
411 | Length Required | "Content-Length" 未定义。服务器无法处理客户端发送的不带 Content-Length 的请求信息。 |
412 | Precondition Failed | 请求中给出的先决条件被服务器评估为 false。 |
413 | Request Entity Too Large | 服务器不接受该请求,因为请求实体过大。 |
414 | Request-url Too Long | 服务器不接受该请求,因为 URL 太长。当你转换一个 “post” 请求为一个带有长的查询信息的 “get” 请求时发生。 |
415 | Unsupported Media Type | 服务器不接受该请求,因为媒体类型不被支持。 |
417 | Expectation Failed | |
500 | Internal Server Error | 未完成的请求。服务器遇到了一个意外的情况。 |
501 | Not Implemented | 未完成的请求。服务器不支持所需的功能。 |
502 | Bad Gateway | 未完成的请求。服务器从上游服务器收到无效响应。 |
503 | Service Unavailable | 未完成的请求。服务器暂时超载或死机。 |
504 | Gateway Timeout | 网关超时。 |
505 | HTTP Version Not Supported | 服务器不支持“HTTP协议”版本。 |
这些标准的状态码无法详细的表示一个项目中的所有情况。且目前SpringMVC不支持自定义状态码。就是类似这样的代码:
ResponseEntity.status(10001).body("");
虽然不报错,但是无法正常响应,后台会报类似“非标准状态码”的错误!
所以我自定义了一个对象Result,用来完成类似ResponseEntity的工作。Result的结构如下:
public class Result {
private int code;//200为正常,其它为相关业务报错
private String msg;//对应的错误信息,200为ok
private Object body;//返回的业务对象
}
提供类似:
Result.ok("body")
Result.error(e);
Result.error(CommonConstants.SERVER_ERROR, e.getMessage());
这样的构造方法,方便使用。
异常处理
异常处理在上面数据返回里涉及了一点(就是Result的构造以及业务的各种场景处理)。这里详细说明。
约束中需要能方便的追踪异常!
Java里提供了CheckedException和UnCheckedException,而对于我们实际使用来说,还是需要区分业务场景。
- 异常是业务异常还是非业务异常?
- 这里的业务异常指的是:由于不符合业务需求而导致的异常,比如:用户没登录,必要字段没填写导致校验失败,订单的数量超出了库存。
- 非业务异常则指的是:和业务场景不相关的异常。例如:数据库连接失败了,网络连接失败。
表现到代码上,对于业务异常我们可以定义BusinessException来表示,所有继承了BusinessException的异常,都是业务异常,而其它异常就是非业务异常。
- 更进一步,业务异常也可以分为:
- 通用业务异常,例如:用户没有登录,必要字段没填写导致校验失败;
- 和特定业务异常,例如:订单的数量超出库存了。
这两种异常,我们可以通过异常码来区分,例如:100开头的为通用业务异常,300开头的为订单异常,400开头的为产品异常,依此类推。
同时异常的Code和Msg与Result对应,方便构建Result.error(e);直接返回。
再进一步,目前的应用都是分布式的,甚至是微服务架构!我们是否可以通过异常能快速的定位到是哪个应用的哪个模块里的哪个代码出问题了呢?
一种可行方案还是通过异常码来处理:以三位数字为间隔,来区分应用+模块+代码,例如:001002301,可以理解为异常是001机器上的,002应用,抛出的301(订单相关)异常。
独立性
当系统变得越来越大后,难免不会出现系统内不同应用之间的相互调用;如果是微服务的话,那么服务间的相互调用是很常见的。如果处理不当,会使得各应用之间相互依赖,无法独立的运行。导致开发、测试、部署都很麻烦。
为了避免这样的问题出现,结合如下两个约束:
- 符合行业规约
- 独立性
故使用RESTful方式,作为应用间通信的方式。这也是微服务推荐的通信方式!
应用间调用会出现Model的依赖,故这里将Model从包提升为模块。方便后续如果有其它应用要依赖时,可直接依赖Model模块,而不是整个应用。
调整后代码结构如下:
intellijweb2
intellijweb2-web
src/main
java
com.ivaneye.intellijweb2
controller
TestController
respository
service
Main
resources
application.properties
logback-spring.xml
intellijweb2-model
src/main
java
com.ivaneye.intellijweb2
model
param
result
将model包移动到了intellijweb2-model模块中,同时新增了param和result包!
测试
SpringBoot本身提供了较为完善的测试功能。包括单元测试、Mocker、Spy等。
基于如下几个考虑:
- 易于测试:我接触的很多开发人员是不喜欢写测试的。如果测试代码不易编写,那就更不愿意写了。
- 不影响环境:我期望的是在发布时是包含测试的,测试不通过即不能发布。也就是说在部署时测试,会使用正式环境的库表数据,所以在测试时不能影响到这些数据。
- 小范围测试:以最少的代码,覆盖最核心的代码逻辑
故决定只对Service测试,原因如下:
- 在上面的分层架构里描述了各层的职责,可以看出,核心业务都在Service层,Controller和Model都没有业务逻辑,只是一些标准化代码,没必要测试
- SpringBoot对Controller的测试是在不同的线程内,不支持事务,如果在正式环境测试的话,会影响正式库数据
部署
SpringBoot可以直接打包为jar包,直接运行启动。这很方便,但是如果想快速的横向扩容,配置文件就是一个问题。因为不同机器上的配置并不是完全相同的。
有两个方案可以解决:
- Docker
- 配置服务器
从便利性考虑,还是选择配置服务器。
配置文件中均是开发环境配置,方便开发人员直接开发、测试。
在正式环境中,应用启动时会从配置服务器获取对应的配置,覆盖本地测试进行部署。
代码生成OR封装
在结束之前,先问个问题?你是喜欢代码生成、还是封装?
- 代码生成就类似Mybatis这样生成了对应的文件,逻辑透明。你可以去改
- 封装就类似Hibernate,你写个对象,然后对对象操作就行了,底层数据库操作由Hibernate来处理
我个人更偏向代码生成,理由是:
- 简单:易于使用,易于上手
- 行业标准:生成的代码是行业标准代码,只要熟悉Mybatis,Spring就可以直接上手(而Mybatis和Spring目前是互联网标配)。如果公司内部进行一些封装,那么新手需要先理解这些封装,增加了学习成本。
基于上面的原因,再考虑到其实我们的框架都是符合规约的(RESTful,JSR303,覆写,Jackson),故对于标准CRUD,我们可以一键生成!
一键生成
其实到上面一节,整个框架应该已经符合预期了!但是为了得到超预期的效果,我们来更进一步!
我们先看目前的开发流程:
- 设计数据表
- 生成Model,Mapper
- 编写Param,Result
- 编写Respository
- 编写Service
- 编写Controller
- 编写测试
- 执行测试
- 提交代码
对于一个典型的CRUD操作,这里有多少重复代码呢?
篇幅有限,举个简单的例子:现在需要编写Order和User的新增逻辑,Controller的代码是什么样的?
Controller:
package ${package.Controller};
import ...
@Api(tags = "${table.controllerName}")
@RestController
@RequestMapping("$!{cfg.basePath}")
public class ${table.controllerName} extends ${superControllerClass}{
@Autowired
private ${table.serviceImplName} ${instanceName}Service;
private Logger logger = LoggerFactory.getLogger(${table.controllerName}.class);
@ApiOperation(value = "创建${entity}")
@RequestMapping(value = "/$!{cfg.version}/${table.entityPath}", method = RequestMethod.POST)
public Result create(@RequestBody @Validated(Create.class) ${entity}Param param, BindingResult bindingResult) {
try {
//验证失败
if (bindingResult.hasErrors()) {
throw new ValidException(bindingResult.getFieldError().getDefaultMessage());
}
Long recId = ${instanceName}Service.create(param);
return Result.ok(recId);
} catch (BusinessException e) {
logger.error("create ${entity} Error!", e);
return Result.error(e);
} catch (Exception e) {
logger.error("create ${entity} Error!", e);
return Result.error(CommonConstants.SERVER_ERROR, e.getMessage());
}
}
}
如上的模板是否能符合OrderController和UserController?再往后看Service,Param,Result等是否都可以用类似的模板来统一处理?
所以,我们完全可以对相应的代码进行自动生成,尽可能的降低模板代码的手动编写。对于标准的CRUD逻辑,我们可以做到如下的开发流程:
- 设计数据表
- 生成CRUD,包括测试(我们测试的是Service,想想测试代码和Controller代码有多少区别?)
- 执行测试
- 提交代码
对于不可重复生成的文件,我们可以设置"存在即不覆盖",在最大限度的提高开发效率的前提下,降低误操作。
总结
如上即是我基于约束所做的Web推导!目前的主要问题还是在Model层面:
- 数据表映射为Model是否是合理的?
- 基于Model的操作是否合适?
- 基于上面Param、Result和Model的关系图来看,实际上Param、Result和Model大部分情况下都不是契合的!把这些Param、Result限制在Model上是否合适?数据结构是否清晰?
目前个人觉得基于data的transform、filter、map操作更适合web开发(我会另开一篇讨论这个)!或者你有什么好的方案,欢迎指教?
公众号:ivaneye