瑞吉外賣實戰項目全攻略——總結篇

瑞吉外賣實戰項目全攻略——總結篇

該系列將記錄一份完整的實戰項目的完成過程,該篇屬於總結篇,主要負責總結整個項目技術點和注意點

案例來自B站黑馬程式設計師Java項目實戰《瑞吉外賣》,請結合課程資料閱讀以下內容

我們將從下面幾個方面進行總結:

  • 項目整體介紹
  • 項目準備階段
  • 後台程式碼開發
  • 前台程式碼開發
  • 項目部署階段

項目整體介紹

本小節我們將介紹項目的整體構架

前言

該項目是我完成的第一份整體項目,主要採用SpringBoot框架來進行完成,該項目中我們只負責後端程式碼開發以及單元測試

項目本身介紹

項目整體介紹:

  • 本項目(瑞吉外賣)是專門為餐飲企業(餐廳、飯店)訂製的一款軟體產 品,包括系統管理後台和移動端應用兩部分。
  • 其中系統管理後台主要提供給餐飲企業內部員工使用,可以對餐廳的菜品、套餐、訂單等進行管理維護。
  • 移動端應用主要提供給消費者使用,可以在線瀏覽菜品添加購物車、下單等。

項目技術展示

我們的項目主要採用以下技術完成:

項目功能展示

我們的項目主要需要完成以下功能:

項目準備階段

本小節我們將介紹項目的準備工作

資料庫

我們的資料庫主要採用MYSQL來保存數據,下面介紹數據整體框架:

此外,我們採用Redis資料庫來進行快取優化階段,用於存儲頁面資訊以及菜品資訊:

開發工具

我們的開發工具主要包括有:

  • IDEA:程式碼書寫軟體

  • Tomcat:常用伺服器

  • Junit:業務層功能測試工具

  • Postman:服務層功能測試工具

  • Maven:依賴導入下載jar包必備

  • Git:程式碼備份工具,保存各版本程式碼以便於回顧檢查

文檔工具

我們的文檔工具主要採用:

  • Swagger:採用knife4j的Swagger拓展工具,配置Docket和ApiInfo配置方法,查詢doc.html即可訪問介面文檔

主要使用步驟:

  1. 導入依賴坐標
        <!--knife4j(Swagger)坐標-->
        <dependency>
            <groupId>com.github.xiaoymin</groupId>
            <artifactId>knife4j-spring-boot-starter</artifactId>
            <version>3.0.2</version>
        </dependency>
  1. 書寫配置方法
package com.qiuluo.reggie.config;

import com.github.xiaoymin.knife4j.spring.annotations.EnableKnife4j;
import com.qiuluo.reggie.common.JacksonObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;

import java.util.List;

// 新導入兩個註解@EnableSwagger2,@EnableKnife4j
@Slf4j
@Configuration
@EnableSwagger2
@EnableKnife4j
public class WebMvcConfig extends WebMvcConfigurationSupport {

    @Override
    protected void addResourceHandlers(ResourceHandlerRegistry registry) {

        // 系統自動幫忙生成這doc.html頁面用於展示我們的介面資訊,我們需要將他們放行
        registry.addResourceHandler("doc.html").addResourceLocations("classpath:/META-INF/resources/");
        registry.addResourceHandler("/webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/");
        
    }

    @Bean
    public Docket createRestApi() {
        // 文檔類型
        // (返回一個文檔類型Docket,下面是返迴文檔的類型,基本為固定形式,除了basePackage,書寫你的Controller包的位置)
        return new Docket(DocumentationType.SWAGGER_2)
                .apiInfo(apiInfo())
                .select()
                .apis(RequestHandlerSelectors.basePackage("com.qiuluo.reggie.controller"))
                .paths(PathSelectors.any())
                .build();
    }

    private ApiInfo apiInfo() {
        // 描述文檔內容
        return new ApiInfoBuilder()
                .title("瑞吉外賣")
                .version("1.0")
                .description("瑞吉外賣介面文檔")
                .build();
    }
}
  1. 書寫文檔注釋資訊
// 這裡僅給出文檔注釋(不給出示例)

// 用於請求的類上,表示對類的說明(Controller)
@Api(tags = "")				

// 用於類上,通常是實體類,表示一個返回數據的資訊(domain,Result)
@ApiModel(value = "")			

// 用於屬性上,描述相應類的屬性(name)
@ApiModelProperty(value = "")	

// 用於請求的方法上,說明方法的用途,作用
@ApiOperation(value = "")		

// 用於請求的方法上,表示一組參數說明
@ApiImplicitParams({@ApiImplicitParam,@ApiImplicitParam})	

// 用於請求的方法上,表示單個參數說明
@ApiImplicitParam(name = "",value = "",required = true/false)	
  1. 打開doc.html頁面即可

後台程式碼開發

本小節我們將介紹項目的後台開發階段

設置映射地址

在項目開始前,我們都需要設置映射地址,讓前端頁面展示直接進行頁面展示而不是映射在服務層方法上:

  • 創建一個配置類WebMvcConfig
  • 設置為配置類@Configuration
  • 繼承WebMvcConfigurationSupport
  • 實現方法addResourceHandlers並設置映射地址

我們給出項目中的實例展示:

package com.qiuluo.reggie.config;

import com.github.xiaoymin.knife4j.spring.annotations.EnableKnife4j;
import com.qiuluo.reggie.common.JacksonObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;

import java.util.List;

@Slf4j
@Configuration
@EnableSwagger2
@EnableKnife4j
public class WebMvcConfig extends WebMvcConfigurationSupport {

    @Override
    protected void addResourceHandlers(ResourceHandlerRegistry registry) {
        log.info("開始靜態映射");

        registry.addResourceHandler("/backend/**").addResourceLocations("classpath:/backend/");
        registry.addResourceHandler("/front/**").addResourceLocations("classpath:/front/");

        //掃描static下的所有html頁面
        registry.addResourceHandler("classpath:/static/*.html");
        //掃描static下的所有資源
        registry.addResourceHandler("/static/**");

        // 系統自動幫忙生成這doc.html頁面用於展示我們的介面資訊,我們需要將他們放行
        registry.addResourceHandler("doc.html").addResourceLocations("classpath:/META-INF/resources/");
        registry.addResourceHandler("/webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/");
    }
}

登錄退出功能

我們一個項目的首先實現就是來完成登錄與退出,本身的邏輯很簡單,但我們需要在登錄退出時對頁面進行部分操作:

  • 在Session中記錄或刪除用戶的網頁資訊,因為我們後期需要根據Session內部的資訊來判斷用戶是否登錄

我們給出項目中的實例展示:

package com.qiuluo.reggie.controller;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.qiuluo.reggie.common.Result;
import com.qiuluo.reggie.domain.Employee;
import com.qiuluo.reggie.service.EmployeeService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.DigestUtils;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletRequest;
import java.time.LocalDateTime;

@Slf4j
@RestController
@RequestMapping("/employee")
public class EmployeeController {

