一. 瑞吉外卖项目总结
瑞吉外卖项目分为后台管理端和移动端(用户端).
主要核心技术是:springboot +mybatis-plus +redis +mysql
1. 后端Controller层返回结果统一封装的R对象
后端的controller层接收完前端的请求后,要返回什么样的结果是需要按情况变化的,但如果每一个controller返回的结果不一样,前端也要用不同的数据类型进行接收。为了避免麻烦,制定统一的controller层返回对象是很有必要的。
public class R<T> implements Serializable {
private Integer code;
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;
}
}
2.定义静态资源映射关系
静态资源映射关系主要用于将前端请求的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/");
}
}
3. 配置消息资源转换器
3.1 Reggie项目中遇到的问题
数据库的主键大都是由mybatis-plus的主键自动生成策略之雪花算法生成的,雪花算法生成的是一个Long类型的数字,而雪花算法生成的主键传输到前端的时候会出现精度丢失现象导致前端拿到的id和数据库中的id不一致。那么前端再发出请求无论是通过id查找数据还是修改数据都会因为id不一致而修改失败。
3.2 原理
后端使用64位存储长整数(long),最大支持9223372036854775807 2.前端的JavaScript使用53位来存放,最大支持9007199254740992,超过最大值的数, 可能会 出现问题(得到的溢出后的值);
3.3 解决方案
springboot前后端资源传输可以采用json格式字符串,我们可以添加消息资源转换器MessageConverters,将Long类型的数据序列化为字符串,添加后spring web mvc在处理controller返回值的时候会采用自定义的序列策略自动将Long/BigInt序列化为字符串,这样就可以解决Long类型数据精度丢失问题。
3.4 示例
MyWebMvcConfig:
@Configuration
public class MyWebMvcConfig extends WebMvcConfigurationSupport {
/*
* 拓展消息资源转换器
* */
@Override
protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
MappingJackson2HttpMessageConverter messageConverter=new MappingJackson2HttpMessageConverter();
messageConverter.setObjectMapper(new JacksonObjectMapper());
//将我们自定义的消息转换器,添加进行集合中,并把优先级设置为最高
converters.add(0,messageConverter);
}
}
JacksonObjectMapper:
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对象之间的序列化器供我们使用。
4. Mybatis-Plus的使用
4.1 基本使用
通过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 {
}
4.2 Mybatis-plus分页查询组件的使用
要使用mybatis-plus为我们提供的插件,我们只需要写一个配置类,为mybatis-plus提供分页插件拦截器PaginationInnerInterceptor类,对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);
LambdaQueryWrapper<Category> queryWrapper=new LambdaQueryWrapper<>();
queryWrapper.orderByAsc(Category::getType,Category::getSort);
categoryService.page(pageInfo,queryWrapper);
return R.success(pageInfo);
}
4.3 Mybatis-plus 提供的公共字段自动填充功能的使用
公共字段的含义:
在数据库表与表中共同含有的字段,在Reggie项目中如createUser,createTime,updateUser,updateTime这些字段十分通用几乎每个表中都有,此时如果对于每个表的每次操作都考虑填充这些字段无疑十分繁琐,代码重复度也高,mybatis-plus可以通过简单配置MetaObjectHandler类就能够在每个sql语句到达数据库之前检查对象是否有这些字段并进行自动注入。
@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.4 编码技巧:借助ThreadLocal本地线程变量来储存信息
4.3当中其实还有一个亟待解决的问题:就是不论是当前是插入记录还是更新记录,即不论是createUser还是updateUser应该都是当前用户,那么如何获取当前用户的id呢?
因为之前是将id存入session中,自然的想从session当中取出值,但当前不是controller层无法取到session。
Tomact会为每一个http请求分配一个单独线程,因此我们可以在controller层或者filter这些能取到session中的id的时候把id储存到线程的本地线程变量中,在我们需要进行元数据对象填充的时候在从线程本地变量中取出id。
ThreadLocal的使用方法都是相近的。
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;
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;
}
filterChain.doFilter(servletRequest,servletResponse);
}
5. 全局异常处理器的使用与配置
请求到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("未知错误!");
}
}
6. DTO数据传输对象的使用
在WEB项目中经常会遇到一种情况,前端传输的参数在后端controller层中原有的对象无法全部接收到前端传输的所有参数,因此我们可以创建一个原有对象对应的DTO对象继承原有对象,拓展新的属性以便接收前端传输的全部参数。
这一点在后端controller层返回值中也可以体现,Reggie项目中,controller的返回值封装成R对象中的data属性,即我们需要用一个对象封装前端想要的所有参数而返回,但有时候前端想要的所有数据可能后端已有的类都无法一个对象封装所有参数。因此我们可以在原有的类基础上继承一个子类拓展属性来满足要求。
示例:
public class DishDTO extends Dish {
private List<DishFlavor> flavors;
private String categoryName;
private Integer copies;
@Override
public String toString() {
return "DishDTO{" +
"flavors=" + flavors +
", categoryName='" + categoryName + '\'' +
", copies=" + copies +
'}';
}
}
7. 文件的上传和下载
在WEB项目中文件上传和下载都是家常饭菜必不可少,而文件上传下载是很套路很模板化的知识点,没什么好说的,只要套用即可。
@RestController
public class CommonController {
@Value("${file.upLoad.path}")
private String FILE_UPLOAD_PATH;
@RequestMapping("/common/upload")
public R<String> fileUpLoadController(MultipartFile file){
if(file.isEmpty()){
return R.error("文件上传失败");
}else{
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){
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();
}
}
}
}
}
8. Redis
8.1 RedisTemplate注意事项
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);
dishService.updateById(dish);
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("修改菜品信息成功!");
}
8.2 Spring Cache简化开发
缓存一般都是用来解决读请求的,来降低落到mysql的访问压力,而当数据发生写操作时,根据实际
需求可能需要删除redis缓存或者同步缓存和数据库的数据。对于一些简单的逻辑我们完全可以用注解来实现,比如需要使用缓存的读请求,一般都是先看缓存中有没有,如果有直接从缓存中拿,没有去mysql中拿并回写到缓存中。spring cache框架支持用简单的注解来满足简单的使用缓存的需求,但若是有较为复杂的逻辑还需要自己来实现。
配置:
pom.xml
// 导入redis的依赖关系
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>2.7.0</version>
</dependency>
// 导入spring-cache的依赖包
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
<version>2.7.0</version>
</dependency>
application.yml
spring:
redis:
host: 192.168.233.141
port: 6379
password: root@123456
database: 0
cache:
redis:
time-to-live: 3600000
在启动类上开启注解缓存方式:
@ServletComponentScan
@SpringBootApplication
@EnableTransactionManagement
@EnableCaching
public class ReggieApplication {
public static void main(String[] args) {
SpringApplication.run(ReggieApplication.class, args);
}
}
spring cache常用注解
9. MySQL主从复制
9.1 为什么要有主从复制
mysql的主从复制的目的和redis主从复制的目的几乎都是一样的,为了解决单点故障问题,主mysql数据库挂了,从mysql数据库可以继续干活。可以进行读写分离,在并发量大的时候并且是读多写少的环境下,我们可以进行读写分离,让从mysql数据库为只读,主mysql数据库即可读也可以写,相当于分担了主msyql读的并发压力,系统可用性更高。
9.2 主从复制原理
9.4 mysql主从复制相关配置
主机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。
9.4 主从复制中可能出现的问题
-
可能出现因为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 查看错误信息
10. Sharding-JDBC框架实现MySQL读写分离
pom.xml
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>sharding-jdbc-spring-boot-starter</artifactId>
<version>4.1.1</version>
</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
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
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
master-data-source-name: master
二:Reggie项目感言
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和库,总体上功能简单,没有什么难点,也没有高并发的场景可以供调优来实践,总体上还是比较简单的。
但还是能够学到很多新技术,新框架的使用,确实大大简化了开发,提高了效率,但写完代码后应该还需要再重构一次。
|