瑞吉外卖

软件开发整体介绍

作为一名软件开发工程师,我们需要了解在软件开发过程中的开发流程, 以及软件开发过程中涉及到的岗位角色,角色的分工、职责, 并了解软件开发中涉及到的三种软件环境

软件开发流程

image-20210725231106363

1). 第1阶段: 需求分析

完成产品原型、需求规格说明书的编写。

产品原型,一般是通过网页(html)的形式展示当前的页面展示什么样的数据, 页面的布局是什么样子的,点击某个菜单,打开什么页面,点击某个按钮,出现什么效果,都可以通过产品原型看到。

需求规格说明书, 一般来说就是使用 Word 文档来描述当前项目有哪些功能,每一项功能的需求及业务流程是什么样的,都会在文档中描述。

2). 第2阶段: 设计

设计的内容包含 产品设计、UI界面设计、概要设计、详细设计、数据库设计。

在设计阶段,会出具相关的UI界面、及相关的设计文档。比如数据库设计,需要设计当前项目中涉及到哪些数据库,每一个数据库里面包含哪些表,这些表结构之间的关系是什么样的,表结构中包含哪些字段,字段类型都会在文档中描述清楚。

3). 第3阶段: 编码

编写项目代码、并完成单元测试。

作为软件开发工程师,我们主要的工作就是在该阶段, 对分配给我们的模块功能,进行编码实现。编码实现完毕后,进行单元测试,单元测试通过后再进入到下一阶段。

4). 第4阶段: 测试

在该阶段中主要由测试人员, 对部署在测试环境的项目进行功能测试, 并出具测试报告。

5). 第5阶段: 上线运维

在项目上线之前, 会由运维人员准备服务器上的软件环境安装、配置, 配置完毕后, 再将我们开发好的项目,部署在服务器上运行。

我们作为软件开发工程师, 我们主要的任务是在编码阶段, 但是在一些小的项目组当中, 也会涉及到数据库的设计、测试等方面的工作。

角色分工

image-20210725234015404

岗位/角色职责/分工
项目经理对整个项目负责,任务分配、把控进度
产品经理进行需求调研,输出需求调研文档、产品原型等
UI设计师根据产品原型输出界面效果图
架构师项目整体架构设计、技术选型等
开发工程师功能代码实现
测试工程师编写测试用例,输出测试报告
运维工程师软件环境搭建、项目上线

上述我们讲解的角色分工, 是在一个项目组中比较标准的角色分工, 但是在实际的项目中, 有一些项目组由于人员配置紧张, 可能并没有专门的架构师或测试人员, 这个时候可能需要有项目经理或者程序员兼任。

软件环境

image-20210725234046008

在我们日常的软件开发中,会涉及到软件开发中的三套环境, 那么这三套环境分别是: 开发环境、测试环境、生产环境。 接下来,我们分别介绍一下这三套环境的作用和特点。

1). 开发环境(development)

我们作为软件开发人员,在开发阶段使用的环境,就是开发环境,一般外部用户无法访问。

比如,我们在开发中使用的MySQL数据库和其他的一些常用软件,我们可以安装在本地, 也可以安装在一台专门的服务器中, 这些应用软件仅仅在软件开发过程中使用, 项目测试、上线时,我们不会使用这套环境了,这个环境就是开发环境。

2). 测试环境(testing)

当软件开发工程师,将项目的功能模块开发完毕,并且单元测试通过后,就需要将项目部署到测试服务器上,让测试人员对项目进行测试。那这台测试服务器就是专门给测试人员使用的环境, 也就是测试环境,用于项目测试,一般外部用户无法访问。

3). 生产环境(production)

当项目开发完毕,并且由测试人员测试通过之后,就可以上线项目,将项目部署到线上环境,并正式对外提供服务,这个线上环境也称之为生产环境。

扩展知识

准生产环境: 对于有的公司来说,项目功能开发好, 并测试通过以后,并不是直接就上生产环境。为了保证我们开发的项目在上线之后能够完全满足要求,就需要把项目部署在真实的环境中, 测试一下是否完全符合要求啊,这时候就诞生了准生产环境,你可以把他当做生产环境的克隆体,准生产环境的服务器配置, 安装的应用软件(JDK、Tomcat、数据库、中间件 ...) 的版本都一样,这种环境也称为 "仿真环境"。

ps.由于项目的性质和类型不同,有的项目可能不需要这个环境

瑞吉外卖项目介绍

项目介绍

image-20210726000655646

本项目(瑞吉外卖)是专门为餐饮企业(餐厅、饭店)定制的一款软件产品,包括 系统管理后台 和 移动端应用 两部分。其中系统管理后台主要提供给餐饮企业内部员工使用,可以对餐厅的分类、菜品、套餐、订单、员工等进行管理维护。移动端应用主要提供给消费者使用,可以在线浏览菜品、添加购物车、下单等

产品原型

产品原型,就是一款产品成型之前,由产品经理绘制的一个简单的框架,就是将页面的排版布局展现出来,使产品的初步构思有一个可视化的展示。通过原型展示,可以更加直观的了解项目的需求和提供的功能。

注意事项: 产品原型主要用于展示项目的功能,并不是最终的页面效果。

1). 管理端

餐饮企业内部员工使用。 主要功能有:

模块描述
登录/退出内部员工必须登录后,才可以访问系统管理后台
员工管理管理员可以在系统后台对员工信息进行管理,包含查询、新增、编辑、禁用等功能
分类管理主要对当前餐厅经营的 菜品分类 或 套餐分类 进行管理维护, 包含查询、新增、修改、删除等功能
菜品管理主要维护各个分类下的菜品信息,包含查询、新增、修改、删除、启售、停售等功能
套餐管理主要维护当前餐厅中的套餐信息,包含查询、新增、修改、删除、启售、停售等功能
订单明细主要维护用户在移动端下的订单信息,包含查询、取消、派送、完成,以及订单报表下载等功能

2). 用户端

移动端应用主要提供给消费者使用。主要功能有:

模块描述
登录/退出在移动端, 用户也需要登录后使用APP进行点餐
点餐-菜单在点餐界面需要展示出菜品分类/套餐分类, 并根据当前选择的分类加载其中的菜品信息, 供用户查询选择
点餐-购物车用户选中的菜品就会加入用户的购物车, 主要包含 查询购物车、加入购物车、删除购物车、清空购物车等功能
订单支付用户选完菜品/套餐后, 可以对购物车菜品进行结算支付, 这时就需要进行订单的支付
个人信息在个人中心页面中会展示当前用户的基本信息, 用户可以管理收货地址, 也可以查询历史订单数据

技术选型

关于本项目的技术选型, 我们将会从 用户层、网关层、应用层、数据层 这几个方面进行介绍,而对于我们服务端开发工程师来说,在项目开发过程中,我们主要关注应用层及数据层技术的应用。

image-20210726005437857

1). 用户层

本项目中在构建系统管理后台的前端页面,我们会用到H5、Vue.js、ElementUI等技术。而在构建移动端应用时,我们会使用到微信小程序。

2). 网关层

Nginx是一个服务器,主要用来作为Http服务器,部署静态资源,访问性能高。在Nginx中还有两个比较重要的作用: 反向代理和负载均衡, 在进行项目部署时,要实现Tomcat的负载均衡,就可以通过Nginx来实现。

3). 应用层

  • SpringBoot: 快速构建Spring项目, 采用 "约定优于配置" 的思想, 简化Spring项目的配置开发。
  • Spring: 统一管理项目中的各种资源(bean), 在web开发的各层中都会用到。
  • SpringMVC:SpringMVC是spring框架的一个模块,springmvc和spring无需通过中间整合层进行整合,可以无缝集成。
  • SpringSession: 主要解决在集群环境下的Session共享问题。
  • lombok:能以简单的注解形式来简化java代码,提高开发人员的开发效率。例如开发中经常需要写的javabean,都需要花时间去添加相应的getter/setter,也许还要去写构造器、equals等方法。
  • Swagger: 可以自动的帮助开发人员生成接口文档,并对接口进行测试。

4). 数据层

  • MySQL: 关系型数据库, 本项目的核心业务数据都会采用MySQL进行存储。
  • MybatisPlus: 本项目持久层将会使用MybatisPlus来简化开发, 基本的单表增删改查直接调用框架提供的方法即可。
  • Redis: 基于key-value格式存储的内存数据库, 访问速度快, 经常使用它做缓存(降低数据库访问压力, 提供访问效率), 在后面的性能优化中会使用。

5). 工具

  • git: 版本控制工具, 在团队协作中, 使用该工具对项目中的代码进行管理。
  • maven: 项目构建工具。
  • junit:单元测试工具,开发人员功能实现完毕后,需要通过junit对功能进行单元测试。

功能架构

image-20210726122825225

1). 移动端前台功能

手机号登录 , 微信登录 , 收件人地址管理 , 用户历史订单查询 , 菜品规格查询 , 购物车功能 , 下单 , 分类及菜品浏览。

2). 系统管理后台功能

员工登录/退出 , 员工信息管理 , 分类管理 , 菜品管理 , 套餐管理 , 菜品口味管理 , 订单管理 。

角色

在瑞吉外卖这个项目中,存在以下三种用户,这三种用户对应三个角色: 后台系统管理员、后台系统普通员工、C端(移动端)用户

角色权限操作
后台系统管理员登录后台管理系统,拥有后台系统中的所有操作权限
后台系统普通员工登录后台管理系统,对菜品、套餐、订单等进行管理 (不包含员工管理)
C端用户登录移动端应用,可以浏览菜品、添加购物车、设置地址、在线下单等