    @Autowired
    private EmployeeService employeeService;

    /**  登錄功能處理邏輯如下:
     1、將頁面提交的密碼password進行 MD5 加密處理
     2、根據頁面提交的用戶名username查詢資料庫
     3、如果沒有查詢到數據,則返回登錄失敗的結果
     4、進行密碼比對,如果不一致,則返回登錄失敗的結果
     5、查看員工狀態,如果為 已禁用狀態,則返回被禁用的結果資訊
     6、登錄成功,將員工id 存入Session並返回登錄成功的結果
     * */

    @PostMapping("/login")
    public Result<Employee> login(HttpServletRequest request, @RequestBody Employee employee){

        String password = employee.getPassword();
        password = DigestUtils.md5DigestAsHex(password.getBytes());

        LambdaQueryWrapper<Employee> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(Employee::getUsername,employee.getUsername());

        Employee emp = employeeService.getOne(queryWrapper);

        if (emp == null){
            return Result.error("用戶名不存在!");
        }

        if (!emp.getPassword().equals(password)){
            return Result.error("用戶名或密碼錯誤!");
        }

        if (emp.getStatus() == 0){  // 帳號被禁用,status == 1,帳號可以正常登錄
            return Result.error("帳號被禁用,請聯繫管理員或客服!");
        }

        // 將用戶ID存入Session
        request.getSession().setAttribute("employee",emp.getId());

        return Result.success(emp);

    }

    //  退出功能實現
    //  1、LocalStorage 清理Session中的用戶id
    //  2、返回結果

    @PostMapping("/logout")
    public Result<String> logout(HttpServletRequest request){
        
        // 將用戶ID移出Session
        request.getSession().removeAttribute("employee");
        
        return Result.success("安全退出成功!");
    }
    
}

實現登錄過濾

我們的前台和後台頁面不能隨意直接訪問,所以我們需要判定用戶是否登錄,若登錄後才可以進入頁面進行操縱

我們採用過濾器來完成這部分功能實現,過濾器實現步驟:

  • 創建一個過濾器類
  • 繼承Filter
  • 設置為過濾器類@WebFilter(fileName = "loginCheckFilter",urlPatterns = "/*")
  • 實現doFilter方法

在其中我們還採用了一個路徑匹配器:

//路徑匹配器,支援通配符 PATH_MATCHER.match(A,B);
public static final AntPathMatcher PATH_MATCHER = new AntPathMatcher();

我們判斷是否登錄成功的依據來自設置登錄時的Session內容:

// 前台
request.getSession().getAttribute("user") != null

// 後台
request.getSession().getAttribute("employee") != null

我們給出項目中的實例展示:

package com.qiuluo.reggie.filter;

import com.alibaba.fastjson.JSON;
import com.qiuluo.reggie.common.BaseContext;
import com.qiuluo.reggie.common.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.AntPathMatcher;

import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 檢查用戶是否已經完成登錄
 */
@WebFilter(filterName = "loginCheckFilter",urlPatterns = "/*")
@Slf4j
public class LoginCheckFilter implements Filter{
    //路徑匹配器,支援通配符
    public static final AntPathMatcher PATH_MATCHER = new AntPathMatcher();

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;

        //1、獲取本次請求的URI
        String requestURI = request.getRequestURI();// /backend/index.html

        log.info("攔截到請求:{}",requestURI);

        //定義不需要處理的請求路徑
        String[] urls = new String[]{
                "/employee/login",
                "/employee/logout",
                "/backend/**",
                "/front/**",
                "/common/**",
                "/user/login",
                "/user/sendMsg",
                "/doc.html",
                "/webjars/**",
                "/swagger-resources",
                "/v2/api-docs"
        };


        //2、判斷本次請求是否需要處理
        boolean check = check(urls, requestURI);

        //3、如果不需要處理,則直接放行
        if(check){
            log.info("本次請求{}不需要處理",requestURI);
            filterChain.doFilter(request,response);
            return;
        }

        //4-1、判斷後台登錄狀態,如果已登錄,則直接放行
        if(request.getSession().getAttribute("employee") != null){
            log.info("用戶已登錄,用戶id為:{}",request.getSession().getAttribute("employee"));

            log.info("登錄中...");
            log.info("執行緒id" + Thread.currentThread().getId());

            // 我們在這裡獲得empID用於公共部分自動填充
            Long empId = (Long) request.getSession().getAttribute("employee");
            BaseContext.setCurrentId(empId);

            filterChain.doFilter(request,response);
            return;
        }

        //4-2、判斷移動端登錄狀態,如果已登錄,則直接放行
        if(request.getSession().getAttribute("user") != null){
            log.info("用戶已登錄,用戶id為:{}",request.getSession().getAttribute("user"));

            log.info("登錄中...");
            log.info("執行緒id" + Thread.currentThread().getId());

            // 我們在這裡獲得empID用於公共部分自動填充
            Long userId = (Long) request.getSession().getAttribute("user");
            BaseContext.setCurrentId(userId);

            filterChain.doFilter(request,response);
            return;
        }

        log.info("用戶未登錄");
        //5、如果未登錄則返回未登錄結果,通過輸出流方式向客戶端頁面響應數據
        response.getWriter().write(JSON.toJSONString(Result.error("NOTLOGIN")));
        return;

    }

    /**
     * 路徑匹配,檢查本次請求是否需要放行
     * @param urls
     * @param requestURI
     * @return
     */
    public boolean check(String[] urls,String requestURI){
        for (String url : urls) {
            boolean match = PATH_MATCHER.match(url, requestURI);
            if(match){
                return true;
            }
        }
        return false;
    }
}

工具類的使用

工具類是為了提供一些通用的、某一非業務領域內的公共方法,不需要配套的成員變數,僅僅是作為工具方法被使用。

項目中的工具類是藉助LocalThread的當前執行緒儲存功能來設置工具類,我們只需要定義LocalThread並給出其方法的新方法定義即可

我們給出項目中的實例展示:

package com.qiuluo.reggie.common;

/**
 * 基於ThreadLocal的工具類,用於保存用戶id
 * 工具類方法大多數是靜態方法,因為重新定義一個類需要一定記憶體,直接設置為靜態方法可以節省記憶體
 */
public class BaseContext {
    private static ThreadLocal<Long> threadLocal = new ThreadLocal<>();

    public static void setCurrentId(Long id){
        threadLocal.set(id);
    }

    public static Long getCurrentId(){
        return threadLocal.get();
    }
}

設置公共欄位

我們在資料庫中會注意到我們的各種菜品,套餐等都具有統一的參數,我們將他們稱為公共欄位

同時這些欄位基本需要初始化設置或者在修改更新時進行數據的更新設置,所以我們希望統一進行設置來簡化操作

我們採用MyBatisPlus提供的公共欄位自動填充的功能:

我們先來簡單介紹一下流程:

  1. 首先在我們需要修改的欄位屬性上添加註解:
// 屬性包括有INSERT,UPDATE,INSERT_UPDATE

