前言 博主最近在学习开发后端项目,因为没有做项目的基础,所以找了黑马的瑞吉外卖项目练练手。开始做这个项目的时候还是在学校,最近也是回到了家里。这几天就把在学校里差的内容做完了,从开始做这个项目到结束差不多一个月的时间。而瑞吉外卖这个项目更多的CRUD,调用API和库,总体上功能简单,没有什么难点,也没有高并发的场景可供调优实践。但是在其中还是学习到很多知识,所以写下这篇博客来总结项目。
后端Controller层返回结果统一封装为R对象 后端的Controller层接收完前端的请求后,要返回什么样的结果是需要按情况变化的,但是如果每一个Controller返回的结果是不一样的,前端也要用不同的数据类型来接收。为了避免麻烦,前后端指定统一的controller层返回对象类型。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 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 ; } }
可以看到我们可以使用这个类中静态方法,并且静态方法是使用了泛型来定义data数据类型的。我们可以举个手机端退出功能 的例子来演示下前端发送请求到后端,后端响应成功并把数据封装到R对象中。可以看到在写Controller方法时确定返回数据类型为R类型对象其中数据类型为String。这样后端返回给前端的信息为code=1 data=”退出成功”,之后再由前端对返回的数据进行判断做出下一步操作。
1 2 3 4 5 6 7 8 9 10 11 @PostMapping("/loginout") public R<String> loginout (HttpSession session) { session.removeAttribute("user" ); return R.success("退出成功" ); }
定义静态资源映射关系 静态资源映射关系主要用于将前端请求的URL路径与后端服务器资源路径进行映射。在Springboot中静态 资源是默认放在static目录下,如果你要把静态资源放在其他目录下,就必须配置静态资源映射关系。否则前端的请求将匹配不到资源。
设置静态资源映射关系后端代码:
1 2 3 4 5 6 7 8 9 10 11 @Slf4j @Configuration public class WebMvcConfig extends WebMvcConfigurationSupport { @Override public void addResourceHandlers (ResourceHandlerRegistry registry) { registry.addResourceHandler("/**" ) .addResourceLocations("classpath:/resources/" ) .addResourceLocations("classpath:/static/" ) .addResourceLocations("classpath:/public/" ); }
配置消息资源转换器 瑞吉外卖项目中遇到的问题 数据库中表的主键大都是由mybatis-plus的主键自动生成策略的雪花算法生成的,雪花算法生成的是一个Long类型的数字,而雪花算法生成的主键传输到前端的时候会出现精度缺失现象导致前端拿到的id和数据库中的id不一致。那么前端再发出请求无论是通过id查找数据还是修改数据都会因为id不一致而修改失败。
精读缺失的原因 后端使用64位存储长整数 (Long) ,而前端的JavaScript是使用53位来存储此类型数据。因此超过最大值的数,可能会出现问题。
解决方法 Springboot项目前后端资源可以采用json格式字符串,我们可以添加消息资源转换器MessageConverters,将Long类型的数据序列化为字符串,添加后Spring web mvc在处理controller返回值的时候会采用自定义的序列化策略自动将Long类型数据序列化为字符串,这样就可以解决Long类型数据精度缺失问题。
导入fastjson的maven依赖:
1 2 3 4 5 <dependency > <groupId > com.alibaba</groupId > <artifactId > fastjson</artifactId > <version > 2.0.13.graal</version > </dependency >
编写对象映射器:JacksonObjectMapper
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 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); } }
编写完对象映射器后,在配置扩展消息资源转换器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @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); } }
文件上传 什么是文件上传 文件上传也称为upload,是指将本地图片、视频、音频文件上传到服务器上 ,可以供其他用户浏览或下载的过程。文件上传在项目中应用非常广泛,我们经常发微博,发微信都用到了文件上传功能。
文件上传时,对页面的form表单有如下要求:
method=”post”,采用post方式提交数据
enctype=”multipart/form-data”,采用multipart格式上传文件
type=”file”,使用input的file控件上传
瑞吉外卖项目中的文件上传 瑞吉外卖项目中使用Spring框架在Spring-web包下对文件上传进行封装,大大简化了服务端代码,我们只需要在Controller的方法中声明一个MultipartFile类型的参数即可接收上传的文件 。注意这里的basePath的值,他的定义的值为applicaton.yml配置文件中配置的值,这样做的好处是如果后期我们将项目部署到服务器上,我们只需要修改配置文件就可以达到修改文件上传位置的更改,方便维护。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 @RestController @RequestMapping("/common") public class CommonController { @Value("${reggie.path}") private String basePath; @PostMapping("/upload") public R<String> upload (MultipartFile file) { String originalFilename = file.getOriginalFilename(); String suffix = originalFilename.substring(originalFilename.lastIndexOf("." )); String fileName = UUID.randomUUID().toString()+suffix; File dir = new File (basePath); if (!dir.exists()){ dir.mkdir(); } try { file.transferTo(new File (basePath+fileName)); } catch (IOException e) { throw new RuntimeException (e); } return R.success(fileName); } }
文件下载 什么是文件下载 文件下载也称为download,是指将文件从服务器传输到本地计算机的过程。通过浏览器进行文件下载,通常有两种表现形式:1.以附件形式下载,弹出保存对话框,将文件保存到指定磁盘目录。2.直接在浏览器页面展示
其实浏览器上进行文件下载,本质上就是服务端将文件以流的方式写回浏览器的过程
瑞吉项目中的文件下载 查看菜品管理页面中文件上传的组件,img标签展示图片,src代表图片存在的位置
1 2 3 4 5 6 7 8 9 <el-upload class ="avatar-uploader" action ="/common/upload" :show-file-list ="false" :on-success ="handleAvatarSuccess" :before-upload ="beforeUpload" ref ="upload" > <img v-if ="imageUrl" :src ="imageUrl" class ="avatar" > </img > <i v-else class ="el-icon-plus avatar-uploader-icon" > </i > </el-upload >
可以看到如果菜品管理页面中的文件上传成功,则**保存文件到服务器中然后返回给前端页面文件名称 (请看文件上传代码最后回显给前端文件名称)**。文件上传成功后触发回调函数on-success=”handleAvatarSuccess”,发送Get请求,其中请求参数name的值为文件上传回显中的数据,也就是文件名称。
1 2 3 handleAvatarSuccess (response, file, fileList) { this .imageUrl = `/common/download?name=${response.data } ` },
我们在后端处理文件下载请求代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 @GetMapping("/download") public void download ( String name, HttpServletResponse response) { try { FileInputStream fileInputStream = new FileInputStream (new File (basePath + name)); ServletOutputStream outputStream = response.getOutputStream(); response.setContentType("image/jpeg" ); int len = 0 ; byte [] bytes=new byte [1024 ]; while ((len = fileInputStream.read(bytes)) != -1 ){ outputStream.write(bytes,0 ,len); outputStream.flush(); } outputStream.close(); fileInputStream.close(); } catch (Exception e) { throw new RuntimeException (e); } }
文件下载,通过文件上传返回给页面的文件名称+basePath找到服务器中存储的文件,然后通过IO流边读边写到浏览器中,这时候imageUrl 在浏览器中展示的就是一副对应的图片,这时候前端再通过img标签进行展示就可以展示我们上传或者服务器中的图片。
Mybatis-Plus的使用 项目中使用Mybatis-Plus 在瑞吉外卖项目中通过使用Mybatis-Plus框架确实明显提高了开发效率,不需要像以往在mapper映射文件中写单独的配置文件mapper.xml。可以简化LambdaQueryWrapper类和LambdaUpdateWrapper类构造查询条件或者修改条件就可以代替在xml配置文件中写sql语句,大大简化开发。同时mapper接口和Service接口和实现类都只需要实现或继承框架指定的类就可以。
使用mybatis-plus我们需要在springboot项目中的application.yml进行相关配置:
1 2 3 4 5 6 7 8 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接口:
只需要继承mybatis-plus中提供BaseMapper接口就可以实现基础的CRUD操作
1 2 3 @Mapper public interface DishMapper extends BaseMapper <Dish> {}
service接口:
1 2 public interface DishService extends IService <Dish> {}
serviceImpl类:
1 2 3 @Service public class DishServiceImpl extends ServiceImpl <DishMapper, Dish> implements DishService {}
Mybatis-plus分页查询组件的使用 Mybatis-plus为我们提供了一个十分好用的分页查询功能,它是基于AOP的思想实现的。要使用这个功能的话,我们只需要写一个配置类,为mybatis-plus提供分页插件拦截器PaginationInnerInterceptor类,对mybatis-plus框架功能进行增强。
分页插件的配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 @Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor getMybatisPlusInterceptor () { MybatisPlusInterceptor mybatisPlusInterceptor=new MybatisPlusInterceptor (); mybatisPlusInterceptor.addInnerInterceptor(new PaginationInnerInterceptor ()); return mybatisPlusInterceptor; } }
分页插件的使用 我们只需要返回给页面page类型的数据,那么执行的SQL语句就会加上Limit来实现查询固定数量数据
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @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); }
Mybatis-plus 提供的公共字段自动填充功能的使用 公共字段的含义: 在数据库表与表中共同含有的字段,在瑞吉外卖项目中如:createUser、createTime、updateUser、updateTime这些字段都是通用的每一张表中都有这些字段。此时如果每个表的每次操作都考虑这些填充字段是十分繁琐的,代码重复率高。mybatis-plus可以通过简单配置MetaObjectHandler (元数据处理器) 就能够在每一个sql语句到达数据库之前检查对象是否有这些字段并进行自动注入。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 @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 ())); } } }
除了编写元数据处理器外,还需要在每一个实体类中的属性打上注解:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 @Data @TableName("employee") @EqualsAndHashCode(callSuper = false) public class Employee implements Serializable { private static final long serialVersionUID = 1L ; private Long id; private String username; private String name; private String password; private String phone; private String sex; private String idNumber; private Integer status; @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; @TableField(fill = FieldFill.INSERT) private Long createUser; @TableField(fill = FieldFill.INSERT_UPDATE) private Long updateUser; }
全局异常处理器的使用与配置 当请求发送到controller之后,调用service进行业务操作。一旦报错,我们一般会在controller中使用try-catch进行异常捕获。但是这个方法有一定的弊端,try-catch全部写到一个类中,代码更简洁,复用性更高。
定义全局异常类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 @ControllerAdvice(annotations = {RestController.class, Controller.class}) @ResponseBody @Slf4j public class GlobaExceptionHandler { @ExceptionHandler(SQLIntegrityConstraintViolationException.class) public R<String> exceptionHandler (SQLIntegrityConstraintViolationException ex) { log.error(ex.getMessage()); if (ex.getMessage().contains("Duplicate entry" )){ String[] split = ex.getMessage().split(" " ); String msg = split[2 ]+"已经存在" ; return R.error(msg); } return R.error("未知错误,请重试" ); } @ExceptionHandler(CustomException.class) public R<String> exceptionHandler (CustomException ex) { log.error(ex.getMessage()); return R.error(ex.getMessage()); } }
我们也可以在全局异常处理器中定义自己的异常处理方法如上,我们自己定义的CustomException类如下:
1 2 3 4 5 6 7 8 public class CustomException extends RuntimeException { public CustomException (String message) { super (message); } }
我们在业务层写方法的时候,就可以去抛出我们自定义业务异常类,来被全局异常类获取。举个瑞吉外卖项目中的例子,我们项目后台系统中的分类管理页面中的删除方法。因为分类管理页面展示的是分类信息。这些信息都联系了其他信息。所以如果关联了信息就不能删除,而且要给页面回显信息。因为我们定义了全局异常处理器,所以这里我们抛出了异常,全局异常处理类就会处理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 @Override public void deleteById (Long id) { List<DishDto> dishes = dishService.getByCategoryId(id); if (dishes!=null ){ throw new CustomException ("当前分类下关联了菜品,不能删除" ); } List<Setmeal> setmeals = setmealService.getByCategoryId(id); if (setmeals!=null ){ throw new CustomException ("当前分类下关联了套餐,不能删除" ); } categoryDao.deleteById(id); }
DTO数据传输对象的使用 在WEB项目中经常会遇到一种情况,前端传输的参数在后端controller层中原有的对象无法全部接收到前端传输的所有参数,因此我们可以创建一个原有对象对应的DTO对象继承原有对象,拓展新的属性以便接收前端传输的全部参数。
这一点在后端controller层返回值中也可以体现,瑞吉外卖项目中,controller的返回值封装成R对象中的data属性,即我们需要用一个对象封装前端想要的所有参数而返回,但有时候前端想要的所有数据可能后端已有的类都无法一个对象封装所有参数。因此我们可以在原有的类基础上继承一个子类拓展属性来满足要求。
示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 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 + '}' ; } }
Redis 瑞吉外卖项目中,我们使用redis来分担数据库的压力。而redis是二进制安全的,在redis中存储的数据其实是经过序列化的字节流,而redis中数据类型仅仅代表数据的组织结构,并不是值其真实存储的数据。在实际项目中我们需要将redis作为缓存使用,将从数据库中查询出来的数据存储在redis中,而查询出来的数据一般都是对象,List集合,甚至需要将map存进redis当中,这时后我们就需要考虑要使用redis提供的啥数据类型进行存储。
Redis Template 在项目中,我们可以统一用redis中的字符串类型来存储,将对象序列化为字节数组然后以字符串的形式保存在数据库当中。这样我们只需要配置RedisTemplate的value序列方式为JdkSerializationRedisSerializer,就可以将jave中的对象序列化为字符串,然后读出来的时候以同样的方式反序列化。而Redis支持很多语言,我们以JDK序列化器序列化的对象,别的语言写的服务器就无法正确的反序列化可能会导致乱码问题。如果真有这种需求可以考虑统一序列化为json格式的字符串,那么所有类型都能够访问。
1 2 3 4 5 6 7 8 9 10 11 @Configuration public class RedisConfig extends CachingConfigurerSupport { @Bean public RedisTemplate<Object, Object> redisTemplate (RedisConnectionFactory connectionFactory) { RedisTemplate<Object, Object> redisTemplate = new RedisTemplate <>(); redisTemplate.setKeySerializer(new StringRedisSerializer ()); redisTemplate.setConnectionFactory(connectionFactory); return redisTemplate; } }
使用Spring Chche简化开发 缓存一般都是用来解决读请求的,来降低落到mysql的访问压力,而当数据发生写操作时,根据实际需求可能需要删除redis缓存或者同步缓存和数据库的数据。对于一些简单的逻辑我们完全可以用注解来实现,比如需要使用缓存的读请求,一般都是先看缓存中有没有,如果有直接从缓存中拿,没有去mysql中拿并回写到缓存中。spring cache框架支持用简单的注解来满足简单的使用缓存的需求,但若是有较为复杂的逻辑还需要自己来实现。
配置:
pom.xml
1 2 3 4 5 6 7 8 9 10 11 12 // 导入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
1 2 3 4 5 6 7 8 9 spring: redis: host: 192.168 .233 .141 port: 6379 password: root@123456 database: 0 cache: redis: time-to-live: 3600000
在启动类上开启注解缓存方式:
1 2 3 4 5 6 7 8 9 10 11 @ServletComponentScan @SpringBootApplication @EnableTransactionManagement @EnableCaching public class ReggieApplication { public static void main (String[] args) { SpringApplication.run(ReggieApplication.class, args); } }
使用时可以在类中加上Spring Cache注解来使用:
注解
说明
@EnableCaching
开启缓存注解功能
@Cacheable
在方法执行前spring先查看缓存中是否有数据,如果有数据,则直接返回缓存数据;若没有数据,调用方法并将方法返回值返回到缓存中
@CachePut
将方法的返回值放到缓存中
@CacheEvict
将一条或多条数据从缓存中删除
MySQL主从复制 mysql的主从复制的目的和redis主从复制的目的几乎都是一样的,为了解决单点故障问题,主mysql数据库挂了,从mysql数据库可以继续干活。可以进行读写分离,在并发量大的时候并且是读多写少的环境下,我们可以进行读写分离,让从mysql数据库为只读,主mysql数据库即可读也可以写,相当于分担了主msyql读的并发压力,系统可用性更高。
使用Sharing-JDBC框架实现MySQL读写分离 Sharing-JDBC定位为轻量级Java框架,在Java的JDBC层提供的额外服务。它使用客户端直连数据库,以jar包形式提供服务,无需额外部署和依赖,可理解为增强版的JDBC驱动,完全兼容JDBC和各种ORM框架。我们在项目中导入jar包,写好配置类,就可以实现MySQL读写分离。
pom.xml:
1 2 3 4 5 6 <dependency > <groupId > org.apache.shardingsphere</groupId > <artifactId > sharding-jdbc-spring-boot-starter</artifactId > <version > 4.0.0-RC1</version > </dependency >
application.yml配置读写分离的相关参数,就可以实现读写分离了。注意一下配置文件中格式就好。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 shardingsphere: datasource: names: master,slave master: type: com.alibaba.druid.pool.DruidDataSource driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://192.168.10.101:3306/xfy?useUnicode=true&characterEncoding=UTF-8 username: root password: '!Zbyzby1124123' slave: type: com.alibaba.druid.pool.DruidDataSource driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://192.168.10.102:3306/xfy?useUnicode=true&characterEncoding=UTF-8 username: root password: '!Zbyzby1124123' masterslave: load-balance-algorithm-type: round_robin name: dataSource master-data-source-name: master slave-data-source-names: slave props: sql: show: true