登录业务

可以使用拦截器或者过滤器,判断请求uri,做出相应的处理

image-20210727233554707

过滤器具体的处理逻辑如下:

A. 获取本次请求的URI

B. 判断本次请求, 是否需要登录, 才可以访问

C. 如果不需要,则直接放行

D. 判断登录状态,如果已登录,则直接放行

E. 如果未登录, 则返回未登录结果(配合前端进行页面跳转)

登录成功后,用户信息后端可以存储在cookie或者session中,或者redis中。前端可以存储在localstorage

使用过滤器还需要在SpringBoot启动类添加@ServletComponentScan 注解:

  • 在SpringBoot项目中, 在引导类/配置类上加了该注解后, 会自动扫描项目中(当前包及其子包下)的@WebServlet , @WebFilter , @WebListener 注解, 自动注册Servlet的相关组件

路径匹配器AntPathMatcher

  • Spring中提供的路径匹配器
  • 支持通配符
  • 可以做URLs匹配,规则如下

    1. ?匹配一个字符
    2. *匹配0个或多个字符
    3. **匹配0个或多个字符(可以匹配多级目录)
  • 匹配方法:boolean match(url,requestUrl)
  • 案例

    • /backend/**:匹配/backend/a、/backend/a/b、...
    • /front/*:匹配/front/a、/front/b、...

id损失精度问题

在前端JS中, js在对长度较长的长整型数据进行处理时,会损失精度, 从而导致提交的id和数据库中的id不一致
  • 解决只需要让js处理的ID数据类型为字符串类型即可, 这样就不会损失精度了

方案一

  • 数据库id类型为bigint
  • 实体类id类型直接定义为String

这样的话传递到前端也直接为字符串,就没有精度损失问题了

方案二:自定义消息转换器

由于在SpringMVC中, 将Controller方法返回值转换为json对象, 是通过jackson来实现的, 涉及到SpringMVC中的一个消息转换器MappingJackson2HttpMessageConverter, 所以我们要解决这个问题, 就需要对该消息转换器的功能进行拓展

具体实现步骤:

  1. 提供对象转换器JacksonObjectMapper,基于Jackson进行Java对象到json数据的转换
  2. 在WebMvcConfig配置类中扩展Spring mvc的消息转换器,在此消息转换器中使用提供的对象转换器进行Java对象到json数据的转换

(1). 引入JacksonObjectMapper

/**
 * 对象映射器:基于jackson将Java对象转为json,或者将json转为Java对象
 * 将JSON解析为Java对象的过程称为 [从JSON反序列化Java对象]
 * 从Java对象生成JSON的过程称为 [序列化Java对象到JSON]
 */
public class JacksonObjectMapper extends ObjectMapper {
    public static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd";
    public static final String DEFAULT_DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
    public static final String DEFAULT_TIME_FORMAT = "HH:mm:ss";
    public JacksonObjectMapper() {
        super();
        //收到未知属性时不报异常
        this.configure(FAIL_ON_UNKNOWN_PROPERTIES, false);
        //反序列化时,属性不存在的兼容处理
        this.getDeserializationConfig().withoutFeatures(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);

        SimpleModule simpleModule = new SimpleModule()
                .addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT)))
                .addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)))
                .addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT)))

                // 将bigint和long类型转换为String
                .addSerializer(BigInteger.class, ToStringSerializer.instance)
                .addSerializer(Long.class, ToStringSerializer.instance)

                .addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT)))
                .addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)))
                .addSerializer(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT)));
        //注册功能模块 例如,可以添加自定义序列化器和反序列化器
        this.registerModule(simpleModule);
    }
}

该自定义的对象转换器, 主要指定了, 在进行json数据序列化及反序列化时, LocalDateTime、LocalDate、LocalTime的处理方式, 以及BigInteger及Long类型数据,直接转换为字符串。

(2). 在WebMvcConfig中重写方法extendMessageConverters

/**
 * 扩展mvc框架的消息转换器
 * @param converters
 */
@Override
protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
    log.info("扩展消息转换器...");
    //创建消息转换器对象
    MappingJackson2HttpMessageConverter messageConverter = new MappingJackson2HttpMessageConverter();
    //设置对象转换器,底层使用Jackson将Java对象转为json
    messageConverter.setObjectMapper(new JacksonObjectMapper());
    //将上面的消息转换器对象追加到mvc框架的转换器集合中
    converters.add(0,messageConverter);
}

ThreadLocal

在自动填充createUser和updateUser时需要动态获取用户id,在MyMetaObjectHandler类中是不能直接获得HttpSession对象的,所以我们需要通过其他方式来获取登录用户id

image-20210801133531663

客户端发送的每次http请求,对应的在服务端都会分配一个新的线程来处理,在处理过程中涉及到下面类中的方法都属于相同的一个线程:

  1. LoginCheckFilter的doFilter方法
  2. EmployeeController的update方法
  3. MyMetaObjectHandler的updateFill方法

我们可以在上述类的方法中加入如下代码(获取当前线程ID,并输出):

long id = Thread.currentThread().getId();
log.info("线程id为:{}",id);

执行编辑员工功能进行验证,通过观察控制台输出可以发现,一次请求对应的线程id是相同的:

image-20210801133827264

经过上述的分析之后,发现我们可以使用JDK提供的一个类, 来解决此问题,它是JDK中提供的 ThreadLocal

ThreadLocal并不是一个Thread,而是Thread的局部变量。当使用ThreadLocal维护变量时,ThreadLocal为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程所对应的副本

ThreadLocal为每个线程提供单独一份存储空间,具有线程隔离的效果,只有在线程内才能获取到对应的值,线程外则不能访问当前线程对应的值。

常用方法

  • public void set(T value) : 设置当前线程的线程局部变量的值
  • public T get() : 返回当前线程所对应的线程局部变量的值
  • public void remove() : 删除当前线程所对应的线程局部变量的值

我们可以在LoginCheckFilter的doFilter方法中获取当前登录用户id,并调用ThreadLocal的set方法来设置当前线程的线程局部变量的值(用户id),然后在MyMetaObjectHandler的updateFill方法中调用ThreadLocal的get方法来获得当前线程所对应的线程局部变量的值(用户id)。如果在后续的操作中, 我们需要在Controller / Service中要使用当前登录用户的ID, 可以直接从ThreadLocal直接获取

操作步骤

  1. 编写BaseContext工具类,基于ThreadLocal封装的工具类
  2. 在LoginCheckFilter的doFilter方法中调用BaseContext来设置当前登录用户的id
  3. 在MyMetaObjectHandler的方法中调用BaseContext获取登录用户的id

代码实现

1). BaseContext工具类

/**
 * 基于ThreadLocal封装工具类,用户保存和获取当前登录用户id
 */
public class BaseContext {
    private static ThreadLocal<Long> threadLocal = new ThreadLocal<>();
    /**
     * 设置值
     * @param id
     */
    public static void setCurrentId(Long id){
        threadLocal.set(id);
    }
    /**
     * 获取值
     * @return
     */
    public static Long getCurrentId(){
        return threadLocal.get();
    }
}

2).LoginCheckFilter中存放当前登录用户到ThreadLocal

在doFilter方法中, 判定用户是否登录, 如果用户登录, 在放行之前, 获取HttpSession中的登录用户信息, 调用BaseContext的setCurrentId方法将当前登录用户ID存入ThreadLocal

Long empId = (Long) request.getSession().getAttribute("employee");
BaseContext.setCurrentId(empId);

image-20210801162053581

3). MyMetaObjectHandler中从ThreadLocal中获取

将之前在代码中固定的当前登录用户1, 修改为动态调用BaseContext中的getCurrentId方法获取当前登录用户ID

image-20210801162345846

image-20210801162436740

文件上传下载

上传介绍

概述

文件上传,也称为upload,是指将本地图片、视频、音频等文件上传到服务器上,可以供其他用户浏览或下载的过程。文件上传在项目中应用非常广泛,我们经常发微博、发微信朋友圈都用到了文件上传功能。

image-20210803081955246

文件上传时,对页面的form表单有如下要求:

表单属性取值说明
methodpost必须选择post方式提交
enctypemultipart/form-data采用multipart格式上传文件
typefile使用input的file控件上传

前端介绍

1). 简单html页面表单

<form method="post" action="/common/upload" enctype="multipart/form-data">
    <input name="myFile" type="file"  />
    <input type="submit" value="提交" /> 
</form>

2). ElementUI中提供的upload上传组件

目前一些前端组件库也提供了相应的上传组件,但是底层原理还是基于form表单的文件上传。

image-20210803084742416

服务端介绍

服务端要接收客户端页面上传的文件,通常都会使用Apache的两个组件:

  • commons-fileupload
  • commons-io

而Spring框架在spring-web包中对文件上传进行了封装,大大简化了服务端代码,我们只需要在Controller的方法中声明一个MultipartFile类型的参数即可接收上传的文件,例如:

/**
 * 文件上传
 * @param file
 * @return
 */
@PostMapping("/upload")
public R<String> upload(MultipartFile file){
    System.out.println(file);
    return R.success(fileName);
}

下载介绍

文件下载,也称为download,是指将文件从服务器传输到本地计算机的过程。通过浏览器进行文件下载,通常有两种表现形式:

1). 以附件形式下载,弹出保存对话框,将文件保存到指定磁盘目录

image-20210803123858691

2). 直接在浏览器中打开