@TableField(fill = FieldFill.屬性)
  1. 按照框架書寫元數據對象處理器,需要實現MetaObjectHandler介面
package com.qiuluo.reggie.common;

import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;

// 記得設置為配置類
@Slf4j
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {

    /**
     * 添加時自動設置
     * @param metaObject
     */
    @Override
    public void insertFill(MetaObject metaObject) {

    }

    /**
     * 修改時自動設置
     * @param metaObject
     */
    @Override
    public void updateFill(MetaObject metaObject) {

    }
}
  1. 在元數據對象處理器中對方法進行書寫,在此類中統一為公共欄位設置值,藉助了LocalThread來獲得當前用戶ID
package com.qiuluo.reggie.common;

import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;

@Slf4j
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {

    /**
     * 添加時自動設置
     * @param metaObject
     */
    @Override
    public void insertFill(MetaObject metaObject) {
        log.info("公共欄位自動填充[insert]...");
        log.info(metaObject.toString());
        
        metaObject.setValue("createTime", LocalDateTime.now());
        metaObject.setValue("updateTime",LocalDateTime.now());
        metaObject.setValue("createUser",BaseContext.getCurrentId());
        metaObject.setValue("updateUser",BaseContext.getCurrentId());
    }

    /**
     * 修改時自動設置
     * @param metaObject
     */
    @Override
    public void updateFill(MetaObject metaObject) {
        log.info("公共欄位自動填充[update]...");
        log.info(metaObject.toString());

        metaObject.setValue("updateTime",LocalDateTime.now());
        metaObject.setValue("updateUser",BaseContext.getCurrentId());
    }
}

實現類型轉換

我們在項目遇到的一個簡單的小問題:

  • 我們的empId設計為Long型,其中資料庫為19位,但網頁的JS為16位,這就會導致empId傳遞時會出現損失

我們通過採用消息轉換器來實現傳送類型發生改變:

  • 使網頁的Long型傳遞過來時變為String類型,在傳遞到後端之後,再變為Long型賦值給後端程式碼

我們的要實現消息轉化器主要需要兩步:

  1. 設置一個消息轉換器
package com.qiuluo.reggie.common;

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer;

import java.math.BigInteger;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;

import static com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES;

/**
 * 對象映射器:基於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)))

                .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);
    }
}
  1. 將該消息轉換器設置到配置類中
package com.qiuluo.reggie.config;

import com.github.xiaoymin.knife4j.spring.annotations.EnableKnife4j;
import com.qiuluo.reggie.common.JacksonObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;

import java.util.List;

@Slf4j
@Configuration
public class WebMvcConfig extends WebMvcConfigurationSupport {

    /**
     * 擴展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);
    }

}

實現異常處理

我們項目中的異常處理通常分為兩部分:

  • 系統意外異常
  • 自定義業務異常

我們在後台不可避免地發生錯誤,這些錯誤通常被稱為系統意外異常

處理系統意外異常我們只需要設置異常處理器即可:

package com.qiuluo.reggie.common;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;

import java.sql.SQLIntegrityConstraintViolationException;

/**
 * 全局異常處理
 * @ControllerAdvice 來書寫需要修改異常的註解類(該類中包含以下注解)
 * @RESTControllerAdvice 表示annotations為RestController.class的@ControllerAdvice
 * @ResponseBody 因為返回數據為JSON數據,需要進行格式轉換
 */
@ControllerAdvice(annotations = {RestController.class, Controller.class})
@ResponseBody
@Slf4j
public class GlobalExceptionHandler {

    /**
     * 處理異常
     * @ExceptionHandler 來書寫需要修改的異常
     * @return
     */
    @ExceptionHandler(SQLIntegrityConstraintViolationException.class)
    public Result<String> exceptionHandler(SQLIntegrityConstraintViolationException ex){

        // 我們可以通過log.error輸出錯誤提醒(我們可以得到以下提示資訊:Duplicate entry '123' for key 'employee.idx_username')
        log.error(ex.getMessage());
        // 我們希望將id:123提取出來做一個簡單的回饋資訊
        if (ex.getMessage().contains("Duplicate entry")){
            String[] split = ex.getMessage().split(" ");
            String msg = split[2] + "已存在";
            return Result.error(msg);
        }
        return Result.error("未知錯誤");
    }
}

程式設計師在後台自我設置的異常被稱為自定義業務異常,通常用於業務層的功能實現無法實現時拋出異常給用戶查看

設置自定義異常主要分為兩步:

  1. 設置自定義異常類
package com.qiuluo.reggie.common;

/**
 * 自定義業務異常類
 */
public class CustomException extends RuntimeException{

    // 實現一個構造方法即可
    public CustomException(String message){
        super(message);
    }
}
  1. 將該自定義異常加入異常處理器即可
package com.qiuluo.reggie.common;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;

import java.sql.SQLIntegrityConstraintViolationException;

/**
 * 全局異常處理
 * @ControllerAdvice 來書寫需要修改異常的註解類(該類中包含以下注解)
 * @ResponseBody 因為返回數據為JSON數據,需要進行格式轉換
 */
@ControllerAdvice(annotations = {RestController.class, Controller.class})
@ResponseBody
@Slf4j
public class GlobalExceptionHandler {

    /**
     * 處理異常
     * @ExceptionHandler 來書寫需要修改的異常
     * @return
     */
    @ExceptionHandler(SQLIntegrityConstraintViolationException.class)
    public Result<String> exceptionHandler(SQLIntegrityConstraintViolationException ex){

        // 我們可以通過log.error輸出錯誤提醒(我們可以得到以下提示資訊:Duplicate entry '123' for key 'employee.idx_username')
        log.error(ex.getMessage());
        // 我們希望將id:123提取出來做一個簡單的回饋資訊
        if (ex.getMessage().contains("Duplicate entry")){
            String[] split = ex.getMessage().split(" ");
            String msg = split[2] + "已存在";
            return Result.error(msg);
        }
        return Result.error("未知錯誤");
    }

    /**
     * 處理自定義異常
     * @ExceptionHandler 來書寫需要修改的異常
     * @return
     */
    @ExceptionHandler(CustomException.class)
    public Result<String> CustomExceptionHandler(CustomException ex){

        log.error(ex.getMessage());

        return Result.error(ex.getMessage());
    }
}

文件上傳下載

我們的文件上傳下載操作之前主要依靠Apache的兩個組件:commons-fileupload 和 commons-io

現在我們的文件上傳下載經過簡化可以採用簡單的方法來實現

首先我們給出文件上傳程式碼:

package com.qiuluo.reggie.controller;

import com.qiuluo.reggie.common.Result;
import lombok.extern.slf4j.Slf4j;
import org.apache.catalina.connector.CoyoteOutputStream;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletResponse;
import java.io.*;
import java.util.UUID;

/*
我們通過MultipartFile獲得文件,但這個文件是暫時性的,我們需要把他保存在伺服器中
*/

@Slf4j
@RestController
@RequestMapping("/common")
public class CommonController {

