本文欲回答这样一个问题: 在 特定环境 下, 如何规划 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 的注解只在方法上生效, 本以为是个问题, 却助我构思了一个方案: 一个结合了自动生成的方便性和手写的灵活性的方案!!!!)
这些都是规约!
针对第二个和第三个问题, 我们先看 ParamResult 和 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 本身提供了较为完善的测试功能包括单元测试 MockerSpy 等
基于如下几个考虑:
易于测试: 我接触的很多开发人员是不喜欢写测试的如果测试代码不易编写, 那就更不愿意写了
不影响环境: 我期望的是在发布时是包含测试的, 测试不通过即不能发布也就是说在部署时测试, 会使用正式环境的库表数据, 所以在测试时不能影响到这些数据
小范围测试: 以最少的代码, 覆盖最核心的代码逻辑
故决定只对 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 的操作是否合适?
基于上面 ParamResult 和 Model 的关系图来看, 实际上 ParamResult 和 Model 大部分情况下都不是契合的! 把这些 ParamResult 限制在 Model 上是否合适? 数据结构是否清晰?
目前个人觉得基于 data 的 transformfiltermap 操作更适合 web 开发(我会另开一篇讨论这个)! 或者你有什么好的方案, 欢迎指教?
公众号: ivaneye
来源: https://www.cnblogs.com/ivaneye/p/8482282.html