瑞吉外卖项目分为后台管理端和移动端(用户端).
主要核心技术是:springboot +mybatis-plus +redis +mysql
后端的controller层接收完前端的请求后,要返回什么样的结果是需要按情况变化的,但如果每一个controller返回的结果不一样,前端也要用不同的数据类型进行接收。为了避免麻烦,制定统一的controller层返回对象是很有必要的。
public class R<T> implements Serializable {
private Integer code; //编码:1成功,0和其它数字为失败
private String msg; //错误信息
private T data; //数据
private Map map = new HashMap(); //动态数据
public static <T> R<T> success(T object) {
R<T> r = new R<T>();
r.data = object;
r.code = 1;
return r;
}
public static <T> R<T> error(String msg) {
R r = new R();
r.msg = msg;
r.code = 0;
return r;
}
public R<T> add(String key, Object value) {
this.map.put(key, value);
return this;
}
}
静态资源映射关系主要用于将前端请求的URI路径与后端服务器资源路径进行映射。
Reggie项目中的用途:springboot中静态资源是默认放在static目录下和template目录下的,如果你要把静态资源放在其它目录下,就必须配置静态资源映射关系。否则前端的请求URI将匹配不到资源。
示例:
后端代码:
@Configuration
public class MyWebMvcConfig extends WebMvcConfigurationSupport {
@Override
protected void addResourceHandlers(ResourceHandlerRegistry registry) {
//设置静态资源映射关系
registry.addResourceHandler("/backend/**").addResourceLocations("classpath:/backend/");
registry.addResourceHandler("/front/**").addResourceLocations("classpath:/front/");
}
}
数据库的主键大都是由mybatis-plus的主键自动生成策略之雪花算法生成的,雪花算法生成的是一个Long类型的数字,而雪花算法生成的主键传输到前端的时候会出现精度丢失现象导致前端拿到的id和数据库中的id不一致。那么前端再发出请求无论是通过id查找数据还是修改数据都会因为id不一致而修改失败。
后端使用64位存储长整数(long),最大支持9223372036854775807 2.前端的JavaScript使用53位来存放,最大支持9007199254740992,超过最大值的数, 可能会 出现问题(得到的溢出后的值);
springboot前后端资源传输可以采用json格式字符串,我们可以添加消息资源转换器MessageConverters,将Long类型的数据序列化为字符串,添加后spring web mvc在处理controller返回值的时候会采用自定义的序列策略自动将Long/BigInt序列化为字符串,这样就可以解决Long类型数据精度丢失问题。
MyWebMvcConfig:
@Configuration
public class MyWebMvcConfig extends WebMvcConfigurationSupport {
/*
* 拓展消息资源转换器
* */
@Override
protected void extendMessageConverters(List> converters) {
MappingJackson2HttpMessageConverter messageConverter=new MappingJackson2HttpMessageConverter();
messageConverter.setObjectMapper(new JacksonObjectMapper());
//将我们自定义的消息转换器,添加进行集合中,并把优先级设置为最高
converters.add(0,messageConverter);
}
}
JacksonObjectMapper:
/**
* 对象映射器:基于jackson将Java对象转为json,或者将json转为Java对象
* 将JSON解析为Java对象的过程称为 [从JSON反序列化Java对象]
* 从Java对象生成JSON的过程称为 [序列化Java对象到JSON]
*/
public class JacksonObjectMapper extends ObjectMapper {
public static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd";
public static final String DEFAULT_DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
public static final String DEFAULT_TIME_FORMAT = "HH:mm:ss";
public JacksonObjectMapper() {
super();
//收到未知属性时不报异常
this.configure(FAIL_ON_UNKNOWN_PROPERTIES, false);
//反序列化时,属性不存在的兼容处理
this.getDeserializationConfig().withoutFeatures(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
SimpleModule simpleModule = new SimpleModule()
.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT)))
.addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)))
.addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT)))
.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);
}
}
maven依赖
<!-- https://mvnrepository.com/artifact/com.alibaba/fastjson -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>2.0.10</version>
</dependency>
fastjson jar包有一系列的Java对象和json对象之间的序列化器供我们使用。
通过mybatis-plus框架的使用,在Reggie项目的实践中,确实明显的提高的开发效率,不需要在像以往一样给mapper映射文件写单独的配置文件mapper.xml,可以用简单的LambdaQueryWrapper类和LambdaUpdateWrapper类构造查询条件或者修改条件就可以代替在xml配置文件中写sql语句,大大简化了开发,同时mapper接口和Service接口和实现类都只需要实现或继承框架指定的类就可以。
样例
application.yaml 进行mybatis-plus相关配置
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
mapper接口:
@Mapper
public interface DishMapper extends BaseMapper<Dish> {
}
service接口:
public interface DishService extends IService<Dish> {
}
serviceImpl类:
@Service
public class DishServiceImpl extends ServiceImpl<DishMapper, Dish> implements DishService {
}
要使用mybatis-plus为我们提供的插件,我们只需要写一个配置类,为mybatis-plus提供分页插件拦截器PaginationInnerInterceptor类,对mybatis-plus框架功能进行增强。
示例:
/*
* mybatis-plus分页插件的配置
* */
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor getMybatisPlusInterceptor(){
MybatisPlusInterceptor mybatisPlusInterceptor=new MybatisPlusInterceptor();
mybatisPlusInterceptor.addInnerInterceptor(new PaginationInnerInterceptor());
return mybatisPlusInterceptor;
}
}
分页插件的使用
步骤:1. 准备分页条件构造器 2.准备查询条件构造器 3.service.page(分页条件构造器,查询条件构造器)
/*
* 分类数据的分页查询
* */
@RequestMapping(value = "/backend/page/category/queryCategoryForPage.do")
public R<Page<Category>> queryCategoryForPage(Integer page,Integer pageSize){
//准备分页条件构造器
Page<Category> pageInfo=new Page<>(page,pageSize);
//进行排序条件的构造
//排序条件: 先按type排序,type相同按sort排序
LambdaQueryWrapper<Category> queryWrapper=new LambdaQueryWrapper<>();
queryWrapper.orderByAsc(Category::getType,Category::getSort);
//在进行完分页查询后,会把查询结果回调设置会pageInfo里面
categoryService.page(pageInfo,queryWrapper);
return R.success(pageInfo);
}
公共字段的含义:
在数据库表与表中共同含有的字段,在Reggie项目中如createUser,createTime,updateUser,updateTime这些字段十分通用几乎每个表中都有,此时如果对于每个表的每次操作都考虑填充这些字段无疑十分繁琐,代码重复度也高,mybatis-plus可以通过简单配置MetaObjectHandler类就能够在每个sql语句到达数据库之前检查对象是否有这些字段并进行自动注入。
/*
* 自定义元数据对象处理器
* 完成公共字段自动填充功能
* 难点:如何动态的获得当前用户的id
* */
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
if(metaObject.hasSetter("createUser")){
metaObject.setValue("createUser", UserIdContextHolder.getContextHolder());
}
if(metaObject.hasSetter("createTime")){
metaObject.setValue("createTime", DateUtils.formatDateTime(new Date()));
}
}
@Override
public void updateFill(MetaObject metaObject) {
if(metaObject.hasSetter("updateUser")){
metaObject.setValue("updateUser", UserIdContextHolder.getContextHolder());
}
if(metaObject.hasSetter("updateTime")){
metaObject.setValue("updateTime", DateUtils.formatDateTime(new Date()));
}
}
}
4.3当中其实还有一个亟待解决的问题:就是不论是当前是插入记录还是更新记录,即不论是createUser还是updateUser应该都是当前用户,那么如何获取当前用户的id呢?
因为之前是将id存入session中,自然的想从session当中取出值,但当前不是controller层无法取到session。
Tomact会为每一个http请求分配一个单独线程,因此我们可以在controller层或者filter这些能取到session中的id的时候把id储存到线程的本地线程变量中,在我们需要进行元数据对象填充的时候在从线程本地变量中取出id。
ThreadLocal的使用方法都是相近的。
/*
* 因为前端每次发出request请求,服务器都会为这次请求分配
* 一个新的线程,我们可以利用线程的ThreadLocal在请求到Controller的时候保存当前
* 用户的id到ThreadLocal中这样,我们就可以在MetaObjectHandler中动态获取到当前
* 用户的id
* */
public class UserIdContextHolder {
private static final ThreadLocal<Long> CONTEXT_HOLDER=new ThreadLocal<>();
public static void setContextHolder(Long id){
CONTEXT_HOLDER.set(id);
}
public static Long getContextHolder(){
return CONTEXT_HOLDER.get();
}
public static void remove(){
CONTEXT_HOLDER.remove();
}
}
使用案例:
Filter中的doFilter方法
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request=(HttpServletRequest)servletRequest;
HttpServletResponse response=(HttpServletResponse)servletResponse;
//业务代码
//1:直接放行
//2:如果已经登录直接放行
if(request.getSession().getAttribute("employee")!=null){
UserIdContextHolder.setContextHolder((Long) request.getSession().getAttribute("employee"));
filterChain.doFilter(servletRequest,servletResponse);
UserIdContextHolder.remove();
return;
}
if(request.getSession().getAttribute("user")!=null){
UserIdContextHolder.setContextHolder((Long) request.getSession().getAttribute("user"));
filterChain.doFilter(servletRequest,servletResponse);
UserIdContextHolder.remove();
return;
}
//3:未登录,如果访问的是后台controller直接拦截
//业务代码
//4:未登录访问的是其它的资源,放行
filterChain.doFilter(servletRequest,servletResponse);
}
请求到controller之后,调用service进行业务操作,一旦报错,一般我们会在controller中使用try-catch进行异常捕获,但是这个方法有一定的弊端,try-catch和业务代码混杂在一起,耦合度高,不易阅读。
我们可以配置全局异常处理器,通过SpringAop切面编程的技术,将全局异常处理器织入到所有被RestController或者Controller注解所注解的类。这样我们就可以把所有controller层中需要写的try-catch全部写到一个类中,代码更简洁,复用性更高。
案例:
@ControllerAdvice(annotations ={RestController.class, Controller.class})
@ResponseBody
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(SQLIntegrityConstraintViolationException.class)
public R<String> SQLExceptionHandler(SQLIntegrityConstraintViolationException ex){
log.error(ex.getMessage());
String message=ex.getMessage();
if(message.contains("Duplicate entry")){
String[] split = message.split(" ");
return R.error("字段:"+split[2]+"不能重复录入!");
}
return R.error("未知错误!");
}
}
在WEB项目中经常会遇到一种情况,前端传输的参数在后端controller层中原有的对象无法全部接收到前端传输的所有参数,因此我们可以创建一个原有对象对应的DTO对象继承原有对象,拓展新的属性以便接收前端传输的全部参数。
这一点在后端controller层返回值中也可以体现,Reggie项目中,controller的返回值封装成R对象中的data属性,即我们需要用一个对象封装前端想要的所有参数而返回,但有时候前端想要的所有数据可能后端已有的类都无法一个对象封装所有参数。因此我们可以在原有的类基础上继承一个子类拓展属性来满足要求。
示例:
public class DishDTO extends Dish {
//封装了口味集合
private List<DishFlavor> flavors;
//菜品分类名称 后端数据库有的是categoryId但前端需要菜品分类的名称,原有的Dish对象不再能满足需求。在DishDTO中拓展categoryName属性满足条件.
private String categoryName;
//菜品的份数 前端传输菜品数据的时候会一并传输用户点这菜的份数,而后端的Dish对象无法封装菜品数目,于是在DishDTO中拓展copies属性以满足需要。
private Integer copies;
@Override
public String toString() {
return "DishDTO{" +
"flavors=" + flavors +
", categoryName='" + categoryName + '\'' +
", copies=" + copies +
'}';
}
}
在WEB项目中文件上传和下载都是家常饭菜必不可少,而文件上传下载是很套路很模板化的知识点,没什么好说的,只要套用即可。
/*
* 实现文件上传和下载的Controller
* */
@RestController
public class CommonController {
@Value("${file.upLoad.path}")
private String FILE_UPLOAD_PATH;
//返回值:文件上传成功时返回文件名称
@RequestMapping("/common/upload")
public R<String> fileUpLoadController(MultipartFile file){
//文件上传时,接收到前端传输文件的file会默认在服务器生成一个临时存储文件
//当这个方法执行完毕后,该临时存储文件会被销毁
//所以我们需要将文件进行转存,转存到指定磁盘目录
//为了防止文件名重复,用UUID生成文件名
if(file.isEmpty()){
return R.error("文件上传失败");
}else{
//1:如果FILE_UPLOAD_PATH文件夹没创建则需要创建
File dic=new File(FILE_UPLOAD_PATH);
if(!dic.exists()){
dic.mkdirs();
}
String filename=GenerateUUID.getByFilename(file.getOriginalFilename());
String realPath=FILE_UPLOAD_PATH+ filename;
try {
file.transferTo(new File(realPath));
} catch (IOException e) {
e.printStackTrace();
return R.error("文件上传失败!");
}
return R.success(filename);
}
}
@RequestMapping("/common/download")
public void download(String name, HttpServletResponse response){
//1:输入流和输出流
//将磁盘中的文件以输入流的方式读进内存
//将内存中的文件写入response当中
String realPath=FILE_UPLOAD_PATH+name;
InputStream inputStream=null;
ServletOutputStream outputStream=null;
response.setContentType("image/jpg");
try {
inputStream=new FileInputStream(new File(realPath));
outputStream = response.getOutputStream();
IOUtils.copy(inputStream,outputStream);
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}finally {
if(inputStream!=null){
try {
inputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if(outputStream!=null){
try {
outputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
redis是二进制安全的,在redis中存储的数据其实是经过序列化的字节流,而redis中数据类型仅仅代表数据的组织结构,并不是值其真实存储的数据。在实际项目中我们需要将redis作为缓存使用,将从数据库中查询出来的数据存储在redis中,而查询出来的数据一般都是对象,List集合,甚至需要将map存进redis当中,这时后我们就需要考虑要使用redis提供的啥数据类型进行存储?
我们可以统一用redis中的字符串类型来存储,将对象序列化为字节数组然后以字符串的形式保存在数据库当中。这样我们只需要配置RedisTemplate的value序列方式为JdkSerializationRedisSerializer,就可以将jave中的对象序列化为字符串,然后读出来的时候以同样的方式反序列化。
存在的问题
:Redis支持很多语言,我们以JDK序列化器序列化的对象,别的语言写的服务器就无法正确的反序列化可能会导致乱码问题。如果真有这种需求可以考虑统一序列化为json格式的字符串,那么所有类型都能够访问。
public R<String> updateDish(@RequestBody DishDTO dishDTO){
//为了避免后台修改菜品的数据时,前端因为直接查缓存而看不到
//所以直接从缓存当中去拿取数据
String key="categoryId:"+dishDTO.getCategoryId();
List<DishDTO> dishDTOList =(List<DishDTO>) redisTemplate.opsForValue().get(key);
if(dishDTOList!=null){
for(int i=0;i<dishDTOList.size();i++){
DishDTO dto=dishDTOList.get(i);
if(Objects.equals(dto.getId(), dishDTO.getId())){
dishDTOList.set(i,dishDTO);
break;
}
}
redisTemplate.opsForValue().set(key,dishDTOList,1,TimeUnit.HOURS);
}
Dish dish=new Dish();
BeanUtils.copyProperties(dishDTO,dish);
//1: 根据id修改菜品表的数据信息
dishService.updateById(dish);
//2: 根据id修改口味表的信息
//先清除该菜品下所有口味,再添加回去
LambdaQueryWrapper<DishFlavor> queryWrapper=new LambdaQueryWrapper<>();
queryWrapper.eq(DishFlavor::getDishId,dishDTO.getId());
dishFlavorService.remove(queryWrapper);
List<DishFlavor> flavors = dishDTO.getFlavors();
for (DishFlavor flavor : flavors) {
flavor.setDishId(dishDTO.getId());
}
dishFlavorService.saveBatch(flavors);
return R.success("修改菜品信息成功!");
}
缓存一般都是用来解决读请求的,来降低落到mysql的访问压力,而当数据发生写操作时,根据实际
需求可能需要删除redis缓存或者同步缓存和数据库的数据。对于一些简单的逻辑我们完全可以用注解来实现,比如需要使用缓存的读请求,一般都是先看缓存中有没有,如果有直接从缓存中拿,没有去mysql中拿并回写到缓存中。spring cache框架支持用简单的注解来满足简单的使用缓存的需求,但若是有较为复杂的逻辑还需要自己来实现。
配置:
pom.xml
// 导入redis的依赖关系
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
<version>2.7.0version>
dependency>
// 导入spring-cache的依赖包
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-cacheartifactId>
<version>2.7.0version>
dependency>
application.yml
spring:
redis:
host: 192.168.233.141
port: 6379
password: root@123456
database: 0
cache:
redis:
time-to-live: 3600000 # redis中设置的key的默认过期时间,实际应用中为了避免缓存雪崩问题,设置的默认过期时间应该尽可能分散。
在启动类上开启注解缓存方式:
@ServletComponentScan
@SpringBootApplication
@EnableTransactionManagement
@EnableCaching //开启spring-cache注解
public class ReggieApplication {
public static void main(String[] args) {
SpringApplication.run(ReggieApplication.class, args);
}
}
spring cache常用注解
mysql的主从复制的目的和redis主从复制的目的几乎都是一样的,为了解决单点故障问题,主mysql数据库挂了,从mysql数据库可以继续干活。可以进行读写分离,在并发量大的时候并且是读多写少的环境下,我们可以进行读写分离,让从mysql数据库为只读,主mysql数据库即可读也可以写,相当于分担了主msyql读的并发压力,系统可用性更高。
主机master:
需要注意的一点是:MySQL8新特性中,不能同时创建用户并给用户授权
要先创建用户,再给用户授权,否则会出语法错误。
让slave从机知道主机的二进制文件的位置在哪里。
从机slave:
配置从机的serverId注意:主机和从机的serverId必须不一样
重启mysql服务器
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-S01yEmQ3-1663037518369)(C:\Users\11425\AppData\Roaming\Typora\typora-user-images\image-20220913101527073.png)]
通过show slave status\G 查看从机的状态:注意这时需要着重查看redis的IO Thread和SQL Thread只有这两个线程都OK才Ok。
可能出现因为MySQL8的身份验证方式是 :Caching_sha2_password 从而导致从机连接主机失败,这是因为Caching_sha2_password验证插件安全性更高需要配置RSA密码交互方式,否则会失败,如果不想配置,可以使用MySQL5.7 之前的版本的密码验证方式:mysql_native_password
指令为:ALTER USER ‘root’@‘localhost’ IDENTIFIED WITH mysql_native_password BY ‘你的密码’;
从机可以通过show slave status\G 查看错误信息
pom.xml
<dependency>
<groupId>org.apache.shardingspheregroupId>
<artifactId>sharding-jdbc-spring-boot-starterartifactId>
<version>4.1.1version>
dependency>
application.yml 配置读写分离的相关参数,就可以实现读写分离了
spring:
shardingsphere:
datasource:
names: master,slave
master:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/reggie?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: 123456
type: com.alibaba.druid.pool.DruidDataSource
#Spring Boot 默认是不注入这些属性值的,需要自己绑定
#druid 数据源专有配置
initialSize: 5
minIdle: 5
maxActive: 20
maxWait: 60000
timeBetweenEvictionRunsMillis: 60000
minEvictableIdleTimeMillis: 300000
validationQuery: SELECT 1 FROM DUAL
testWhileIdle: true
testOnBorrow: false
testOnReturn: false
poolPreparedStatements: true
slave:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://192.168.233.141:3306/reggie?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: Ai@15012706016
type: com.alibaba.druid.pool.DruidDataSource
#Spring Boot 默认是不注入这些属性值的,需要自己绑定
#druid 数据源专有配置
initialSize: 5
minIdle: 5
maxActive: 20
maxWait: 60000
timeBetweenEvictionRunsMillis: 60000
minEvictableIdleTimeMillis: 300000
validationQuery: SELECT 1 FROM DUAL
testWhileIdle: true
testOnBorrow: false
testOnReturn: false
poolPreparedStatements: true
masterslave: # 主从复制的配置
# 负载均衡的配置:配置为轮询
load-balance-algorithm-type: round_robin
# 最终暴露的数据源名称
name: datasource
# 从数据库名称列表,用','号隔开
slave-data-source-names: slave
props:
sql:
show: true # 开启在控制台显示sql,默认是false
master-data-source-name: master
Reegie外卖项目更多的是CRUD,调用API和库,总体上功能简单,没有什么难点,也没有高并发的场景可以供调优来实践,总体上还是比较简单的。
maxActive: 20
maxWait: 60000
timeBetweenEvictionRunsMillis: 60000
minEvictableIdleTimeMillis: 300000
validationQuery: SELECT 1 FROM DUAL
testWhileIdle: true
testOnBorrow: false
testOnReturn: false
poolPreparedStatements: true
masterslave: # 主从复制的配置
# 负载均衡的配置:配置为轮询
load-balance-algorithm-type: round_robin
# 最终暴露的数据源名称
name: datasource
# 从数据库名称列表,用','号隔开
slave-data-source-names: slave
props:
sql:
show: true # 开启在控制台显示sql,默认是false
master-data-source-name: master
## 二:Reggie项目感言
Reegie外卖项目更多的是CRUD,调用API和库,总体上功能简单,没有什么难点,也没有高并发的场景可以供调优来实践,总体上还是比较简单的。
但还是能够学到很多新技术,新框架的使用,确实大大简化了开发,提高了效率,但写完代码后应该还需要再重构一次。