实战项目-苍穹外卖

实战项目-苍穹外卖

此项目由黑马程序员提供,此笔记用以记录在此实战项目中的知识点欠缺部分

Nginx

Nginx的功能其实有很多很多,我们在此项目中就简单应用于部署前端工程项目

我们在以前的JavaWeb学习中,前端工程项目是使用npm run dev功能直接测试运行

但是在实际生产环境中,前端项目和后端项目一样,需要扔进一个类似于Tomcat的容器中运行,这个容器就是Nginx

使用npm run build 命令即可将项目打包,将打包好的项目扔进nginx的html目录(静态资源目录)下,直接启动即可


反向代理

前端的请求路径为:http://localhost/api/employee/login

而后端实际的业务路径为:http://localhost:8080/admin/employee/login

请求路径并不一致,但却能成功请求,这里就用到了Nginx的重要功能之一——反向代理

  • 提高访问速度
  • 进行负载均衡
  • 保证后端服务的安全

负载均衡

# 使用权重方式配置负载均衡服务器
upstream webservers{
  server 127.0.0.1:8080 weight=90 ;
  #server 127.0.0.1:8088 weight=10 ;
}

# 反向代理到负载均衡服务器
  location /user/ {
      proxy_pass   http://webservers/user/;
  }	
负载均衡的策略
名称说明
轮询默认方式
weight权重方式,默认为1,权重越高,被分配的客户端请求越多
ip_hash依据ip分配方式,这样每个访客可以固定访问一个后端服务
least_conn依据最少连接方式,把请求优先分配给连接数少的后端服务
url_hash依据url分配方式,这样相同的url会被分配到同一个后端服务
fair依据响应时间方式,响应时间短的服务将会被优先分配

分模块设计

我们之前都是一个模块下用包结构来区分功能层级,但包多了就容易混淆

这个项目使用模块来区分功能层级,使用maven的聚合功能(modules)来实现子模块的统一构建

在父工程中,使用dependencyManagement进行版本管理

  • common:用以存放工具类
  • pojo:存放实体类
  • server:用于存放三层架构,做web业务处理

这样使用模块分层后,每一个模块根据需求单独导入依赖,结构清晰,更容易理解


POJO

在之前的学习中,我们也学到Vo的作用,其实pojo分为三种实体类型

  • Entity:实体,通常跟数据库中表对应
  • DTO:数据传输对象,通常用于程序中各层之间的数据传输,比如接收前端传过来的JSON
  • VO:视图对象,为前端展示数据提供的对象,通常用于封装数据库查询结果

我们在之前接收请求的参数,是直接使用实体类来接收

但如果前端提交的数据和实体类中对应的属性差别比较大时,建议采用DTO来封装数据

但在Service层交给mapper层处理数据的时候,应该将DTO转换为实体类对象

BeanUtils

SpringBoot提供了此工具类,可调用copyProperties()方法对对象进行属性拷贝

要求对象中属性名完全一致

BeanUtils.copyProperties(employeeDTO,employee);// 由employeeDTO拷贝到employee

Lombok

@Builder,lombok提供的实体类中注解,让我们可以通过链式调用给对象中的属性进行赋值

EmployeeLoginVO.builder()
        .id(employee.getId())
        .userName(employee.getUsername())
        .name(employee.getName())
        .token(token)
        .build();

MD5加密

我们在之前一直使用自己提供的MD5Util对明文密码进行加密后存入数据库

SPring给我们提供了此工具类——DigestUtils,其中的md5DigestAsHex()可以将一个字符数组转换成为md5加密后的字符串

digest中文释义:消化

password = DigestUtils.md5DigestAsHex(password.getBytes());

接口管理工具

YApi Pro-高效、易用、功能强大的可视化接口管理平台

Apifox - API 文档、调试、Mock、测试一体化协作平台。拥有接口文档管理、接口调试、Mock、自动化测试等功能,接口开发、测试、联调效率,提升 10 倍。最好用的接口文档管理工具,接口自动化测试工具。

Swagger&knife4j

早期,swagger-boostrap-ui是1.x版本,如今swagger-bootsrap-ui到2.x,同时也更改名字Knife4j,适用于单体和微服务项目

knife4j就相当于一个项目内置的postman,它可以生成接口文档,生成接口所需参数,直接在项目内对后端接口做测试

快速开始 | Knife4j (xiaominfo.com)

  • Yapi和Apifox是设计阶段使用的工具,管理和维护接口

  • Swagger在开发阶段使用的框架,帮助后端开发人员做后端的接口测试