    // 定義主路徑(在yaml中配置一個自定義路徑即可)
    @Value("${reggie.path}")
    private String BasePath;

    /**
     * 上傳操作
     * @param file 注意需要與前端傳來的數據名一致
     * @return
     */
    @PostMapping("/upload")
    public Result<String> upload(MultipartFile file){
        // 注意:file只是一個臨時文件,當我們的request請求結束時,file也會消失,所以我們需要將它保存起來

        // 這個方法可以獲得文件的原名稱,但不推薦設置為文件名保存(因為可能出現重複名稱導致文件覆蓋)
        String originalFilename = file.getOriginalFilename();

        // 將原始文件的後綴截取下來
        String substring = originalFilename.substring(originalFilename.lastIndexOf("."));

        // UUID生成隨機名稱,文件名設置為 UUID隨機值+源文件後綴
        String fileName = UUID.randomUUID().toString() + substring;

        // 判斷文件夾是否存在,若不存在需創建一個
        File dir = new File(BasePath);

        if (!dir.exists()){
            dir.mkdirs();
        }

        // 這個方法可以轉載文件到指定目錄
        try {
            file.transferTo(new File(BasePath + fileName));
        } catch (IOException e) {
            e.printStackTrace();
        }
        return Result.success(fileName);
    }
}

我們再給出文件下載程式碼:

package com.qiuluo.reggie.controller;

import com.qiuluo.reggie.common.Result;
import lombok.extern.slf4j.Slf4j;
import org.apache.catalina.connector.CoyoteOutputStream;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletResponse;
import java.io.*;
import java.util.UUID;

@Slf4j
@RestController
@RequestMapping("/common")
public class CommonController {

    @Value("${reggie.path}")
    private String BasePath;

    /**
     * 文件下載
     * @param name
     * @param response
     * @return
     */
    @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();
            }

            // 關閉數據
            fileInputStream.close();
            outputStream.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

簡單功能開發

我們的項目中大多都是簡單功能,可以直接根據MyBatisPlus提供的基本方法完成,我們在這裡介紹簡單模板:

  1. 在項目中查看該方法的請求資訊

  1. 在項目中查看該方法的請求數據

  1. 實現實體類
package com.qiuluo.reggie.domain;

import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
import java.time.LocalDateTime;

/**
 * 分類
 */
@Data
public class Category implements Serializable {

    private static final long serialVersionUID = 1L;

    private Long id;

    //類型 1 菜品分類 2 套餐分類
    private Integer type;


    //分類名稱
    private String name;


    //順序
    private Integer sort;


    //創建時間
    @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;

}
  1. 實現業務層介面
package com.qiuluo.reggie.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.qiuluo.reggie.common.Result;
import com.qiuluo.reggie.domain.Category;

public interface CategoryService extends IService<Category> {
}
  1. 實現業務層
package com.qiuluo.reggie.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.qiuluo.reggie.common.CustomException;
import com.qiuluo.reggie.common.Result;
import com.qiuluo.reggie.domain.Category;
import com.qiuluo.reggie.domain.Dish;
import com.qiuluo.reggie.domain.Setmeal;
import com.qiuluo.reggie.mapper.CategoryMapper;
import com.qiuluo.reggie.service.CategoryService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class CategoryServiceImpl extends ServiceImpl<CategoryMapper, Category> implements CategoryService {
}
  1. 實現服務層
package com.qiuluo.reggie.controller;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.qiuluo.reggie.common.Result;
import com.qiuluo.reggie.domain.Category;
import com.qiuluo.reggie.service.impl.CategoryServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletRequest;
import java.util.List;

@Slf4j
@RestController
@RequestMapping("/category")
public class CategoryController {

    @Autowired
    private CategoryServiceImpl categoryService;

    @PostMapping
    public Result<String> save(@RequestBody Category category){
        categoryService.save(category);
        return Result.success("新增成功");
    }

}

複雜功能開發

有時候我們的MyBatisPlus提供的簡單方法不足以滿足我們的需求,這時我們就需要採用MyBatis的原始方法來定義方法完成功能開發

例如我們的需求中需要進行部分判斷或操作兩個數據表,我們需要創建新方法來完成新功能的開發:

  1. 在項目中查看該方法的請求資訊

  1. 在項目中查看該方法的請求數據

  1. 在業務層介面定義方法
package com.qiuluo.reggie.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.qiuluo.reggie.common.Result;
import com.qiuluo.reggie.domain.Category;

public interface CategoryService extends IService<Category> {

    // 根據id刪除
    public Result<String> remove(Long id);
}
  1. 在業務層實現方法
package com.qiuluo.reggie.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.qiuluo.reggie.common.CustomException;
import com.qiuluo.reggie.common.Result;
import com.qiuluo.reggie.domain.Category;
import com.qiuluo.reggie.domain.Dish;
import com.qiuluo.reggie.domain.Setmeal;
import com.qiuluo.reggie.mapper.CategoryMapper;
import com.qiuluo.reggie.service.CategoryService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class CategoryServiceImpl extends ServiceImpl<CategoryMapper, Category> implements CategoryService {

    @Autowired
    private DishServiceImpl dishService;

    @Autowired
    private SetmealServiceImpl setmealService;

    // 實現方法
    public Result<String> remove(Long id){

        // 判斷是否有菜品相連
        LambdaQueryWrapper<Dish> dishLambdaQueryWrapper = new LambdaQueryWrapper<>();
        dishLambdaQueryWrapper.eq(Dish::getCategoryId,id);

        int count1 = dishService.count(dishLambdaQueryWrapper);

        if (count1 > 0){
            // 拋出業務異常
            throw new CustomException("已有菜品關聯,無法刪除!");
        }

        // 判斷是否有套餐相連
        LambdaQueryWrapper<Setmeal> setmealLambdaQueryWrapper = new LambdaQueryWrapper<>();
        setmealLambdaQueryWrapper.eq(Setmeal::getCategoryId,id);

        int count2 = setmealService.count(setmealLambdaQueryWrapper);

        if (count2 > 0){
            // 拋出業務異常
            throw new CustomException("已有套餐關聯,無法刪除!");
        }

        super.removeById(id);

        return Result.success("成功刪除");
    }

}
  1. 在服務層使用方法
package com.qiuluo.reggie.controller;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.qiuluo.reggie.common.Result;
import com.qiuluo.reggie.domain.Category;
import com.qiuluo.reggie.service.impl.CategoryServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletRequest;
import java.util.List;

@Slf4j
@RestController
@RequestMapping("/category")
public class CategoryController {

    @Autowired
    private CategoryServiceImpl categoryService;

    @DeleteMapping
    public Result<String> delete(Long ids){

        categoryService.remove(ids);

        return Result.success("刪除成功");
    }

}

DTO具體使用

我們在實際開發中,其操作可能會同時兼顧兩張數據表,這時我們就需要採用DTO並且採用複雜功能開發來重新定義方法

首先我們先來講解DTO的具體使用:

  1. 首先我們需要一張數據表的實體類
