1.功能模块
1.1 管理端功能
员工登录/退出 , 员工信息管理 , 分类管理 , 菜品管理 , 套餐管理 , 菜品口味管理 , 订单管理 ,数据统计,来单提醒。
模块 | 描述 |
---|---|
登录/退出 | 内部员工必须登录后,才可以访问系统管理后台 |
员工管理 | 管理员可以在系统后台对员工信息进行管理,包含查询、新增、编辑、禁用等功能 |
分类管理 | 主要对当前餐厅经营的 菜品分类 或 套餐分类 进行管理维护, 包含查询、新增、修改、删除等功能 |
菜品管理 | 主要维护各个分类下的菜品信息,包含查询、新增、修改、删除、启售、停售等功能 |
套餐管理 | 主要维护当前餐厅中的套餐信息,包含查询、新增、修改、删除、启售、停售等功能 |
订单管理 | 主要维护用户在移动端下的订单信息,包含查询、取消、派送、完成,以及订单报表下载等功能 |
数据统计 | 主要完成对餐厅的各类数据统计,如营业额、用户数量、订单等 |
1.2 用户端功能
微信登录 , 收件人地址管理 , 用户历史订单查询 , 菜品规格查询 , 购物车功能 , 下单 , 支付、分类及菜品浏览。
模块 | 描述 |
---|---|
登录/退出 | 用户需要通过微信授权后登录使用小程序进行点餐 |
点餐-菜单 | 在点餐界面需要展示出菜品分类/套餐分类, 并根据当前选择的分类加载其中的菜品信息, 供用户查询选择 |
点餐-购物车 | 用户选中的菜品就会加入用户的购物车, 主要包含 查询购物车、加入购物车、删除购物车、清空购物车等功能 |
订单支付 | 用户选完菜品/套餐后, 可以对购物车菜品进行结算支付, 这时就需要进行订单的支付 |
个人信息 | 在个人中心页面中会展示当前用户的基本信息, 用户可以管理收货地址, 也可以查询历史订单数据 |
2.技术选型
2.1 用户层
用户层是指最终用户直接交互的界面或应用程序。本项目中在构建前端页面,我们会用到H5、Vue.js、ElementUI、apache echarts(展示图表)等技术。而在构建移动端应用时,我们会使用到微信小程序。
2.2 网关层
网关层值位于用户层和应用层之间的中间层,负责连接用户层和应用层,并提供额外的功能。
本项目使用到Nginx,Nginx是一个服务器,主要用来作为Http服务器,部署静态资源,访问性能高。在Nginx中还有两个比较重要的作用: 反向代理和负载均衡, 在进行项目部署时,要实现Tomcat的负载均衡,就可以通过Nginx来实现。
2.3 应用层
提供了不同应用程序之间进行通讯和数据交换的接口。
SpringBoot: 快速构建Spring项目, 采用 "约定优于配置" 的思想, 简化Spring项目的配置开发。简单来说,Spring是开发中的整体框架,而SpringBoot则是脚手架,有了脚手架的帮助能使开发更简单快速。
SpringMVC: SpringMVC是spring框架的一个模块,SpringMVC和spring无需通过中间整合层进行整合,可以无缝集成。(包括接受请求,响应数据,拦截器,全局异常处理等)。
Spring Task: 由Spring提供的定时任务框架。
HttpClient: 主要实现了对http请求的发送。
Spring Cache: 由Spring提供的数据缓存框架
JWT: 用于对应用程序上的用户进行身份验证的标记。
阿里云OSS: 对象存储服务,在项目中主要存储文件,如图片等。
Swagger: 可以自动的帮助开发人员生成接口文档,并对接口进行测试。
POI: 封装了对Excel表格的常用操作。
WebSocket: 一种通信网络协议,使客户端和服务器之间的数据交换更加简单,用于项目的来单、催单功能实现。
2.4 数据层
指存储和管理系统中的数据的层次。包括数据库和数据库管理系统等。
MySQL: 关系型数据库, 本项目的核心业务数据都会采用MySQL进行存储。
Redis: 基于key-value格式存储的内存数据库, 访问速度快, 经常使用它做缓存。
Mybatis: 本项目持久层将会使用Mybatis开发。
pagehelper: 分页插件。
spring data redis: 简化java代码操作Redis的API。
2.5 使用到的工具
git: 版本控制工具, 在团队协作中, 使用该工具对项目中的代码进行管理。
maven: 项目构建工具。
junit:单元测试工具,开发人员功能实现完毕后,需要通过junit对功能进行单元测试。
postman: 接口测工具,模拟用户发起的各类HTTP请求,获取对应的响应结果。
3.项目初始化
该项目采用前后端分离,这里重心放在后端开发上,前端环境直接导入即可。
3.1 了解项目的整体结构:
项目中的几大模块
序号 | 名称 | 说明 |
---|---|---|
1 | sky-take-out | maven父工程,统一管理依赖版本,聚合其他子模块 |
2 | sky-common | 子模块,存放公共类,例如:工具类、常量类、异常类等 |
3 | sky-pojo | 子模块,存放实体类、VO、DTO等 |
4 | sky-server | 子模块,后端服务,存放配置文件、Controller、Service、Mapper等 |
sky-common: 模块中存放的是一些公共类,可以供其他模块使用
名称 | 说明 |
---|---|
constant | 存放相关常量类 |
context | 存放上下文类 |
enumeration | 项目的枚举类存储 |
exception | 存放自定义异常类 |
json | 处理json转换的类 |
properties | 存放SpringBoot相关的配置属性类 |
result | 返回结果类的封装 |
utils | 常用工具类 |
sky-pojo: 模块中存放的是一些 entity、DTO、VO
名称 | 说明 |
---|---|
Entity | 实体,通常和数据库中的表对应 |
DTO | 数据传输对象,通常用于程序中各层之间传递数据 |
VO | 视图对象,为前端展示数据提供的对象 |
POJO | 普通Java对象,只有属性和对应的getter和setter |
知识补充:
3.1.1 DTO 和 VO 的概念
DTO(Data Transfer Object)和 VO(Value Object)都是一种设计模式,用于封装数据和提供服务。它们的主要区别在于:
-
DTO:用于封装数据传输对象,可以将数据库中的数据转换为前端需要的格式,方便前后端之间的数据交互。
-
VO:用于封装值对象,可以根据具体的需求来封装不同的数据属性,方便前端页面的显示和交互。
3.1.2 DTO 和 VO 的区别
-
数据传输对象 vs 值对象 DTO 是一种数据传输对象,用于将数据库中的数据转换为前端需要的格式,方便前后端之间的数据交互。而 VO 是一种值对象,用于封装不同的数据属性,方便前端页面的显示和交互。
-
封装方式不同 DTO 通常封装一些业务逻辑和数据转换的方法,用于将数据从数据库中查询出来,并将其转换为前端需要的格式。而 VO 通常只包含数据属性,不包含任何业务逻辑。
-
包含的数据属性不同 DTO 可以包含数据库中的全部属性,也可以只包含部分属性,具体根据业务需求而定。而 VO 只包含需要在前端页面上显示的属性,不包含敏感数据和不必要的属性。
sky-server:** 模块中存放的是 配置文件、配置类、拦截器、controller、service、mapper、启动类等
名称 | 说明 |
---|---|
config | 存放配置类 |
controller | 存放controller类 |
interceptor | 存放拦截器类 |
mapper | 存放mapper接口 |
service | 存放service类 |
SkyApplication | 启动类 |
3.2 使用Git版本控制
创建Git本地仓库(add添加到缓存区 commit添加到本地仓库)
创建Git远程仓库(push推送到远程仓库)
将后端初始环境代码推送到远程仓库
具体步骤:创建好仓库后,直接在idea中操作git,点击上方控制面板中的git
,然后有一个commit and push ,点击即可上传到远程仓库。
3.3 数据库环境搭建
根据提供的sql文件导入对应的11张表。
具体的表说明:
序号 | 表名 | 中文名 |
---|---|---|
1 | employee | 员工表 |
2 | category | 分类表 |
3 | dish | 菜品表 |
4 | dish_flavor | 菜品口味表 |
5 | setmeal | 套餐表 |
6 | setmeal_dish | 套餐菜品关系表 |
7 | user | 用户表 |
8 | address_book | 地址表 |
9 | shopping_cart | 购物车表 |
10 | orders | 订单表 |
11 | order_detail | 订单明细表 |
3.6 导入接口文档
第一步:定义接口,确定接口的路径、请求方式、传入参数、返回参数。
第二步:前端开发人员和后端开发人员并行开发,同时,也可自测。
第三步:前后端人员进行连调测试。
第四步:提交给测试人员进行最终测试。 操作步骤: 1). 从资料中找到项目接口文件 2). 下载Apifox软件,导入到该应用中, 在Apifox平台创建出两个项目,选择苍穹外卖-管理端/用户端接口.json导入。
3.7 Swagger
Swagger 是一个规范和完整的框架,用于生成、描述、调用和可视化 RESTful 风格的 Web 服务
它的主要作用是:
-
使得前后端分离开发更加方便,有利于团队协作
-
接口的文档在线自动生成,降低后端开发人员编写接口文档的负担
-
功能测试
Spring已经将Swagger纳入自身的标准,建立了Spring-swagger项目,现在叫Springfox。通过在项目中引入Springfox ,即可非常简单快捷的使用Swagger。
目前,一般都使用knife4j框架。
使用步骤:
1.导入knife4j的maven坐标
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-spring-boot-starter</artifactId>
</dependency>
2.2.配置类中加入knife4j -- WebMvcConfiguration.java
/**
* 通过knife4j生成接口文档
* @return
*/
@Bean
public Docket docket() {
ApiInfo apiInfo = new ApiInfoBuilder()
.title("苍穹外卖项目接口文档")
.version("2.0")
.description("苍穹外卖项目接口文档")
.build();
Docket docket = new Docket(DocumentationType.SWAGGER_2)
.apiInfo(apiInfo)
.select()
.apis(RequestHandlerSelectors.basePackage("com.sky.controller"))
.paths(PathSelectors.any())
.build();
return docket;
}
3.设置静态资源映射-WebMvcConfiguration.java
/**
* 设置静态资源映射
* @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/");
}
4.访问测试
接口文档访问路径:http://ip:port/doc.html
在本项目中也就是: http://localhost:8080/doc.html
常用注解:
注解 | 说明 |
---|---|
@Api | 用在类上,例如Controller,表示对类的说明 |
@ApiModel | 用在类上,例如entity、DTO、VO |
@ApiModelProperty | 用在属性上,描述属性信息 |
@ApiOperation | 用在方法上,例如Controller的方法,说明方法的用途、作用 |
注!!!:一定要注意knife4j的版本和你所用springboot的版本是兼容导入,否则无法使用Swagger工具。
4.技术应用
4.1 JWT令牌技术
定义了一种简洁的、自包含的格式,用于在通信双方以json数据格式安全的传输信息。由于数字签名的存在,这些信息是可靠的。
简单来讲,jwt就是将原始的json数据格式进行了安全的封装,这样就可以直接基于jwt在通信双方安全的进行信息传输了。
第一部分:Header(头), 记录令牌类型、签名算法等。 例如:{"alg":"HS256","type":"JWT"}
第二部分:Payload(有效载荷),携带一些自定义信息、默认信息等。 例如:{"id":"1","username":"Tom"}
第三部分:Signature(签名),防止Token被篡改确保安全性。将header、payload,并加入指定秘钥,通过指定签名算法计算而来:HS256(header+payload, secret)
在JWT登录认证的场景中我们发现,整个流程当中涉及到两步操作:
-
在登录成功之后,要生成令牌。
-
每一次请求当中,要接收令牌并对令牌进行校验。
4.1.1 在校验jwt前是我们所设置的拦截器产生效果:
protected void addInterceptors(InterceptorRegistry registry) {
log.info("开始注册自定义拦截器...");
registry.addInterceptor(jwtTokenAdminInterceptor)
.addPathPatterns("/admin/**")
.excludePathPatterns("/admin/employee/login");
registry.addInterceptor(jwtTokenUserInterceptor)
.addPathPatterns("/user/**")
.excludePathPatterns("/user/user/login")
.excludePathPatterns("/user/shop/status");
}
4.1.2 生成JWT令牌并解密 :
public static String createJWT(String secretKey, long ttlMillis, Map<String, Object> claims) {
// 指定签名的时候使用的签名算法,也就是header那部分
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
// 生成JWT的时间
long expMillis = System.currentTimeMillis() + ttlMillis;
Date exp = new Date(expMillis);
// 设置jwt的body
JwtBuilder builder = Jwts.builder()
// 如果有私有声明,一定要先设置这个自己创建的私有的声明,这个是给builder的claim赋值,一旦写在标准的声明赋值之后,就是覆盖了那些标准的声明的
.setClaims(claims)
// 设置签名使用的签名算法和签名使用的秘钥
.signWith(signatureAlgorithm, secretKey.getBytes(StandardCharsets.UTF_8))
// 设置过期时间
.setExpiration(exp);
return builder.compact();
}
public static Claims parseJWT(String secretKey, String token) {
// 得到DefaultJwtParser
Claims claims = Jwts.parser()
// 设置签名的秘钥
.setSigningKey(secretKey.getBytes(StandardCharsets.UTF_8))
// 设置需要解析的jwt
.parseClaimsJws(token).getBody();
return claims;
}
4.1.3 例如,管理端jwt校验:
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//判断当前拦截到的是Controller的方法还是其他资源
if (!(handler instanceof HandlerMethod)) {
//当前拦截到的不是动态方法,直接放行
return true;
}
//System.out.println("当前线程id为:"+Thread.currentThread().getId());
//1、从请求头中获取令牌
String token = request.getHeader(jwtProperties.getAdminTokenName());
//2、校验令牌
try {
//log.info("jwt校验:{}", token);
Claims claims = JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token);
Long empId = Long.valueOf(claims.get(JwtClaimsConstant.EMP_ID).toString());
log.info("当前员工id:", empId);
//3、通过,放行
BaseContext.setCurrentId(empId);
return true;
} catch (Exception ex) {
//4、不通过,响应401状态码
response.setStatus(401);
return false;
}
}
4.2 Nginx反向代理和负载均衡
在前后端联调时我们发现前端发出的请求和后端接口中的地址不一样。这里使用到了nginx反向代理。
nginx 反向代理的好处:
-
提高访问速度 因为nginx本身可以进行缓存,如果访问的同一接口,并且做了数据缓存,nginx就直接可把数据返回,不需要真正地访问服务端,从而提高访问速度。
-
进行负载均衡 所谓负载均衡,就是把大量的请求按照我们指定的方式均衡的分配给集群中的每台服务器。
-
保证后端服务安全 因为一般后台服务地址不会暴露,所以使用浏览器不能直接访问,可以把nginx作为请求访问的入口,请求到达nginx后转发到具体的服务中,从而保证后端服务的安全。
# 反向代理,处理管理端发送的请求
location /api/ {
proxy_pass http://localhost:8080/admin/;
#proxy_pass http://webservers/admin/;
}
# 反向代理,处理用户端发送的请求
location /user/ {
proxy_pass http://webservers/user/;
}
# WebSocket
location /ws/ {
proxy_pass http://webservers/ws/;
proxy_http_version 1.1;
proxy_read_timeout 3600s;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "$connection_upgrade";
}
4.3 MD5加密登录
员工表中的密码是明文存储,安全性太低。
--->修改数据库中明文密码,改为MD5加密后的密文:
//后期进行md5加密,然后再进行比对
password = DigestUtils.md5DigestAsHex(password.getBytes());
4.4 ThreadLocal -- 使用 ThreadLocal 来提供线程本地变量的存储,用于在多线程环境中共享数据
public class BaseContext {
//threadLocal 是一个静态的 ThreadLocal 对象,它用于存储线程本地变量
public static ThreadLocal<Long> threadLocal = new ThreadLocal<>();
//setCurrentId 方法用于在当前线程中设置一个 Long 类型的标识符,将其存储在 threadLocal 中。这可以用于在当前线程的执行上下文中保留某个标识符的值。
public static void setCurrentId(Long id) {
threadLocal.set(id);
}
//getCurrentId 方法用于获取当前线程中存储的 Long 类型的标识符的值。
public static Long getCurrentId() {
return threadLocal.get();
}
//removeCurrentId 方法用于移除当前线程中存储的 Long 类型的标识符。这是为了确保在线程结束时清理 ThreadLocal 中的数据,以防止内存泄漏。
public static void removeCurrentId() {
threadLocal.remove();
}
}
ThreadLocal叫做线程变量,意思是ThreadLocal中填充的变量属于当前线程,该变量对其他线程而言是隔离的,也就是说该变量是当前线程独有的变量。ThreadLocal为变量在每个线程中都创建了一个副本,那么每个线程可以访问自己内部的副本变量。
ThreadLoal 变量,线程局部变量,同一个 ThreadLocal 所包含的对象,在不同的 Thread 中有不同的副本。这里有几点需要注意:
-
因为每个 Thread 内有自己的实例副本,且该副本只能由当前 Thread 使用。这是也是 ThreadLocal 命名的由来。
-
既然每个 Thread 有自己的实例副本,且其它 Thread 不可访问,那就不存在多线程间共享的问题。
ThreadLocal 提供了线程本地的实例。它与普通变量的区别在于,每个使用该变量的线程都会初始化一个完全独立的实例副本。ThreadLocal 变量通常被private static修饰。当一个线程结束时,它所使用的所有 ThreadLocal 相对的实例副本都可被回收。
总的来说,ThreadLocal 适用于每个线程需要自己独立的实例且该实例需要在多个方法中被使用,也即变量在线程间隔离而在方法或类间共享的场景
注!!!!!: springmvc默认是单例的,每一个请求进入,都会启动一个线程
Long userId = BaseContext.getCurrentId();
在很多方法中都会使用该方法来获取当前用户id----每个请求一次线程,获取当前userId。
一句话理解ThreadLocal:
threadlocl是作为当前线程中属性ThreadLocalMap集合中的某一个Entry的key值--Entry(threadlocal,value),在Entry内部使用ThreadLocal作为key,使用我们设置的value作为value。虽然不同的线程之间threadlocal这个key值是一样,但是不同的线程所拥有的ThreadLocalMap是独一无二的,也就是不同的线程间同一个ThreadLocal(key)对应存储的值(value)不一样,从而到达了线程间变量隔离的目的,但是在同一个线程中这个value变量地址是一样的。
4.4.1 ThreadLocal的简单使用
直接上代码:
public class ThreadLocaDemo {
private static ThreadLocal<String> localVar = new ThreadLocal<String>();
static void print(String str) {
//打印当前线程中本地内存中本地变量的值
System.out.println(str + " :" + localVar.get());
//清除本地内存中的本地变量
localVar.remove();
}
public static void main(String[] args) throws InterruptedException {
new Thread(new Runnable() {
public void run() {
ThreadLocaDemo.localVar.set("local_A");
print("A");
//打印本地变量
System.out.println("after remove : " + localVar.get());
}
},"A").start();
Thread.sleep(1000);
new Thread(new Runnable() {
public void run() {
ThreadLocaDemo.localVar.set("local_B");
print("B");
System.out.println("after remove : " + localVar.get());
}
},"B").start();
}
}
输出结果: A :local_A after remove : null B :local_B after remove : null
从这个示例中我们可以看到,两个线程分表获取了自己线程存放的变量,他们之间变量的获取并不会错乱。
4.4.2 ThreadLocal与Thread,ThreadLocalMap之间的关系
从这个图中我们可以非常直观的看出,ThreadLocalMap其实是Thread线程的一个属性值,而ThreadLocal是维护ThreadLocalMap。
Thread线程可以拥有多个ThreadLocal维护的自己线程独享的共享变量(这个共享变量只是针对自己线程里面共享)。
4.5 完善时间显示
方式一:
在类的属性上加上注解,对日期进行格式化。
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime;
//但这种方式,需要在每个时间属性上都要加上该注解,使用较麻烦,不能全局处理。
方式二(推荐): 在WebMvcConfiguration中扩展SpringMVC的消息转换器,使用自定义的JacksonObjectMapper统一对日期类型进行格式处理: 其中JacksonObjectMapper是通用的类已经定义好,直接使用即可。
/*
* 扩展Spring MVC框架的消息转化器
* @param converters
*/
protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
log.info("扩展消息转换器...");
//创建一个消息转换器对象
MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
//需要为消息转换器设置一个对象转换器,对象转换器可以将Java对象序列化为json数据
converter.setObjectMapper(new JacksonObjectMapper());
//将自己的消息转化器加入容器中
converters.add(0,converter);//将自己定义的消息转化器优先使用
}
/**
* 对象映射器:基于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_DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm";
public static final String DEFAULT_TIME_FORMAT = "HH:mm:ss";
//省略后续代码
}
4.6 AOP切面编程
4.6.1介绍
我们使用AOP切面编程,实现功能增强,来完成公共字段自动填充功能。 AOP(面向切面编程):
重要名词:
-
通知Advice(方法中的共性功能),
-
切入点Pointcut(哪些方法),
-
切面Aspect(描述切入点和通知位置关系),
-
通知类型(前置,后置:方法前边加还是后边加)
在插入或者更新的时候为指定字段赋予指定的值,使用它的好处就是可以统一对这些字段进行处理,避免了重复代码。 ★ 技术点:枚举、注解、AOP、反射.
4.6.2 操作步骤
4.6.2.1 自定义注解
/*
*自定义注解,用于标识某个方法需要进行功能字段的填充处理
**/
//@Documented ---- 注解是否将包含在JavaDoc(根据代码生成文档)中
@Target(ElementType.METHOD)//指明注解只能加到方法上
@Retention(RetentionPolicy.RUNTIME) //定义注解的生命周期
public @interface AutoFill {
//数据库操作类型 ,Update, Insert
OperationType value();
//使用value时候,@AutoFill(value = OperationType.UPDATE)
}
4.6.2.2 枚举定义
package com.sky.enumeration;
/**
* 数据库操作类型
*/
public enum OperationType {
/**
* 更新操作
*/
UPDATE,
/**
* 插入操作
*/
INSERT
}
4.6.2.3 自定义切面类(包含切入点表达式)
/*
*自定义切面类,实现公共字段自动填充处理逻辑
*/
@Aspect
@Component
@Slf4j
public class AutoFillAspect {
/*
*切入点
* 在表达式中,如果只用后面@annotation注解也可以,加上前面execution是为了减小搜索范围
*/
@Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill) " )
public void autoFillPointCut(){
}
/*
*前置通知,在通知前进行公共字段赋值
*/
@Before("autoFillPointCut()")
public void autoFill(JoinPoint joinPoint) throws InvocationTargetException, IllegalAccessException, NoSuchMethodException {
log.info("开始进行公共字段填充");
//获取当前被拦截的方法上数据库操作类型
//当前被拦截到的是一个Mapper方法,将Signature接口向下转型为MethodSignature子接口
MethodSignature signature = (MethodSignature)joinPoint.getSignature(); //方法签名对象----方法签名是指方法的名称以及参数的个数和类型
AutoFill autoFill = signature.getMethod().getAnnotation(AutoFill.class);//获得方法上的注解对象
OperationType operationType = autoFill.value();//获取数据库操作类型
/*
*上面三行代码获得该注解@AutoFill(OperationType.UPDATE)
*/
//获取到前被拦截的方法的参数---实体对象
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){
//为4个公共字段赋值
Class<?> aClass = entity.getClass();
Method setCreateTime = aClass.getDeclaredMethod(AutoFillConstant.SET_CREATE_TIME, LocalDateTime.class);
Method setCreateUser = aClass.getDeclaredMethod(AutoFillConstant.SET_CREATE_USER, Long.class);
Method setUpdateTime = aClass.getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
Method setUpdateUser = aClass.getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);
//通过反射为对象属性赋值
setCreateTime.invoke(entity,now);
setCreateUser.invoke(entity,currentId);
setUpdateTime.invoke(entity,now);
setUpdateUser.invoke(entity,currentId);
} else if (operationType==OperationType.UPDATE) {
//为2个公共字段赋值
Class<?> aClass = entity.getClass();
Method setUpdateTime = aClass.getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
Method setUpdateUser = aClass.getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);
setUpdateTime.invoke(entity,now);
setUpdateUser.invoke(entity,currentId);
}
}
}
4.6.2.4 加入AutoFill注解
在Mapper接口的方法上加入 AutoFill 注解
@Mapper
public interface CategoryMapper {
/**
* 插入数据
@param category
*/
@Insert("insert into category(type, name, sort, status, create_time, update_time, create_user, update_user)" +
" VALUES" +
" (#{type}, #{name}, #{sort}, #{status}, #{createTime}, #{updateTime}, #{createUser}, #{updateUser})")
@AutoFill(value = OperationType.INSERT)
void insert(Category category);
/**
* 根据id修改分类
@param category
*/
@AutoFill(value = OperationType.UPDATE)
void update(Category category);
}
4.7 阿里云OSS存储
阿里云对象存储服务(Object Storage Service,简称OSS)为您提供基于网络的数据存取服务。使用OSS,您可以通过网络随时存储和调用包括文本、图片、音频和视频等在内的各种非结构化数据文件。 阿里云OSS将数据文件以对象(object)的形式上传到存储空间(bucket)中。
4.7.2 操作步骤
4.7.2.1 定义OSS相关配置
application-dev.yml
#sky:
alioss:
endpoint: oss-cn-hangzhou.aliyuncs.com
#改成自己的key和秘钥
access-key-id:
access-key-secret:
#改成自己创建桶的名称
bucket-name: sky-take-out
#application.yml
spring:
profiles:
active: dev #设置环境
sky:
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}
4.7.2.2 读取OSS配置
在sky-common模块的properties包中:
package com.sky.properties;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "sky.alioss")
@Data
public class AliOssProperties {
private String endpoint;
private String accessKeyId;
private String accessKeySecret;
private String bucketName;
}
4.7.2.3 生成OSS工具类对象
在sky-server创建AliOSS对象
/*
*配置类,用于创建AilOssUtil对象
*/
@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()
);
}
4.7.2.4 导入AliOSS工具类
package com.sky.utils;
import com.aliyun.oss.ClientException;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.OSSException;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import java.io.ByteArrayInputStream;
/*
*工具类
* */
@Data
@AllArgsConstructor
@Slf4j
public class AliOssUtil {
private String endpoint;
private String accessKeyId;
private String accessKeySecret;
private String bucketName;
/**
* 文件上传
*
* @param bytes
* @param objectName
* @return
*/
public String upload(byte[] bytes, String objectName) {
// 创建OSSClient实例。
OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
try {
// 创建PutObject请求。
ossClient.putObject(bucketName, objectName, new ByteArrayInputStream(bytes));
} catch (OSSException oe) {
System.out.println("Caught an OSSException, which means your request made it to OSS, "
+ "but was rejected with an error response for some reason.");
System.out.println("Error Message:" + oe.getErrorMessage());
System.out.println("Error Code:" + oe.getErrorCode());
System.out.println("Request ID:" + oe.getRequestId());
System.out.println("Host ID:" + oe.getHostId());
} catch (ClientException ce) {
System.out.println("Caught an ClientException, which means the client encountered "
+ "a serious internal problem while trying to communicate with OSS, "
+ "such as not being able to access the network.");
System.out.println("Error Message:" + ce.getMessage());
} finally {
if (ossClient != null) {
ossClient.shutdown();
}
}
//文件访问路径规则 https://BucketName.Endpoint/ObjectName
StringBuilder stringBuilder = new StringBuilder("https://");
stringBuilder
.append(bucketName)
.append(".")
.append(endpoint)
.append("/")
.append(objectName);
log.info("文件上传到:{}", stringBuilder.toString());
return stringBuilder.toString();
}
}
4.7.2.5 定义文件上传接口
/*
*通用接口
*/
@RestController
@Slf4j
@RequestMapping("/admin/common")
@Api(tags = "通用接口")
public class CommonController {
/*
*文件上传---阿里云
*/
@Autowired
private AliOssUtil aliOssUtil;
@PostMapping("/upload")
@ApiOperation("文件上传")
public Result<String> upload(MultipartFile file) throws IOException {
try {
log.info("文件进行上传:{}",file);
//获取上传文件的文件名
String originalFilename = file.getOriginalFilename();
//截取后缀
String extension = originalFilename.substring(originalFilename.lastIndexOf("."));
//利用UUID加密文件名
String objectName = UUID.randomUUID().toString() + extension;
//调用方法返回文件的请求路径
String filePath=aliOssUtil.upload(file.getBytes(),objectName);
return Result.success(filePath);
}catch (IOException e){
log.info("文件上传失败,{}",e);
}
return Result.error(MessageConstant.UPLOAD_FAILED);
}
}
4.8 事务处理
4.8.1事务管理
事务是一组操作的集合,它是一个不可分割的工作单位。事务会把所有的操作作为一个整体,一起向数据库提交或者是撤销操作请求。所以这组操作要么同时成功,要么同时失败。
怎么样来控制这组操作,让这组操作同时成功或同时失败呢?此时就要涉及到事务的具体操作了。
事务的操作主要有三步:
-
开启事务(一组操作开始前,开启事务):start transaction / begin ;
-
提交事务(这组操作全部成功后,提交事务):commit ;
-
回滚事务(中间任何一个操作出现异常,回滚事务):rollback ;
在方法运行之前,开启事务,如果方法成功执行,就提交事务,如果方法执行的过程当中出现异常了,就回滚事,回滚事务之后数据库不发生改变,。
思考:开发中所有的业务操作,一旦我们要进行控制事务,是不是都是这样的套路?
答案:是的。
所以在spring框架当中就已经把事务控制的代码都已经封装好了,并不需要我们手动实现。我们使用了spring框架,我们只需要通过一个简单的注解@Transactional就搞定了。
4.8.2 具体操作
按照产品原型中的要求,删除实现比较复杂:
业务规则:
-
起售中的菜品(status=1)不能删除
-
被套餐关联的菜品(setmeal_dish表)不能删除
-
删除菜品后,关联的口味数据(dish_flavor表)也需要删除掉
实现代码如下:
@Transactional//事务注解
public void deleteBatch(List<Long> ids) {
//判断当前菜品是否能够删除---是否存在起售中的菜品??
for (Long id : ids) {
Dish dish = dishMapper.getById(id);//后绪步骤实现
if (dish.getStatus().equals(StatusConstant.ENABLE)) {
//当前菜品处于起售中,不能删除
throw new DeletionNotAllowedException(MessageConstant.DISH_ON_SALE);
}
}
//上述检查方式可以进行如下优化
//另外一种判断方式:SELECT id FROM dish WHERE id IN (51, 52, 53) AND STATUS = 1
//select * from dish where id = 51
//select * from dish where id = 52
//select * from dish where id = 53
//for循环外边
//List<Long> = SELECT id FROM dish WHERE id IN (51, 52, 53) and status = 1
//可自行实现
//判断当前菜品是否能够删除---是否被套餐关联了??
//select setmeal_id from setmeal_dish where dish_id IN (52,54,56)
List<Long> setmealIds = setmealDishMapper.getSetmealIdsByDishIds(ids);
if (setmealIds != null && setmealIds.size() > 0) {
//当前菜品被套餐关联了,不能删除
throw new DeletionNotAllowedException(MessageConstant.DISH_BE_RELATED_BY_SETMEAL);
}
//删除菜品表中的菜品数据
for (Long id : ids) {
dishMapper.deleteById(id);//后绪步骤实现
//删除菜品关联的口味数据
dishFlavorMapper.deleteByDishId(id);//后绪步骤实现
}
}
4.9 Redis
4.9.1 redis简介
Redis是一个基于内存的key-value结构数据库。Redis 是互联网技术领域使用最为广泛的存储中间件。 主要特点:
-
基于内存存储,读写性能高
-
适合存储热点数据(热点商品、资讯、新闻)
-
企业应用广泛
Redis是用C语言开发的一个开源的高性能键值对(key-value)数据库,官方提供的数据是可以达到100000+的QPS(每秒内查询10W次)。它存储的value类型比较丰富,也被称为结构化的NoSql数据库。 NoSql(Not Only SQL),不仅仅是SQL,泛指非关系型数据库。NoSql数据库并不是要取代关系型数据库,而是关系型数据库的补充。
4.9.2 redis的启动服务命令
redis-server.exe redis.windows.conf
4.9.3 在application.yml中添加读取application-dev.yml中的相关Redis配置
spring:
profiles:
active: dev
redis:
host: ${sky.redis.host}
port: ${sky.redis.port}
password: ${sky.redis.password}
database: ${sky.redis.database}
4.9.4 编写配置类,创建RedisTemplate对象
Configuration
@Slf4j
public class RedisConfiguration {
@Bean
public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) {
log.info("开始创建redis模版对象");
RedisTemplate redisTemplate = new RedisTemplate();
//设置redis连接工程对象,工程对象由springboot框架自动创建
redisTemplate.setConnectionFactory(redisConnectionFactory);
//设置redis key和value的序列化器
redisTemplate.setKeySerializer(new StringRedisSerializer());
//redisTemplate.setValueSerializer(new StringRedisSerializer());
return redisTemplate;
}
}
当前配置类不是必须的,因为 Spring Boot 框架会自动装配 RedisTemplate 对象:RedisAutoConfiguration,但是默认的key序列化器为 JdkSerializationRedisSerializer,导致我们存到Redis中后的数据和原始数据有差别,故设置为StringRedisSerializer序列化器。
4.9.5 通过RedisTemplate对象操作Redis
@SpringBootTest
public class SpringDataRedisTest {
@Autowired
private RedisTemplate redisTemplate;
@Test
public void testRedisTemplate(){
System.out.println(redisTemplate);
//操纵redis数据库,创建五种不同数据对象
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 setex setnx
redisTemplate.opsForValue().set("city","北京");
String city = (String) redisTemplate.opsForValue().get("city");
System.out.println(city);
redisTemplate.opsForValue().set("code","1234",3, TimeUnit.MINUTES);
redisTemplate.opsForValue().setIfAbsent("lock","1");
redisTemplate.opsForValue().setIfAbsent("lock","2");
}
/*
*操作哈希类型数据
*/
@Test
public void testHash(){
//hset hget hdel hkeys hvals
HashOperations hashOperations=redisTemplate.opsForHash();
hashOperations.put("100","name","tom");
hashOperations.put("100","age","20");
String name =(String) hashOperations.get("100", "name");
System.out.println(name);
Set keys = hashOperations.keys("100");
System.out.println(keys);
List values = hashOperations.values("100");
System.out.println(values);
hashOperations.delete("100","age");
}
/*
*操作列表类型的数据
*/
@Test
public void testList(){
//lpush lrange lpop llen
ListOperations listOperations=redisTemplate.opsForList();
listOperations.leftPushAll("mylist","a","b","c","d");
listOperations.leftPush("mylist","e");
List mylist = listOperations.range("mylist", 0, -1);
System.out.println(mylist);
listOperations.rightPop("mylist");
long size= listOperations.size("mylist");
System.out.println(size);
}
/*
*操作集合类型的数据
*/
@Test
public void testSet(){
SetOperations setOperations = redisTemplate.opsForSet();
setOperations.add("set1","a","b","c","d");
setOperations.add("set2","a","b","x","y");
Set members = setOperations.members("set1");
System.out.println(members);
Long size = setOperations.size("set1");
System.out.println(size);
Set intersect = setOperations.intersect("set1", "set2");
System.out.println(intersect);
Set union = setOperations.union("set1", "set2");
System.out.println(union);
setOperations.remove("set1","a","b");
}
/**
* 操作有序集合类型的数据
*/
@Test
public void testZset(){
//zadd zrange zincrby zrem
ZSetOperations zSetOperations = redisTemplate.opsForZSet();
zSetOperations.add("zset1","a",10);
zSetOperations.add("zset1","b",12);
zSetOperations.add("zset1","c",9);
Set zset1 = zSetOperations.range("zset1", 0, -1);
System.out.println(zset1);
zSetOperations.incrementScore("zset1","c",10);
zSetOperations.remove("zset1","a","b");
}
/**
* 通用命令操作
*/
@Test
public void testCommon(){
//keys exists type del
Set keys = redisTemplate.keys("*");
System.out.println(keys);
Boolean name = redisTemplate.hasKey("name");
Boolean set1 = redisTemplate.hasKey("set1");
for (Object key : keys) {
DataType type = redisTemplate.type(key);
System.out.println(type.name());
}
redisTemplate.delete("mylist");
}
}
4.9.6 数据同步
为了保证数据库和Redis中的数据保持一致,修改管理端接口 DishController的相关方法,加入清理缓存逻辑。
注意:在使用缓存过程中,要注意保证数据库中的数据和缓存中的数据一致
如果MySQL中的数据发生变化,需要及时清理缓存数据。否则就会造成缓存数据与数据库数据不一致的情况。 抽取清理缓存的方法:
/*
*菜品管理
*/
@Api(tags = "菜品相关接口")
@Slf4j
@RestController
@RequestMapping("/admin/dish")
public class DishController {
@Autowired
private DishService dishService;
@Autowired
private RedisTemplate redisTemplate;
/*
*新增菜品
*/
@ApiOperation("新增菜品")
@PostMapping
public Result<String> save(@RequestBody DishDTO dishDTO){
log.info("新增菜品:{}",dishDTO);
dishService.saveWithFlavor(dishDTO);
//清理redis缓存数据
String key="dish_"+dishDTO.getCategoryId();
cleanCache(key);
return Result.success();
}
/*
*批量删除菜品
*/
@DeleteMapping
@ApiOperation("批量删除菜品")
public Result<String> delete(@RequestParam List<Long> ids){
log.info("批量删除菜品:{}",ids);
dishService.deleteBatch(ids);
//清理redis缓存数据----删除删一个或多个,直接按照全部删除处理
cleanCache("dish_*");
return Result.success();
}
/*
*修改菜品和对应的口味数据
*/
@PutMapping
@ApiOperation("修改菜品")
public Result<String> update(@RequestBody DishDTO dishDTO){
log.info("修改菜品:{}",dishDTO);
dishService.updateWithFlavor(dishDTO);
//清理redis缓存数据----修改菜品会影响删一个或多个分类数据,直接按照全部清除处理
cleanCache("dish_*");
return Result.success();
}
/*
*菜品起售、停售
*/
@ApiOperation("菜品起售、停售")
@PostMapping("/status/{status}")
public Result<String> startOrStop(@PathVariable Integer status, Long id){
log.info("菜品起售、停售:{},{}",status,id);
dishService.startOrStop(status,id);
//清理redis缓存数据----修改菜品status,直接按照全部清除处理
cleanCache("dish_*");
return Result.success();
}
private void cleanCache(String pattern){
Set keys=redisTemplate.keys("dish_*");
redisTemplate.delete(keys);
}
}
对于菜品来说:新增一个菜品只需要清除对应相关分类的缓存数据即可,不影响其他数据;而删除、修改菜品的话,可能相关联数据(口味等)没有更新,造成数据错误,所以需要把所有Redis缓存清理掉。
4.10 Spring Cache框架
Spring Cache 是一个框架,实现了基于注解的缓存功能,只需要简单地加一个注解,就能实现缓存功能。
Spring Cache 提供了一层抽象,底层可以切换不同的缓存实现,例如:
-
EHCache
-
Caffeine
-
Redis(常用)
4.10.1 常用的注解
注解 | 说明 |
---|---|
@EnableCaching | 开启缓存注解功能,通常加在启动类上 |
@Cacheable | 在方法执行前先查询缓存中是否有数据,如果有数据,则直接返回缓存数据;如果没有缓存数据,调用方法并将方法返回值放到缓存中 |
@CachePut | 将方法的返回值放到缓存中 |
@CacheEvict | 将一条或多条数据从缓存中删除 |
在spring boot项目中,使用缓存技术只需在项目中导入相关缓存技术的依赖包,并在启动类上使用@EnableCaching开启缓存支持即可。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
<version>2.7.3</version>
</dependency>
而使用Redis作为缓存技术,只需要导入Spring data Redis的maven坐标即可。
4.10.2 @EnableCaching注解
在项目启动类上加上该注解---即可使用SpringCache
4.10.3 @CachePut注解
作用: 将方法返回值,放入缓存
value: 缓存的名称, 每个缓存名称下面可以有很多key
key: 缓存的key ----------> 支持Spring的表达式语言SPEL语法
在save方法上加注解@CachePut
/*
*新增套餐
*/
@PostMapping
@ApiOperation("新增套餐")
@CacheEvict(cacheNames = "setmealCache",key = "#setmealDTO.categoryId") //key:setmealCache::categoryId
public Result save(@RequestBody SetmealDTO setmealDTO){
log.info("新增套餐:{}",setmealDTO);
setmealservice.saveWithDish(setmealDTO);
return Result.success();
}
注意:设置@CachePut注解中的key属性时,推荐使用形参.属性名的方式,
即EL表达式形式 #{形参.属性名}
4.10.4 @Cacheable注解----只在用户端Controller中的查询方法上使用
作用: 在方法执行前,spring先查看缓存中是否有数据,如果有数据,则直接返回缓存数据;若没有数据,调用方法并将方法返回值放到缓存中
在查询方法上加注解@Cacheable
@ApiOperation("根据分类id查询套餐")
@GetMapping("/list")
@Cacheable(cacheNames = "setmealCache",key = "#categoryId")//key:setmealCache::categoryId
public Result<List<Setmeal>> list(Long categoryId){
Setmeal setmeal=Setmeal.builder()
.categoryId(categoryId)
.status(StatusConstant.ENABLE)
.build();
List<Setmeal> list=setmealservice.list(setmeal);
return Result.success(list);
}
4.10.5 @CacheEvict注解
作用: 清理指定缓存
在 删除、更新方法上加注解@CacheEvict
/*
*批量删除套餐
*/
@ApiOperation("批量删除套餐")
@DeleteMapping
@CacheEvict(cacheNames = "setmealCache",allEntries = true)
public Result<String> delete(@RequestParam List<Long> ids){
log.info("批量删除套餐:{}",ids);
setmealservice.deleteBatch(ids);
return Result.success();
}
/*
*修改套餐
*/
@PutMapping
@ApiOperation("修改套餐")
@CacheEvict(cacheNames = "setmealCache",allEntries = true)
public Result<String> update(@RequestBody SetmealDTO setmealDTO){
log.info("修改套餐:{}",setmealDTO);
setmealservice.update(setmealDTO);
return Result.success();
}