WebConfig配置类

在配置类中注入Bean,将配置好的knife4j注入到IoC容器中

通过配置静态资源映射的方式,可以直接通过url访问到接口文档

http://localhost:8080/doc.html

我们可以通过.groupName对用户端和管理端接口分组

 /**
     * 通过knife4j生成接口文档
     *
     * @return
     */
    @Bean
    public Docket docket1() {
        ApiInfo apiInfo = new ApiInfoBuilder()
                .title("苍穹外卖项目接口文档")
                .version("2.0")
                .description("苍穹外卖项目接口文档")
                .build();
        Docket docket = new Docket(DocumentationType.SWAGGER_2)
                .groupName("管理端接口")
                .apiInfo(apiInfo)
                .select()
                .apis(RequestHandlerSelectors.basePackage("com.sky.controller.admin"))
                .paths(PathSelectors.any())
                .build();
        return docket;
    }

    /**
     * 通过knife4j生成接口文档
     *
     * @return
     */
    @Bean
    public Docket docket2() {
        ApiInfo apiInfo = new ApiInfoBuilder()
                .title("苍穹外卖项目接口文档")
                .version("2.0")
                .description("苍穹外卖项目接口文档")
                .build();
        Docket docket = new Docket(DocumentationType.SWAGGER_2)
                .groupName("用户端接口")
                .apiInfo(apiInfo)
                .select()
                .apis(RequestHandlerSelectors.basePackage("com.sky.controller.user"))
                .paths(PathSelectors.any())
                .build();
        return docket;
    }

/**
 * 设置静态资源映射
 * @param registry
 */
protected void addResourceHandlers(ResourceHandlerRegistry registry) {
    registry.addResourceHandler("/doc.html").addResourceLocations("classpath:/META-INF/resources/");
    registry.addResourceHandler("/webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/");	
}

常用注解
注解说明
@Api用在类上,例如Controller,表示对类的说明
@ApiOperation用在方法上,例如Controller的方法,说明方法的用途和作用
@ApiModel用在实体类上,例如entity、DTO、VO
@ApiModelProperty用在实体类的属性上,描述属性信息

通过这些注解,就可以在接口文档中丰富内容和参数,让接口文档有更好的可读性


token问题

再进行业务功能测试的时候,有一些业务需要在登陆的状态下进行,需要验证你的token

获取登录的token后,在全局参数设置中添加token请求头,这样每次请求都会带着token去测试业务接口了


LogBack

SpringBoot内置了LogBack,所以直接使用slf4j进行日志处理即可

使用@Slf4j注解标识要使用打印日志功能的类

我们一般都会在controller层,使用log.info来打印一下接收到的参数是否为正确参数

在log.info处添加断点,可更直观的观测变量的变化

log.info("新增员工:{}", employeeDTO);

主键值获取

我们在之前的学习中,从token中获取主键值,是通过在请求头中获取token值,使用工具类反向解密token获取主键值

现在,我们在拦截其中,直接通过token拿到了id值,现在思考如何将id值传到service层即可

Claims claims = JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token);
Long empId = Long.valueOf(claims.get(JwtClaimsConstant.EMP_ID).toString());
log.info("当前员工id:", empId);

ThreadLocal

Thread的局部变量,每个线程都有自己单独的线程空间,具有线程隔离的效果

然而,Tomcat针对一次业务请求,分配的是同一个线程

在这个项目中,我们将对ThreadLocal局部变量的存取封装成一个工具类

public class BaseContext {

    public static ThreadLocal<Long> threadLocal = new ThreadLocal<>();

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

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

    public static void removeCurrentId() {
        threadLocal.remove();
    }

}

将拦截器中解析出来的id存放到ThreadLocal中,在service中取出使用即可

ThreadLocal在此就可以看作在一次请求中传递数据的变量即可,具有请求隔离(线程隔离)的效果


日期格式问题

在实现员工的分页查询功能时,我们从数据库中查找到的创建时间修改时间的值不太正确

这是因为LocalDateTime类型数据直接放到json中返回给前端,达不到想要的日期格式效果

"createTime": [
  2022,
  2,
  15,
  15,
  51,
  20
],
"updateTime": [
  2022,
  2,
  17,
  9,
  16,
  20
]

有两种方法解决此问题


@JsonFormat

在属性上加入@JsonFormat注解,对日期进行格式化

@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;

@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime;

这种方式处理的不够彻底,只能针对于单个类中的单个属性做格式化处理