而我们在今天所需要实现的菜品展示,表现形式为在浏览器中直接打开。

image-20210803124220869

通过浏览器进行文件下载,本质上就是服务端将文件以流的形式写回浏览器的过程

上传代码实现

前端代码

文件上传,我们作为服务端工程师,主要关注服务端代码实现。对于前端页面,可以使用ElementUI提供的上传组件。可以直接使用资料中提供的上传页面,位置:资料/文件上传下载页面/upload.html,将其拷贝到项目的目录(resources/backend/page/demo)下,启动项目,访问上传页面。

http://localhost:8080/backend/page/demo/upload.html

image-20210803125619732

在上述的浏览器抓取的网络请求中,上传文件的调用url,在哪里配置的呢,这个时候,我们需要去看一下前端上传组件。

image-20210803125942295

虽然上述是ElementUI封装的代码,但是实际上最终还通过file域上传文件,如果未指定上传文件的参数名,默认为file。

image-20210803130455590

1.3.2 服务端实现

1). application.yml

需要在application.yml中定义文件存储路径

reggie:
  path: D:\img\

2). CommonController

编写文件上传的方法, 通过MultipartFile类型的参数即可接收上传的文件, 方法形参的名称需要与页面的file域的name属性一致

上传逻辑:

  1. 获取文件的原始文件名, 通过原始文件名获取文件后缀
  2. 通过UUID重新声明文件名, 文件名称重复造成文件覆盖
  3. 创建文件存放目录
  4. 将上传的临时文件转存到指定位置

代码实现:

/**
 * 文件上传和下载
 */
@RestController
@RequestMapping("/common")
@Slf4j
public class CommonController {
    @Value("${reggie.path}")
    private String basePath;
    /**
     * 文件上传
     * @param file
     * @return
     */
    @PostMapping("/upload")
    public R<String> upload(MultipartFile file){
        //file是一个临时文件,需要转存到指定位置,否则本次请求完成后临时文件会删除
        log.info(file.toString());

        //原始文件名
        String originalFilename = file.getOriginalFilename();//abc.jpg
        String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));

        //使用UUID重新生成文件名,防止文件名称重复造成文件覆盖
        String fileName = UUID.randomUUID().toString() + suffix;//dfsdfdfd.jpg

        //创建一个目录对象
        File dir = new File(basePath);
        //判断当前目录是否存在
        if(!dir.exists()){
            //目录不存在,需要创建
            dir.mkdirs();
        }

        try {
            //将临时文件转存到指定位置
            file.transferTo(new File(basePath + fileName));
        } catch (IOException e) {
            e.printStackTrace();
        }
        return R.success(fileName);
    }
}  

测试

代码编写完成之后,我们重新启动项目,访问上传页面 http://localhost:8080/backend/page/demo/upload.html,然后点击上传图片,选择图片上传时,会发现图片并不能正常的上传,而且在浏览器中可以抓取到响应的数据,从图中我们可以判断出需要登录才可以操作。

image-20210803232054156

而这样的话,就要求我们在测试时,每一次都需要先登录,登录完成后在进行图片上传的测试,为了简化我们的测试,我们可以在 LoginCheckFilter 的doFilter方法中,在不需要处理的请求路径的数组中再加入请求路径 /common/** , 如下:

image-20210803232433983

然后,我们在测试文件的上传功能时,就不需要登录就可以操作了。

我们在测试文件上传时,可以通过debug的形式来跟踪上传的整个过程,验证一下临时文件是否存在,以及上传完成之后,临时文件是否会自动删除

image-20210805160234154

下载代码实现

前端代码

文件下载,前端页面可以使用<img>标签展示下载的图片。

<img v-if="imageUrl" :src="imageUrl" class="avatar"></img>

那么,通过<img>标签如何展示图片数据呢,接下来我们来解析一下具体的流程:

image-20210803224818828

在文件上传成功后,在 handleAvatarSuccess 方法中获取文件上传成功之后返回的数据(文件名),然后调用 /common/download?name=xxx 进行文件的下载。在这里,我们想让上传的照片能够在页面展示出来,所以我们就需要在服务端将文件以流的形式写回浏览器

服务端实现

在 CommonController 中定义方法download,并接收页面传递的参数name,然后读取图片文件的数据,然后以流的形式写回浏览器。

具体逻辑如下:

  1. 定义输入流,通过输入流读取文件内容
  2. 通过response对象,获取到输出流
  3. 通过response对象设置响应数据格式(image/jpeg)
  4. 通过输入流读取文件数据,然后通过上述的输出流写回浏览器
  5. 关闭资源

代码实现:

/**
 * 文件下载
 * @param name
 * @param response
 */
@GetMapping("/download")
public void download(String name, HttpServletResponse response){
    try {
        //输入流,通过输入流读取文件内容
        FileInputStream fileInputStream = new FileInputStream(new File(basePath + name));

        //输出流,通过输出流将文件写回浏览器
        ServletOutputStream outputStream = response.getOutputStream();

        response.setContentType("image/jpeg");

        int len = 0;
        byte[] bytes = new byte[1024];
        while ((len = fileInputStream.read(bytes)) != -1){
            outputStream.write(bytes,0,len);
            outputStream.flush();
        }

        //关闭资源
        outputStream.close();
        fileInputStream.close();
    } catch (Exception e) {
        e.printStackTrace();
    }
}

测试

访问页面 http://localhost:8080/backend/page/demo/upload.html , 点击上传图片 , 选择图片进行上传, 上传完毕之后, 查看图片是否可以展示出来。

image-20210803231134157

通过F12查询页面发起的请求及响应的数据:

image-20210803231302464

实体模型

我们在做项目时,经常会涉及到各种类型的实体模型。基本包含以下几种

实体模型描述
DTOData Transfer Object(数据传输对象),一般用于展示层与服务层之间的数据传输。
Entity最常用实体类,基本和数据表一一对应,一个实体类对应一张表。
VOValue Object(值对象), 主要用于封装前端页面展示的数据对象,用一个VO对象来封装整个页面展示所需要的对象数据
POPersistant Object(持久层对象), 是ORM(Objevt Relational Mapping)框架中Entity,PO属性和数据库中表的字段形成一一对应关系

SpringCache

介绍

Spring Cache是一个框架,实现了基于注解的缓存功能,只需要简单地加一个注解,就能实现缓存功能,大大简化我们在业务中操作缓存的代码。

Spring Cache只是提供了一层抽象,底层可以切换不同的cache实现。具体就是通过CacheManager接口来统一不同的缓存技术。CacheManager是Spring提供的各种缓存技术抽象接口。

针对不同的缓存技术需要实现不同的CacheManager:

CacheManager描述
EhCacheCacheManager使用EhCache作为缓存技术
GuavaCacheManager使用Google的GuavaCache作为缓存技术
RedisCacheManager使用Redis作为缓存技术

注解

在SpringCache中提供了很多缓存操作的注解,常见的是以下的几个:

注解说明
@EnableCaching开启缓存注解功能
@Cacheable在方法执行前spring先查看缓存中是否有数据,如果有数据,则直接返回缓存数据;若没有数据,调用方法并将方法返回值放到缓存中
@CachePut将方法的返回值放到缓存中
@CacheEvict将一条或多条数据从缓存中删除

在spring boot项目中,使用缓存技术只需在项目中导入相关缓存技术的依赖包,并在启动类上使用@EnableCaching开启缓存支持即可。

例如,使用Redis作为缓存技术,只需要导入Spring data Redis的maven坐标即可

入门程序

接下来,我们将通过一个入门案例来演示一下SpringCache的常见用法。 上面我们提到,SpringCache可以集成不同的缓存技术,如Redis、Ehcache甚至我们可以使用Map来缓存数据, 接下来我们在演示的时候,就先通过一个Map来缓存数据,最后我们再换成Redis来缓存。

环境准备

1). 数据库准备

image-20210822230236957

2). 导入基础工程

image-20210822225934512

由于SpringCache的基本功能是Spring核心(spring-context)中提供的,所以目前我们进行简单的SpringCache测试,是可以不用额外引入其他依赖的。

3). 注入CacheManager

我们可以在UserController注入一个CacheManager,在Debug时,我们可以通过CacheManager跟踪缓存中数据的变化。

image-20210822231333527

我们可以看到CacheManager是一个接口,默认的实现有以下几种 ;

image-20210822231217450

而在上述的这几个实现中,默认使用的是 ConcurrentMapCacheManager。稍后我们可以通过断点的形式跟踪缓存数据的变化。

4). 引导类上加@EnableCaching

在引导类上加该注解,就代表当前项目开启缓存注解功能。

image-20210822231616569

@CachePut注解

@CachePut说明:

  • 作用: 将方法返回值,放入缓存
  • value: 缓存的名称, 每个缓存名称下面可以有很多key
  • key: 缓存的key ----------> 支持Spring的表达式语言SPEL语法

1). 在save方法上加注解@CachePut

当前UserController的save方法是用来保存用户信息的,我们希望在该用户信息保存到数据库的同时,也往缓存中缓存一份数据,我们可以在save方法上加上注解 @CachePut,用法如下:

/**
* CachePut:将方法返回值放入缓存
* value:缓存的名称,每个缓存名称下面可以有多个key
* key:缓存的key
*/
@CachePut(value = "userCache", key = "#user.id")
@PostMapping
public User save(User user){
    userService.save(user);
    return user;
}

