# SAP商城系统 **Repository Path**: mlming/sapmall ## Basic Information - **Project Name**: SAP商城系统 - **Description**: 使用SpringBoot + Redis + RabbitMQ 实现的一个商城的后端接口项目 - **Primary Language**: Java - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2022-01-28 - **Last Updated**: 2022-05-26 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # SAPMall 电商系统 ## 项目背景: * 随着在线支付的风潮,现在基本所有系统都要一套支付流程,所以我着手于开发这样一套通用型的支付系统,只提供对外接口,从而实现所有的系统都可以接入该支付系统,通过该支付系统去实现支付流程,实现了原系统的业务流程与支付流程的解耦合 * 同时,当前电商项目比较热门,其涉及高并发等企业级问题,所以我着手开发一个电商系统,着手于实现高并发的解决,同时与支付系统进行对接,实现企业级的电商购物流程+支付流程的完整业务 ## 项目核心功能流程: ![image-20211008090721422](F:\acer\ProjectForWork\sapmall\mdImage\image-20211008090721422.png) ![image-20211008090835187](F:\acer\ProjectForWork\sapmall\mdImage\image-20211008090835187.png) ## 项目整体感知 ![image-20211008083533695](F:\acer\ProjectForWork\sapmall\mdImage\image-20211008083533695.png) ## 数据库设计 : 这里数据库设计顺便把支付系统的加上了 * 数据库表关系设计: ![02-数据库表设计](F:\acer\ProjectForWork\sapmall\mdImage\02-数据库表设计.jpg) * 数据库表结构: ![03-用户表](F:\acer\ProjectForWork\sapmall\mdImage\03-用户表.jpg) **在产品表中,价格用decimal类型。decimal(20,2)表示最大整数位支持18位,小数位2位。** **decimal:数字型,128bit,不存在精度损失,常用于银行帐目计算** ![04-分类表](F:\acer\ProjectForWork\sapmall\mdImage\04-分类表.jpg) ![05-产品表](F:\acer\ProjectForWork\sapmall\mdImage\05-产品表.jpg) ![06-订单表](F:\acer\ProjectForWork\sapmall\mdImage\06-订单表.jpg) ![07-订单表](F:\acer\ProjectForWork\sapmall\mdImage\07-订单表.jpg) ![09-收货地址表](F:\acer\ProjectForWork\sapmall\mdImage\09-收货地址表.jpg)![08-订单明细表](F:\acer\ProjectForWork\sapmall\mdImage\08-订单明细表.jpg) * 索引设计: ​ ***设置索引的原因: 对于经常查询且很少修改的字段, 加以索引,加快条件查询的效率*** ![10-索引设计](F:\acer\ProjectForWork\sapmall\mdImage\10-索引设计-1633653584311.jpg) * 时间戳的设计: 一直对时间戳这个概念比较模糊,相信有很多朋友也都会误认为:时间戳是一个时间字段,每次增加数据时,填入当前的时间值。其实这误导了很多朋友。 **时间戳:数据库中自动生成的唯一二进制数字,与时间和日期无关的, 通常用作给表行加版本戳的机制。存储大小为 8个字节。** **每个数据库都有一个计数器,当对数据库中包含 timestamp 列的表执行插入或更新操作时,该计数器值就会增加。该计数器是数据库时间戳。这可以跟踪数据库内的相对时间,而不是时钟相关联的实际时间。一个表只能有一个 timestamp 列。每次修改或插入包含 timestamp 列的行时,就会在 timestamp 列中插入增量数据库时间戳值**。这一属性使 timestamp 列不适合作为键使用,尤其是不能作为主键使用。对行的任何更新都会更改 timestamp 值,从而更改键值。如果该列属于主键,那么旧的键值将无效,进而引用该旧值的外键也将不再有效。如果该表在动态游标中引用,则所有更新均会更改游标中行的位置。如果该列属于索引键,则对数据行的所有更新还将导致索引更新。 使用某一行中的 timestamp 列可以很容易地确定该行中的任何值自上次读取以后是否发生了更改。如果对行进行了更改,就会更新该时间戳值。如果没有对行进行更改,则该时间戳值将与以前读取该行时的时间戳值一致。若要返回数据库的当前时间戳值,请使用 @@DBTS。 在控制并发时起到作用: **用户A/B同时打开某条记录开始编辑,保存是可以判断时间戳,因为记录每次被更新时,系统都会自动维护时间戳,所以如果保存时发现取出来的时间戳与数据库中的时间戳如果不相等,说明在这个过程中记录被更新过,这样的话可以防止别人的更新被覆盖.** ![11-时间戳设计](F:\acer\ProjectForWork\sapmall\mdImage\11-时间戳设计.jpg) * 数据库建立: ![12-数据库建立](F:\acer\ProjectForWork\sapmall\mdImage\12-数据库建立.jpg) ## 技术栈: * SpringBoot 2.1.7 * MySQL 5.7 * MyBatis 2.1 * Redis * RabbitMQ ## 依赖: ```xml org.springframework.boot spring-boot-starter-web mysql mysql-connector-java org.mybatis.spring.boot mybatis-spring-boot-starter 2.1.0 org.projectlombok lombok com.github.pagehelper pagehelper-spring-boot-starter 1.2.13 org.springframework.boot spring-boot-starter-data-redis com.google.code.gson gson org.springframework.boot spring-boot-starter-amqp ``` ## 配置: application.yml ```yml # 配置连接mysql,redis数据库信息 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver username: root password: iq20010318. url: jdbc:mysql://127.0.0.1:3306/mall?characterEncoding=utf-8&serverTimezone=UTC redis: host: 192.168.131.133 port: 6379 # rabbitMQ 的相关配置 rabbitmq: addresses: 192.168.131.133 port: 5672 username: admin password: admin virtual-host: /sapmall # 配置mapper文件所在位置 mybatis: mapper-locations: classpath:mappers/*.xml configuration: # MyBatis打印sql语句-控制台打印 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl server: port: 8081 ``` ## Dao层初始化: 使用MyBatis-generator生成 * dao层架构: ![image-20211014185922400](F:\acer\ProjectForWork\sapmall\mdImage\image-20211014185922400.png) * 配置mapper文件位置: application.yml ```yml # 配置mapper文件所在位置 mybatis: mapper-locations: classpath:mappers/*.xml configuration: # MyBatis打印sql语句-控制台打印 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl ``` * Application类上加上扫描包注解@MapperScan ```java @SpringBootApplication @MapperScan("com.mlming.springboot.dao") public class SapmallApplication { public static void main(String[] args) { SpringApplication.run(SapmallApplication.class, args); } } ``` ## 项目开发注意事项: * **严格按照前后端开发api接口进行**: **一般前后端项目开发,都会在开发之前就确立下一个接口文档, 前端只需要关心如何使用接口, 后端只需要关心如何开发接口** **对于后端程序员而言,面向接口文档开发** 开发文档路径: F:\acer\ProjectForWork\sapmall\doc\api接口文档 * **前后端分离的项目,后端程序员一般就是在开发接口,所以我们一般都是只使用PostMan,ApiPost等接口测试工具来实现对接口的测试,而不是结合前端来测试接口,结合前端一般是最后开发完毕才做的** * **开发推荐: 给每一个service层类对应一个测试类,单独测试里面各个方法** 实现方式: 在每个service层接口,右键->goto->Test 从而创建对应测试类 **因为对于后端程序员而言,Service层是最重要的最需要测试的,它代表着业务逻辑的正确性, 至于Controller大多数情况下也只是封装交互数据的, 而且Controller一般是交由测试人员根据api接口文档进行测试的, 所以后端程序员平时一定要注意对Service层的单例测试** **注意: 由于在SpringBoot环境下,所以必须加上@RunWith(SpringRunner.class),@SpringBootTest注解** **所以,为了方便开发测试类, 我们可以将主测试类加上两个接口后(注意要写个空方法,否则maven打包时会提示没有方法所以报错), 其他测试类去继承该主测试类,那么就不用手动添加两个注解了** ## 用户模块开发: * 接口文档: * 1.登录 **POST /user/login** > request Content-Type: application/json ``` { "username":"admin", "password":"admin", } ``` > response fail ``` { "status": 1, "msg": "密码错误" } ``` success ``` { "status": 0, "data": { "id": 12, "username": "aaa", "email": "aaa@163.com", "phone": null, "role": 0, "createTime": 1479048325000, "updateTime": 1479048325000 } } ``` ------- * 2.注册 **POST /user/register** > request ``` { "username":"admin", "password":"admin", "email":"admin@qq.com" } ``` > response success ``` { "status": 0, "msg": "校验成功" } ``` fail ``` { "status": 2, "msg": "用户已存在" } ``` * 3.获取登录用户信息 **GET /user** > request ``` 无参数 ``` > response success ``` { "status": 0, "data": { "id": 12, "username": "aaa", "email": "aaa@163.com", "phone": null, "role": 0, "createTime": 1479048325000, "updateTime": 1479048325000 } } ``` fail ``` { "status": 10, "msg": "用户未登录,无法获取当前用户信息" } ``` * 4.退出登录 **POST /user/logout** > request ``` 无 ``` > response success ``` { "status": 0, "msg": "退出成功" } ``` fail ``` { "status": -1, "msg": "服务端异常" } ``` * 代码实现: * 架构: ![image-20211019160419209](F:\acer\ProjectForWork\sapmall\mdImage\image-20211019160419209.png) * 各种实体类与枚举准备: **实体类: 方便各层之间的数据传输 , 枚举: 避免代码内硬编码** 由于面向接口文档开发时,我们会发现有很多硬编码的东西,例如状态码status以及响应信息msg, 所以可以新建枚举 ```java public enum ResponseEnum { ERROR(-1,"服务端异常"), SUCCESS(0,"成功"), USERNAME_OR_PASSWORD_ERROR(1,"用户名或密码错误"), USER_EXISTS(2,"用户已存在"), EMAIL_EXISTS(3,"邮箱已存在"), PARAMS_ERROR(4,"参数错误"), NEED_LOGIN(10,"用户未登陆,请先登录") ; Integer code; String desc; ResponseEnum(Integer code, String desc) { this.code = code; this.desc = desc; } public Integer getCode() { return code; } public String getDesc() { return desc; } } ``` 同时,因为返回的数据都大差不差,为了方便与前端响应,可以新建vo包下的类(vo是规范的命名,一般用于放存储响应信息的对象) ```java // 响应信息实体类对象 @Data @JsonInclude(value = JsonInclude.Include.NON_NULL) // 在以json形式响应该对象时,会把里面的null值去除 // 由于看接口文档里面, 那个data属性是一个类型不定的对象,所以要用到泛型 public class ResponseVo { private Integer status; private String msg; private T data; public ResponseVo() { } public ResponseVo(Integer status, String msg) { this.status = status; this.msg = msg; } // 返回具有全部信息的响应对象 public static ResponseVo success(T data) { ResponseVo responseVo = new ResponseVo(); responseVo.setData(data); responseVo.setStatus(ResponseEnum.SUCCESS.getCode()); return responseVo; } // 返回只具有code,msg的成功的响应对象 public static ResponseVo success() { return new ResponseVo(ResponseEnum.SUCCESS.getCode(), ResponseEnum.SUCCESS.getDesc()); } // 传递枚举返回错误响应对象 public static ResponseVo error(ResponseEnum responseEnum) { return new ResponseVo(responseEnum.getCode(), responseEnum.getDesc()); } // 根据发过来的BindingResult来获取属性错误信息来返回错误响应对象 public static ResponseVo error(ResponseEnum responseEnum, BindingResult bindingResult) { return new ResponseVo(responseEnum.getCode(), bindingResult.getFieldError().getField() + " " + bindingResult.getFieldError().getDefaultMessage()); } } ``` 同时,观察接口文档,我们会发现前端发过来的数据虽然是User的属性,但是并不全,只有一小部分,这种情况下,一般都要求新建form包下的类来专门作为前端传递参数的存储类 ( 对于这种参数存储类, 一般经常要对各个参数进行非空判断,这样会导致controller里面if-else太多,不好看,而且代码极度重复,所以一般: ​ 会使用@NotBlank,@NotNull,@NotEmpty注解标识这些属性 ​ 然后结合controller里面的@Valid注解以及参数BindingResult类来进行自动的内置判断并获取错误信息(这个详情看下文的controller) ) ```java /** * 注册时提交的部分User数据 */ @Data public class UserRegisterForm { // @NotBlank: 用于String判断空格 // @NotEmpty: 用于集合 // @NotNull: 用于非null判断 @NotBlank private String username; @NotBlank private String password; @NotBlank private String email; } ``` ```java /** * 登录时提交的部分User数据 */ @Data public class UserLoginForm { // @NotBlank: 用于String判断空格 // @NotEmpty: 用于集合 // @NotNull: 用于非null判断 @NotBlank private String username; @NotBlank private String password; } ``` 同时,我们新建一个consts包下面的类,专门用于存储一些不那么复杂,用不着枚举的一些**常量** ```java /** * 定义商城会用的一些常量,例如字符硬编码等 */ public class MallConst { public final static String CURRENT_USER = "currentUser"; //session存储用户登录状态的key名 } ``` * Dao层: UserMapper.java ```java /** * 判断是否存在该用户名 */ int countByUsername(String username); /** * 判断是否存在该邮箱 */ int countByEmail(String email); /** * 根据用户名查询是否有该用户 */ User selectByUserName(String username); ``` UserMapper.xml ```xml ``` * Service层: ```java public interface UserService { /** * 注册功能 */ ResponseVo register(User user); /** * 登陆功能 */ ResponseVo login(String username,String password); } ``` ```java @Service public class UserServiceImpl implements UserService { @Autowired private UserMapper userMapper; @Override public ResponseVo register(User user) { // 首先验证用户名是否存在: int count = userMapper.countByUsername(user.getUsername()); if(count > 0) { return ResponseVo.error(ResponseEnum.USER_EXISTS); } // 验证邮箱是否已经存在: count = userMapper.countByEmail(user.getEmail()); if(count > 0) { return ResponseVo.error(ResponseEnum.EMAIL_EXISTS); } // 设置用户的身份为Customer user.setRole(RoleEnum.CUSTOMER.getCode()); // 对密码进行MD5加密 user.setPassword(DigestUtils.md5DigestAsHex(user.getPassword().getBytes(StandardCharsets.UTF_8))); // 写入数据库 int res = userMapper.insertSelective(user); if(res > 0) { return ResponseVo.success(); } else { return ResponseVo.error(ResponseEnum.ERROR); } } @Override public ResponseVo login(String username,String password) { User user = userMapper.selectByUserName(username); // 验证用户是否存在 if(user == null) { return ResponseVo.error(ResponseEnum.USERNAME_OR_PASSWORD_ERROR); } // 验证密码 if(!user.getPassword().equals(DigestUtils.md5DigestAsHex(password.getBytes(StandardCharsets.UTF_8)))) { return ResponseVo.error(ResponseEnum.USERNAME_OR_PASSWORD_ERROR); } // 如果都无误,根据接口文档,返回用户信息 // 注意: password要屏蔽掉 user.setPassword(""); return ResponseVo.success(user); } } ``` * Controller层: ```java @RestController public class UserController { @Autowired private UserService userService; /** * 注册 */ // 使用@Valid与@NotXXX注解方便对内部属性进行判断,可以使用BindingResult类来接收错误信息 @PostMapping("/user/register") public ResponseVo register(@Valid UserRegisterForm userRegisterForm, BindingResult bindingResult) { // 如果有错误属性,则找出错误 if(bindingResult.hasErrors()) { return ResponseVo.error(ResponseEnum.PARAMS_ERROR,bindingResult); } // 复制抽取属性对象的属性到User对象里 User user = new User(); BeanUtils.copyProperties(userRegisterForm,user); // 调用Service层的注册逻辑方法 return userService.register(user); } /** * 登录 */ // 使用@Valid与@NotXXX注解方便对内部属性进行判断,可以使用BindingResult类来接收错误信息 @PostMapping("/user/login") public ResponseVo register(@Valid UserLoginForm userLoginForm, BindingResult bindingResult, HttpSession session) { // 如果有错误属性,则找出错误 if(bindingResult.hasErrors()) { return ResponseVo.error(ResponseEnum.PARAMS_ERROR,bindingResult); } // 调用Service层的登录逻辑方法 ResponseVo result = userService.login(userLoginForm.getUsername(), userLoginForm.getPassword()); // 判断登录是否成功,如果成功则设置session,保存登录状态 if(result.getStatus() == 0) { session.setAttribute(MallConst.CURRENT_USER,result.getData()); } return result; } /** * 登出 */ @PostMapping("/user/logout") public ResponseVo logout(HttpSession session) { // 由于之前登陆状态会由拦截器进行拦截,所以 session.removeAttribute(MallConst.CURRENT_USER); return ResponseVo.success(); } /** * 查询登录状态的用户信息 */ @GetMapping("/user") public ResponseVo userInfo(HttpSession session) { // 由于之前登陆状态会由拦截器进行拦截,所以 User user = (User) session.getAttribute(MallConst.CURRENT_USER); return ResponseVo.success(user); } } ``` * 拦截器: 由于观察到我们要保存登录状态,而且其中两个请求: 请求用户详细信息,登出,是要首先有登录状态的,所以我们要设置一个拦截器来拦截登录状态 拦截器: ```java public class UserLoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { User user = (User) request.getSession().getAttribute(MallConst.CURRENT_USER); // 如果用户是null,则不能登录 if(user == null) { // 不推荐使用response向前端打印信息,因为我们已经封装好一个vo类来返回信息了,所以我们推荐下面一种方式: // 抛出RuntimeException类型错误,从而可以被全局异常处理器捕捉到,然后向前端返回信息 throw new UserLoginException(); } // 如果上面没报错,说明是处于登录状态,放行 return true; } } ``` 拦截器的配置类: ```java @Configuration public class UserInterceptConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { String[] addPathPatterns = { "/user/**" }; // 配置该拦截器不会拦截的请求路径(一般建立在 会拦截的范围 里面选几个特例) String[] excludePathPatterns = { "/user/login","/user/register" }; // 相当于 标签里面的配置 // addInterceptor(new UserInterceptor()) => 添加拦截器 // addPathPatterns(拦截路径) => 添加该拦截器会拦截的路径的格式 // excludePathPatterns(放行路径) => 添加该拦截器的不会拦截的路径格式 registry.addInterceptor(new UserLoginInterceptor()) .addPathPatterns(addPathPatterns) .excludePathPatterns(excludePathPatterns); } } ``` * 全局异常处理器: 一来是因为一般项目都会有一个全局异常处理器来统一处理项目的异常 二来是因为拦截器要以来全局异常处理器来向前端返回信息**(其他地方如果有不方便返回vo类对象的情况,也可以采取这种思想)** ```java /** * 全局异常处理类 */ @ControllerAdvice public class GlobalExceptionHandler { /** * 对用户状态未登录时抛出的异常的处理 * @return */ @ExceptionHandler(UserLoginException.class) @ResponseBody //返回json格式给前端,所以说: 其实SpringMVC的异常处理器就相当于一个Controller @ResponseStatus(HttpStatus.FORBIDDEN) // 响应的状态码,默认是200 public ResponseVo userLoginExceptionHandler() { // 向浏览器返回错误信息的vo对象 return ResponseVo.error(ResponseEnum.NEED_LOGIN); } } ``` * 开发心得: * **对于各层间格式比较确定的数据传输,最好设置一个类专门用于传输:** * pojo(entity)包: 三层架构之间的传递: Controller,Service,Dao层之间的传递, 例如User类 一般由数据库表决定, 一般只有set,get方法 * vo包: 后端的响应对象 一般由接口文档决定, 一般除了set,get方法,**为了方便后端方便调用响应信息,一般会设置static静态方法来根据参数自动设置对应的响应格式** 例如: ```java // 响应信息实体类对象 @Data @JsonInclude(value = JsonInclude.Include.NON_NULL) // 在以json形式响应该对象时,会把里面的null值去除 // 由于看接口文档里面, 那个data属性是一个类型不定的对象,所以要用到泛型 public class ResponseVo { private Integer status; private String msg; private T data; public ResponseVo() { } public ResponseVo(Integer status, String msg) { this.status = status; this.msg = msg; } // 返回具有全部信息的响应对象 public static ResponseVo success(T data) { ResponseVo responseVo = new ResponseVo(); responseVo.setData(data); responseVo.setStatus(ResponseEnum.SUCCESS.getCode()); return responseVo; } // 返回只具有code,msg的成功的响应对象 public static ResponseVo success() { return new ResponseVo(ResponseEnum.SUCCESS.getCode(), ResponseEnum.SUCCESS.getDesc()); } // 传递枚举返回错误响应对象 public static ResponseVo error(ResponseEnum responseEnum) { return new ResponseVo(responseEnum.getCode(), responseEnum.getDesc()); } // 根据发过来的BindingResult来获取属性错误信息来返回错误响应对象 public static ResponseVo error(ResponseEnum responseEnum, BindingResult bindingResult) { return new ResponseVo(responseEnum.getCode(), bindingResult.getFieldError().getField() + " " + bindingResult.getFieldError().getDefaultMessage()); } } ``` * form包: 前端传递参数的封装 一般是pojo里面抽取部分属性封装而成(主要还是因为接口文档中,前端传递的数据一般只是pojo的部分属性) 一般由接口文档决定 一般只有get,set方法, **为了节省Controller对参数的判断,一般会使用@NotXXX注解配合@Valid与BindingResult类进行自动判断** 然后可以**通过BeanUtils.copyProperties(form对象,pojo对象)拷贝属性过去,然后把pojo对象传递给service进行处理** 例如: 上面的UserRegisterForm,UseLoginForm类 * **要避免代码中的硬编码, 一般通过枚举, 或者, 定义一个普通类里面通过static final定义的常量 进行修改** * 例如: 状态码是0,1,2等, 要避免在代码里面直接写0,1,2, 这样可读性太差, 应该新建一个枚举或者一个类存储常量,然后通过枚举/类.XXXX来写 * 枚举与常量类的选择: - 如果要存储的比较复杂, 例如上面的ResponseEnum, 不仅要存一个数字(状态码status),还得存一段字符串(描述msg),而且是绑定的,那就使用枚举 - 如果要存储的仅仅只是一个常量, 例如上面的MallConst里面的CURRENT_USER,仅仅只是一个字符串,那就使用常量类 * 对于有些地方需要向前端返回数据(vo类对象),但是又因为一些原因不能直接return vo类对象的,例如拦截器 那么,**就可以throw一个异常(可以是自定义的),然后交由全局异常处理器进行捕获,然后让全局异常处理器对应return vo类对象给前端** * 可以使用Spring框架内置的DigestUtils.md5DigestAsHex()对字符串,例如密码等进行MD5加密 ## 分类模块: * 接口文档: * 1.所有类目 **GET /categories** > request 无需登录 > response success ```markdown { "status": 0, "data": [{ "id": 100001, "parentId": 0, "name": "家用电器", "sortOrder": 1, "subCategories": [{ "id": 100006, "parentId": 100001, "name": "冰箱", "sortOrder": 2, "subCategories": [{ "id": 100040, "parentId": 100006, "name": "进口冰箱", "sortOrder": 1, "subCategories": [] }] }, { "id": 100005, "parentId": 0, "name": "酒水饮料", "sortOrder": 1, "subCategories": [{ "id": 100026, "parentId": 100005, "name": "白酒", "sortOrder": 1, "subCategories": [] }, { "id": 100027, "parentId": 100005, "name": "红酒", "sortOrder": 1, "subCategories": [] }] }] } ``` * 代码实现 * 架构: ![image-20211019235203129](F:\acer\ProjectForWork\sapmall\mdImage\image-20211019235203129.png) * 实体类和常量的准备: 观察接口文档,其实响应信息格式还是按照之前的ResponseVo,但是我们发现返回的Category属性并非完全和数据库一样,故我们得新建一个CategoryVo专门用于响应,届时只需要把该List作为ResponseVo的data即可: ```java @Data public class CategoryVo { private Integer id; private Integer parentId; private String name; private Boolean status; private Integer sortOrder; /** * 子类目列表 */ private List subCategories; } ``` 由于涉及到一个常量: 一级目录时,其parentID=0,但是之前也说了要避免硬编码,但是由于这个只是一个数字,无需用到枚举,所以加到常量类里面 ```java /** * 定义商城会用的一些常量,例如字符硬编码等 */ public class MallConst { public final static String CURRENT_USER = "currentUser"; //session存储用户登录状态的key名 public final static Integer ROOT_PARENT_ID = 0;// 第一级目录的parentID } ``` * Dao层: CategoryMapper.java: ```java /** * 获取所有的类目 * @return */ List selectAll(); ``` CategoryMapper.xml: ```xml ``` * Service层: ```java public interface CategoryService { /** * 获取所有的类目 * @return */ ResponseVo> getAllCategories(); } ``` ```java @Service public class CategoryServiceImpl implements CategoryService { @Autowired private CategoryMapper categoryMapper; @Override public ResponseVo> getAllCategories() { List categoryVoList = new ArrayList<>(); // 要返回的列表 /* 由于查询数据库是非常耗时的,所以一开始直接从数据库读取所有的数据(因为类目表一般数据量不大) 然后再进行操作,这样就减少了对数据库的查询次数 */ List categories = categoryMapper.selectAll(); // 数据库中的所有类目数据 // 1) 先获取一级目录 for (Category category : categories) { // 如果parentId==0,说明是一级目录 if(category.getParentId().equals(MallConst.ROOT_PARENT_ID)) { // 由于返回的是vo对象,所以必须得转成vo CategoryVo categoryVo = new CategoryVo(); BeanUtils.copyProperties(category,categoryVo); // 将一级目录存入categoryVoList中 categoryVoList.add(categoryVo); // 寻找其子目录 findAllChildren(categoryVo,categories); } } // 按照业务要求,按照sortOrder字段进行排序 => 从大到小 // 所以最后还得reserved反转一下 categoryVoList.sort(Comparator.comparing(CategoryVo::getSortOrder).reversed()); return ResponseVo.success(categoryVoList); } // 递归函数: 不断往下寻找多级目录 private void findAllChildren(CategoryVo parentVo,List categories) { parentVo.setSubCategories(new ArrayList<>()); // 初始化子目录列表 for (Category category : categories) { // 如果有一个类目的parentId == 当前parentVo的id,则说明是子目录 if(category.getParentId().equals(parentVo.getId())) { // 由于返回的是vo对象,所以必须得转成vo CategoryVo categoryVo = new CategoryVo(); BeanUtils.copyProperties(category,categoryVo); // 将子目录存到parentVo的子目录列表中 parentVo.getSubCategories().add(categoryVo); // 寻找该子目录的子目录 findAllChildren(categoryVo,categories); } } // 根据业务要求,要根据sortOrder字段进行排序 => 从大到小 // 所以sort之后,还要reversed反转一下 parentVo.getSubCategories().sort(Comparator.comparing(CategoryVo::getSortOrder).reversed()); } } ``` * Controller层: ```java @RestController public class CategoryController { @Autowired private CategoryService categoryService; @GetMapping("/category/getall") public ResponseVo> getAllCategories() { return categoryService.getAllCategories(); } } ``` * 心得: * **虽然有时候我们是想表达嵌套递归的目录结构,但是数据库存储时没必要这样,尤其是关系型数据库更做不到这样** **所以,一般数据库只存着正常目录字段,至于目录的嵌套关系一般交由Java代码实时操控** * **数据库映射的pojo类, 并不一定就是届时返回给前端的类, 这种情况一般都是新建一个vo类**(一般就算能直接返回pojo作为响应结果,也会新建一个vo类代替) ## 商品模块 * 接口文档: * 1.商品列表 **GET /products > request ``` categoryId(非必传,子类目的商品也要查出来) pageNum(default=1) pageSize(default=10) ``` > response success ``` { "status": 0, "data": { "pageNum": 1, "pageSize": 10, "size": 2, "orderBy": null, "startRow": 1, "endRow": 2, "total": 2, "pages": 1, "list": [ { "id": 1, "categoryId": 3, "name": "iphone7", "subtitle": "双十一促销", "mainImage": "mainimage.jpg", "status":1, "price": 7199.22 }, { "id": 2, "categoryId": 2, "name": "oppo R8", "subtitle": "oppo促销进行中", "mainImage": "mainimage.jpg", "status":1, "price": 2999.11 } ], "firstPage": 1, "prePage": 0, "nextPage": 0, "lastPage": 1, "isFirstPage": true, "isLastPage": true, "hasPreviousPage": false, "hasNextPage": false, "navigatePages": 8, "navigatepageNums": [ 1 ] } } ``` ------ * 2.商品详情 **GET /products/{productId} > request ``` productId ``` > response success ``` { "status": 0, "data": { "id": 2, "categoryId": 2, "name": "oppo R8", "subtitle": "oppo促销进行中", "mainImage": "mainimage.jpg", "subImages": "[\"mmall/aa.jpg\",\"mmall/bb.jpg\",\"mmall/cc.jpg\",\"mmall/dd.jpg\",\"mmall/ee.jpg\"]", "detail": "richtext", "price": 2999.11, "stock": 71, "status": 1, "createTime": "2016-11-20 14:21:53", "updateTime": "2016-11-20 14:21:53" } } ``` fail ``` { "status": 1, "msg": "该商品已下架或删除" } ``` * 商品列表功能的 业务逻辑的说明: 由于本次不是简单的面向一个模块的 ``` 如果传递了categoryId,则只是分页查询该类目以及其多级子目录类目下的商品 如果不传递, 则是分页查询所有的商品 所以, 如果传递了categoryId,我们必须通过category类目模块,查询到该id以及其下面所有目录categoryId的集合,然后通过这个集合再去商品表里面查询对应的商品 如果没有传递,则直接查出所有的商品表的商品 ``` * 代码实现: * 架构: ![image-20211024161241640](F:\acer\ProjectForWork\sapmall\mdImage\image-20211024161241640.png) * 实体类与常量类准备: 由接口文档可知,要返回的商品信息与pojo中的不一致,所以要新建一个ProductVo类: ```java @Data public class ProductVo { private Integer id; private Integer categoryId; private String name; private String subtitle; private String mainImage; private BigDecimal price; private Integer status; } ``` 虽然detail这个请求里面,返回的是与pojo一模一样的属性,但是因为一直以来都是以vo类作为返回,所以这里也新建一个ProductDetailVo类: ```java @Data public class ProductDetailVo { private Integer id; private Integer categoryId; private String name; private String subtitle; private String mainImage; private BigDecimal price; private Integer stock; private Integer status; private String subImages; private String detail; private Date createTime; private Date updateTime; } ``` 由于观察数据库,因为会涉及到一个status的字段,其含义是具有枚举意味的,所以新建一个枚举: ```java public enum ProductStatusEnum { ON_SALE(1,"在售"), OFF_SALE(2,"下架"), DELETE(3,"删除"), ; Integer code; String msg; ProductStatusEnum(Integer code, String msg) { this.code = code; this.msg = msg; } public Integer getCode() { return code; } public String getMsg() { return msg; } } ``` * Dao层: 由接口文档可知,因为要根据类目id查询商品信息,所以: ```java public interface ProductMapper { /** * 根据类目id集合查询Product记录 */ List selectByCategoryIdSet(@Param("categoryIdSet") Set categoryIdSet); } ``` ```xml ``` * Service层: 前面业务逻辑说明也说了, 如果传递了categoryid,必须要借助category类目模块来进行查找多级目录的categoryId,所以这里涉及到了之前的categoryService: ```java /** * 根据categoryId查询所有的类目的id * @param categoryId */ void findAllCategoryIdsByCategoryId(Integer categoryId, Set categoryIdSet); ``` ```java @Override public void findAllCategoryIdsByCategoryId(Integer categoryId, Set categoryIdSet) { List categories = categoryMapper.selectAll(); // 数据库中的所有类目数据 findSubCategoryIds(categoryId,categoryIdSet,categories); } // 递归函数: 不断往下寻找多级目录的id private void findSubCategoryIds(Integer categoryId,Set categoryIdSet,List categories) { for (Category category : categories) { if(category.getParentId().equals(categoryId)) { categoryIdSet.add(category.getId()); findSubCategoryIds(category.getId(),categoryIdSet,categories); } } } ``` Product模块: ```java public interface ProductService { /** * 按照categoryid查询商品列表 * @param categoryId * @param pageNum * @param pageSize * @return */ ResponseVo getProducts(Integer categoryId, Integer pageNum, Integer pageSize); /** * 根据productId查询商品 * @param productId * @return */ ResponseVo getProductDetailById(Integer productId); } ``` ```java @Service public class ProductServiceImpl implements ProductService { @Autowired private CategoryService categoryService; @Autowired private ProductMapper productMapper; @Override public ResponseVo getProducts(Integer categoryId, Integer pageNum, Integer pageSize) { /* 业务逻辑: 如果传递了categoryId,则只是分页查询该类目以及其多级子目录类目下的商品 如果不传递, 则是分页查询所有的商品 体现在: productMapper.selectByCategoryIdSet(categoryIdSet) 执行的sql是: select from mall_product where status = 1 and category_id in #{categoryId} 可见,如果没有categoryId,则不会有if里面的where条件,所以就是查询所有的商品 */ // 先获取到参数categoryId以及其下面的所有子目录的categoryid的集合 Set categoryIdSet = new HashSet<>(); if(categoryId != null) { categoryService.findAllCategoryIdsByCategoryId(categoryId,categoryIdSet); categoryIdSet.add(categoryId); } // 通过pageHelper实现分页功能: 会导致紧接着的查询自动加以分页 PageHelper.startPage(pageNum,pageSize); // 根据set里面的categoryid查询实际Product列表 List products = productMapper.selectByCategoryIdSet(categoryIdSet); // 返回的是ProductVo类,所以要进行转化: List productVoList = new ArrayList<>(); for (Product product : products) { ProductVo productVo = new ProductVo(); BeanUtils.copyProperties(product,productVo); productVoList.add(productVo); } // 通过pageHelper加入接口文档中要求的那堆分页参数 PageInfo pageInfo = new PageInfo(products); pageInfo.setList(productVoList); return ResponseVo.success(pageInfo); } @Override public ResponseVo getProductDetailById(Integer productId) { Product product = productMapper.selectByPrimaryKey(productId); // 如果商品编号不存在 或者 当前商品状态是已下架或者删除,则返回报错信息 if(product == null || product.getStatus().equals(ProductStatusEnum.OFF_SALE.getCode()) || product.getStatus().equals(ProductStatusEnum.DELETE.getCode())) { return ResponseVo.error(ResponseEnum.PRODUCT_OFF_SALE_OR_DELETE); } // 否则,返回商品详情 ProductDetailVo productDetailVo = new ProductDetailVo(); BeanUtils.copyProperties(product,productDetailVo); // 对商品的一些敏感信息进行一些虚假处理 productDetailVo.setStock(productDetailVo.getStock() > 100 ? 100 : productDetailVo.getStock()); return ResponseVo.success(productDetailVo); } } ``` * Controller层: ```java @RestController public class ProductController { @Autowired private ProductService productService; /** * 分页获取商品列表 */ @GetMapping("/products") public ResponseVo getProducts(@RequestParam(required = false) Integer categoryId, @RequestParam(required = false,defaultValue = "1") Integer pageNum, @RequestParam(required = false,defaultValue = "10") Integer pageSize) { return productService.getProducts(categoryId, pageNum, pageSize); } /** * 获取商品详细信息 */ // 由接口文档可知: 该请求是RESTFUL风格的GET请求,所以要通过@PathVariable @GetMapping("/products/{productId}") public ResponseVo getProductDetail(@PathVariable Integer productId) { if(productId == null) { return ResponseVo.error(ResponseEnum.PARAMS_ERROR); } return productService.getProductDetailById(productId); } } ``` * 心得: * 一个模块的一个功能的实现可能会涉及其他多个模块的交互,此时不能图方便全写到这个模块理,而是要归类然后写到对应模块里,供这个模块调用 * 就算接口文档中要返回的数据与pojo的一模一样,一般都会新建一个vo类代替其作为响应对象 * 不要在循环里面查询数据库, 因为每次查询数据库都要去连接数据库等是非常耗费时间的. 如果确实需要一个个进行对应条件查询, 可以交由Java代码处理好后再由sql处理 1) 事先把所有记录都查出来,用Java的集合类List等接收 2) 然后通过Java代码对集合进行遍历, 通过判断等找出需要的参数存在一个集合里 3) 然后通过 传递该集合作为dao层的参数 以及 sql语句使用 in作为条件, foreach拼接in 等实现一次性条件查询 例如: 上面的根据类目id查询商品 ## 购物车模块 * 接口文档: * 1.购物车List列表 ** GET /carts > request ``` 无参数,需要登录状态 ``` > response success ``` { "status": 0, "data": { "cartProductVoList": [ { "productId": 1, "quantity": 1, "productName": "iphone7", "productSubtitle": "双十一促销", "productMainImage": "mainimage.jpg", "productPrice": 7199.22, "productStatus": 1, "productTotalPrice": 7199.22, "productStock": 86, "productSelected": true, }, { "productId": 2, "quantity": 1, "productName": "oppo R8", "productSubtitle": "oppo促销进行中", "productMainImage": "mainimage.jpg", "productPrice": 2999.11, "productStatus": 1, "productTotalPrice": 2999.11, "productStock": 86, "productSelected": false, } ], "selectedAll": false, "cartTotalPrice": 10198.33, "cartTotalQuantity": 2 } } ``` fail ``` { "status": 10, "msg": "用户未登录,请登录" } ``` * 2.购物车添加商品 ** POST /carts > request ``` productId selected: true ``` `注意`数量不用传,添加商品永远是以1累加 > response success ``` { "status": 0, "data": { "cartProductVoList": [ { "productId": 1, "quantity": 12, "productName": "iphone7", "productSubtitle": "双十一促销", "productMainImage": "mainimage.jpg", "productPrice": 7199.22, "productStatus": 1, "productTotalPrice": 86390.64, "productStock": 86, "productSelected": true }, { "productId": 2, "quantity": 1, "productName": "oppo R8", "productSubtitle": "oppo促销进行中", "productMainImage": "mainimage.jpg", "productPrice": 2999.11, "productStatus": 1, "productTotalPrice": 2999.11, "productStock": 86, "productSelected": true } ], "selectedAll": true, "cartTotalPrice": 89389.75, "cartTotalQuantity": 13 } } ``` fail ``` { "status": 10, "msg": "用户未登录,请登录" } ``` ------ * 3.更新购物车 ** PUT /carts/{productId} > request ``` quantity //非必填 selected: true //非必填 ``` > response 响应同2 success ``` { "status": 0, "data": { "cartProductVoList": [ { "productId": 1, "quantity": 12, "productName": "iphone7", "productSubtitle": "双十一促销", "productMainImage": "mainimage.jpg", "productPrice": 7199.22, "productStatus": 1, "productTotalPrice": 86390.64, "productStock": 86, "productSelected": true }, { "productId": 2, "quantity": 1, "productName": "oppo R8", "productSubtitle": "oppo促销进行中", "productMainImage": "mainimage.jpg", "productPrice": 2999.11, "productStatus": 1, "productTotalPrice": 2999.11, "productStock": 86, "productSelected": true, } ], "selectedAll": true, "cartTotalPrice": 89389.75, "cartTotalQuantity": 13 } } ``` fail ``` { "status": 10, "msg": "用户未登录,请登录" } ``` ------ * 4.移除购物车某个产品 ** DELETE /carts/{productId} > request ``` productId ``` > response success ``` { "status": 0, "data": { "cartProductVoList": [ { "productId": 2, "quantity": 1, "productName": "oppo R8", "productSubtitle": "oppo促销进行中", "productMainImage": "mainimage.jpg", "productPrice": 2999.11, "productStatus": 1, "productTotalPrice": 2999.11, "productStock": 86, "productSelected": true } ], "selectedAll": true, "cartTotalPrice": 2999.11, "cartTotalQuantity": 1 } } ``` fail ``` { "status": 10, "msg": "用户未登录,请登录" } ``` ------ * 5.全选中 ** PUT /carts/selectAll > request ``` 无参数,需要登录状态 ``` > response success 同接口 获取购物车列表 ------ * 6.全不选中 ** PUT /carts/unSelectAll > request ``` 无参数,需要登录状态 ``` > response success 同接口 获取购物车列表 ------ * 7.获取购物中所有商品数量总和 ** GET /carts/products/sum > request ``` 无参数,需要登录状态 ``` > response ``` { "status": 0, "data": 2 } ``` * 架构: ![image-20211029224152549](F:\acer\ProjectForWork\sapmall\mdImage\image-20211029224152549.png) * 代码实现: * 实体类与常量类准备: 由于接口文档有两种传递参数形式,所以创建两个form类用于存储: ```java @Data public class CartAddForm { @NonNull private Integer productId; private Boolean selected = true; public CartAddForm() { } public CartAddForm(@NonNull Integer productId, Boolean selected) { this.productId = productId; this.selected = selected; } } ``` ```java @Data public class CartUpdateForm { private Integer quantity; private Boolean selected = true; } ``` 观察接口文档,返回值里面,首先data要是一个vo类, 然后该vo类里面有个List类型的,里面应该也要新建一个vo类 ```java @Data public class CartVo { private List cartProductVoList; private Boolean selectedAll; private BigDecimal cartTotalPrice; private Integer cartTotalQuantity; public CartVo() { } public CartVo(List cartProductVoList, Boolean selectedAll, BigDecimal cartTotalPrice, Integer cartTotalQuantity) { this.cartProductVoList = cartProductVoList; this.selectedAll = selectedAll; this.cartTotalPrice = cartTotalPrice; this.cartTotalQuantity = cartTotalQuantity; } } ``` ```java @Data public class CartProductVo { private Integer productId; private Integer quantity; private String productName; private String productSubtitle; private String productMainImage; private BigDecimal productPrice; private Integer productStatus; private Integer productStock; // productPrice * quantity private BigDecimal productTotalPrice; private Boolean productSelected; } ``` 由于我们购物车模块使用的是Redis存储,所以之前并没有新建该购物车模块对应的实体类: ```java @Data public class Cart { private Integer productId; private Integer quantity; private Boolean productSelected; public Cart() { } public Cart(Integer productId, Integer quantity, Boolean productSelected) { this.productId = productId; this.quantity = quantity; this.productSelected = productSelected; } } ``` * Dao层: Redis的起步依赖自动注入了一个RedisTemplate的dao层对象: ![image-20211029225003229](F:\acer\ProjectForWork\sapmall\mdImage\image-20211029225003229.png) Redis数据类型的选择: ``` // 选取的Redis数据类型: // 由于Redis要存多用户,所以必须得是以userId作为key // 同时又因为每个用户,即每个key对应的value,即购物车,必须是要有多个Cart类对象的 // 所以应该采用的是hash数据类型: 因为hash类型对应的是 对象,所以下面以对象的形式解释形式 // 对象名(key): 用户id相关的唯一标识 // 属性列: // 属性名: productId唯一标识 // 属性值: Cart类转json格式 => 主要是为了方便存储,如果这里要存对象,Redis没有对应的数据类型 // 即: **以对象的形式存一个购物车, 属性列的形式存储购物车里面的多个Cart** // 利用Redis对hash数据类型的添加操作的机制: // 如果不存在key,则新建一个;如果key存在,则会覆盖已存在的属性名的属性值,并新建不存在的属性名 // 所以根据这个机制,**我们无论是添加还是修改,都只需要put往redis里添加数据就行了** ``` * Service层: ```java public interface CartService { /** * 往购物车添加商品 */ ResponseVo addCartItem(Integer userId, CartAddForm cartAddForm); /** * 返回购物车商品清单 */ ResponseVo getCartList(Integer userId); /** * 更新购物车 */ ResponseVo updateCartItem(Integer userId,Integer productId,CartUpdateForm cartUpdateForm); /** * 更新购物车 */ ResponseVo deleteCartItem(Integer userId,Integer productId); /** * 全选 */ ResponseVo selectAll(Integer userId); /** * 全不选 */ ResponseVo unSelectAll(Integer userId); /** * 返回购物车内商品总和 */ ResponseVo sum(Integer userId); } ``` ```java @Service public class CartServiceImpl implements CartService { @Autowired private ProductMapper productMapper;// 用于查询商品数据 @Autowired private RedisTemplate redisTemplate; // redis依赖提供的内置dao层对象 private Gson gson = new Gson(); // 用于json与Cart对象之间的转换 @Override public ResponseVo addCartItem(Integer userId, CartAddForm cartAddForm) { Integer productId = cartAddForm.getProductId();// 商品id // 通过productId从商品表查出该商品 Product product = productMapper.selectByPrimaryKey(productId); // 判断该商品存在不存在,不存在就报错误信息 if(product == null) { return ResponseVo.error(ResponseEnum.PRODUCT_NOT_EXISTS); } // 判断该商品是否在售: if(product.getStatus().equals(ProductStatusEnum.OFF_SALE.getCode()) || product.getStatus().equals(ProductStatusEnum.DELETE.getCode())) { return ResponseVo.error(ResponseEnum.PRODUCT_OFF_SALE_OR_DELETE); } // 判断该商品库存是否足够 if(product.getStock() <= 0) { return ResponseVo.error(ResponseEnum.PRODUCT_STOCK_NOT_ENOUGH); } // 以上判断均成功后, 开始往Redis写入数据: // 选取的Redis数据类型: // 由于Redis要存多用户,所以必须得是以userId作为key // 同时又因为每个用户,即每个key对应的value,即购物车,必须是要有多个Cart类对象的 // 所以应该采用的是hash数据类型: 因为hash类型对应的是 对象,所以下面以对象的形式解释形式 // 对象名(key): 用户id相关的唯一标识 // 属性列: // 属性名: productId唯一标识 // 属性值: Cart类转json格式 => 主要是为了方便存储,如果这里要存对象,Redis没有对应的数据类型 // 即: **以对象的形式存一个购物车, 属性列的形式存储购物车里面的多个Cart** // 利用Redis对hash数据类型的添加操作的机制: // 如果不存在key,则新建一个;如果key存在,则会覆盖已存在的属性名的属性值,并新建不存在的属性名 // 所以根据这个机制,**我们无论是添加还是修改,都只需要put往redis里添加数据就行了** HashOperations hashOperations = redisTemplate.opsForHash(); // 首先得查出该Cart是否存在: String value = hashOperations.get(userId, productId); // 如果不存在,则新建一个Cart类 Cart cart; if(StringUtils.isNullOrEmpty(value)) { cart = new Cart(productId, MallConst.CART_INIT_QUANTITY,cartAddForm.getSelected()); } // 如果存在的话,根据接口文档,是在原有数量的基础上+1作为新数量 else { cart = gson.fromJson(value, Cart.class); cart.setQuantity(cart.getQuantity()+1); } cart.setProductSelected(cartAddForm.getSelected()); // 处理结束后,根据上述的存储格式规定, 写入redis hashOperations.put(userId,productId,gson.toJson(cart)); return getCartList(userId); } @Override public ResponseVo getCartList(Integer userId) { HashOperations hashOperations = redisTemplate.opsForHash(); // 从Redis中查询出key==userId的value => 购物车的 所有属性列表 => 每个购物车记录 Map entries = hashOperations.entries(userId); // 获取到所有的商品id,并从商品表中查询商品列表 Set productSet = entries.keySet(); List products = productMapper.selectByProductIdSet(productSet); // 遍历所有的商品,装载到要返回的CartProductVo类里面 // 并把CartProductVo类加入到cartProductVoList, // 中间要业务性地求出CartVo的其他两个字段cartTotalPrice,selectedAll,cartTotalQuantity List cartProductVoList = new ArrayList<>(); // vo商品列表 Boolean selectedAll = true;// 全选 BigDecimal cartTotalPrice = BigDecimal.ZERO;// 总价格 Integer cartTotalQuantity = 0;// 总数 for (Product product : products) { // 通过productId取出entries中的当前对应的Cart Cart curCart = gson.fromJson(entries.get(product.getId()),Cart.class); CartProductVo cartProductVo = new CartProductVo(); cartProductVo.setProductId(product.getId()); cartProductVo.setQuantity(curCart.getQuantity()); cartProductVo.setProductName(product.getName()); cartProductVo.setProductSubtitle(product.getSubtitle()); cartProductVo.setProductMainImage(product.getMainImage()); cartProductVo.setProductPrice(product.getPrice()); cartProductVo.setProductStatus(product.getStatus()); cartProductVo.setProductStock(product.getStock()); cartProductVo.setProductTotalPrice(product.getPrice().multiply(BigDecimal.valueOf(curCart.getQuantity()))); cartProductVo.setProductSelected(curCart.getProductSelected()); // CartVo类的参数修改: // 注意这里有业务要求: 结合现实的购物车 // 关于在购物车里面的商品是否还在售的状态,即status: // 商品列表不受影响,无论在售与否都要返回给前端显示 // 商品数量也不受影响,同上理 // 商品是否是全选, 由于不在售的商品没有选择可言,所以不能影响商品的全选属性 // 商品的总价格,肯定是求在售的商品的 cartProductVoList.add(cartProductVo); cartTotalQuantity = cartTotalQuantity + cartProductVo.getQuantity(); if(cartProductVo.getProductStatus().equals(ProductStatusEnum.ON_SALE.getCode()) && cartProductVo.getProductSelected() == false) { selectedAll = false; } // 业务要求: 总价格只求被选中的 if(cartProductVo.getProductStatus().equals(ProductStatusEnum.ON_SALE.getCode()) && cartProductVo.getProductSelected()) { cartTotalPrice = cartTotalPrice.add(cartProductVo.getProductTotalPrice()); } } // 遍历结束后,设置CartVo作为返回的data CartVo cartVo = new CartVo(cartProductVoList,selectedAll,cartTotalPrice,cartTotalQuantity); return ResponseVo.success(cartVo); } @Override public ResponseVo updateCartItem(Integer userId,Integer productId,CartUpdateForm cartUpdateForm) { HashOperations hashOperations = redisTemplate.opsForHash(); String value = hashOperations.get(userId, productId); // 如果redis原来不存在该商品,则报错 if(StringUtils.isNullOrEmpty(value)) { return ResponseVo.error(ResponseEnum.CART_NOT_EXISTS); } // 如果存在,修改其内容 Cart cart = gson.fromJson(value,Cart.class); if(cartUpdateForm.getQuantity() != null && cartUpdateForm.getQuantity() >= 0) { cart.setQuantity(cartUpdateForm.getQuantity()); } if(cartUpdateForm.getSelected() != null) { cart.setProductSelected(cartUpdateForm.getSelected()); } // 写入redis数据库 hashOperations.put(userId,productId,gson.toJson(cart)); return getCartList(userId); } @Override public ResponseVo deleteCartItem(Integer userId, Integer productId) { HashOperations hashOperations = redisTemplate.opsForHash(); String value = hashOperations.get(userId, productId); // 如果redis原来不存在该商品,则报错 if(StringUtils.isNullOrEmpty(value)) { return ResponseVo.error(ResponseEnum.CART_NOT_EXISTS); } // 如果存在,删除 hashOperations.delete(userId,productId); return getCartList(userId); } @Override public ResponseVo selectAll(Integer userId) { HashOperations hashOperations = redisTemplate.opsForHash(); for(Cart cart : getCarts(userId)) { cart.setProductSelected(true); hashOperations.put(userId,cart.getProductId(),gson.toJson(cart)); } return getCartList(userId); } @Override public ResponseVo unSelectAll(Integer userId) { HashOperations hashOperations = redisTemplate.opsForHash(); for(Cart cart : getCarts(userId)) { cart.setProductSelected(false); hashOperations.put(userId,cart.getProductId(),gson.toJson(cart)); } return getCartList(userId); } @Override public ResponseVo sum(Integer userId) { Integer totalQuantity = 0; for(Cart cart : getCarts(userId)) { totalQuantity = totalQuantity + cart.getQuantity(); } return ResponseVo.success(totalQuantity); } // 查询出所有的购物车内所有Cart列表 private List getCarts(Integer userId) { HashOperations hashOperations = redisTemplate.opsForHash(); Map entries = hashOperations.entries(userId); List list = new ArrayList<>(); for(Map.Entry entry : entries.entrySet()) { list.add(gson.fromJson(entry.getValue(),Cart.class)); } return list; } } ``` * Controller层: ```java @RestController public class CartController { @Autowired private CartService cartService; @GetMapping("/carts") public ResponseVo getCarts(HttpSession httpSession) { User user = (User) httpSession.getAttribute(MallConst.CURRENT_USER); return cartService.getCartList(user.getId()); } @PostMapping("/carts") public ResponseVo addCart(@Valid @RequestBody CartAddForm cartAddForm, HttpSession httpSession) { User user = (User) httpSession.getAttribute(MallConst.CURRENT_USER); return cartService.addCartItem(user.getId(),cartAddForm); } @PutMapping("/carts/{productId}") public ResponseVo updateCart(@PathVariable("productId") Integer productId, CartUpdateForm cartUpdateForm, HttpSession httpSession) { User user = (User) httpSession.getAttribute(MallConst.CURRENT_USER); return cartService.updateCartItem(user.getId(),productId,cartUpdateForm); } @DeleteMapping("/carts/{productId}") public ResponseVo deleteCart(@PathVariable("productId") Integer productId, HttpSession httpSession) { User user = (User) httpSession.getAttribute(MallConst.CURRENT_USER); return cartService.deleteCartItem(user.getId(),productId); } @PutMapping("/carts/selectAll") public ResponseVo selectAll(HttpSession httpSession) { User user = (User) httpSession.getAttribute(MallConst.CURRENT_USER); return cartService.selectAll(user.getId()); } @PutMapping("/carts/unSelectAll") public ResponseVo unSelectAll(HttpSession httpSession) { User user = (User) httpSession.getAttribute(MallConst.CURRENT_USER); return cartService.unSelectAll(user.getId()); } @GetMapping("/carts/products/sum") public ResponseVo getCartCount(HttpSession httpSession) { User user = (User) httpSession.getAttribute(MallConst.CURRENT_USER); return cartService.sum(user.getId()); } } ``` * @Valid搭配@NotNull,@NotEmpty,@NotBlank三大注解 以及 BindingResult类接收错误信息的 自动化判断参数 的改进: * 之前: 在用到@Valid参数的controller方法处新增一个参数BindingResult类来接收错误信息并进行处理 ![image-20211029230217250](F:\acer\ProjectForWork\sapmall\mdImage\image-20211029230217250.png) * 现在: 由于大多数报错处理都一样,都是告知前端: 参数错误 同时我们发现@Valid自动检查会报错同一个异常: MethodArgumentNotValidException 然后SpringBoot会发起/error请求, 然后抛出该异常 所以我们可以: 1) 在Interceptor配置中放行/error 2) 在全局异常处理器里面统一处理该异常 从而就可以统一处理之前每个controller方法都要写的BindingResult类参数接收错误信息并处理的代码了 ![image-20211029230146992](F:\acer\ProjectForWork\sapmall\mdImage\image-20211029230146992.png) ![image-20211029230415874](F:\acer\ProjectForWork\sapmall\mdImage\image-20211029230415874.png) ![image-20211029230453978](F:\acer\ProjectForWork\sapmall\mdImage\image-20211029230453978.png) * 心得: * 多去了解一些封装好的东西的实现原理,这样可以多使得代码更优雅 例如:@Valid与BindingResult的自动检测的报错原理,使得我们可以统一处理 * Redis的数据类型虽然只有五个,但是实际上基本所有情况都可以通过好设计而实现存储 * 像购物车等经常要被访问,修改等的, 放在Redis缓存数据库, 可以大大加快系统效率 * 进阶: * 把持久性数据库与Redis数据库进行一定的联系: * Redis作为系统运行时的存储,即:只要使用过了就放在Redis里面, 如果用户查询可以很快获得 * MongoDB,MySQL等持久性数据库作为持久化存储, 即: * 平时数据持久化存储在持久性数据库里面, 如果那些常用数据,例如购物车等 , 用户查询过一次后, 就放在Redis里面方便之后用户读取 * 如果Redis的数据涉及到修改, 则必须实时同步到持久性数据库里面(如果只是查询则无需写入) ## 收货地址模块: * 接口文档: * 添加地址 ** POST /shippings > request ``` receiverName=廖师兄 receiverPhone=010 receiverMobile=18688888888 receiverProvince=北京 receiverCity=北京市 receiverDistrict=海淀区 receiverAddress=中关村 receiverZip=100000 ``` > response success ``` { "status": 0, "msg": "新建地址成功", "data": { "shippingId": 28 } } ``` fail ``` { "status": 1, "msg": "新建地址失败" } ``` * 删除地址 **DELETE /shippings/{shippingId} DELETE /shippings/28 > request ``` shippingId ``` > response success ``` { "status": 0, "msg": "删除地址成功" } ``` fail ``` { "status": 1, "msg": "删除地址失败" } ``` ------ * 更新地址 **PUT /shippings/{shippingId} > request ``` receiverName=廖师兄 receiverPhone=010 receiverMobile=18688888888 receiverProvince=北京 receiverCity=北京市 receiverDistrict=海淀区 receiverAddress=中关村 receiverZip=100000 ``` > response success ``` { "status": 0, "msg": "更新地址成功" } ``` fail ``` { "status": 1, "msg": "更新地址失败" } ``` * 地址列表 **GET /shippings** > request ``` pageNum(默认1),pageSize(默认10) ``` > response success ``` { "status": 0, "data": { "pageNum": 1, "pageSize": 10, "size": 2, "orderBy": null, "startRow": 1, "endRow": 2, "total": 2, "pages": 1, "list": [ { "id": 4, "userId": 13, "receiverName": "廖师兄", "receiverPhone": "010", "receiverMobile": "18688888888", "receiverProvince": "北京", "receiverCity": "北京市", "receiverDistrict": "海淀区", "receiverAddress": "中关村", "receiverZip": "100000", "createTime": 1485066385000, "updateTime": 1485066385000 }, { "id": 5, "userId": 13, "receiverName": "廖师兄", "receiverPhone": "010", "receiverMobile": "18688888888", "receiverProvince": "北京", "receiverCity": "北京市", "receiverDistrict": "海淀区", "receiverAddress": "中关村", "receiverZip": "100000", "createTime": 1485066392000, "updateTime": 1485075875000 } ], "firstPage": 1, "prePage": 0, "nextPage": 0, "lastPage": 1, "isFirstPage": true, "isLastPage": true, "hasPreviousPage": false, "hasNextPage": false, "navigatePages": 8, "navigatepageNums": [ 1 ] } } ``` fail ``` { "status": 1, "msg": "请登录之后查询" } ``` * 架构: ![image-20211103225811888](F:\acer\ProjectForWork\sapmall\mdImage\image-20211103225811888.png) * 代码实现: * 实体类和常量类准备: 观察接口文档,发现前端发来的参数不是全部的Shipping实体类中的字段,所以要新增一个form类: ```java @Data public class ShippingForm { @NotBlank private String receiverName; @NotBlank private String receiverPhone; @NotBlank private String receiverMobile; @NotBlank private String receiverProvince; @NotBlank private String receiverCity; @NotBlank private String receiverDistrict; @NotBlank private String receiverAddress; @NotBlank private String receiverZip; } ``` 我们发现其实返回的内容和实体类Shipping是一模一样的,但是之前说了,为了方便统一规范,一般会建立一个vo类: ```java @Data public class ShippingVo { private Integer id; private Integer userId; private String receiverName; private String receiverPhone; private String receiverMobile; private String receiverProvince; private String receiverCity; private String receiverDistrict; private String receiverAddress; private String receiverZip; private Date createTime; private Date updateTime; } ``` 我们发现接口文档存在一处硬编码,就是新增收货地址的返回值的"shippingid",为了避免硬编码,在之前就设立好的MallConst类里面新增属性: ```java public final static String ADD_SHIPPING_RETURN_SHIPPING_ID = "shippingId";// 添加收货地址时返回的参数名 ``` * Dao层: 这里由于insertSelective是一个增操作,返回值是: 影响了几行数据 但是我们实际要的是增加的那一行的自增id, 如果再一次去查, 可以,但是很麻烦,所以我们可以借用mybatis的一个功能: 在sql标签加入useGeneratedKeys="true" keyProperty="id"属性, 从而给传递进来的Shipping对象赋值自增id, 使得Service层可以通过该对象获得id值 ```java public interface ShippingMapper { int insertSelective(Shipping record); int deleteByUserIdAndShippingId(@Param("userId") Integer userId, @Param("shippingId") Integer shippingId); List selectByUserId(Integer userId); } ``` ```xml insert into mall_shipping id, user_id, receiver_name, receiver_phone, receiver_mobile, receiver_province, receiver_city, receiver_district, receiver_address, receiver_zip, #{id,jdbcType=INTEGER}, #{userId,jdbcType=INTEGER}, #{receiverName,jdbcType=VARCHAR}, #{receiverPhone,jdbcType=VARCHAR}, #{receiverMobile,jdbcType=VARCHAR}, #{receiverProvince,jdbcType=VARCHAR}, #{receiverCity,jdbcType=VARCHAR}, #{receiverDistrict,jdbcType=VARCHAR}, #{receiverAddress,jdbcType=VARCHAR}, #{receiverZip,jdbcType=VARCHAR}, delete from mall_shipping where id = #{shippingId,jdbcType=INTEGER} and user_id = #{userId,jdbcType=INTEGER} ``` * Service层: ```java public interface ShippingService { /** * 添加收货地址 */ ResponseVo> addShipping(Integer userId, ShippingForm form); /** * 删除收货地址 */ ResponseVo deleteShipping(Integer userId, Integer shippingId); /** * 更新收货地址 */ ResponseVo updateShipping(Integer userId,Integer shippingId,ShippingForm form); /** * 获取用户的收获地址列表 */ ResponseVo getShippingList(Integer userId,Integer pageNum,Integer pageSize); } ``` ```java @Service public class ShippingServiceImpl implements ShippingService { @Autowired private ShippingMapper shippingMapper; @Override public ResponseVo> addShipping(Integer userId, ShippingForm form) { Shipping shipping = new Shipping(); BeanUtils.copyProperties(form,shipping); shipping.setUserId(userId); int row = shippingMapper.insertSelective(shipping); if(row == 0) { return ResponseVo.error(ResponseEnum.ERROR); } Map res = new HashMap<>(); res.put(MallConst.ADD_SHIPPING_RETURN_SHIPPING_ID,shipping.getId()); return ResponseVo.success(res); } @Override public ResponseVo deleteShipping(Integer userId, Integer shippingId) { int row = shippingMapper.deleteByUserIdAndShippingId(userId, shippingId); if(row == 0) { return ResponseVo.error(ResponseEnum.ERROR); } return ResponseVo.success(); } @Override public ResponseVo updateShipping(Integer userId, Integer shippingId, ShippingForm form) { Shipping shipping = new Shipping(); BeanUtils.copyProperties(form,shipping); shipping.setUserId(userId); shipping.setId(shippingId); int row = shippingMapper.updateByPrimaryKeySelective(shipping); if(row == 0) { return ResponseVo.error(ResponseEnum.ERROR); } return ResponseVo.success(); } @Override public ResponseVo getShippingList(Integer userId, Integer pageNum, Integer pageSize) { // 调用page-helper分页插件: PageHelper.startPage(pageNum,pageSize); List shippings = shippingMapper.selectByUserId(userId); List shippingVos = new ArrayList<>(); for (Shipping shipping : shippings) { ShippingVo shippingVo = new ShippingVo(); BeanUtils.copyProperties(shipping,shippingVo); shippingVos.add(shippingVo); } // 通过pageHelper加入接口文档中要求的那堆分页参数 PageInfo pageInfo = new PageInfo(shippings); pageInfo.setList(shippingVos); return ResponseVo.success(pageInfo); } } ``` * Controller层: ```java @RestController public class ShippingController { @Autowired private ShippingService shippingService; @PostMapping("/shippings") public ResponseVo> addShipping(HttpSession session, @Valid @RequestBody ShippingForm shippingForm) { User user = (User) session.getAttribute(MallConst.CURRENT_USER); return shippingService.addShipping(user.getId(),shippingForm); } @DeleteMapping("/shippings/{shippingId}") public ResponseVo deleteShipping(HttpSession session, @PathVariable Integer shippingId) { User user = (User) session.getAttribute(MallConst.CURRENT_USER); return shippingService.deleteShipping(user.getId(),shippingId); } @PutMapping("/shippings/{shippingId}") public ResponseVo updateShipping(HttpSession session, @PathVariable Integer shippingId, @Valid @RequestBody ShippingForm shippingForm) { User user = (User) session.getAttribute(MallConst.CURRENT_USER); return shippingService.updateShipping(user.getId(),shippingId,shippingForm); } @GetMapping("/shippings") public ResponseVo getShippingList(HttpSession session, @RequestParam(required = false,defaultValue = "1") Integer pageNum, @RequestParam(required = false,defaultValue = "10") Integer pageSize) { User user = (User) session.getAttribute(MallConst.CURRENT_USER); return shippingService.getShippingList(user.getId(),pageNum,pageSize); } } ``` * 心得: * 可以借助MyBatis提供的功能: **在sql标签加入useGeneratedKeys="true" keyProperty="id"属性, 从而给传递进来的Shipping对象赋值自增id, 使得Service层可以通过该对象获得id值** 从而实现**可以在插入数据库数据时,获得增加的那一行数据的自增id值, 而不用再次去查找** ## 订单模块 * 接口文档 * 1.创建订单 ** POST /orders > request ``` shippingId ``` > response success ``` { "status": 0, "data": { "orderNo": 1291136461000, "payment": 2999.11, "paymentType": 1, "postage": 0, "status": 10, "paymentTime": null, "sendTime": null, "endTime": null, "closeTime": null, "createTime": 1291136461000, "orderItemVoList": [ { "orderNo": 1291136461000, "productId": 2, "productName": "oppo R8", "productImage": "mainimage.jpg", "currentUnitPrice": 2999.11, "quantity": 1, "totalPrice": 2999.11, "createTime": null } ], "shippingId": 5, "shippingVo": { "id": 4, "userId": 13, "receiverName": "廖师兄", "receiverPhone": "010", "receiverMobile": "18688888888", "receiverProvince": "北京", "receiverCity": "北京市", "receiverDistrict": "海淀区", "receiverAddress": "中关村", "receiverZip": "100000", "createTime": 1485066385000, "updateTime": 1485066385000 } } } ``` fail ``` { "status": 1, "msg": "创建订单失败" } ``` ​ * 2.订单List ** GET /orders > request ``` pageSize(default=10) pageNum(default=1) ``` 订单状态:0-已取消-10-未付款,20-已付款,40-已发货,50-交易成功,60-交易关闭 > response success ``` { "status": 0, "data": { "pageNum": 1, "pageSize": 3, "size": 3, "orderBy": null, "startRow": 1, "endRow": 3, "total": 16, "pages": 6, "list": [ { "orderNo": 1291136461000, "payment": 2999.11, "paymentType": 1, "paymentTypeDesc": "在线支付", "postage": 0, "status": 10, "statusDesc": "未支付", "paymentTime": "2010-02-11 12:27:18", "sendTime": "2010-02-11 12:27:18", "endTime": "2010-02-11 12:27:18", "closeTime": "2010-02-11 12:27:18", "createTime": "2010-01-23 16:04:36", "orderItemVoList": [ { "orderNo": 1291136461000, "productId": 2, "productName": "oppo R8", "productImage": "mainimage.jpg", "currentUnitPrice": 2999.11, "quantity": 1, "totalPrice": 2999.11, "createTime": "2010-01-23 16:04:36" } ], "shippingId": 5, "receiverName": "廖师兄", "shippingVo": null }, { "orderNo": 1291136461001, "payment": 2999.11, "paymentType": 1, "paymentTypeDesc": "在线支付", "postage": 0, "status": 10, "statusDesc": "未支付", "paymentTime": "2010-02-11 12:27:18", "sendTime": "2010-02-11 12:27:18", "endTime": "2010-02-11 12:27:18", "closeTime": "2010-02-11 12:27:18", "createTime": "2010-01-23 16:04:35", "orderItemVoList": [ { "orderNo": 1291136461001, "productId": 2, "productName": "oppo R8", "productImage": "mainimage.jpg", "currentUnitPrice": 2999.11, "quantity": 1, "totalPrice": 2999.11, "createTime": "2010-01-23 16:04:35" } ], "shippingId": 5, "receiverName": "廖师兄", "shippingVo": null }, { "orderNo": 1291136461002, "payment": 2999.11, "paymentType": 1, "paymentTypeDesc": "在线支付", "postage": 0, "status": 10, "statusDesc": "未支付", "paymentTime": "2010-02-11 12:27:18", "sendTime": "2010-02-11 12:27:18", "endTime": "2010-02-11 12:27:18", "closeTime": "2010-02-11 12:27:18", "createTime": "2010-01-23 16:04:35", "orderItemVoList": [ { "orderNo": 1291136461002, "productId": 2, "productName": "oppo R8", "productImage": "mainimage.jpg", "currentUnitPrice": 2999.11, "quantity": 1, "totalPrice": 2999.11, "createTime": "2010-01-23 16:04:35" } ], "shippingId": 5, "receiverName": "廖师兄", "shippingVo": null } ], "firstPage": 1, "prePage": 0, "nextPage": 2, "lastPage": 6, "isFirstPage": true, "isLastPage": false, "hasPreviousPage": false, "hasNextPage": true, "navigatePages": 8, "navigatepageNums": [ 1, 2, 3, 4, 5, 6 ] } } ``` fail ``` { "status": 10, "msg": "用户未登录,请登录" } 或 { "status": 1, "msg": "没有权限" } ``` ------ ​ * 3.订单详情 ** GET /orders/{orderNo} > request ``` orderNo ``` > response success ``` { "status": 0, "data": { "orderNo": 1291136461000, "payment": 30000.00, "paymentType": 1, "paymentTypeDesc": "在线支付", "postage": 0, "status": 10, "statusDesc": "未支付", "paymentTime": "", "sendTime": "", "endTime": "", "closeTime": "", "createTime": "2010-11-30 22:23:49", "orderItemVoList": [ { "orderNo": 1291136461000, "productId": 1, "productName": "iphone7", "productImage": "mainimage.jpg", "currentUnitPrice": 10000.00, "quantity": 1, "totalPrice": 10000.00, "createTime": "2010-11-30 22:23:49" }, { "orderNo": 1291136461000, "productId": 2, "productName": "oppo R8", "productImage": "mainimage.jpg", "currentUnitPrice": 20000.00, "quantity": 1, "totalPrice": 20000.00, "createTime": "2010-11-30 22:23:49" } ], "shippingId": 3, "receiverName": "廖师兄", "shippingVo": { "receiverName": "廖师兄", "receiverPhone": "0100", "receiverMobile": "186", "receiverProvince": "北京", "receiverCity": "北京", "receiverDistrict": "昌平区", "receiverAddress": "慕课网", "receiverZip": "100000" } } } ``` fail ``` { "status": 1, "msg": "没有找到订单" } ``` ------ ​ * 4.取消订单 ** PUT /orders/{orderNo} > request ``` orderNo ``` > response success ``` { "status": 0 } ``` fail ``` { "status": 1, "msg": "该用户没有此订单" } 或 { "status": 1, "msg": "此订单已付款,无法被取消" } ``` ------ ## 与支付模块对接(作为RabbitMQ的消费者, 支付系统作为生产者) * 项目架构 ![image-20211109170646237](F:\acer\ProjectForWork\sapmall\mdImage\image-20211109170646237.png) * 说明: 一般这种多模块的, 是会从另一个系统把别的系统需要用到的导出成jar包供别的系统导入使用里面的实体类的 就跟Dubbo的公共工程一个作用 此处由于只有一个类,所以就直接复制了 * 电商系统作为一个RabbitMQ的消费者存在: **如果绑定的消息队列上有消息, 就会自动地调动方法, 从而执行内部代码, 实现异步的处理** 即: **我们只需要关心消息来了应该怎么处理, 无需关心消息怎么来,什么时候来, 来的时候别的业务在干什么, 无需关心, 因为这是异步的, 只要满足条件就会唤醒,就会执行** * 业务逻辑: 获得异步通知发送过来的Msg, 其实就是一个payInfoVo类对象, 从中获取信息进行验证, 从而对数据库内的那个订单进行状态修改,修改为[已支付] ```java /** * 支付系统异步通知的RabbitMQ消费者: * 一旦消息队列有消息, 此处就会执行 * 该消费者用于: 接收到异步消息, 获得到支付信息, 通过业务判断后, 把数据库的订单状态修改为 已支付 */ @Component // 用于设置绑定的队列 @RabbitListener(queuesToDeclare = @Queue(value = MallConst.RABBITMQ_QUEUE_NAME,durable = "true")) @Slf4j public class PayMsgConsumer { @Autowired private OrderService orderService; @RabbitHandler public void setOrderStatus(String msg) { PayInfoVo payInfoVo = new Gson().fromJson(msg, PayInfoVo.class); log.info("异步通知接收,订单号为:"+payInfoVo.getOrderNo()); // 验证该支付信息的状态: 如果支付成功,则去修改数据 if("SUCCESS".equals(payInfoVo.getPlatformStatus())) { orderService.paidOrder(payInfoVo.getOrderNo()); log.info("订单状态处理成功! 订单号为:"+payInfoVo.getOrderNo()); } } } ``` ## 拓展项目难点: 集群架构后出现的问题与改进 * **Session => JWT:** * 原因: * Session是存在服务器本地的, 当集群或分布式多服务器节点架构时, 各个服务器之间内存不共享, 所以Session也不共享 这就导致了一个问题: **用户A在一个服务器A登录了, 结果下一次发某请求时, 负载均衡到了另一个服务器B, 此时服务器B没有该用户的JSessionId(Cookie)对应的一个Session, 那么就会被认为是未登录状态, 则会要求用户再登录一次, 用户体验极其不好** 这被称为: **Session共享问题** * 为了解决Session共享问题, 有人提出, 要在集群或分布式架构中, 加一个服务器或者用redis,反正核心思想就是: **把Session存在多服务器共享的一个地方** 这样的话,每个服务器都能从共享的这个地方, 通过SessionId(Cookie)取出用户对应的session, 来进行登录验证 * 但是, 以上问题还是会因为Session的存储特性: 存在服务器端, 而出现问题: * 如果共享的那个服务器或者redis崩溃, 那么所有的session都会丢失! 这样全世界使用该网站的用户, 均要在之后重新登录一次,这是非常差的体验 * 分布式或集群架构的项目, 一般都是为了应对用户量并发量, 那么session肯定会很多, 这就加大了服务器的负担 * **所以, 人们就想, 能不能不把验证的东西存在服务器端, 而是由后端生成, 然后发给客户端来保存, 客户端每次都携带这个东西来后端, 后端进行验证, 但是后端这个验证, 是基于这个东西本身的, 而不是对照式的, 这样就能不用把任何东西存在服务器端, 减少服务器端的负担, 同时因为验证是基于这个东西本身的, 而用户每次发请求大概率还是同一个客户端, 所以每次发的东西一样, 服务器的处理一样, 验证结果必然一样, 那么登录状态就能实现共享** **这个东西, 人们称之为Token, 而JWT(JSON Web Token) 是token的一种规范** * JWT特点: ![img](F:\acer\ProjectForWork\sapmall\mdImage\ACC1E08941942E95CA613710537116CD.png) ![img](F:\acer\ProjectForWork\sapmall\mdImage\7A1CB58ED2E54134B57FA7B15D886F15.png) ![img](F:\acer\ProjectForWork\sapmall\mdImage\BCF35DAFD7E1DABF66364F1DDC214A09-1643450683530.png) ![img](F:\acer\ProjectForWork\sapmall\mdImage\504A13B7691628D15F0E992C082888BC.png) * 代码实现: ```xml com.auth0 java-jwt 3.4.0 ``` ```java public class JWTUtil { /** * 生成token * @param map 数据体, 即payload * @return */ public static String getToken(Map map) { // 过期时间 Calendar calendar = Calendar.getInstance(); calendar.add(Calendar.DATE, MallConst.TOKEN_EXPIRE_DATE_COUNT); // 默认三天过期 // 创建JWT Builder JWTCreator.Builder builder = JWT.create(); // 加载payload map.forEach((k,v) -> { builder.withClaim(k,v); }); // 生成token String token = builder.withExpiresAt(calendar.getTime()) // 设置过期时间 .sign(Algorithm.HMAC256(MallConst.PRIVATE_SIGN)); // 设置签名 return token; } /** * 验证token * @param token * @return */ public static void verifyToken(String token) throws JWTVerificationException { JWT.require(Algorithm.HMAC256(MallConst.PRIVATE_SIGN)).build().verify(token); } /** * 获取token的payload对应的数据 * @param token * @return */ public static Map getPayLoad(String token) { Map claims = JWT.require(Algorithm.HMAC256(MallConst.PRIVATE_SIGN)).build().verify(token).getClaims(); Map map = new HashMap<>(); claims.forEach((k,v) -> { map.put(k,v.asString()); }); return map; } /** * 获取payLoad里面的特定数据 * @param token * @param fieldName * @return */ public static String getFieldValuePayLoad(String token,String fieldName) { Map payLoad = JWTUtil.getPayLoad(token); String fieldValue = payLoad.get(fieldName); return fieldValue; } } ``` ```java public class UserLoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // User user = (User) request.getSession().getAttribute(MallConst.CURRENT_USER); // // 如果用户是null,则没有登录 // if(user == null) { // // 不推荐使用response向前端打印信息,因为我们已经封装好一个vo类来返回信息了,所以我们推荐下面一种方式: // // 抛出RuntimeException类型错误,从而可以被全局异常处理器捕捉到,然后向前端返回信息 // throw new UserLoginException(); // } // 从header中获取token String token = request.getHeader("token"); if(token == null) throw new UserLoginException(); // 验证token, 如果出错, 为了保密, 返回的应该是登录失效的错误 try { JWTUtil.verifyToken(token); } catch (JWTVerificationException e) { throw new UserLoginException(); } // 如果上面没报错,说明是处于登录状态,放行 return true; } } ``` ```java /** * 登录 */ // 使用@Valid与@NotXXX注解方便对内部属性进行判断,可以使用BindingResult类来接收错误信息 @PostMapping("/user/login") public ResponseVo login(@Valid UserLoginForm userLoginForm,HttpSession session) { // 调用Service层的登录逻辑方法 ResponseVo result = userService.login(userLoginForm.getUsername(), userLoginForm.getPassword()); // 判断登录是否成功,// 如果成功则设置session,保存登录状态 // 如果成功则返回JWT给前端进行保存,用于之后访问服务器 if(result.getStatus() == 0) { // session.setAttribute(MallConst.CURRENT_USER,result.getData()); UserVo userVo = this.getUserVoWithTokenAndUserData(result.getData()); // 返回包含着token + user数据的UserVo数据 return ResponseVo.success(userVo); } return result; } ``` ```java /** * 查询登录状态的用户信息 */ @GetMapping("/user") public ResponseVo userInfo(HttpServletRequest request) { // 由于之前登陆状态会由拦截器进行拦截,所以 // User user = (User) session.getAttribute(MallConst.CURRENT_USER); // 从JWT中获取到id值,查询用户 String token = request.getHeader(MallConst.TOKEN_NAME); Integer id = Integer.valueOf(JWTUtil.getFieldValuePayLoad(token, MallConst.USER_ID_IN_PAYLOAD)); return userService.getUser(id); } ``` * Redis存储商品列表(分页存储): **会导致后续的: 缓存击穿与缓存穿透问题** productServiceImpl: ```java @Override public ResponseVo getProducts(Integer categoryId, Integer pageNum, Integer pageSize) { // 因为一般而言, 前几页的商品列表是经常要访问的数据, 每次都去数据库查, 十分浪费时间, 所以用redis存下来 // 对于获取商品列表的请求, 首先从redis里面访问看看有没有这该页对应的responseVO // redis数据结构: hash: // productList { // 页号: ResponseVo // 页号: ResponseVo // ... // } HashOperations> hashOperations = redisTemplate.opsForHash(); // 先去redis里面查该页对应的商品列表 ResponseVo responseVo = hashOperations.get(MallConst.PRODUCT_LIST_KEY, pageNum); // 如果redis没有 才去访问数据库 if(responseVo == null) { /* 业务逻辑: 如果传递了categoryId,则只是分页查询该类目以及其多级子目录类目下的商品 如果不传递, 则是分页查询所有的商品 体现在: productMapper.selectByCategoryIdSet(categoryIdSet) 执行的sql是: select from mall_product where status = 1 and category_id in #{categoryId} 可见,如果没有categoryId,则不会有if里面的where条件,所以就是查询所有的商品 */ // 先获取到参数categoryId以及其下面的所有子目录的categoryid的集合 Set categoryIdSet = new HashSet<>(); if(categoryId != null) { categoryService.findAllCategoryIdsByCategoryId(categoryId,categoryIdSet); categoryIdSet.add(categoryId); } // 通过pageHelper实现分页功能: 会导致紧接着的查询自动加以分页 PageHelper.startPage(pageNum,pageSize); // 根据set里面的 categoryId 查询实际Product列表 List products = productMapper.selectByCategoryIdSet(categoryIdSet); // 返回的是ProductVo类,所以要进行转化: List productVoList = new ArrayList<>(); for (Product product : products) { ProductVo productVo = new ProductVo(); BeanUtils.copyProperties(product,productVo); productVoList.add(productVo); } // 通过pageHelper加入接口文档中要求的那堆分页参数 PageInfo pageInfo = new PageInfo(products); // 因为此处要显示的list与查出来的list不是同一个,所以要setList pageInfo.setList(productVoList); // 更新responseVo作为返回 responseVo = ResponseVo.success(pageInfo); // 如果是当前查询到的列表不是空列表, 则把当前页对应的responseVo存入redis,方便下一次访问 if(products.size() > 0){ hashOperations.put(MallConst.PRODUCT_LIST_KEY,pageNum,responseVo); } } return responseVo; } ``` * **Redis实现分布式锁(借助Redisson):** * 为什么在本项目要实现分布式锁 * **超卖问题: ** 在本项目中, 提交订单的流程是: **查询redis购物车数据 --- 查询数据库商品数据 --- 判断库存 --- 生成订单对应pojo --- 更新mysql库存数据 -- 订单写入mysql数据库--删除购物车数据** 虽然我们使用了事务, 但是在集群架构下, 即: 在多线程并发下, 还是会导致**超卖问题** 原因: ​ 虽然给Mysql加了事务, 但是事务隔离级别是可重复读,(可简单认为是写锁, 不是读锁), 所以有可能高并发的访问可以同时先把 原库存读出来, 那接下 来, 大家都用原库存来判断是否够 卖的, 假设每个人都能通过, 那就都会去删除库存, 这一步由事务保证串行, 但是没有意义了, 因为按理说前面用户 把库存删完了, 后面就不应该还能买, 但是因为他们一开始用于判断能不能买的条件, 是原库存, 而不是前面用户删除后的库存, 大家还是能买, 这就 导致了超卖 ​ 假设: 原库存5, A买3,B卖买3, ​ 理想情况(单体架构): A读出库存为5, 判断5>3, 所以A可以买, 然后库存-3=2, B再来读出库存=2, B因为3>2, 就不能买了 ​ 但是并发情况, 就可能出现: ​ A,B同时并发, 读出 stock变量 = 5, 保存在代码里了(内存) ​ 然后A,B对于库存的判断是通过stock的判断, 3,3<5, 所以都能通过库存判断 ​ 然后, 因为事务串行, 假设A先买, 那库存: 5-3 = 2, 然后写入数据库, 库存=2 ​ 然后, B再买, 那库存: 5-3=2, 然后写入数据库, 库存=2 ​ 所以, 最后, **数据库里面的库存=2, 但是原库存=5, 实际购买量=3+3=6, 6>5, 这就是超卖** * **缓存击穿**: 在本项目中, 商品列表会存在Redis中, 加快该热点数据的访问速度 但是, **如果某一时刻, 大量并发访问某个在redis中不存在的页号对应的商品列表, 则会给数据库带来巨大的压力** 压力测试访问pageNum=2, 此时pageNum对应的商品列表并不在redis中,但确实在数据库中 ![image-20220130121958042](F:\acer\ProjectForWork\sapmall\mdImage\image-20220130121958042.png) 压测结果: ![image-20220130122101979](F:\acer\ProjectForWork\sapmall\mdImage\image-20220130122101979.png) 说明确实发生了缓存击穿. 所以, 我们要给**大量并发加锁** **实现原理: ** **1) 先并发地读redis, 在此场景下必然结果是null** **2) 那么就会开始去访问数据库, 然后给数据库带来大量压力, 但是,在此时, 我们加个锁, 使得所有线程是串行的, 那么第一个就会去访问, 且只有第一个在访问** **3) 在加锁后, 再次查询redis(双重校验锁机制,例如单例模式), 看看当前这个查询结果是否还是null, 如果不是, 则访问redis, 如果是, 则访问数据库然后存入redis. 这样一来, 结合2)的串行化, 当第一个线程进来时, 此时再次访问redis, 肯定还是null, 那就访问数据库获取到数据存入redis, 因为是缓存击穿不是缓存穿透, 所以此时数据库查到的数据不会是null, 则存入redis, 然后释放锁, 串行化第二个线程进来, 再次访问redis时,此时就会因为串行化的第一个线程已经把数据加入到redis了, 所以后续的线程都会在再次访问redis后,不会再访问数据库, 从而解决了一下子都因为redis没有而去访问数据库的缓存击穿问题** 如果是单体项目, 其实可以加个synchronized关键字即可, 但是synchronized是JVM锁, 但是集群架构下, 相当于有多个JVM, 即: 不同服务器的synchronized是不生效的, 对集群架构下的并发导致的缓存击穿, 无法解决 所以, **需要分布式锁** * 分布式锁: 借助Redis实现: * **分布式锁原理:** ![分布式锁原理](F:\acer\ProjectForWork\sapmall\mdImage\分布式锁原理.jpg) * redis提供了一个原子操作, 可以把 查询+设置+设置过期时间 三个操作作为一个原子性操作进行, 这样就能使**加锁**成为现实 但是, redis并没有提供一个 查询+删除 的原子操作, 所以**解锁**会出现一些问题(下面会展开说明这些问题), 所以**一般redis实现分布式锁的解锁, 要借助LUA脚本 => 能确保解锁是原子性操作:** ![image-20220129183519985](F:\acer\ProjectForWork\sapmall\mdImage\image-20220129183519985.png) ![LUA](F:\acer\ProjectForWork\sapmall\mdImage\LUA.jpg) * **Redis实现分布式锁的一些问题:** ![分布式锁概述](F:\acer\ProjectForWork\sapmall\mdImage\分布式锁概述.jpg) * Redisson框架: **Redis+看门狗机制 实现的分布式锁框架, 能够大大降低项目中加入分布式锁的难度--只需要几个api即可** * ![image-20220129183619373](F:\acer\ProjectForWork\sapmall\mdImage\image-20220129183619373.png) ![image-20220129183651396](F:\acer\ProjectForWork\sapmall\mdImage\image-20220129183651396.png) ​ * **解决上述的 商品列表的缓存击穿问题:** ps:在上述的 实现商品列表的分页缓存 的代码上加以分布式锁 ```java private static int MYSQL_count = 0;// 统计并发下, 访问数据库的次数 private static int REDIS_count = 0;// 统计并发下, 访问Redis的次数 @Override public ResponseVo getProducts(Integer categoryId, Integer pageNum, Integer pageSize) throws InterruptedException { HashOperations> hashOperations = redisTemplate.opsForHash(); // 先去redis里面查该页对应的商品列表 ResponseVo responseVo = hashOperations.get(MallConst.PRODUCT_LIST_KEY, pageNum); // 如果redis没有 才去访问数据库 if(responseVo == null) { // 获取当前页对应的商品列表的分布式锁, 并尝试加锁tryLock RLock rLock = redissonClient.getLock(MallConst.PRODUCT_LIST_LOCK_NAME + pageNum); boolean isLock = false; try { isLock = rLock.tryLock(10,3, TimeUnit.SECONDS); } catch (InterruptedException e) { e.printStackTrace(); } // 如果加锁成功, 则执行下面的代码: if(isLock) { // 双重校验锁机制: 在加锁后, 看看当前这个查询结果是否还是null, // 如果不是, 则访问redis, 如果是, 则访问数据库然后存入redis // 这样一来, 结合2)的串行化, 当第一个线程进来时, 此时再次访问redis, 肯定还是null, 那就访问数据库获取到数据存入redis, // 因为是缓存击穿不是缓存穿透, 所以此时数据库查到的数据不会是null, 则存入redis, 然后释放锁, // 串行化第二个线程进来, 再次访问redis时,此时就会因为串行化的第一个线程已经把数据加入到redis了, 所以会直接返回redis查出来的数据 // 所以后续的线程都会在再次访问redis后,不会再访问数据库, 从而解决了一下子都因为redis没有而去访问数据库的缓存击穿问题 ResponseVo checkResponseVO = hashOperations.get(MallConst.PRODUCT_LIST_KEY, pageNum); if(checkResponseVO == null) { log.warn("------------------访问了数据库哦------------------------------" + (++MYSQL_count)); // 先获取到参数categoryId以及其下面的所有子目录的categoryid的集合 Set categoryIdSet = new HashSet<>(); if(categoryId != null) { categoryService.findAllCategoryIdsByCategoryId(categoryId,categoryIdSet); categoryIdSet.add(categoryId); } // 通过pageHelper实现分页功能: 会导致紧接着的查询自动加以分页 PageHelper.startPage(pageNum,pageSize); // 根据set里面的 categoryId 查询实际Product列表 List products = productMapper.selectByCategoryIdSet(categoryIdSet); // 返回的是ProductVo类,所以要进行转化: List productVoList = new ArrayList<>(); for (Product product : products) { ProductVo productVo = new ProductVo(); BeanUtils.copyProperties(product,productVo); productVoList.add(productVo); } // 通过pageHelper加入接口文档中要求的那堆分页参数 PageInfo pageInfo = new PageInfo(products); // 因为此处要显示的list与查出来的list不是同一个,所以要setList pageInfo.setList(productVoList); // 更新responseVo作为返回 responseVo = ResponseVo.success(pageInfo); // 如果是当前查询到的列表不是空列表, 则把当前页对应的responseVo存入redis,方便下一次访问 if(products.size() > 0){ hashOperations.put(MallConst.PRODUCT_LIST_KEY,pageNum,responseVo); } } else { // 如果再次查到的responseVo不是null, 则说明这是第二个串行的线程了, 那这重新查询的结果就是要返回的结果, 所以赋值 responseVo = checkResponseVO; } // 无论如何, 最后一定要释放这个锁: rLock.unlock(); } } log.warn("------------------访问了Redis哦------------------------------" + (++REDIS_count)); return responseVo; } ``` 解决后, 再部署集群, 通过ApacheAB再次测压, 这次的运行结果, 就可以看出完美解决缓存击穿问题了: ![image-20220130140625327](F:\acer\ProjectForWork\sapmall\mdImage\image-20220130140625327.png) ![image-20220130140645837](F:\acer\ProjectForWork\sapmall\mdImage\image-20220130140645837.png) ![image-20220130140659900](F:\acer\ProjectForWork\sapmall\mdImage\image-20220130140659900.png) * **解决上述的商品超卖问题**: ```java @Override @Transactional public ResponseVo createOrder(Integer userId, Integer shippingId) { // 查询收货地址, 并对收货地址进行验证: Shipping shipping = shippingMapper.selectByPrimaryKey(shippingId); if(shipping == null) { return ResponseVo.error(ResponseEnum.SHIPPING_NOT_EXISTS); } // 查询购物车中被选中的商品,并进行验证 List carts = cartService.getCarts(userId).stream().filter(Cart::getProductSelected).collect(Collectors.toList()); if(CollectionUtils.isEmpty(carts)) { return ResponseVo.error(ResponseEnum.CART_NOT_SELECTED_PRODUCT); } // 查询出商品的id集合, 由此查询被选择的商品的信息, 为了验证时, Cart遍历时,可以方便从products里面取值,所以可以将List转为Map Set productIds = carts.stream().map(Cart::getProductId).collect(Collectors.toSet()); // 对购物车中的每一个商品, 都要加一个分布式锁 // **只有当当前购物车里面每一个商品, 都可以被加锁成功时,购物车才算加锁成功**, 才能执行业务代码 boolean isLock = true; Map locks = new HashMap<>();// 把每个商品的锁存起来,方便之后释放 for (Integer productId : productIds) { String curProductLockName = MallConst.PRODUCT_LOCK_NAME + productId;// 以 前缀标识 + 商品id作为锁名称 RLock curProductLock = redissonClient.getLock(curProductLockName); boolean curIsLock = false; try { curIsLock = curProductLock.tryLock(10,3, TimeUnit.MINUTES); // 如果加锁成功, 就把当前的锁加到Map里面去 if(curIsLock) locks.put(curProductLockName,curProductLock); } catch (InterruptedException e) { e.printStackTrace(); } // 购物车的锁 = 每一个商品的锁 的 &运算, 只要出现一个是false, 那么购物车就不会锁 isLock = isLock & curIsLock; } // 如果购物车加锁成功, 就执行下面的业务代码, 从而阻止并发下的超卖问题: // 使用try-catch-finally括起来的原因: // 因为中间有很多次都会因为各种验证不通过, 而直接return // 那么此时就不会执行到 **释放锁** 那一步 // 但是, 无论发生了什么, 都必须释放锁, 所以, 要加一个try-catch-finally, // 主要是**为了可以利用finally里面必然执行的性质, 从而保证释放锁** try { if(isLock) { // 再查一次redis, 如果此时该购物车已经被删除, 则不要再执行该购物车转订单的代码了 carts = cartService.getCarts(userId).stream().filter(Cart::getProductSelected).collect(Collectors.toList()); if(carts != null) { // 从数据库中查询出购物车中所有商品的信息: List productList = productMapper.selectByProductIdSet(productIds); Map productMap = productList.stream().collect(Collectors.toMap(Product::getId,product -> product)); // 校验商品库存,状态等: // 同时构建: OrderItemList, 用于存入数据库的orderItem表 List orderItemList = new ArrayList<>(); // 订单号orderNo是要代码生成的唯一值 Long orderNo = generateOrderNo(); for (Cart cart : carts) { Product product = productMap.getOrDefault(cart.getProductId(),null); // 虽然productIds是从carts里面获得的, 但是数据库查询时使用的是in作为条件判断,所以不一定producIds里面有的,最终返回的productMap里面也有 if(product == null) { return ResponseVo.error(ResponseEnum.PRODUCT_IN_CART_NOT_EXISTS,ResponseEnum.PRODUCT_IN_CART_NOT_EXISTS.getDesc()+":商品id为:"+cart.getProductId()); } // 验证商品状态 if(!ProductStatusEnum.ON_SALE.getCode().equals(product.getStatus())) { return ResponseVo.error(ResponseEnum.PRODUCT_OFF_SALE_OR_DELETE,ResponseEnum.PRODUCT_OFF_SALE_OR_DELETE.getDesc()+".商品是:"+product.getName()); } // 验证商品库存 if(product.getStock() < cart.getQuantity()) { return ResponseVo.error(ResponseEnum.PRODUCT_STOCK_NOT_ENOUGH,ResponseEnum.PRODUCT_STOCK_NOT_ENOUGH.getDesc()+".商品是:"+product.getName()); } // 构建OrderItemVo OrderItem orderItem = buildOrderItemVo(userId, orderNo, cart.getQuantity(), product); orderItemList.add(orderItem); // 减少库存,由事务保证与下面的操作一起成功 product.setStock(product.getStock() - cart.getQuantity()); int row = productMapper.updateByPrimaryKeySelective(product); if(row <= 0) { return ResponseVo.error(ResponseEnum.ERROR); } } // 构建Order数据 Order order = buildOrder(orderNo,userId,shippingId,orderItemList); // 往数据库写入Order与OrderItem // 注意: 此时必须使用事务来保证两者的同时成功与失败 int rowForOrder = orderMapper.insertSelective(order); if(rowForOrder <= 0) { return ResponseVo.error(ResponseEnum.ERROR); } int rowForOrderItem = orderItemMapper.insertSelectiveByOrderItemList(orderItemList); if(rowForOrderItem <= 0) { return ResponseVo.error(ResponseEnum.ERROR); } // 更新Redis数据库: 把购物车的数据删除 for (Cart cart : carts) { cartService.deleteCartItem(userId,cart.getProductId()); } // 构建OrderVo作为返回 OrderVo orderVo = buildOrderVo(order,orderItemList,shipping); return ResponseVo.success(orderVo); } } } catch (Exception e) { e.printStackTrace(); } finally { // 无论怎么样, 都要释放锁, 所以把释放锁的代码放到finally里面 // 由于购物车的锁 = 每个商品的锁, 所以要一个个把商品的锁给释放了 for (Integer productId : productIds) { locks.get(MallConst.PRODUCT_LOCK_NAME + productId).unlock(); } } return ResponseVo.error(ResponseEnum.ERROR); } ``` * **Redis缓存穿透问题:** * 原因: 当用户(黑客)用一个 redis不存在且**数据库也不存在** 的属性值来访问数据时, 根据正常业务流程: ​ 先查redis, 没有查数据库, 然后一般都会当不为null时就会存入redis, 下一次就直接从redis查了 ​ 但是, 由于此时该属性值是数据库不存在的, 所以每次查数据库也只会得到null值, 所以也一般不会存入redis, 这就导致了: ​ 每次访问该属性值, 就会访问一次redis+一次数据库, 当高并发时, 必然会给redis和数据库都带来很大的压力 * **在本题的应用场景:** ​ 我们是按页把商品列表存下来, 同时为了避免浪费redis空间, 如果当前页的商品列表的size是0, 那也不会存入redis ​ 如果我们数据库的数据量只有100页数据, 但是**用户却一下子高并发访问 第150页的数据** ​ **第150页的数据是数据库中不存在的, 所以每次都会去访问redis+mysql** ​ 这就是本项目会出现的缓存穿透问题 ​ ![image-20220130150217192](F:\acer\ProjectForWork\sapmall\mdImage\image-20220130150217192.png) * 与缓存击穿的区别: * 用于访问的属性值不一样: * 击穿是: redis不存在, 但是数据库存在 * 穿透是:redis不存在, 但是数据库也不存在 * 侧重点, 或者说, 业务场景诞生的逻辑不一样: * 击穿强调的是: 一个数据, 当高并发访问时, 他在那个时刻不在redis里, 所以所有线程都去数据库取该数据, * 穿透强调的是: 一个数据, 本来就不存在于整个数据库系统中, 无论查多少次, 都必须经过整个流程, 并且每次都是查个寂寞, * 解决方式不同: * 击穿: 加锁使其串行化, 主要是为了让第一个线程把数据加到redis里, 后面的线程都可以从redis里取 * 穿透: 要过滤类似于这种的无效访问 * 解决方式: * 给不存在的Key设置默认值,存入Redis: ​ 虽然确实可以达到像击穿那样的减压效果, 即: 后面因为redis里面有该key了, 就直接从redis读了 ​ 但是, 如果对方每次发的是不同的不存在的key, 那每次还是得去查redis+数据库 ​ 同时, 很容易就给redis存入一大堆没用的数据, 浪费了宝贵的内存空间 ​ 不过可以通过设置较短的过期时间来缓解内存占用 * **布隆过滤器(推荐方案)**: ​ 布隆过滤器由于其独特的机制(在此不多赘述), 可以在很短时间内, **判断一个值是否存在在布隆过滤器中** ​ 虽然布隆过滤器说**存在不一定就是存在的,所以还是有可能会出现穿透现象, 但是其说不在就必不在 但是总归比原来裸奔好** ​ 使用: **事先把所有可能的请求属性值存入布隆过滤器, 当一个请求属性值来时, 先通过布隆过滤器判断一下, 如果不存在, 不处理该请求, 如果存 在, 才处理** * 本题采用的是 **给不存在的Key设置默认值,存入Redis:** * 原因: * 不使用布隆过滤器 以及 查询总页数 进行判断的原因: ​ 使用布隆过滤器固然很酷, 但是, 我们这是页号, 是**连续的,并不是一堆离散的数据集,** 使用布隆过滤器有点杀鸡用牛刀的感觉, ​ 所以, 其实如果我们知道总页号, 直接判断一下 传入的页号 在[1,总页数]之间即可, ​ 这样问题就来了, **如果我们要知道当前的总页数, 那就得查Mysql数据库**, ​ 这样一来, 高并发情况下, 就给数据库带来巨大压力, ​ 这时,我们可能又会想到用redis把总页数存着? ​ 那么, 仔细想想, 不就会导致缓存击穿问题了吗? 那么又得分布式锁一次? 就为了查个页数? 没必要没必要 ​ 其实这个原因也是不使用布隆过滤器的主要原因: 因为布隆过滤器要提前把可能的请求参数值存入过滤器中, 而我们的可能请求就是[1,总页 数], 这样我们使用布隆过滤器之前就得查一次总页数, 导致的问题就和上面说的一样, 要么压死数据库, 要么缓存击穿 * 使用缓存无效Key的原因: ​ 因为我们已经在解决缓存击穿问题上,使用了分布式锁, 那么如果重复查询同一个Key, 是不会多次查询数据库的, 因为第一个线程就把数据加 入到redis了. ​ 那么, **我们如果当判断size == 0时, 不再像之前那样舍去, 而是也把他存入redis, 那么如果高并发下, 访问的同一个不存在的Key, 那么第一个 线程会把该无效Key数据存入redis, 那后续的线程都会访问redis, 这就不会给数据库增加压力了, 也就解决了缓存穿透问题** ​ **当然, 为了减少无效Key的内存占用, 要设置一个较短的过期时间** ​ **PS: 当然, 如果黑客可以做到在同一次高并发下, 传的都是不一样的不存在的Key, 那这种方法就没办法像布隆过滤器那样解决缓存穿透问 题, 因为即使我们存了无效Key来让后续访问该Key的线程直接访问redis, 但是此时传的都是不一样的不存在的Key, 那你就算缓存了 AKey, 后续查BKey,CKey...也毫无影响, 还是会引发缓存穿透问题** * 代码实现: ```java private static int MYSQL_count = 0;// 统计并发下, 访问数据库的次数 private static int REDIS_count = 0;// 统计并发下, 访问Redis的次数 @Override public ResponseVo getProducts(Integer categoryId, Integer pageNum, Integer pageSize) throws InterruptedException { // 因为一般而言, 前几页的商品列表是经常要访问的数据, 每次都去数据库查, 十分浪费时间, 所以用redis存下来 // 对于获取商品列表的请求, 首先从redis里面访问看看有没有这该页对应的responseVO // redis数据结构: hash: // productList { // 页号: ResponseVo // 页号: ResponseVo // ... // } // 由于这样无法给单个页面对应的商品列表设置过期时间, 这就无法实现 缓存穿透使用的"缓存无效Key"的策略 // 所以, 之后采取这样的数据结构: hash: // productList+页号(key) : responseVo对象(value) ValueOperations> hashOperations = redisTemplate.opsForValue(); // 先去redis里面查该页对应的商品列表 ResponseVo responseVo = hashOperations.get(MallConst.PRODUCT_LIST_KEY + pageNum); // 如果redis没有 才去访问数据库 if(responseVo == null) { // 获取当前页对应的商品列表的分布式锁, 并尝试加锁tryLock RLock rLock = redissonClient.getLock(MallConst.PRODUCT_LIST_LOCK_NAME + pageNum); boolean isLock = false; try { isLock = rLock.tryLock(10,3, TimeUnit.SECONDS); } catch (InterruptedException e) { e.printStackTrace(); } // 如果加锁成功, 则执行下面的代码: if(isLock) { ResponseVo checkResponseVO = hashOperations.get(MallConst.PRODUCT_LIST_KEY + pageNum); if(checkResponseVO == null) { log.warn("------------------访问了数据库哦------------------------------" + (++MYSQL_count)); // 先获取到参数categoryId以及其下面的所有子目录的categoryid的集合 Set categoryIdSet = new HashSet<>(); if(categoryId != null) { categoryService.findAllCategoryIdsByCategoryId(categoryId,categoryIdSet); categoryIdSet.add(categoryId); } // 通过pageHelper实现分页功能: 会导致紧接着的查询自动加以分页 PageHelper.startPage(pageNum,pageSize); // 根据set里面的 categoryId 查询实际Product列表 List products = productMapper.selectByCategoryIdSet(categoryIdSet); // 返回的是ProductVo类,所以要进行转化: List productVoList = new ArrayList<>(); for (Product product : products) { ProductVo productVo = new ProductVo(); BeanUtils.copyProperties(product,productVo); productVoList.add(productVo); } // 通过pageHelper加入接口文档中要求的那堆分页参数 PageInfo pageInfo = new PageInfo(products); // 因为此处要显示的list与查出来的list不是同一个,所以要setList pageInfo.setList(productVoList); // 更新responseVo作为返回 responseVo = ResponseVo.success(pageInfo); // 如果是当前查询到的列表不是空列表, 则把当前页对应的responseVo存入redis,方便下一次访问 if(products.size() > 0){ hashOperations.set(MallConst.PRODUCT_LIST_KEY+pageNum,responseVo); } // 如果查到的是空列表, 说明这个是 无效Key, 是会引发缓存穿透的无效Key, 所以根据我们采用的 "缓存Key"的策略 // 所以, 我们也要缓存该无效页号对应的responseVo, 但是要设置一个比较短的过期时间: 这里设置为3分钟 else { hashOperations.set(MallConst.PRODUCT_LIST_KEY+pageNum,responseVo,MallConst.WASTED_KEY_EXPIRE,TimeUnit.MINUTES); } } else { // 如果再次查到的responseVo不是null, 则说明这是第二个串行的线程了, 那这重新查询的结果就是要返回的结果, 所以赋值 responseVo = checkResponseVO; } // 无论如何, 最后一定要释放这个锁: rLock.unlock(); } } log.warn("------------------访问了Redis哦------------------------------" + (++REDIS_count)); return responseVo; } ``` 运行结果: 从运行结果就能看出, 缓存穿透被解决了,并且过了设置的过期时间三分钟后, 无效的Key数据会被redis删除, 节省了空间 压测用例: pageNum = 10, 是此时不存在的页面, 所以是会引发缓存穿透的 ![image-20220130153543751](F:\acer\ProjectForWork\sapmall\mdImage\image-20220130153543751.png) ![image-20220130153431107](F:\acer\ProjectForWork\sapmall\mdImage\image-20220130153431107.png) ![image-20220130153443613](F:\acer\ProjectForWork\sapmall\mdImage\image-20220130153443613.png) ![image-20220130153458948](F:\acer\ProjectForWork\sapmall\mdImage\image-20220130153458948.png)