扩展消息转换器

在配置类中扩展SpringMVC的消息转换器,统一对日期类型进行格式化处理

/**
 * 配置扩展消息转换器,对后端返回给前端的数据进行统一格式处理
 *
 * @param converters
 */
@Override
protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
    // 创建一个消息转换器对象
    MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
    // 需要为消息转换器设置一个对象转换器,对象转换器可以将Java对象序列化为Json数据(或反序列化)
    converter.setObjectMapper(jacksonObjectMapper);
    // 将消息转换器存放入容器集合中(将自定义消息转换器设置为优先级最高)
    converters.add(0,converter);
}

其中,对象转换器由项目本身提供,其中定义了一些序列化和反序列化的规则,比如jacksonObjectMapper对象中提供了日期格式转换的规则


公共字段自动填充

表中数据有一些字段是公共字段,比如创建人,创建时间,修改人,修改时间等

这些字段每次由我们手动填写就很麻烦,也可以将其利用AOP的思路抽取出来

当我们执行更新操作的时候,需要设置修改人,修改时间

当我们执行插入操作的时候,需要设置创建时间,创建人,修改时间,修改人四条属性

实现思路

使用枚举类型标注正在进行的操作是什么 (UPDATE,INSERT)

通过注解@AutoFill表示需要进行公共字段填充的方法(Mapper层)

自定义切面类AutoFillAspect,统一拦截加入注解的方法,通过反射为公共字段赋值


自定义注解
/**
 * 自定义注解,用于公共字段的填充
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoFillAspect {
    // 数据库操作类型	
    OperationType value();
}

自定义切面

在我们利用反射来获取具体方法时,不再手写字符串,而是通过常量类的方式来填写,这样做有两个好处:

  • 不容易写错
  • 代码书写更加规范
/**
 * 自动填充切面类
 */
@Aspect
@Component
@Slf4j
public class AutoFillAspect {
    // 指定切入点
    @Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFillAspect)")
    public void autoFillPointCut(){

    }
    // 通知方法(增强方法)
    @Before("autoFillPointCut()")
    public void autoFill(JoinPoint joinPoint){
        log.info("开始进行公共字段的自动填充……");
        // 获得注解的值,Insert/Update
        MethodSignature signature = (MethodSignature) joinPoint.getSignature(); // 获得方法签名对象
        com.sky.annotation.AutoFillAspect autoFillAspect = signature.getMethod().getAnnotation(com.sky.annotation.AutoFillAspect.class); // 获得方法注解对象
        OperationType operationType = autoFillAspect.value(); // 获得注解中的内容(操作类型)

        // 获得方法的参数(实体类)
        Object[] args = joinPoint.getArgs();
        if (args == null || args.length == 0){
            return;
        }
        Object entity = args[0];

        // 准备数据
        LocalDateTime  now = LocalDateTime.now();
        Long currentId = BaseContext.getCurrentId();

        // 判断操作类型,为公共字段赋值
        if (operationType == OperationType.INSERT){
            try {
                // 通过反射获取到set方法
                Method setCreateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_TIME, LocalDateTime.class);
                Method setCreateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_USER, Long.class);
                Method setUpdateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
                Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);
                
                // 执行set方法,为实体entity的属性赋值
                setCreateTime.invoke(entity,now);
                setCreateUser.invoke(entity,currentId);
                setUpdateTime.invoke(entity,now);
                setUpdateUser.invoke(entity,currentId);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }else {
            try {
                // 通过反射获取到set方法
                Method setUpdateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
                Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);

                // 执行set方法,为实体entity的属性赋值
                setUpdateTime.invoke(entity,now);
                setUpdateUser.invoke(entity,currentId);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}

阿里云对象存储服务OSS

Java_对象存储(OSS)-阿里云帮助中心 (aliyun.com)

用户在前端上传文件后,由前端将文件内容放在请求体中发送到后端

  • 配置文件:配置OSS所需的配置文件
  • 工具类:OSS具体操作的工具类
  • 配置类:使用配置文件创建OSS工具类的对象,注入到IoC容器
  • Controller:注入对象,使用对象进行文件上传业务

配置文件

和JWT的处理方式一样,我们通过配置属性类的方式读取配置文件中的字段,对配置进行封装成类的操作

在yaml中的-分割和类中驼峰命名的字段在SpringBoot中可以得到自动转换