key的写法如下:

  • #user.id : #user指的是方法形参的名称, id指的是user的id属性 , 也就是使用user的id属性作为key
  • #user.name: #user指的是方法形参的名称, name指的是user的name属性 ,也就是使用user的name属性作为key
  • #result.id : #result代表方法返回值,该表达式 代表以返回对象的id属性作为key
  • #result.name : #result代表方法返回值,该表达式 代表以返回对象的name属性作为key

2). 测试

启动服务,通过postman请求访问UserController的方法, 然后通过断点的形式跟踪缓存数据。

image-20210822233438182

第一次访问时,缓存中的数据是空的,因为save方法执行完毕后才会缓存数据。

image-20210822233724439

第二次访问时,我们通过debug可以看到已经有一条数据了,就是上次保存的数据,已经缓存了,缓存的key就是用户的id。

image-20210822234105085

注意: 上述的演示,最终的数据,实际上是缓存在ConcurrentHashMap中,那么当我们的服务器重启之后,缓存中的数据就会丢失。 我们后面使用了Redis来缓存就不存在这样的问题了。

@CacheEvict注解

@CacheEvict说明:

  • 作用: 清理指定缓存
  • value: 缓存的名称,每个缓存名称下面可以有多个key
  • key: 缓存的key ----------> 支持Spring的表达式语言SPEL语法

1). 在 delete 方法上加注解@CacheEvict

当我们在删除数据库user表的数据的时候,我们需要删除缓存中对应的数据,此时就可以使用@CacheEvict注解, 具体的使用方式如下:

/**
* CacheEvict:清理指定缓存
* value:缓存的名称,每个缓存名称下面可以有多个key
* key:缓存的key
*/
@CacheEvict(value = "userCache",key = "#p0")  //#p0 代表第一个参数
//@CacheEvict(value = "userCache",key = "#root.args[0]") //#root.args[0] 代表第一个参数
//@CacheEvict(value = "userCache",key = "#id") //#id 代表变量名为id的参数
@DeleteMapping("/{id}")
public void delete(@PathVariable Long id){
    userService.removeById(id);
}

2). 测试

要测试缓存的删除,我们先访问save方法4次,保存4条数据到数据库的同时,也保存到缓存中,最终我们可以通过debug看到缓存中的数据信息。 然后我们在通过postman访问delete方法, 如下:

image-20210823000431356

删除数据时,通过debug我们可以看到已经缓存的4条数据:

image-20210823000458089

当执行完delete操作之后,我们再次保存一条数据,在保存的时候debug查看一下删除的ID值是否已经被删除。

image-20210823000733218

3). 在 update 方法上加注解@CacheEvict

在更新数据之后,数据库的数据已经发生了变更,我们需要将缓存中对应的数据删除掉,避免出现数据库数据与缓存数据不一致的情况。

//@CacheEvict(value = "userCache",key = "#p0.id")   //第一个参数的id属性
//@CacheEvict(value = "userCache",key = "#user.id") //参数名为user参数的id属性
//@CacheEvict(value = "userCache",key = "#root.args[0].id") //第一个参数的id属性
@CacheEvict(value = "userCache",key = "#result.id")         //返回值的id属性
@PutMapping
public User update(User user){
    userService.updateById(user);
    return user;
}

加上注解之后,我们可以重启服务,然后测试方式,基本和上述相同,先缓存数据,然后再更新某一条数据,通过debug的形式查询缓存数据的情况。

@Cacheable注解

@Cacheable 说明:

  • 作用: 在方法执行前,spring先查看缓存中是否有数据,如果有数据,则直接返回缓存数据;若没有数据,调用方法并将方法返回值放到缓存中
  • value: 缓存的名称,每个缓存名称下面可以有多个key
  • key: 缓存的key ----------> 支持Spring的表达式语言SPEL语法

1). 在getById上加注解@Cacheable

/**
* Cacheable:在方法执行前spring先查看缓存中是否有数据,如果有数据,则直接返回缓存数据;若没有数据,调用方法并将方法返回值放到缓存中
* value:缓存的名称,每个缓存名称下面可以有多个key
* key:缓存的key
*/
@Cacheable(value = "userCache",key = "#id")
@GetMapping("/{id}")
public User getById(@PathVariable Long id){
    User user = userService.getById(id);
    return user;
}

2). 测试

我们可以重启服务,然后通过debug断点跟踪程序执行。我们发现,第一次访问,会请求我们controller的方法,查询数据库。后面再查询相同的id,就直接获取到数据库,不用再查询数据库了,就说明缓存生效了。

image-20210823002517941

当我们在测试时,查询一个数据库不存在的id值,第一次查询缓存中没有,也会查询数据库。而第二次再查询时,会发现,不再查询数据库了,而是直接返回,那也就是说如果根据ID没有查询到数据,那么会自动缓存一个null值。 我们可以通过debug,验证一下:

image-20210823002907048

我们能不能做到,当查询到的值不为null时,再进行缓存,如果为null,则不缓存呢? 答案是可以的

3). 缓存非null值

在@Cacheable注解中,提供了两个属性分别为: condition, unless 。

  • condition : 表示满足什么条件, 再进行缓存 ;
  • unless : 表示满足条件则不缓存 ; 与上述的condition是反向的 ;

具体实现方式如下:

/**
 * Cacheable:在方法执行前spring先查看缓存中是否有数据,如果有数据,则直接返回缓存数据;若没有数据,调用方法并将方法返回值放到缓存中
 * value:缓存的名称,每个缓存名称下面可以有多个key
 * key:缓存的key
 * condition:条件,满足条件时才缓存数据
 * unless:满足条件则不缓存
 */
@Cacheable(value = "userCache",key = "#id", unless = "#result == null")
@GetMapping("/{id}")
public User getById(@PathVariable Long id){
    User user = userService.getById(id);
    return user;
}
注意: 此处,我们使用的时候只能够使用 unless, 因为在condition中,我们是无法获取到结果 #result的

4). 在list方法上加注解@Cacheable

在list方法中进行查询时,有两个查询条件,如果传递了id,根据id查询; 如果传递了name, 根据name查询,那么我们缓存的key在设计的时候,就需要既包含id,又包含name。 具体的代码实现如下:

@Cacheable(value = "userCache",key = "#user.id + '_' + #user.name")
@GetMapping("/list")
public List<User> list(User user){
    LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.eq(user.getId() != null,User::getId,user.getId());
    queryWrapper.eq(user.getName() != null,User::getName,user.getName());
    List<User> list = userService.list(queryWrapper);
    return list;
}

然后再次重启服务,进行测试。

image-20210823005220230

第一次查询时,需要查询数据库,在后续的查询中,就直接查询了缓存,不再查询数据库了。

集成Redis

在使用上述默认的ConcurrentHashMap做缓存时,服务重启之后,之前缓存的数据就全部丢失了,操作起来并不友好。在项目中使用,我们会选择使用redis来做缓存,主要需要操作以下几步:

1). pom.xml

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

2). application.yml

spring:
  redis:
    host: 192.168.200.200
    port: 6379
    password: root@123456
    database: 0
  cache:
    redis:
      time-to-live: 1800000   #设置缓存过期时间,可选

3). 测试

重新启动项目,通过postman发送根据id查询数据的请求,然后通过redis的图形化界面工具,查看redis中是否可以正常的缓存数据。

image-20210823010810680

image-20210823010742530

缓存套餐数据

实现思路

我们已经实现了移动端套餐查看功能,对应的服务端方法为SetmealController的list方法,此方法会根据前端提交的查询条件进行数据库查询操作。在高并发的情况下,频繁查询数据库会导致系统性能下降,服务端响应时间增长。现在需要对此方法进行缓存优化,提高系统的性能

具体的实现思路如下:

  1. 导入Spring Cache和Redis相关maven坐标
  2. 在application.yml中配置缓存数据的过期时间
  3. 在启动类上加入@EnableCaching注解,开启缓存注解功能
  4. 在SetmealController的list方法上加入@Cacheable注解
  5. 在SetmealController的save和delete方法上加入CacheEvict注解

代码实现

1). pom.xml中引入依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
备注: spring-boot-starter-data-redis 这个依赖前面已经引入了, 无需再次引入

2). application.yml中设置缓存过期时间

spring:  
  cache:
    redis:
      time-to-live: 1800000 #设置缓存数据的过期时间

3). 启动类上加入@EnableCaching注解

image-20210823232419408

4). SetmealController的list方法上加入@Cacheable注解

在进行套餐数据查询时,我们需要根据分类ID和套餐的状态进行查询,所以我们在缓存数据时,可以将套餐分类ID和套餐状态组合起来作为key,如: 1627182182_1 (1627182182为分类ID,1为状态)。

/**
* 根据条件查询套餐数据
* @param setmeal
* @return
*/
@GetMapping("/list")
@Cacheable(value = "setmealCache",key = "#setmeal.categoryId + '_' + #setmeal.status")
public R<List<Setmeal>> list(Setmeal setmeal){
    LambdaQueryWrapper<Setmeal> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.eq(setmeal.getCategoryId() != null,Setmeal::getCategoryId,setmeal.getCategoryId());
    queryWrapper.eq(setmeal.getStatus() != null,Setmeal::getStatus,setmeal.getStatus());
    queryWrapper.orderByDesc(Setmeal::getUpdateTime);

    List<Setmeal> list = setmealService.list(queryWrapper);

    return R.success(list);
}

测试

缓存数据的代码编写完毕之后,重新启动服务,访问移动端进行测试,我们登陆之后在点餐界面,点击某一个套餐分类,查询套餐列表数据时,服务端报错了,错误信息如下:

image-20210823233406888

image-20210823233514356

