实战项目-苍穹外卖
实战项目-苍穹外卖
此项目由黑马程序员提供,此笔记用以记录在此实战项目中的知识点欠缺部分
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());
接口管理工具
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访问到接口文档
我们可以通过.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对象
-
创建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封装为工具类,想要使用的时候直接使用工具类即可