配置属性类
@Component
@ConfigurationProperties(prefix = "sky.alioss")
@Data
public class AliOssProperties {
    private String endpoint;
    private String accessKeyId;
    private String accessKeySecret;
    private String bucketName;
}
配置文件
  alioss:
    endpoint: ${sky.alioss.endpoint}
    access-key-id: ${sky.alioss.access-key-id}
    access-key-secret: ${sky.alioss.access-key-secret}
    bucket-name: ${sky.alioss.bucket-name}

配置类

使用配置类的方式,将AliOssUtil工具类的对象注入到IoC容器中

@ConditionalOnMissingBean注解来保证IoC容器中只存在一个此工具类

@Configuration
@Slf4j
public class OssConfiguration {
    @Bean
    @ConditionalOnMissingBean
    public AliOssUtil aliOssUtil(AliOssProperties aliOssProperties) {
        log.info("开始创建阿里云文件上传工具类对象:{}", aliOssProperties);
        return new AliOssUtil(aliOssProperties.getEndpoint(),
                aliOssProperties.getAccessKeyId(),
                aliOssProperties.getAccessKeySecret(),
                aliOssProperties.getBucketName());
    }
}

业务代码

在controller中,使用@RequestPart MultipartFile file来接收文件

@Autowired
private AliOssUtil aliOssUtil;

@PostMapping
@ApiOperation("文件上传")
public Result<String> upload(@RequestPart MultipartFile file) {
    log.info("文件上传:{}", file);
    try {
        // 获取文件的原始文件名
        String originalFilename = file.getOriginalFilename();
        // 获取文件的后缀
        String substring = originalFilename.substring(originalFilename.lastIndexOf("."));
        // 构造文件名称
        String objectName = UUID.randomUUID().toString() + substring;

        String filePath = aliOssUtil.upload(file.getBytes(), objectName);
        return Result.success(filePath);
    } catch (IOException e) {
        log.error("文件上传失败:{}", e.getMessage());
    }
    return null;
}

小总结

学到这里,我们发现每一个插件的配置都是使用配置文件来配置

但生产环境与开发环境不同,为了解决这个问题,我们使用application-dev.yaml的方式,在主配置文件中激活

spring:
  profiles:
    active: dev

我们发现配置文件直接使用到类中十分麻烦,就创建配置属性类来封装配置文件中的字段

使用@ConfigurationProperties(prefix = "sky.***")注解来读取配置文件中的字段,填充到类中属性


对于工具类和其他框架的使用,不再实例化工具类直接使用,而是通过配置类将其配置好后注入IoC容器中,再由IoC中的其他组件调用


我们发现,很多自定义的字符串内容都使用常量或者枚举来提前定义,这样减少了书写错误,增加代码规范


我们发现,在进行数据库操作时,如果数据库表有存在自己的mapper(逻辑外键),则可以在Service中对两个mapper进行单独的sql操作,最后返回到service封装(涉及到多表查询,要在service上面加上事务)

如果使用联合查询,就写resultMap来映射数据库的表中列和实体类的属性对应,还有相同属性名的别名问题


当更新操作使用动态sql语句操作时,我们所有的更新操作都可以使用update这一条mapper,它会根据不同的参数而生成不同的sql进行数据更新


杨老师说,一张表的VO尽量只有一个,返回数据的时候可以一起返回空key,前端只需要用什么拿什么就好,这个VO中的其他表属性使用对象的形式存储在VO的属性中,这样就需要写resultMap来进行数据库列明——类中属性名的多层次映射


当controller层的用户端和管理端有相同的业务接口时,我们可以在@RestController注解后设置值的方式区分开

@RestController("userShopController")

Redis

在Java中使用Redis:

  • Jedis
  • Lettuce
  • Spring Data Redis

Spring Data Redis就是对Jedis和Lettuce进行高度的封装,我们直接使用即可


Spring Data Redis

导入依赖
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
配置Redis

在application.yaml中配置Redis

spring:
  redis:
    host: 192.168.2.2 # 配置主机地址
    password: Zhuwenxue2002 # 配置Redis密码
    database: 0 # 配置使用数据库
编写配置类,创建RedisTemplate对象*

如果不编写配置类的话,Redis也能按照默认配置运行

package com.sky.config;

import lombok.extern.slf4j.Slf4j;
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
@Slf4j
public class RedisConfiguration {
    @Bean
    public RedisTemplate redisTemplate(RedisConnectionFactory factory) {
        log.info("开始创建redis模板对象");
        RedisTemplate redisTemplate = new RedisTemplate();
        // 设置redis连接工厂对象
        redisTemplate.setConnectionFactory(factory);
        // 设置redis key的序列化器
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        return redisTemplate;
    }
}