为什么会报出这个错误呢?

因为 @Cacheable 会将方法的返回值R缓存在Redis中,而在Redis中存储对象,该对象是需要被序列化的,而对象要想被成功的序列化,就必须得实现 Serializable 接口。而当前我们定义的R,并未实现 Serializable 接口。所以,要解决该异常,只需要让R实现 Serializable 接口即可。如下:

image-20210823233904520

修复完毕之后,再次重新测试,访问套餐分类下对应的套餐列表数据后,我们会看到Redis中确实可以缓存对应的套餐列表数据。

image-20210823234146526

清理套餐数据

为了保证数据库中数据与缓存数据的一致性,在我们添加套餐或者删除套餐数据之后,需要清空当前套餐缓存的全部数据。那么@CacheEvict注解如何清除某一份缓存下所有的数据呢,这里我们可以指定@CacheEvict中的一个属性 allEnties,将其设置为true即可

1). 在delete方法上加注解@CacheEvict

/**
 * 删除套餐
 * @param ids
 * @return
 */
@DeleteMapping
@CacheEvict(value = "setmealCache",allEntries = true) //清除setmealCache名称下,所有的缓存数据
public R<String> delete(@RequestParam List<Long> ids){
    log.info("ids:{}",ids);
    setmealService.removeWithDish(ids);
    return R.success("套餐数据删除成功");
}

2). 在delete方法上加注解@CacheEvict

/**
 * 新增套餐
 * @param setmealDto
 * @return
 */
@PostMapping
@CacheEvict(value = "setmealCache",allEntries = true) //清除setmealCache名称下,所有的缓存数据
public R<String> save(@RequestBody SetmealDto setmealDto){
    log.info("套餐信息:{}",setmealDto);

    setmealService.saveWithDish(setmealDto);

    return R.success("新增套餐成功");
}

MySQL主从复制

存在的问题

在前面基础功能实现的过程中,我们后台管理系统及移动端的用户,在进行数据访问时,都是直接操作数据库MySQL的。结构如下图:

image-20210825100741985

而在当前,MySQL服务器只有一台,那么就可能会存在如下问题:

1). 读和写所有压力都由一台数据库承担,压力大

2). 数据库服务器磁盘损坏则数据丢失,单点故障

解决方案

为了解决上述提到的两个问题,我们可以准备两台MySQL,一台主(Master)服务器,一台从(Slave)服务器,主库的数据变更,需要同步到从库中(主从复制)。而用户在访问我们项目时,如果是写操作(insert、update、delete),则直接操作主库;如果是读(select)操作,则直接操作从库(在这种读写分离的结构中,从库是可以有多个的),这种结构我们称为 读写分离

image-20210825101438683

MySQL数据库默认是支持主从复制的,不需要借助于其他的技术,我们只需要在数据库中简单的配置即可。

介绍

MySQL主从复制是一个异步的复制过程,底层是基于Mysql数据库自带的 二进制日志 功能。就是一台或多台MySQL数据库(slave,即从库)从另一台MySQL数据库(master,即主库)进行日志的复制,然后再解析日志并应用到自身,最终实现 从库 的数据和 主库 的数据保持一致。MySQL主从复制是MySQL数据库自带功能,无需借助第三方工具。

二进制日志(BINLOG)记录了所有的 DDL(数据定义语言)语句和 DML(数据操纵语言)语句,但是不包括数据查询语句。此日志对于灾难时的数据恢复起着极其重要的作用,MySQL的主从复制, 就是通过该binlog实现的。默认MySQL是未开启该日志的。

MySQL的主从复制原理如下:

image-20210825110417975

MySQL复制过程分成三步:

  1. MySQL master 将数据变更写入二进制日志( binary log)
  2. slave将master的binary log拷贝到它的中继日志(relay log)
  3. slave重做中继日志中的事件,将数据变更反映它自己的数据

搭建

准备工作

提前准备两台服务器,并且在服务器中安装MySQL,服务器的信息如下:

数据库IP数据库版本
Master192.168.200.2005.7.25
Slave192.168.200.2015.7.25

并在两台服务器上做如下准备工作:

  1. 防火墙开放3306端口号
firewall-cmd --zone=public --add-port=3306/tcp --permanent

firewall-cmd --zone=public --list-ports

image-20210825124800430

  1. 并将两台数据库服务器启动起来:
systemctl start mysqld

登录MySQL,验证是否正常启动

image-20210825111414157

主库配置

服务器: 192.168.200.200

1). 修改Mysql数据库的配置文件/etc/my.cnf

在最下面增加配置:

log-bin=mysql-bin   #[必须]启用二进制日志
server-id=200       #[必须]服务器唯一ID(唯一即可)

image-20210825115719668

2). 重启Mysql服务

执行指令:

 systemctl restart mysqld

image-20210825115853116

3). 创建数据同步的用户并授权

登录mysql,并执行如下指令,创建用户并授权:

GRANT REPLICATION SLAVE ON *.* to 'xiaoming'@'%' identified by 'Root@123456';
注:上面SQL的作用是创建一个用户 xiaoming ,密码为 Root@123456 ,并且给xiaoming用户授予REPLICATION SLAVE权限。常用于建立复制时所需要用到的用户权限,也就是slave必须被master授权具有该权限的用户,才能通过该用户复制。

MySQL密码复杂程度说明:

image-20210825144818269

目前mysql5.7默认密码校验策略等级为 MEDIUM , 该等级要求密码组成为: 数字、小写字母、大写字母 、特殊字符、长度至少8位

4). 登录Mysql数据库,查看master同步状态

执行下面SQL,记录下结果中FilePosition的值

show master status;

image-20210825120355600

注:上面SQL的作用是查看Master的状态,执行完此SQL后不要再执行任何操作

从库配置

服务器: 192.168.200.201

1). 修改Mysql数据库的配置文件/etc/my.cnf

server-id=201     #[必须]服务器唯一ID

image-20210825125156597

2). 重启Mysql服务

systemctl restart mysqld

3). 登录Mysql数据库,设置主库地址及同步位置

change master to master_host='192.168.200.200',master_user='xiaoming',master_password='Root@123456',master_log_file='mysql-bin.000001',master_log_pos=154;

start slave;

参数说明:

  • master_host : 主库的IP地址
  • master_user : 访问主库进行主从复制的用户名(上面在主库创建的)
  • master_password : 访问主库进行主从复制的用户名对应的密码
  • master_log_file : 从哪个日志文件开始同步(上述查询master状态中展示的有)
  • master_log_pos : 从指定日志文件的哪个位置开始同步(上述查询master状态中展示的有)

4). 查看从数据库的状态

show slave status;

然后通过状态信息中的 Slave_IO_running 和 Slave_SQL_running 可以看出主从同步是否就绪,如果这两个参数全为Yes,表示主从同步已经配置完成。

image-20210825142313382

MySQL命令行技巧:

\G : 在MySQL的sql语句后加上\G,表示将查询结果进行按列打印,可以使每个字段打印到单独的行。即将查到的结构旋转90度变成纵向;

测试

主从复制的环境,已经搭建好了,接下来,我们可以通过Navicat连接上两台MySQL服务器,进行测试。测试时,我们只需要在主库Master执行操作,查看从库Slave中是否将数据同步过去即可。

1). 在master中创建数据库itcast, 刷新slave查看是否可以同步过去

image-20210825143518383

2). 在master的itcast数据下创建user表, 刷新slave查看是否可以同步过去

image-20210825143549689

3). 在master的user表中插入一条数据, 刷新slave查看是否可以同步过去

image-20210825143658516

读写分离案例

背景介绍

面对日益增加的系统访问量,数据库的吞吐量面临着巨大瓶颈。 对于同一时刻有大量并发读操作和较少写操作类型的应用系统来说,将数据库拆分为主库从库,主库负责处理事务性的增删改操作,从库负责处理查询操作,能够有效的避免由数据更新导致的行锁,使得整个系统的查询性能得到极大的改善。

image-20210825145647274

通过读写分离,就可以降低单台数据库的访问压力, 提高访问效率,也可以避免单机故障。

主从复制的结构,我们在第一节已经完成了,那么我们在项目中,如何通过java代码来完成读写分离呢,如何在执行select的时候查询从库,而在执行insert、update、delete的时候,操作主库呢?这个时候,我们就需要介绍一个新的技术 ShardingJDBC。

ShardingJDBC介绍

Sharding-JDBC定位为轻量级Java框架,在Java的JDBC层提供的额外服务。 它使用客户端直连数据库,以jar包形式提供服务,无需额外部署和依赖,可理解为增强版的JDBC驱动,完全兼容JDBC和各种ORM框架。

使用Sharding-JDBC可以在程序中轻松的实现数据库读写分离

Sharding-JDBC具有以下几个特点:

1). 适用于任何基于JDBC的ORM框架,如:JPA, Hibernate, Mybatis, Spring JDBC Template或直接使用JDBC。

2). 支持任何第三方的数据库连接池,如:DBCP, C3P0, BoneCP, Druid, HikariCP等。

3). 支持任意实现JDBC规范的数据库。目前支持MySQL,Oracle,SQLServer,PostgreSQL以及任何遵循SQL92标准的数据库。

依赖:

<dependency>
    <groupId>org.apache.shardingsphere</groupId>
    <artifactId>sharding-jdbc-spring-boot-starter</artifactId>
    <version>4.0.0-RC1</version>
</dependency>

数据库环境

在主库中创建一个数据库rw, 并且创建一张表, 该数据库及表结构创建完毕后会自动同步至从数据库,SQL语句如下:

create database rw default charset utf8mb4;

use rw;

CREATE TABLE `user` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) DEFAULT NULL,
  `age` int(11) DEFAULT NULL,
  `address` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

image-20210825160658477

初始工程导入

我们本案例主要是演示一下读写分离操作,对于基本的增删改查的业务操作,我们就不再去编写了,我们可以直接导入资料中提供的demo工程(rw_demo),在demo工程中,我们已经完成了user的增删改查操作,具体的工程结构如下:

image-20210825161155163

读写分离配置

1). 在pom.xml中增加shardingJdbc的maven坐标

<dependency>
    <groupId>org.apache.shardingsphere</groupId>
    <artifactId>sharding-jdbc-spring-boot-starter</artifactId>
    <version>4.0.0-RC1</version>
</dependency>

2). 在application.yml中增加数据源的配置

spring:
  shardingsphere:
    datasource:
      names:
        master,slave
      # 主数据源
      master:
        type: com.alibaba.druid.pool.DruidDataSource
        driver-class-name: com.mysql.cj.jdbc.Driver
        url: jdbc:mysql://192.168.200.200:3306/rw?characterEncoding=utf-8
        username: root
        password: root
      # 从数据源
      slave:
        type: com.alibaba.druid.pool.DruidDataSource
        driver-class-name: com.mysql.cj.jdbc.Driver
        url: jdbc:mysql://192.168.200.201:3306/rw?characterEncoding=utf-8
        username: root
        password: root
    masterslave:
      # 读写分离配置
      load-balance-algorithm-type: round_robin #轮询
      # 最终的数据源名称
      name: dataSource
      # 主库数据源名称
      master-data-source-name: master
      # 从库数据源名称列表,多个逗号分隔
      slave-data-source-names: slave
    props:
      sql:
        show: true #开启SQL显示,默认false

配置解析:

image-20210825162910711

3). 在application.yml中增加配置

spring:  
  main:
    allow-bean-definition-overriding: true

该配置项的目的,就是如果当前项目中存在同名的bean,后定义的bean会覆盖先定义的。

如果不配置该项,项目启动之后将会报错:

image-20210825163737687

报错信息表明,在声明 org.apache.shardingsphere.shardingjdbc.spring.boot 包下的SpringBootConfiguration中的dataSource这个bean时出错, 原因是有一个同名的 dataSource 的bean在com.alibaba.druid.spring.boot.autoconfigure包下的DruidDataSourceAutoConfigure类加载时已经声明了。

image-20210825164147056

image-20210825164227927

而我们需要用到的是 shardingjdbc包下的dataSource,所以我们需要配置上述属性,让后加载的覆盖先加载的

测试

我们使用shardingjdbc来实现读写分离,直接通过上述简单的配置就可以了。配置完毕之后,我们就可以重启服务,通过postman来访问controller的方法,来完成用户信息的增删改查,我们可以通过debug及日志的方式来查看每一次执行增删改查操作,使用的是哪个数据源,连接的是哪个数据库。

1). 保存数据

image-20210825170601641

控制台输出日志,可以看到操作master主库:

image-20210825172748209

2). 修改数据

image-20210825171507059

控制台输出日志,可以看到操作master主库:

image-20210825172534790

3). 查询数据

image-20210825171609997

控制台输出日志,可以看到操作slave主库:

image-20210825171623011

4). 删除数据

image-20210825172329600

控制台输出日志,可以看到操作master主库:

image-20210825172353414

项目实现读写分离

数据库环境准备

直接使用我们前面在虚拟机中搭建的主从复制的数据库环境即可。在主库中创建瑞吉外卖项目的业务数据库reggie, 并导入相关表结构和数据(我们可以将自己之前在本地开发时使用的数据库数据导出, 然后导入到服务器中的主库即可)。

1). 将自己本地的reggie数据库的数据导出SQL文件

image-20210825175039571

这样做的话,我们之前自己开发时,添加的测试数据都还在的,便于测试

2). 在主数据库master中,创建数据库reggie,并导入该SQL文件

master中创建数据库,会自动同步至slave从库

image-20210825175416295

在master的reggie中导入sql文件

image-20210825175747138

读写分离配置

1). 在项目的pom.xml增加依赖

<dependency>
    <groupId>org.apache.shardingsphere</groupId>
    <artifactId>sharding-jdbc-spring-boot-starter</artifactId>
    <version>4.0.0-RC1</version>
</dependency>

2). 在项目的application.yml中配置数据源相关信息

spring:
  shardingsphere:
    datasource:
      names:
        master,slave
      # 主数据源
      master:
        type: com.alibaba.druid.pool.DruidDataSource
        driver-class-name: com.mysql.cj.jdbc.Driver
        url: jdbc:mysql://192.168.200.200:3306/reggie?characterEncoding=utf-8
        username: root
        password: root
      # 从数据源
      slave:
        type: com.alibaba.druid.pool.DruidDataSource
        driver-class-name: com.mysql.cj.jdbc.Driver
        url: jdbc:mysql://192.168.200.201:3306/reggie?characterEncoding=utf-8
        username: root
        password: root
    masterslave:
      # 读写分离配置
      load-balance-algorithm-type: round_robin #轮询
      # 最终的数据源名称
      name: dataSource
      # 主库数据源名称
      master-data-source-name: master
      # 从库数据源名称列表,多个逗号分隔
      slave-data-source-names: slave
    props:
      sql:
        show: true #开启SQL显示,默认false
  main:
    allow-bean-definition-overriding: true

功能测试

配置完毕之后,我们启动项目进行测试,直接访问系统管理后台的页面,然后执行相关业务操作,看控制台输出的日志信息即可。

查询操作:

image-20210825181207319

更新操作:

image-20210825181524065

插入操作:

image-20210825181915511

删除操作:

image-20210825182259837

前后端分离开发

介绍

前后端分离开发,就是在项目开发过程中,对于前端代码的开发由专门的前端开发人员负责,后端代码则由后端开发人员负责,这样可以做到分工明确、各司其职,提高开发效率,前后端代码并行开发,可以加快项目开发进度。

目前,前后端分离开发方式已经被越来越多的公司所采用,成为当前项目开发的主流开发方式。

前后端分离开发后,从工程结构上也会发生变化,即前后端代码不再混合在同一个maven工程中,而是分为 前端工程后端工程

image-20210901082121874

前后端分离之后,不仅工程结构变化,后期项目上线部署时,与之前也不同:

1). 之前: 前后端代码都混合在一起,我们只需要将前端和后端的代码统一打成jar包,直接运行就可以了。

2). 现在: 拆分为前后端分离的项目后,最终部署时,后端工程会打成一个jar包,运行在Tomcat中(springboot内嵌的tomcat)。前端工程的静态资源,会直接部署在Nginx中进行访问。

开发流程

前后端分离开发后,面临一个问题,就是前端开发人员和后端开发人员如何进行配合来共同开发一个项目?可以按照如下流程进行:

image-20210901084945348 image-20210901085057990

  1. 定制接口: 这里所说的接口不是我们之前在service, mapper层定义的interface; 这里的接口(API接口)就是一个http的请求地址,主要就是去定义:请求路径、请求方式、请求参数、响应数据等内容。(具体接口文档描述的信息, 如上图)
  2. 前后端并行开发: 依据定义好的接口信息,前端人员开发前端的代码,服务端人员开发服务端的接口; 在开发中前后端都需要进行测试,后端需要通过对应的工具来进行接口的测试,前端需要根据接口定义的参数进行Mock数据模拟测试。
  3. 联调: 当前后端都开发完毕并且自测通过之后,就可以进行前后端的联调测试了,在这一阶段主要就是校验接口的参数格式。
  4. 提测: 前后端联调测试通过之后,就可以将项目部署到测试服务器,进行自动化测试了

前端技术栈

1). 开发工具

Visual Studio Code (简称VsCode)

Hbuilder

2). 技术框架

  • Node.js: Node.js 是一个基于 Chrome V8 引擎的 JavaScript 运行环境。(类似于java语言中的JDK)。
  • Vue : 目前最火的的一个前端javaScript框架。
  • ElementUI: 一套为开发者、设计师和产品经理准备的基于 Vue 2.0 的桌面端组件库,通过ElementUI组件可以快速构建项目页面。
  • Mock: 生成随机数据,拦截 Ajax 请求,前端可以借助于Mock生成测试数据进行功能测试。
  • Webpack: webpack 是一个现代 JavaScript 应用程序的模块打包器(module bundler),分析你的项目结构,找到JavaScript模块以及其它的一些浏览器不能直接运行的拓展语言(Sass,TypeScript等),并将其转换和打包为合适的格式供浏览器使用

Yapi

介绍

image-20210901110936381

YApi 是高效、易用、功能强大的 api 管理平台,旨在为开发、产品、测试人员提供更优雅的接口管理服务。可以帮助开发者轻松创建、发布、维护 API,YApi 还为用户提供了优秀的交互体验,开发人员只需利用平台提供的接口数据写入工具以及简单的点击操作就可以实现接口的管理。

使用

准备

注册账号,登录平台

image-20210901115408908

定义接口

登录到Yapi平台之后,我们可以创建项目,在项目下创建接口分类,在对应的分类中添加接口。

1). 创建项目

image-20210901123709298

image-20210901124623325

2). 添加分类

在当前项目中,有针对于员工、菜品、套餐、订单的操作,我们在进行接口维护时,可以针对接口进行分类,如果没有对应的分类,我们自己添加分类。