package com.qiuluo.reggie.domain;

import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;

/**
 菜品
 */
@Data
public class Dish implements Serializable {

    private static final long serialVersionUID = 1L;

    private Long id;


    //菜品名稱
    private String name;


    //菜品分類id
    private Long categoryId;


    //菜品價格
    private BigDecimal price;


    //商品碼
    private String code;


    //圖片
    private String image;


    //描述資訊
    private String description;


    //0 停售 1 起售
    private Integer status;


    //順序
    private Integer sort;


    @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;

}
  1. 我們根據實際需求,在實體類的基礎上,添加一些其他屬性
package com.qiuluo.reggie.dto;

import com.qiuluo.reggie.domain.Dish;
import com.qiuluo.reggie.domain.DishFlavor;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;

// 在Dish的基礎上添加了DishFlavor數據表,以及categoryName所屬分類名

@Data
public class DishDto extends Dish {

    private List<DishFlavor> flavors = new ArrayList<>();

    private String categoryName;

    private Integer copies;
}
  1. 然後我們在業務層使用時,就可以引入DTO類作為參數,對內部數據進行操作
package com.qiuluo.reggie.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.qiuluo.reggie.common.Result;
import com.qiuluo.reggie.domain.Dish;
import com.qiuluo.reggie.domain.DishFlavor;
import com.qiuluo.reggie.dto.DishDto;
import com.qiuluo.reggie.mapper.DishMapper;
import com.qiuluo.reggie.service.DishService;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.stream.Collectors;

@Service
public class DishServiceImpl extends ServiceImpl<DishMapper, Dish> implements DishService {

    // 調入dishFlavor的業務層實現類
    @Autowired
    private DishFlavorServiceImpl dishFlavorService;

    public void saveWithFlavor(DishDto dishDto){

        // 將菜品數據導入
        this.save(dishDto);

        // 將Flavor導入(注意:Flavor傳入時沒有傳入dishID,需要我們手動設置)
        List<DishFlavor> flavors = dishDto.getFlavors();
        for (DishFlavor flavor:flavors) {
            flavor.setDishId(dishDto.getId());
        }

        dishFlavorService.saveBatch(flavors);

    }


    /**
     * 查詢菜品,並查詢對應口味
     * @param id
     * @return
     */
    public DishDto getByIdWithFlavors(Long id){

        // 創造返回對象
        DishDto dishDto = new DishDto();

        // 首先根據id獲得菜品資訊
        Dish dish = this.getById(id);

        // 根據id獲得調料資訊
        LambdaQueryWrapper<DishFlavor> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(DishFlavor::getDishId,id);

        List<DishFlavor> list = dishFlavorService.list(queryWrapper);


        // 將數據傳入
        BeanUtils.copyProperties(dish,dishDto);
        dishDto.setFlavors(list);

        return dishDto;
    }

    // 修改菜品
    public void updateWithFlavor(DishDto dishDto){

        // Dish修改
        this.updateById(dishDto);

        // Flavor修改(我們先全部刪除,再全部重新添加)

        // 刪除操作
        LambdaQueryWrapper<DishFlavor> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(DishFlavor::getDishId,dishDto.getId());

        dishFlavorService.remove(queryWrapper);


        // 添加操作
        List<DishFlavor> flavors = dishDto.getFlavors();

        flavors = flavors.stream().map((item) -> {
            item.setDishId(dishDto.getId());
            return item;
        }).collect(Collectors.toList());


    }

}

前台程式碼開發

本小節我們將介紹項目的前台開發階段

簡訊發送技術

我們的簡訊發送技術的原理其實很簡單:

  • 自定義生成驗證碼並暫時保存
  • 將驗證碼通過簡訊服務發給用戶手機
  • 用戶收到後填寫進行比對判斷是否登陸成功

簡訊服務實現

我們目前的難點是簡訊服務,我們在項目中採用了阿里雲簡訊服務,下面做簡單介紹:

  1. 進入阿里雲登陸註冊,做簡單身份驗證等

  1. 進入簡訊服務頁面,註冊簡訊簽名,簡訊模板,AcessKey並授予SMS許可權

  1. 根據阿里雲給出的簡訊發送API設置工具類,實現簡訊發送
package com.qiuluo.reggie.utils;

import com.aliyuncs.DefaultAcsClient;
import com.aliyuncs.IAcsClient;
import com.aliyuncs.dysmsapi.model.v20170525.SendSmsRequest;
import com.aliyuncs.dysmsapi.model.v20170525.SendSmsResponse;
import com.aliyuncs.exceptions.ClientException;
import com.aliyuncs.profile.DefaultProfile;

/**
 * 簡訊發送工具類
 */
public class SMSUtils {

	/**
	 * 發送簡訊
	 * @param signName 簽名
	 * @param templateCode 模板
	 * @param phoneNumbers 手機號
	 * @param param 參數
	 */
	public static void sendMessage(String signName, String templateCode,String phoneNumbers,String param){
		// 下面兩個空分別填寫
		// AccessKey ID 
		// AccessKey Secret 
		DefaultProfile profile = DefaultProfile.getProfile("cn-hangzhou", "", "");
		IAcsClient client = new DefaultAcsClient(profile);

		SendSmsRequest request = new SendSmsRequest();
		request.setSysRegionId("cn-hangzhou");
		request.setPhoneNumbers(phoneNumbers);
		request.setSignName(signName);
		request.setTemplateCode(templateCode);
		request.setTemplateParam("{\"code\":\""+param+"\"}");
		try {
			SendSmsResponse response = client.getAcsResponse(request);
			System.out.println("簡訊發送成功");
		}catch (ClientException e) {
			e.printStackTrace();
		}
	}

}

簡訊發送實現

最後我們再來介紹整個簡訊發送流程:

  1. 製作工具類生成四位隨機數
package com.qiuluo.reggie.utils;

import java.util.Random;

/**
 * 隨機生成驗證碼工具類
 */
public class ValidateCodeUtils {
    /**
     * 隨機生成驗證碼
     * @param length 長度為4位或者6位
     * @return
     */
    public static Integer generateValidateCode(int length){
        Integer code =null;
        if(length == 4){
            code = new Random().nextInt(9999);//生成隨機數,最大為9999
            if(code < 1000){
                code = code + 1000;//保證隨機數為4位數字
            }
        }else if(length == 6){
            code = new Random().nextInt(999999);//生成隨機數,最大為999999
            if(code < 100000){
                code = code + 100000;//保證隨機數為6位數字
            }
        }else{
            throw new RuntimeException("只能生成4位或6位數字驗證碼");
        }
        return code;
    }

    /**
     * 隨機生成指定長度字元串驗證碼
     * @param length 長度
     * @return
     */
    public static String generateValidateCode4String(int length){
        Random rdm = new Random();
        String hash1 = Integer.toHexString(rdm.nextInt());
        String capstr = hash1.substring(0, length);
        return capstr;
    }
}
  1. 實現用戶發送簡訊功能