通过RedisTemplate对象操作Redis

需要注意的是,使用spring框架去进行redis操作会对数据进行序列化操作之后再存储,直接查看redis中的数据会有乱码情况出现

测试了String和hash类型的数据,其他类型的数据在使用上也大同小异

@SpringBootTest
public class test {
    @Autowired
    private RedisTemplate redisTemplate;

    @Test
    public void testRedisTemplate() {
        ValueOperations valueOperations = redisTemplate.opsForValue();
        HashOperations hashOperations = redisTemplate.opsForHash();
        ListOperations listOperations = redisTemplate.opsForList();
        SetOperations setOperations = redisTemplate.opsForSet();
        ZSetOperations zSetOperations = redisTemplate.opsForZSet();
    }

    /**
     * 操作字符串类型的数据
     */
    @Test
    public void testString() {
        // set get
        redisTemplate.opsForValue().set("key", "value");
        String key = (String) redisTemplate.opsForValue().get("key");
        System.out.println(key);

        // setex(插入数据并设置有效期)使用枚举类型设置时间单位
        redisTemplate.opsForValue().set("key2", "value2", 3, TimeUnit.MINUTES);

        // setnx(在插入前检查数据是否存在)
        redisTemplate.opsForValue().setIfAbsent("key3", "value3");
    }

    /**
     * 操作哈希类型的数据
     */
    @Test
    public void testHash() {
        // hset
        redisTemplate.opsForHash().put("1", "name", "xiaobai");
        redisTemplate.opsForHash().put("1", "age", "20");

        // hget
        String name = (String) redisTemplate.opsForHash().get("1", "name");

        System.out.println(name);

        // hkeys
        Set keys = redisTemplate.opsForHash().keys("1");
        System.out.println(keys);

        // hvals
        List values = redisTemplate.opsForHash().values("1");
        System.out.println(values);

        // hdels
        redisTemplate.opsForHash().delete("1", "age");

    }
}

HttpClient

我们在后端使用HttpClient来发送请求实现微信登陆功能

正常在使用HttpClient时,我们应该导入依赖,但阿里云OSS的依赖传递了此依赖,所以在此项目中无需额外导入

httpclient.png

  • 创建HttpClient对象

  • 创建Http请求对象

  • 调用HttpClient的execute方法发送请求

execute中文释义:执行


发送GET请求

@Test
public void testGet() throws IOException {
    // 创建HttpClient对象
    CloseableHttpClient httpClient = HttpClients.createDefault();

    // 创建请求对象
    HttpGet httpGet = new HttpGet("http://localhost:8080/user/shop/status");

    // 发送请求
    CloseableHttpResponse response = httpClient.execute(httpGet);

    // 获取服务端返回的响应状态码
    int statusCode = response.getStatusLine().getStatusCode();
    System.out.println(statusCode);

    // 获取响应体中的内容
    HttpEntity entity = response.getEntity();
                                   
    // 使用EntityUtils工具类解析响应体中的内容,转成字符串
    String string = EntityUtils.toString(entity);
    System.out.println("string = " + string);

    // 关闭资源
    response.close();
    httpClient.close();

}

发送Post请求

@Test
public void testPost() throws IOException {
    // 创建httpclient对象
    CloseableHttpClient httpClient = HttpClients.createDefault();
    // 创建请求对象
    HttpPost httpPost = new HttpPost("http://localhost:8080/admin/employee/login");

    //使用阿里提供的工具类生成JSON对象 com.alibaba.fastjson.JSONObject;
    JSONObject jsonObject = new JSONObject();
    jsonObject.put("username", "admin");
    jsonObject.put("password", "123456");
    StringEntity entity = new StringEntity(jsonObject.toString());
    // 指定编码方式和数据格式
    entity.setContentType("application/json");
    entity.setContentEncoding("UTF-8");

    httpPost.setEntity(entity);
    // 发送请求
    CloseableHttpResponse response = httpClient.execute(httpPost);
    // 解析返回结果
    System.out.println(response.getStatusLine().getStatusCode());

    HttpEntity entity1 = response.getEntity();
    String string = EntityUtils.toString(entity1);
    System.out.println(string);
    // 关闭资源
    response.close();
    httpClient.close();
}

工具类

和其他依赖框架的使用思路一样,我们将HttpClient封装为工具类,想要使用的时候直接使用工具类即可