image-20210901125311166

3). 添加接口

image-20210901125517274

接口基本信息录入之后,添加提交,就可以看到该接口的基本信息:

image-20210901125617777

但是目前,接口中我们并未指定请求参数,响应数据等信息,我们可以进一步点击编辑,对该接口 详情进行编辑处理。

image-20210901140052897

4). 运行接口

Yapi也提供了接口测试功能,当我们接口编辑完毕后,后端服务的代码开发完毕,启动服务,就可以使用Yapi进行接口测试了。

image-20210901140924816

注意: 由于菜品分页查询接口,是需要登录后才可以访问的,所以在测试该接口时,需要先请求员工管理接口中的登录接口,登录完成后,再访问该接口。

在Yapi平台中,将接口文档定义好了之后,前后端开发人员就需要根据接口文档中关于接口的描述进行前端和后端功能的开发。

导出接口文档

在Yapi平台中我们不仅可以在线阅读文档,还可以将Yapi中维护的文档直接导出来,可以导出md,json,html格式,在导出时自行选择即可 。

image-20210901150153468

而在导出的html文件或md文件中,主要描述的就是接口的基本信息, 包括: 请求路径、请求方式、接口描述、请求参数、返回数据等信息。展示形式如下:

image-20210901150401976

导入接口文档

上述我们讲解了接口文档的导出,我们也可以将外部的接口文档导入到Yapi的平台中,这样我们就不用一个接口一个接口的添加了。我们可以将课程资料中提供的json格式的接口文档直接导入Yapi平台中来。

image-20210901151127926

导入过程中出现的确认弹窗,选择"确认"。

image-20210901151508478

导入成功之后,我们就可以在Yapi平台查看到已导入的接口。

image-20210901151721356

Swagger

介绍

官网:https://swagger.io/

image-20210901160434736

Swagger 是一个规范和完整的框架,用于生成、描述、调用和可视化 RESTful 风格的 Web 服务。功能主要包含以下几点:

  • 使得前后端分离开发更加方便,有利于团队协作
  • 接口文档在线自动生成,降低后端开发人员编写接口文档的负担
  • 接口功能测试

使用Swagger只需要按照它的规范去定义接口及接口相关的信息,再通过Swagger衍生出来的一系列项目和工具,就可以做到生成各种格式的接口文档,以及在线接口调试页面等等

直接使用Swagger, 需要按照Swagger的规范定义接口, 实际上就是编写Json文件,编写起来比较繁琐、并不方便, 。而在项目中使用,我们一般会选择一些现成的框架来简化文档的编写,而这些框架是基于Swagger的,如knife4j。knife4j是为Java MVC框架集成Swagger生成Api文档的增强解决方案。而我们要使用kinfe4j,需要在pom.xml中引入如下依赖即可:

<dependency>
    <groupId>com.github.xiaoymin</groupId>
    <artifactId>knife4j-spring-boot-starter</artifactId>
    <version>3.0.2</version>
</dependency>

使用方式

接下来,我们就将我们的项目集成Knife4j,来自动生成接口文档。这里我们还是需要再创建一个新的分支v1.2,在该分支中进行knife4j的集成,集成测试完毕之后,没有问题,我们再将v1.2分支合并到master。

使用knife4j,主要需要操作以下几步:

1). 导入knife4j的maven坐标

<dependency>
    <groupId>com.github.xiaoymin</groupId>
    <artifactId>knife4j-spring-boot-starter</artifactId>
    <version>3.0.2</version>
</dependency>

2). 导入knife4j相关配置类

这里我们就不需要再创建一个新的配置类了,我们直接在WebMvcConfig配置类中声明即可。

  1. 在该配置类中加上两个注解 @EnableSwagger2 @EnableKnife4j ,开启Swagger和Knife4j的功能。
  2. 在配置类中声明一个Docket类型的bean, 通过该bean来指定生成文档的信息。
@Slf4j
@Configuration
@EnableSwagger2
@EnableKnife4j
public class WebMvcConfig extends WebMvcConfigurationSupport {

    /**
     * 设置静态资源映射
     * @param registry
     */
    @Override
    protected void addResourceHandlers(ResourceHandlerRegistry registry) {
        log.info("开始进行静态资源映射...");
        registry.addResourceHandler("/backend/**").addResourceLocations("classpath:/backend/");
        registry.addResourceHandler("/front/**").addResourceLocations("classpath:/front/");
    }

    /**
     * 扩展mvc框架的消息转换器
     * @param converters
     */
    @Override
    protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
        log.info("扩展消息转换器...");
        //创建消息转换器对象
        MappingJackson2HttpMessageConverter messageConverter = new MappingJackson2HttpMessageConverter();
        //设置对象转换器,底层使用Jackson将Java对象转为json
        messageConverter.setObjectMapper(new JacksonObjectMapper());
        //将上面的消息转换器对象追加到mvc框架的转换器集合中
        converters.add(0,messageConverter);
    }

    @Bean
    public Docket createRestApi() {
        // 文档类型
        return new Docket(DocumentationType.SWAGGER_2)
                .apiInfo(apiInfo())
                .select()
                .apis(RequestHandlerSelectors.basePackage("com.itheima.reggie.controller"))
                .paths(PathSelectors.any())
                .build();
    }

    private ApiInfo apiInfo() {
        return new ApiInfoBuilder()
                .title("瑞吉外卖")
                .version("1.0")
                .description("瑞吉外卖接口文档")
                .build();
    }
}
注意: Docket声明时,指定的有一个包扫描的路径,该路径指定的是Controller所在包的路径。因为Swagger在生成接口文档时,就是根据这里指定的包路径,自动的扫描该包下的@Controller, @RestController, @RequestMapping等SpringMVC的注解,依据这些注解来生成对应的接口文档

3). 设置静态资源映射

由于Swagger生成的在线文档中,涉及到很多静态资源,这些静态资源需要添加静态资源映射,否则接口文档页面无法访问。因此需要在 WebMvcConfig类中的addResourceHandlers方法中增加如下配置。

registry.addResourceHandler("doc.html").addResourceLocations("classpath:/META-INF/resources/");
registry.addResourceHandler("/webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/");

4). 在LoginCheckFilter中设置不需要处理的请求路径

需要将Swagger及Knife4j相关的静态资源直接放行,无需登录即可访问,否则我们就需要登录之后,才可以访问接口文档的页面。

在原有的不需要处理的请求路径中,再增加如下链接:

"/doc.html",
"/webjars/**",
"/swagger-resources",
"/v2/api-docs"

image-20210901171132242

查看接口文档

经过上面的集成配置之后,我们的项目集成Swagger及Knife4j就已经完成了,接下来我们可以重新启动项目,访问接口文档,访问链接为: http://localhost:8080/doc.html

image-20210901200739975

我们可以看到,在所有的Controller中提供的所有的业务增删改查的接口,全部都已经自动生成了,我们通过接口文档可以看到请求的url、请求方式、请求参数、请求实例、响应的参数,响应的示例。 并且呢,我们也可以通过这份在线的接口文档,对接口进行测试。

image-20210901201229838

注意: 由于我们服务端的Controller中的业务增删改查的方法,都是必须登录之后才可以访问的,所以,我们在测试时候,也是需要先访问登录接口。登录完成之后,我们可以再访问其他接口进行测试。

我们不仅可以在浏览器浏览生成的接口文档,Knife4j还支持离线文档,对接口文档进行下载,支持下载的格式有:markdown、html、word、openApi。

image-20210901214706928

常用注解

问题说明

在上面我们直接访问Knife4j的接口文档页面,可以查看到所有的接口文档信息,但是我们发现,这些接口文档分类及接口描述都是Controller的类名(驼峰命名转换而来)及方法名,而且在接口文档中,所有的请求参数,响应数据,都没有中文的描述,并不知道里面参数的含义,接口文档的可读性很差。

image-20210901215244539

注解介绍

为了解决上述的问题,Swagger提供了很多的注解,通过这些注解,我们可以更好更清晰的描述我们的接口,包含接口的请求参数、响应数据、数据模型等。核心的注解,主要包含以下几个:

注解位置说明
@Api加载Controller类上,表示对类的说明
@ApiModel类(通常是实体类)描述实体类的作用
@ApiModelProperty属性描述实体类的属性
@ApiOperation方法说明方法的用途、作用
@ApiImplicitParams方法表示一组参数说明
@ApiImplicitParam方法用在@ApiImplicitParams注解中,指定一个请求参数的各个方面的属性

3.4.3 注解测试

1). 实体类

可以通过 @ApiModel , @ApiModelProperty 来描述实体类及属性
@Data
@ApiModel("套餐")
public class Setmeal implements Serializable {
    private static final long serialVersionUID = 1L;
    @ApiModelProperty("主键")
    private Long id;
  
    //分类id
    @ApiModelProperty("分类id")
    private Long categoryId;
  
    //套餐名称
    @ApiModelProperty("套餐名称")
    private String name;

    //套餐价格
    @ApiModelProperty("套餐价格")
    private BigDecimal price;

    //状态 0:停用 1:启用
    @ApiModelProperty("状态")
    private Integer status;

    //编码
    @ApiModelProperty("套餐编号")
    private String code;

    //描述信息
    @ApiModelProperty("描述信息")
    private String description;

    //图片
    @ApiModelProperty("图片")
    private String image;

    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;

    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;

    @TableField(fill = FieldFill.INSERT)
    private Long createUser;

    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Long updateUser;
}

2). 响应实体R

@Data
@ApiModel("返回结果")
public class R<T> implements Serializable{