package com.qiuluo.reggie.controller;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.qiuluo.reggie.common.Result;
import com.qiuluo.reggie.domain.User;
import com.qiuluo.reggie.service.UserService;
import com.qiuluo.reggie.utils.SMSUtils;
import com.qiuluo.reggie.utils.ValidateCodeUtils;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.jws.soap.SOAPBinding;
import javax.servlet.http.HttpSession;
import java.util.Map;
import java.util.concurrent.TimeUnit;

@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

    @Autowired
    private UserService userService;

    @Autowired
    private RedisTemplate redisTemplate;

    @PostMapping("/sendMsg")
    public Result<String> sendMsg(@RequestBody User user, HttpSession session){

        // 保存手機號
        String phone = user.getPhone();

        // 判斷手機號是否存在並設置內部邏輯
        if (phone != null){

            // 隨機生成四位密碼
            String code = ValidateCodeUtils.generateValidateCode(4).toString();

            // 因為無法申請signName簽名,我們直接在後台查看密碼
            // log.info(code);

            // 我們採用阿里雲發送驗證碼
            SMSUtils.sendMessage("簽名","模板",phone,code);

            // 將驗證碼放在session中待比對
            session.setAttribute(phone,code);

            return Result.success("驗證碼發送成功");

        }

        return Result.success("驗證碼發送失敗");
    }

}
  1. 完成比對驗證碼用戶登錄功能
package com.qiuluo.reggie.controller;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.qiuluo.reggie.common.Result;
import com.qiuluo.reggie.domain.User;
import com.qiuluo.reggie.service.UserService;
import com.qiuluo.reggie.utils.SMSUtils;
import com.qiuluo.reggie.utils.ValidateCodeUtils;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.jws.soap.SOAPBinding;
import javax.servlet.http.HttpSession;
import java.util.Map;
import java.util.concurrent.TimeUnit;

@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

    @Autowired
    private UserService userService;

    @Autowired
    private RedisTemplate redisTemplate;

    @PostMapping("/login")
    public Result<User> login(@RequestBody Map map, HttpSession session){

        // 獲得手機號
        String phone = map.get("phone").toString();

        // 獲得驗證碼
        String code = map.get("code").toString();

        // 獲得Session中的驗證碼
        String codeInSession = session.getAttribute(phone).toString();

        // 進行驗證碼比對
        if (codeInSession != null && codeInSession.equals(code) ){
            // 登陸成功
            log.info("用戶登陸成功");

            // 判斷是否為新用戶,如果是自動註冊
            LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
            queryWrapper.eq(User::getPhone,phone);

            User user = userService.getOne(queryWrapper);

            if (user == null){

                user = new User();

                 user.setPhone(phone);
                 user.setStatus(1);
                 userService.save(user);
            }

            // 登陸成功就刪除驗證碼
            redisTemplate.delete(phone);

            session.setAttribute("user",user.getId());

            return Result.success(user);
        }


        // 比對失敗登陸失敗
        return Result.error("登陸失敗");
    }

}

Redis快取技術

我們在菜品選擇介面會發現有很多套餐分類菜品數據,如果訪問人數過多,資料庫訪問次數過多會導致系統崩毀

所以我們希望將相關重要的數據進行快取,同時為了保證前台後台數據一致的前提下,我們採用Redis來實現快取技術

Redis環境搭建

首先我們來回顧Redis基礎環境搭建:

  1. 導入Redis相關依賴坐標
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
  1. 配置Redis相關資訊
server:
  port: 8080
  
# redis設置在spring下
spring:
  application:
    name: qiuluo
  datasource:
    druid:
      driver-class-name: com.mysql.cj.jdbc.Driver
      url: jdbc:mysql://localhost:3306/reggie
      username: root
      password: 123456
  redis:
    host: localhost
    port: 6379
    password: 123456
    database: 0

mybatis-plus:
  configuration:
    map-underscore-to-camel-case: true
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  global-config:
    db-config:
      id-type: ASSIGN_ID
reggie:
  path: E:\編程內容\實戰項目\瑞吉外賣\Code\reggie\imgs\
  1. 配置序列化配置類
// 我們希望在Redis資料庫中可以直接查看到key的原始名稱,所以我們需要修改其序列化方法

package com.qiuluo.reggie.config;

import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig extends CachingConfigurerSupport {
    @Bean
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();
        //默認的Key序列化器為:JdkSerializationRedisSerializer
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setConnectionFactory(connectionFactory);
        return redisTemplate;
    }
}

Redis基本操作

在完成上述環境搭建操作之後,我們就可以來實現RedisTemplate的自動裝配,然後我們就可以採用RedisTemplate來實現Redis操作

@Autowired
private RedisTemplate redisTemplate;

我們項目中以Dish為例來完成了Redis的基本菜品快取操作:

package com.qiuluo.reggie.controller;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.qiuluo.reggie.common.Result;
import com.qiuluo.reggie.domain.Category;
import com.qiuluo.reggie.domain.Dish;
import com.qiuluo.reggie.domain.DishFlavor;
import com.qiuluo.reggie.dto.DishDto;
import com.qiuluo.reggie.service.impl.CategoryServiceImpl;
import com.qiuluo.reggie.service.impl.DishFlavorServiceImpl;
import com.qiuluo.reggie.service.impl.DishServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.*;

import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

@Slf4j
@RestController
@RequestMapping("/dish")
public class DishController {

    @Autowired
    private DishServiceImpl dishService;

    @Autowired
    private DishFlavorServiceImpl dishFlavorService;

    @Autowired
    private CategoryServiceImpl categoryService;

    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * 根據id查詢菜品
     * @param dish
     * @return
     */
    @GetMapping("/list")
    public Result<List<DishDto>> list(Dish dish){

        // 構造返回類型
        List<DishDto> dishDtoList = null;

        // 動態構造構造Redis的key
        String key = "dish_" + dish.getCategoryId() + "_" + dish.getStatus();

        // 1. 先從Redis中查找是否有菜品快取
        dishDtoList = (List<DishDto>) redisTemplate.opsForValue().get(key);

        // 2.如果存在,則直接返回即可
        if (dishDtoList != null){
            return Result.success(dishDtoList);
        }

        // 3.如果不存在,採用mysql語法調用獲得值

        // 提取CategoryID
        Long id = dish.getCategoryId();

        // 判斷條件
        LambdaQueryWrapper<Dish> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(id != null,Dish::getCategoryId,id);
        queryWrapper.eq(Dish::getStatus,1);
        queryWrapper.orderByAsc(Dish::getSort);

        List<Dish> list = dishService.list(queryWrapper);

        // 創建返回類型
        dishDtoList = list.stream().map((item) -> {

            // 創建新的返回類型內部
            DishDto dishDto = new DishDto();

            // 將元素複製過去
            BeanUtils.copyProperties(item,dishDto);

            // 設置CategoryName
            Long categoryId = item.getCategoryId();

            LambdaQueryWrapper<Category> categoryLambdaQueryWrapper = new LambdaQueryWrapper<>();
            categoryLambdaQueryWrapper.eq(Category::getId,categoryId);

            Category category = categoryService.getOne(categoryLambdaQueryWrapper);

            String categoryName = category.getName();

            dishDto.setCategoryName(categoryName);

            // 設置flavor
            Long dishId = item.getId();

            LambdaQueryWrapper<DishFlavor> lambdaQueryWrapper = new LambdaQueryWrapper();
            lambdaQueryWrapper.eq(DishFlavor::getDishId,dishId);

            List<DishFlavor> dishFlavors = dishFlavorService.list(lambdaQueryWrapper);

            dishDto.setFlavors(dishFlavors);

            return dishDto;
        }).collect(Collectors.toList());

        // 4.最後獲得成功後,將數據存入redis快取中
        redisTemplate.opsForValue().set(key,dishDtoList,60, TimeUnit.MINUTES);

        return Result.success(dishDtoList);

    }
}

