思考执行表达

Web开发框架推导

2018-03-05  本文已影响16人  IvanEye

本文欲回答这样一个问题:在 「特定环境 」下,如何规划Web开发框架,使其能满足 「期望 」?

假设我们的「特定环境 」如下:

我们的 「期望 」是:

本文从一个空框架开始,逐步加入上面的约束,最终推导出符合期望的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四个包,职责如下:

分层后的框架逻辑清晰,且切分方式符合行业规约,更易于上手。

前后端分离

考虑到,目前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,需要符合四个核心的约束:

绝大部分声称符合RESTful的应用都不是百分百符合这四个约束,特别是超媒体作为应用状态引擎(hypermedia as the engine of application state)这个约束。

基于注解的数据处理

确定了以JSON的方式进行参数的传递后,就需要确定如何来处理参数和返回结果?这涉及到几个问题:

这里选择了Mybatis作为持久化框架,我们先从Mybatis的角度来回答上面的几个问题!

首先Mybatis作为框架,会生成几个文件:Model.java,Mapper.java和Mapper.xml!(这里不做过多解释!对Mybatis不熟悉的朋友请自行google!)这几个文件可以自动生成,也可以手写!

不论是自动生成还是手写都有其优缺点:

一种优化方案是,第一次使用自动生成,后续手动修改。

但是结合前面的约束:

此方法并不适用。 此方法只对于改动不太频繁的项目还算适用,但是如果表结构改动较频繁,后续的每次修改还是要手动修改,非常的麻烦(无法适应频繁的变更,快速迭代)。且只能第一次使用自动生成这个规定并没法强制实施,你没法保证谁不会误操作了自动生成(考虑开发人员资历较浅),导致手写的代码被覆盖了!

结合以上约束,为了尽量避免错误,优先选择自动生成!再来尝试解决其短板,即生成的三个文件无法进行修改。是否有可行方案呢?

我们先考虑几个问题:

  1. Controller需要对页面传过来的参数做哪些操作

  2. 页面传来的参数和Model是一个什么关系

  3. 从Controller返回给页面的数据又和Model是什么关系

  4. Controller对返回给页面的数据又要做哪些操作

为方便起见,我们把入参称为Param,返回结果称为Result。我们先回答第一个和第四个问题!

这些操作都可以方便的处理:

这些都是规约!

针对第二个和第三个问题,我们先看Param、Result和Model之间的关系:


50e52024-4589-4069-be6b-297b5d4ebe53-image1.png

从上图可以看出,除了第一种情况(且这种情况很少),其它四种情况Param和Model实际是一个包含的关系。既然是一种包含的情况,那这种包含关系,在Java里我们可以使用继承来实现。也就是说可以使Param extends Model,以这样的方式来复用Model的内容!
我们来看以这种方式来实现Param和Result,如何来解决上面的问题!

尽量以扩展规约的方式来处理问题,在不增加理解难度的情况下提高易用性和开发效率!

数据返回

在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测试,原因如下:

部署

SpringBoot可以直接打包为jar包,直接运行启动。这很方便,但是如果想快速的横向扩容,配置文件就是一个问题。因为不同机器上的配置并不是完全相同的。
有两个方案可以解决:

从便利性考虑,还是选择配置服务器。
配置文件中均是开发环境配置,方便开发人员直接开发、测试。
在正式环境中,应用启动时会从配置服务器获取对应的配置,覆盖本地测试进行部署。

代码生成OR封装

在结束之前,先问个问题?你是喜欢代码生成、还是封装?

我个人更偏向代码生成,理由是:

基于上面的原因,再考虑到其实我们的框架都是符合规约的(RESTful,JSR303,覆写,Jackson),故对于标准CRUD,我们可以一键生成!

一键生成

其实到上面一节,整个框架应该已经符合预期了!但是为了得到超预期的效果,我们来更进一步!

我们先看目前的开发流程:

对于一个典型的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逻辑,我们可以做到如下的开发流程:

对于不可重复生成的文件,我们可以设置"存在即不覆盖",在最大限度的提高开发效率的前提下,降低误操作。

总结

如上即是我基于约束所做的Web推导!目前的主要问题还是在Model层面:

目前个人觉得基于data的transform、filter、map操作更适合web开发(我会另开一篇讨论这个)!或者你有什么好的方案,欢迎指教?

上一篇下一篇

猜你喜欢

热点阅读