    @ApiModelProperty("编码")
    private Integer code; //编码:1成功,0和其它数字为失败

    @ApiModelProperty("错误信息")
    private String msg; //错误信息

    @ApiModelProperty("数据")
    private T data; //数据

    @ApiModelProperty("动态数据")
    private Map map = new HashMap(); //动态数据

    //省略静态方法 ....
}  

3). Controller类及其中的方法

描述Controller、方法及其方法参数,可以通过注解: @Api, @APIOperation, @ApiImplicitParams, @ApiImplicitParam
@RestController
@RequestMapping("/setmeal")
@Slf4j
@Api(tags = "套餐相关接口")
public class SetmealController {

    @Autowired
    private SetmealService setmealService;
    @Autowired
    private CategoryService categoryService;
    @Autowired
    private SetmealDishService setmealDishService;

    /**
     * 新增套餐
     * @param setmealDto
     * @return
     */
    @PostMapping
    @CacheEvict(value = "setmealCache",allEntries = true)
    @ApiOperation(value = "新增套餐接口")
    public R<String> save(@RequestBody SetmealDto setmealDto){
        log.info("套餐信息:{}",setmealDto);

        setmealService.saveWithDish(setmealDto);

        return R.success("新增套餐成功");
    }

    /**
     * 套餐分页查询
     * @param page
     * @param pageSize
     * @param name
     * @return
     */
    @GetMapping("/page")
    @ApiOperation(value = "套餐分页查询接口")
    @ApiImplicitParams({
            @ApiImplicitParam(name = "page",value = "页码",required = true),
            @ApiImplicitParam(name = "pageSize",value = "每页记录数",required = true),
            @ApiImplicitParam(name = "name",value = "套餐名称",required = false)
    })
    public R<Page> page(int page,int pageSize,String name){
        //分页构造器对象
        Page<Setmeal> pageInfo = new Page<>(page,pageSize);
        Page<SetmealDto> dtoPage = new Page<>();

        LambdaQueryWrapper<Setmeal> queryWrapper = new LambdaQueryWrapper<>();
        //添加查询条件,根据name进行like模糊查询
        queryWrapper.like(name != null,Setmeal::getName,name);
        //添加排序条件,根据更新时间降序排列
        queryWrapper.orderByDesc(Setmeal::getUpdateTime);

        setmealService.page(pageInfo,queryWrapper);

        //对象拷贝
        BeanUtils.copyProperties(pageInfo,dtoPage,"records");
        List<Setmeal> records = pageInfo.getRecords();

        List<SetmealDto> list = records.stream().map((item) -> {
            SetmealDto setmealDto = new SetmealDto();
            //对象拷贝
            BeanUtils.copyProperties(item,setmealDto);
            //分类id
            Long categoryId = item.getCategoryId();
            //根据分类id查询分类对象
            Category category = categoryService.getById(categoryId);
            if(category != null){
                //分类名称
                String categoryName = category.getName();
                setmealDto.setCategoryName(categoryName);
            }
            return setmealDto;
        }).collect(Collectors.toList());

        dtoPage.setRecords(list);
        return R.success(dtoPage);
    }

    /**
     * 删除套餐
     * @param ids
     * @return
     */
    @DeleteMapping
    @CacheEvict(value = "setmealCache",allEntries = true)
    @ApiOperation(value = "套餐删除接口")
    public R<String> delete(@RequestParam List<Long> ids){
        log.info("ids:{}",ids);

        setmealService.removeWithDish(ids);

        return R.success("套餐数据删除成功");
    }

    /**
     * 根据条件查询套餐数据
     * @param setmeal
     * @return
     */
    @GetMapping("/list")
    @Cacheable(value = "setmealCache",key = "#setmeal.categoryId + '_' + #setmeal.status")
    @ApiOperation(value = "套餐条件查询接口")
    public R<List<Setmeal>> list(Setmeal setmeal){
        LambdaQueryWrapper<Setmeal> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(setmeal.getCategoryId() != null,Setmeal::getCategoryId,setmeal.getCategoryId());
        queryWrapper.eq(setmeal.getStatus() != null,Setmeal::getStatus,setmeal.getStatus());
        queryWrapper.orderByDesc(Setmeal::getUpdateTime);

        List<Setmeal> list = setmealService.list(queryWrapper);

        return R.success(list);
    }
}

4). 重启服务测试

我们上述通过Swagger的注解,对实体类及实体类中的属性,以及Controller和Controller的方法进行描述,接下来,我们重新启动服务,然后看一下自动生成的接口文档有何变化。

image-20210901221213897

在接口文档的页面中,我们可以看到接口的中文描述,清晰的看到每一个接口是做什么的,接口方法参数什么含义,参数是否是必填的,响应结果的参数是什么含义等,都可以清楚的描述出来。

总之,我们要想清晰的描述一个接口,就需要借助于Swagger给我们提供的注解。

项目部署

部署架构

image-20210901221425159

PC端: 主要是为餐厅的员工及管理员使用的后台管理系统,对分类、菜品、套餐信息进行维护。

移动端: 可以基于微信公众号或小程序实现,我们课上并未实现,这部分的工作是前端开发人员需要开发的。

前端部署服务器: Nginx

后端部署服务器: Tomcat(内嵌)

环境说明

由于我们的服务器数量有限,就使用这三台服务器,具体的软件规划如下:

服务器软件名称
192.168.138.100Nginx(部署前端项目、配置反向代理),MySQL(主从复制的主库)服务器A
192.168.138.101JDK1.8、Git、Maven、jar(项目jar包基于内嵌Tomcat运行)、MySQL(主从复制的从库)服务器B
172.17.2.94Redis(缓存中间件)服务器C

前端部署

1). 在服务器A(192.168.138.100)中安装Nginx,将课程资料中的dist目录上传到Nginx的html目录下

image-20210901231943256

将整个dist目录上传至/usr/local/nginx/html目录下

image-20210901231924028

2). 修改Nginx配置文件nginx.conf

将nginx.conf配置文件中,将原有的监听80, 82, 8080端口号 的虚拟主机注释掉,引入如下的配置信息:

    server {
        listen       80;
        server_name  localhost;

        location / {
            root   html/dist;
            index  index.html;
        }

        location ^~ /api/ {
            rewrite ^/api/(.*)$ /$1 break;
            proxy_pass http://192.168.138.101:8080;
        }

        location = /50x.html {
            root   html;
        }
    }

image-20210901232931577

3). 通过nginx访问前端工程

http://192.168.138.100

image-20210901233135468

反向代理配置

前端工程部署完成之后,我们可以正常的访问到系统的登录页面,点击登录按钮,可以看到服务端发起的请求,请求信息如下:

image-20210901234142706

而大家知道,在我们之前开发的工程中,是没有/api这个前缀的,那这个时候,在不修改服务端代码的情况下,如何处理该请求呢?

实际上,通过nginx的就可以轻松解决这个问题。

在上述我们配置的nginx.conf中,除了配置了静态资源的加载目录以外,我们还配置了一段反向代理的配置,配置信息如下:

location ^~ /api/ {
    rewrite ^/api/(.*)$ /$1 break;
    proxy_pass http://192.168.138.101:8080;
}

这一段配置代表,如果请求当前nginx,并且请求的路径如果是 /api/ 开头,将会被该location处理。而在该location中,主要配置了两块儿信息: rewrite(url重写) 和 proxy_pass(反向代理)。 接下来我们就来解析一下这两项的配置。

1). 路径重写rewrite

rewrite ^/api/(.*)$ /$1 break;

这里写的是一个正则表达式,代表如果请求路径是以 /api/ 开头,后面的请求路径任意,此时将原始的url路径重写为 /$1,这里的$1指代的就是通配符 .* 这一块的内容。比如:

/api/employee/login ------> ^/api/(.*)$ --------> 此时 (.*) 匹配的就是 employee/login ------> 最终重写为/$1 : /employee/login

2). 反向代理

proxy_pass http://192.168.138.101:8080;

路径重写后的请求,将会转发到后端的 http://192.168.138.101:8080 服务器中。 而这台服务器中,就部署的是我们的后端服务

服务端部署

1). 在服务器B(192.168.138.101)中安装jdk、git、maven、MySQL,使用git clone命令将git远程仓库的代码克隆下来

A. 确认JDK环境

image-20210902002307537

B. 确认Git环境

image-20210902002328883

C. 确认Maven环境

image-20210902002357900

D. 将我们开发完成的代码推送至远程仓库,并在服务器B中克隆下来

#创建java代码存放目录
mkdir -p /usr/local/javaapp

#切换目录
cd /usr/local/javaapp

#克隆代码 , 需要使用自己的远程仓库
git clone https://gitee.com/ChuanZhiBoKe/reggie_take_out.git 

image-20210902004033417

2). 将资料中提供的reggieStart.sh文件上传到服务器B,通过chmod命令设置执行权限

image-20210902004308106

3). 执行reggieStart.sh脚本文件,自动部署项目

image-20210902005320980

执行完shell脚本之后,我们可以通过 ps -ef | grep java 指令,查看服务是否启动。

image-20210902005450399

4). 访问系统测试

http://192.168.138.101/

image-20210902005640875

END
本文作者: 文章标题:【项目实战】瑞吉外卖
本文地址:https://www.jiusi.cc/archives/235/
版权说明:若无注明,本文皆九思のJava之路原创,转载请保留文章出处。
最后修改:2023 年 01 月 06 日
如果觉得我的文章对你有用,请随意赞赏