同時為了保證前後台數據一致,我們在後台進行數據修改時,需要將快取消除,使前台再次從MYSQL中讀取數據:

package com.qiuluo.reggie.controller;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.qiuluo.reggie.common.Result;
import com.qiuluo.reggie.domain.Category;
import com.qiuluo.reggie.domain.Dish;
import com.qiuluo.reggie.domain.DishFlavor;
import com.qiuluo.reggie.dto.DishDto;
import com.qiuluo.reggie.service.impl.CategoryServiceImpl;
import com.qiuluo.reggie.service.impl.DishFlavorServiceImpl;
import com.qiuluo.reggie.service.impl.DishServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.*;

import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

@Slf4j
@RestController
@RequestMapping("/dish")
public class DishController {

    @Autowired
    private DishServiceImpl dishService;

    @Autowired
    private DishFlavorServiceImpl dishFlavorService;

    @Autowired
    private CategoryServiceImpl categoryService;

    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * 新增菜品
     * @param dishDto
     * @return
     */
    @PostMapping
    public Result<String> save(@RequestBody DishDto dishDto){

        dishService.saveWithFlavor(dishDto);

        // 全局快取清理
        // Set keys = redisTemplate.keys("dish_*");
        // redisTemplate.delete(keys);

        // 單個清理
        String key = "dish_" + dishDto.getCategoryId() + "_1";
        redisTemplate.delete(key);

        return Result.success("新創成功");
    }

    /**
     * 修改數據
     * @param
     * @return
     */
    @PutMapping
    public Result<String> update(@RequestBody DishDto dishDto){

        dishService.updateWithFlavor(dishDto);

        log.info("修改完成");

        // 全局快取清理
        // Set keys = redisTemplate.keys("dish_*");
        // redisTemplate.delete(keys);

        // 單個清理
        String key = "dish_" + dishDto.getCategoryId() + "_1";
        redisTemplate.delete(key);

        return Result.success("修改完成");


    }

    /**
     * 多個刪除
     * @param ids
     * @return
     */
    @DeleteMapping
    public Result<String> deleteByIds(Long[] ids){

        for (Long id:ids
             ) {
            dishService.removeById(id);
        }

        // 全局快取清理
        // Set keys = redisTemplate.keys("dish_*");
        // redisTemplate.delete(keys);

        // 單個清理
        String key = "dish_" + ids + "_1";
        redisTemplate.delete(key);

        return Result.success("刪除成功");
    }

}

Redis高級操作

Redis為我們提供了一種註解快取的方法來簡化操作,主要依賴於框架Spring Cache

Spring Cache提供了一層抽象,底層可以切換不同的Cache實現,我們主要使用RedisCacheManager這個介面來完成操作

我們來介紹Spring Cache用於快取的常用的四個註解:

註解 說明
@EnableCaching 開啟快取註解功能
@Cacheable 在方法執行前先查看快取中是否存有數據,如果有數據直接返回數據;如果沒有,調用方法並將返回值存入快取
@CachePut 將方法的返回值放到快取
@CacheEvict 將一條或多條從快取中刪除

下面我們來介紹Spring Cache的具體實現步驟:

  1. 導入相關依賴坐標
        <!--Cache坐標-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-cache</artifactId>
        </dependency>
  1. 在配置文件中統一設置過期時間
server:
  port: 8080
spring:
  application:
    name: qiuluo
  datasource:
    druid:
      driver-class-name: com.mysql.cj.jdbc.Driver
      url: jdbc:mysql://localhost:3306/reggie
      username: root
      password: 123456
  redis:
    host: localhost
    port: 6379
    password: 123456
    database: 0
  cache:
    redis:
      time-to-live: 180000 # 注意單位是毫秒

mybatis-plus:
  configuration:
    #在映射實體或者屬性時,將資料庫中表名和欄位名中的下劃線去掉,按照駝峰命名法映射
    map-underscore-to-camel-case: true
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  global-config:
    db-config:
      id-type: ASSIGN_ID
reggie:
  path: E:\編程內容\實戰項目\瑞吉外賣\Code\reggie\imgs\
  1. 在啟動類上添加@EnableCaching註解,開啟快取註解功能
package com.qiuluo.reggie;

import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.servlet.ServletComponentScan;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.transaction.event.TransactionalEventListener;

@Slf4j
@SpringBootApplication
@ServletComponentScan
@EnableCaching
public class ReggieApplication {
    public static void main(String[] args) {
        SpringApplication.run(ReggieApplication.class,args);
        log.info("項目成功運行");
    }
}
  1. 在SetmealController的list方法上加上@Cacheable註解
package com.qiuluo.reggie.controller;


import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.api.R;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.qiuluo.reggie.common.Result;
import com.qiuluo.reggie.domain.Category;
import com.qiuluo.reggie.domain.Dish;
import com.qiuluo.reggie.domain.Setmeal;
import com.qiuluo.reggie.domain.SetmealDish;
import com.qiuluo.reggie.dto.DishDto;
import com.qiuluo.reggie.dto.SetmealDto;
import com.qiuluo.reggie.service.impl.CategoryServiceImpl;
import com.qiuluo.reggie.service.impl.DishServiceImpl;
import com.qiuluo.reggie.service.impl.SetmealDishServiceImpl;
import com.qiuluo.reggie.service.impl.SetmealServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.stream.Collectors;

@Slf4j
@RestController
@RequestMapping("/setmeal")
public class SetmealController {

    @Autowired
    private DishServiceImpl dishService;

    @Autowired
    private SetmealServiceImpl setmealService;

    @Autowired
    private SetmealDishServiceImpl setmealDishService;

    @Autowired
    private CategoryServiceImpl categoryService;

    /**
     * 根據條件查詢套餐數據
     * @param setmeal
     * @return
     */
    @Cacheable(value = "setmealCache",key = "#setmeal.categoryId + '_' + #setmeal.status")
    @GetMapping("/list")
    public Result<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 Result.success(list);
    }
}
  1. 在SetmealController的save,update,delete方法上加上@CacheEvict註解
package com.qiuluo.reggie.controller;


import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.api.R;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.qiuluo.reggie.common.Result;
import com.qiuluo.reggie.domain.Category;
import com.qiuluo.reggie.domain.Dish;
import com.qiuluo.reggie.domain.Setmeal;
import com.qiuluo.reggie.domain.SetmealDish;
import com.qiuluo.reggie.dto.DishDto;
import com.qiuluo.reggie.dto.SetmealDto;
import com.qiuluo.reggie.service.impl.CategoryServiceImpl;
import com.qiuluo.reggie.service.impl.DishServiceImpl;
import com.qiuluo.reggie.service.impl.SetmealDishServiceImpl;
import com.qiuluo.reggie.service.impl.SetmealServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.stream.Collectors;

@Slf4j
@RestController
@RequestMapping("/setmeal")
public class SetmealController {

    @Autowired
    private DishServiceImpl dishService;

    @Autowired
    private SetmealServiceImpl setmealService;

    @Autowired
    private SetmealDishServiceImpl setmealDishService;

    @Autowired
    private CategoryServiceImpl categoryService;

    /**
     * 新增
     * @CacheEvict:刪除快取功能,allEntries = true表示刪除該value類型的所有快取
     * @param setmealDto
     * @return
     */
    @CacheEvict(value = "setmealCache",allEntries = true)
    @PostMapping
    public Result<String> save(@RequestBody SetmealDto setmealDto){

        setmealService.saveWithDish(setmealDto);

        log.info("套餐新增成功");

        return Result.success("新創套餐成功");
    }

     /**
     * 修改
     * @CacheEvict:刪除快取功能,allEntries = true表示刪除該value類型的所有快取
     * @param setmealDto
     * @return
     */
    @PutMapping
    @CacheEvict(value = "setmealCache",allEntries = true)
    public Result<String> update(@RequestBody SetmealDto setmealDto){

        setmealService.updateById(setmealDto);

        return Result.success("修改成功");
    }
    
    /**
     * 刪除
     * @CacheEvict:刪除快取功能,allEntries = true表示刪除該value類型的所有快取
     * @param ids
     * @return
     */
    @CacheEvict(value = "setmealCache",allEntries = true)
    @DeleteMapping
    public Result<String> delete(@RequestParam List<Long> ids){

        setmealService.removeWithDish(ids);

        return Result.success("刪除成功");
    }

}

項目部署階段

本小節我們將介紹項目的項目部署階段

資料庫讀寫分離

資料庫的讀寫分離操作相對而言比較簡單,但前置的mysql主從複製相對比較繁瑣

主從複製

我們先來介紹主從複製的具體流程:

  1. 主庫從庫設置固定ID,並且給主庫設置日誌打開
# 進入配置文件
vim /etc/my.cnf

# 主庫設置
[mysqld]
log-bin=mysql-bin # 啟動二進位日誌
server-id=128 # 設置伺服器唯一ID

# 從庫設置
server-id=129 # 設置伺服器唯一ID

# 記得刷新資料庫服務
systemctl restart mysqld
  1. 主庫創建用戶並記錄日誌當前狀況
# 登錄資料庫
mysql -uroot -p123456

# 執行下列語句(生成一個用戶,使其具有查詢日誌的權力)
GRANT REPLICATION SLAVE ON *.* to 'xiaoming'@'%' identified by 'Root@123456';

# 執行語句,你將會看到File和Position資訊,該頁面不要改變
# (你將會看到日誌相關資訊,接下來不要對資料庫操作,因為操作會導致日誌資訊改變)
show master status;
  1. 從庫使用用戶連接主庫並記錄日誌資訊,實現slave同步
# 執行下列語句(使用該用戶查詢日誌,注意內容是需要修改的)
# master_host主庫IP,master_user主庫用戶,master_password主庫用戶密碼,master_log_file,master_log_pos為日誌資訊
change master to
master_host='192.168.44.128',master_user='xiaoming',master_password='Root@123456',master_log_file='mysql-bin.000001',master_log_pos=439;

# 輸入後執行以下語句開啟slave
start slave;

# 如果顯示slave衝突(如果你之前執行過slave),使用下列方法結束之前slave
stop slave;
  1. 從庫查看主從複製是否成功
# 查看語句
show slave starts\G;

# 我們只需要關注三個點:(為下述即為成功)
Slave_IO_State: Waiting for master to send event
Slave_IO_Running: Yes
Slave_SQL_Running: Yes

讀寫分離

我們再來介紹讀寫分離的具體流程:

  1. 導入Sharding-JDBC的maven坐標
        <!--Sharding-jdbc坐標-->
        <dependency>
            <groupId>org.apache.shardingsphere</groupId>
            <artifactId>sharding-jdbc-spring-boot-starter</artifactId>
            <version>4.0.0-RC1</version>
        </dependency>
  1. 在配置文件中書寫讀寫分離原則和Bean定義覆蓋原則
server:
  port: 8080
spring:
  application:
    name: qiuluo
  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.44.128:3306/reggie?characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false
        username: root
        password: 123456
      # 從數據源
      slave:
        type: com.alibaba.druid.pool.DruidDataSource
        driver-class-name: com.mysql.cj.jdbc.Driver
        url: jdbc:mysql://192.168.44.129:3306/reggie?characterEncoding=utf-8&serverTimezone=Asia/Shanghai&useSSL=false
        username: root
        password: 123456
    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 # 允許bean定義覆蓋
  redis:
    host: localhost
    port: 6379
    # password: 123456
    database: 0
  cache:
    redis:
      time-to-live: 180000 # 注意單位是毫秒

mybatis-plus:
  configuration:
    #在映射實體或者屬性時,將資料庫中表名和欄位名中的下劃線去掉,按照駝峰命名法映射
    map-underscore-to-camel-case: true
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  global-config:
    db-config:
      id-type: ASSIGN_ID
reggie:
  path: E:\編程內容\實戰項目\瑞吉外賣\Code\reggie\imgs\

前後端項目部署

我們的實際部署通常分為兩台伺服器,來完成前後端分開部署

前端項目部署

我們首先來完成前端項目的部署:

  1. 在伺服器中安裝Nginx,並將課程中的dist目錄(已打包的前端數據)上傳至Nginx下的html頁面

  1. 修改Nginx配置文件nginx.conf

後端項目部署

我們再來完成後端項目的部署:

  1. 使用git clone命令將git遠程倉庫的程式碼克隆下來:

  1. 將資料中的reggieStart.sh文件上傳到伺服器B中,通過chmod命令設置許可權

  1. 然後我們直接執行sh文件即可,後端項目開啟

結束語

到這裡我們的第一個項目就徹底完成了,以上就是《瑞吉外賣》所有技術點的總結內容,希望能為你帶來幫助!

附錄

該文章屬於總結內容,具體參考B站黑馬程式設計師的Java項目實戰《瑞吉外賣》

這裡附上影片鏈接:黑馬程式設計師Java項目實戰《瑞吉外賣》嗶哩嗶哩_bilibili