SpringCloud - 商城高级篇(下)

SpringCloud - 商城项目(下)

1. 商城业务 - 认证服务

1.1 环境搭建

① 创建项目gulimall-auth-server服务,引入common坐标依赖:

<dependency>
    <groupId>com.atguigu.gulimall</groupId>
    <artifactId>gulimall-common</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <exclusions>
        <exclusion>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
        </exclusion>
        <exclusion>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
        </exclusion>
    </exclusions>
</dependency>

② 编写application.properties :

spring.application.name=gulimall-auth-server
spring.cloud.nacos.discovery.server-addr=127.0.0.1:8848
server.port=20000

③ 主启动类添加注解开启服务注册与发现以及远程调用功能 :

@EnableFeignClients
@EnableDiscoveryClient
@SpringBootApplication
public class GulimallAuthServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(GulimallAuthServerApplication.class, args);
    }
}

④ 配置域名:

在这里插入图片描述

⑤ 将所有的静态资源复制到nginx中

在这里插入图片描述

⑥ 配置网关:

- id: gulimall_auth_route
  uri: lb://gulimall-auth-server
  predicates:
    - Host=auth.gulimall.com

⑦ 访问登录页面和注册页面 :

@Controller
public class LoginController {

    @GetMapping("/login.html")
    public String loginPage(){
        return "login";
    }

    @GetMapping("/reg.html")
    public String regPage(){
        return "reg";
    }
}

对于上面那种只处理视图页面跳转逻辑的,可以使用下面方式代替,不用写空方法:

@Configuration
public class GulimallWebConfig implements WebMvcConfigurer {
    /**
     * 视图映射,处理试图跳转逻辑,不处理任何业务
     * @param registry
     */
    public void addViewControllers(ViewControllerRegistry registry){
        registry.addViewController("/login.html").setViewName("login");
        registry.addViewController("/reg.html").setViewName("reg");
    }
}

1.2 开通阿里云的短信服务

① 进入阿里云的短信服务:https://www.aliyun.com/product/sms?spm=5176.10695662.J_3717714080.1.27b83583LyzB5o,点击管理控制台:

在这里插入图片描述

② 开通短信服务:
在这里插入图片描述

③ 申请短信模板 :

在这里插入图片描述

在这里插入图片描述

申请模板之后需要等待审核

在这里插入图片描述

④ 申请签名管理:现在无法申请成功,借用的accessKeyID、accessKeySecret、signName、templateCode

在这里插入图片描述

1.3 整合阿里云短信服务

说明雷神的短信验证码模板需要企业用户资质,如果使用短信模板,又无法达到本项目的需求,因此建议大家申请一个短信模板,但是12月17号后审核很严格,因此建议直接借用12月17号之前申请成功的同学的。

① 导入坐标依赖:

<dependency>
    <groupId>com.aliyun</groupId>
    <artifactId>aliyun-java-sdk-core</artifactId>
</dependency>

② 在gulimall-third-party服务中封装发送验证码的接口:

@ConfigurationProperties(prefix = "spring.cloud.alicloud.sms")
@Data  
@Component
public class SmsComponent {

    private String accessKeyID;
    private String accessKeySecret;
    private String signName;//您的申请签名
    private String templateCode; //你的模板

    public void sendSmsCode(String phone, String param) {
        //判断手机号是否为空
        if (StringUtils.isEmpty(phone)) {
            System.out.println("手机号为空");
        }else {
            DefaultProfile profile = DefaultProfile.getProfile("default", accessKeyID, accessKeySecret);
            IAcsClient client = new DefaultAcsClient(profile);

            //设置相关固定的参数
            CommonRequest request = new CommonRequest();
            request.setMethod(MethodType.POST); //提交方式
            request.setDomain("dysmsapi.aliyuncs.com");
            request.setVersion("2017-05-25");
            request.setAction("SendSms");

            //设置发送相关的参数
            request.putQueryParameter("PhoneNumbers", phone);   //手机号
            request.putQueryParameter("SignName", signName);    //申请的阿里云的 签名名称
            request.putQueryParameter("TemplateCode", templateCode);   //申请的阿里云的模板code

            HashMap<String, Object> params = new HashMap<>();
            params.put("code",param);
            request.putQueryParameter("TemplateParam", JSONObject.toJSONString(params));

            try {
                //最终发送
                CommonResponse response = client.getCommonResponse(request);
                System.out.println("发送成功");
                //判断成功还是失败
            }catch (ServerException e) {
                e.printStackTrace();
                System.out.println("发送失败1");
            } catch (ClientException e) {
                e.printStackTrace();
                System.out.println("发送失败2");
            }
        }
    }
}

③ 在application.properties中配置属性值:

spring:
  cloud:
    alicloud:
      sms:
        access-key-i-d: 阿里云得到的
        access-key-secret: 阿里云得到的
        sign-name: 阿里云申请的签名
        template-code: 阿里云申请的模板

④ 在测试类中调试该接口:

@RunWith(SpringRunner.class)
@SpringBootTest(classes = GulimallThirdPartyApplication.class)
public class GulimallThirdPartyApplicationTests {

    @Autowired
    SmsComponent smsComponent;

    @Test
    public void sendSmscode(){
        smsComponent.sendSmsCode("18751887307","3d4c5b");
    }
}

在这里插入图片描述

1.4 发送验证码并防刷

gulimall-auth-server服务远程调用gulimall-third-party服务来发送验证码:

① 在gulimall-third-party服务中,提供给别的服务进行调用的方法:

@RestController
@RequestMapping("/sms")
public class SmsSendController {
    @Autowired
    SmsComponent smsComponent;

    /**
     * 提供给别的服务进行调用
     */
    @GetMapping("/sendcode")
    public R sendCode(@RequestParam("phone") String phone, @RequestParam("code") String code){
        smsComponent.sendSmsCode(phone,code);
        return R.ok();
    }
}

② 编写feign接口:

@FeignClient("gulimall-third-party")
public interface ThirdPartyFeignService {
    @GetMapping("/sms/sendcode")
    public R sendCode(@RequestParam("phone") String phone, @RequestParam("code") String code);
}

③ 在gulimall-auth-server服务中远程调用gulimall-third-party服务:

@Controller
public class LoginController {
     @Autowired
    ThirdPartyFeignService thirdPartyFeignService;

    @ResponseBody
    @GetMapping("/sms/sendcode")
    public R sendCode(@RequestParam("phone") String phone){
        String code = UUID.randomUUID().toString().substring(0,5);
        thirdPartyFeignService.sendCode(phone,code);
        return R.ok();
    }
}

要解决的问题:

  • 在页面上检查元素时,暴露了验证码的请求路径,那么别人拿到这个请求路径就可以无限制的发送验证码。

  • 尽管我们设置了60秒之后才能再次发送验证码,但是只要刷新页面,还是可以重新发送验证码,因此需要设置验证码防刷功能,即使刷新页面仍然需要等待60秒之后才能再次发送验证码。

  • 验证码在注册时需要再次校验,因此生成验证码之后,需要重新存起来

  • 我们需要设置验证码的过期时间,即验证码在5分钟内有效,即设置redis的过期时间

① 在gulimall-auth-server服务中导入redis的坐标依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

② 在application.properties中配置redis的端口:

spring.redis.port=6379
spring.redis.host=192.168.38.22

③ 在gulimall-auth-server服务的LoginController类中添加以上功能解决问题:

@Controller
public class LoginController {
    @Autowired
    ThirdPartyFeignService thirdPartyFeignService;

    @Autowired
    StringRedisTemplate redisTemplate;

     /**
     * 验证码
     * 调用远程服务gulimall-third-party发送验证码,这个验证码并不是由阿里云生成的,
     * 而是由我们后台产生的,我们把生成的验证码传给阿里云让它发送
     */
    @ResponseBody
    @GetMapping("/sms/sendcode")
    public R sendCode(@RequestParam("phone") String phone){
        String code = redisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + phone);
        //验证码防刷
        if(!StringUtils.isEmpty(code)){
            long time = Long.parseLong(code.split("_")[1]);
            //如果当前时间减去redis中设置的过期时间小于60秒,就不能再发送验证码
            if(System.currentTimeMillis()-time<60000){
                return R.error(BizCodeEnume.SMS_CODE_EXCEPTION.getCode(),
                               BizCodeEnume.SMS_CODE_EXCEPTION.getMsg());
            }
        }
        // 生成随机验证码
        code = UUID.randomUUID().toString().substring(0,5)+"_"
            +System.currentTimeMillis();
        // redis缓存验证码,防止同一个phone在60秒内再次发送验证码,并设置过期时间
        redisTemplate.opsForValue().set(AuthServerConstant.SMS_CODE_CACHE_PREFIX+phone,
                                        code,5, TimeUnit.MINUTES);
        
        thirdPartyFeignService.sendCode(phone,code);
        return R.ok();
    }
}

1.5 注册功能

① 用户点击立即注册提交的注册页面数据:

@Data
public class UserRegistVo {

    @NotEmpty(message = "用户名必须提交")
    @Length(min = 6,max = 18,message = "用户名必须是6-18位字符")
    private String userName;

    @NotEmpty(message = "密码必须填写")
    @Length(min = 6,max = 18,message = "密码必须是6-18位字符")
    private String password;

    @NotEmpty(message = "手机号必须填写")
    @Pattern(regexp = "^[1]([3-9])[0-9]{9}$",message = "手机号格式不正确")
    private String phone;

    @NotEmpty(message = "验证码必须填写")
    private String code;
}

② 注册功能:

  1. 后端需要对前端传入的参数进行JSR303校验,如果校验不通过,需要收集错误数据,并传给页面
  2. 在进行注册逻辑之前,首先需要判断验证码是否正确,如果验证码不正确就不需要注册了,即判断页面提交的验证码和我们后台生成的验证码(给手机用户发送的验证码)是否一致,如果一致就说明验证码正确
  3. 进行注册时,需要远程调用用户服务即gulimall-member服务来完成注册功能,如果注册成功,跳转到登录页面,如果不成功,跳转到注册页面。
@PostMapping("/regist")
public String regist(@Valid UserRegistVo vo, BindingResult result, /*Model model*/  RedirectAttributes redirectAttributes){
    //1、校验前端传入参数,如果参数格式不正确,携带错误数据重定向到注册页面
    if(result.hasErrors()){
        //简写形式
        Map<String, String> errors = result.getFieldErrors().stream().collect(
            Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage));
        redirectAttributes.addFlashAttribute("errors",errors);

        return "redirect:http://auth.gulimall.com/reg.html";
    }

    // 2、在进行注册逻辑之前,先校验验证码是否正确,判断页面提交的验证码和生成的验证码是否相同
    String code = vo.getCode();
    String s = redisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + vo.getPhone());
    if(!StringUtils.isEmpty(s)){
        //断页面提交的验证码和生成的验证码是否相同
        if(code.equals(s.split("_")[0])){
            // 验证码验证成功后,删除验证码:如果下次带了相同的验证码过来就不会通过了
            redisTemplate.delete(AuthServerConstant.SMS_CODE_CACHE_PREFIX+vo.getPhone());
            // 3、调用远程服务gulimall-member进行注册
            R r  = memberFeignService.regist(vo);
            if(r.getCode()==0){
                //成功
                return "redirect:http://auth.gulimall.com/login.html";
            }else{
                Map<String,String> errors = new HashMap<>();
                errors.put("msg",r.getData("msg",new TypeReference<String>(){}));
                redirectAttributes.addFlashAttribute("errors",errors);
                return "redirect:http://auth.gulimall.com/reg.html";
            }
        }else{
            //校验出错,转发到注册页
            Map<String,String> errors = new HashMap<>();
            errors.put("code","验证码错误");
            redirectAttributes.addFlashAttribute("errors",errors);
            return "redirect:http://auth.gulimall.com/reg.html";
        }
    }else{
        //校验出错,转发到注册页
        Map<String,String> errors = new HashMap<>();
        errors.put("code","验证码错误");
        redirectAttributes.addFlashAttribute("errors",errors);
        return "redirect:http://auth.gulimall.com/reg.html";
    }
}

③ 远程调用服务gulimall-member进行注册 :

在gulimall-auth-server服务中编写feign接口,用来远程调用gulimall-member服务中的方法:

@FeignClient("gulimall-member")
public interface MemberFeignService {
    @PostMapping("/member/member/regist")
    public R regist(@RequestBody UserRegistVo vo);
}

gulimall-member服务中被调用的方法:

@RestController
@RequestMapping("member/member")
public class MemberController {
    @Autowired
    private MemberService memberService;

    @PostMapping("/regist")
    public R regist(@RequestBody MemberRegistVo vo){
        try {
            memberService.regist(vo);
        } catch (UsernameExistException e) {
            return R.error(BizCodeEnume.USER_EXIST_EXCEPTION.getCode()
                           ,BizCodeEnume.USER_EXIST_EXCEPTION.getMsg());
        } catch (PhoneExistException e){
            return R.error(BizCodeEnume.PHONE_EXIST_EXCEPTION.getCode()
                           ,BizCodeEnume.PHONE_EXIST_EXCEPTION.getMsg());
        }
        return R.ok();
    }
}

向数据库中插入一条用户数据 :

在这里插入图片描述

@Service("memberService")
public class MemberServiceImpl extends ServiceImpl<MemberDao, MemberEntity> implements MemberService {

    @Autowired
    private MemberLevelDao memberLevelDao;

    @Override
    public void regist(MemberRegistVo vo) {
        MemberEntity memberEntity = new MemberEntity();

        //设置会员默认等级
        MemberLevelEntity memberLevelEntity = memberLevelDao.getDefaultLevel();
        memberEntity.setLevelId(memberLevelEntity.getId());

        //检查用户名和手机号是否唯一
        checkPhoneUnique(vo.getPhone());
        checkUsernameUnique(vo.getUserName());
        memberEntity.setMobile(vo.getPhone());
        memberEntity.setUsername(vo.getUserName());

        //密码及逆行加密加盐存储
        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
        String encode = passwordEncoder.encode(vo.getPassword());
        memberEntity.setPassword(encode);

        //其他的默认信息

        //存入数据库
        this.baseMapper.insert(memberEntity);
    }

    @Override
    public void checkUsernameUnique(String userName) throws UsernameExistException {
        MemberDao memberDao = this.baseMapper;
        Integer username = memberDao.selectCount(
                new QueryWrapper<MemberEntity>().eq("username", userName));
        if(username>0){
            throw new UsernameExistException();
        }
    }

    @Override
    public void checkPhoneUnique(String phone) throws PhoneExistException{
        MemberDao memberDao = this.baseMapper;
        Integer mobile = memberDao.selectCount(
                new QueryWrapper<MemberEntity>().eq("mobile", phone));
        if(mobile>0){
            throw new PhoneExistException();
        }
    }
}

④ 完成注册:

在这里插入图片描述

1.6 简单登录功能

在这里插入图片描述

① 前端登陆页面提交的数据:

@Data
public class UserLoginVo {
    private String loginacct;
    private String password;
}

② 在gulimall-member服务中被远程调用的接口方法:

@RestController
@RequestMapping("member/member")
public class MemberController {
    @PostMapping("/login")
    public R login(@RequestBody MemberLoginVo vo){
        MemberEntity memberEntity = memberService.login(vo);
        if(memberEntity!=null){
            return R.ok().setData(memberEntity);
        }else {
            return R.error(BizCodeEnume.LOGINACCT_PASSWORD_INVAILD_EXCEPTION.getCode(),
                           BizCodeEnume.LOGINACCT_PASSWORD_INVAILD_EXCEPTION.getMsg());
        }
    }
}
@Override
public MemberEntity login(MemberLoginVo vo) {
    String loginacct = vo.getLoginacct();
    String password = vo.getPassword();

    //1、去数据库查询
    MemberDao memberDao = this.baseMapper;
    MemberEntity entity = memberDao.selectOne(new QueryWrapper<MemberEntity>()
            .eq("username", loginacct).or().eq("mobile", loginacct));
    if(entity==null){
        //登录失败
        return null;
    }else{
        //2、数据库中的密码
        String passwordDb = entity.getPassword();
        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
        boolean matches = passwordEncoder.matches(password, passwordDb);
        if(matches){
            return entity;
        }else{
            return null;
        }
    }
}

③ 在gulimall-auth-server服务中编写feign接口:

@FeignClient("gulimall-member")
public interface MemberFeignService {
    @PostMapping("/member/member/login")
    public R login(@RequestBody UserLoginVo vo);
}

④ 在gulimall-auth-server服务中远程调用gulimall-member服务的接口方法:

@Controller
public class LoginController {
    @PostMapping("/login")
    public String login(UserLoginVo vo, RedirectAttributes redirectAttributes){
        //远程调用gulimall-member服务实现登录
        R r = memberFeignService.login(vo);
        if(r.getCode()==0){
            //成功
            return  "redirect:http://gulimall.com";
        }else{
            Map<String,String> errors = new HashMap<>();
            errors.put("msg",r.getData("msg",new TypeReference<String>(){}));
            // 携带数据进行重定向
            redirectAttributes.addFlashAttribute("errors",errors);
            return "redirect:http://auth.gulimall.com/login.html";
        }
    }
}

⑤ 前端页面表单提交登录数据:

<form action="/login" method="post">
    <div style="color: red" th:text="${errors!=null?(#maps.containsKey(errors, 'msg')?errors.msg:''):''}"></div>
    <ul>
        <li class="top_1">
            <img src="/static/login/JD_img/user_03.png" class="err_img1"/>
            <input type="text" name="loginacct" placeholder=" 邮箱/用户名/已验证手机" class="user"/>
        </li>
        <li>
            <img src="/static/login/JD_img/user_06.png" class="err_img2"/>
            <input type="password" name="password" placeholder=" 密码" class="password"/>
        </li>
        <li class="bri">
            <a href="/static/login/">忘记密码</a>
        </li>
        <li class="ent">
            <button class="btn2" type="submit"><a>&nbsp; &nbsp;</a></button>
        </li>
    </ul>
</form>

1.7 OAuth2.0

OAuth: OAuth(开放授权) 是一个开放标准, 允许用户授权第三方网站访问他们存储在另外的服务提供者上的信息, 而不需要将用户名和密码提供给第三方网站或分享他们数据的所有内容。

OAuth2.0: 对于用户相关的 OpenAPI(例如获取用户信息, 动态同步, 照片, 日志, 分享等) , 为了保护用户数据的安全和隐私, 第三方网站访问用户数据前都需要显式的向用户征求授权。

在这里插入图片描述

( A) 用户打开客户端以后, 客户端要求用户给予授权。
( B) 用户同意给予客户端授权。
( C) 客户端使用上一步获得的授权, 向认证服务器申请令牌。
( D) 认证服务器对客户端进行认证以后, 确认无误, 同意发放令牌。
( E) 客户端使用令牌, 向资源服务器申请获取资源。
( F) 资源服务器确认令牌无误, 同意向客户端开放资源。

QQ、微博、 github 等网站的用户量非常大, 别的网站为了简化自我网站的登陆与注册逻辑, 引入社交登陆;

① 想要使用QQ来登录CSDN网站 :

在这里插入图片描述

② 引导跳转到 QQ 授权页 :

在这里插入图片描述

③ 用户主动点击授权, 跳回之前网页:

在这里插入图片描述

1.8 微博社交登录

① 登录新浪微博开放平台(使用手机号登录的):https://open.weibo.com/,创建个人信息,创建新应用

在这里插入图片描述

设置高级信息:
在这里插入图片描述

② API文档中的授权流程 :

1、引导需要授权的用户到如下地址:

https://api.weibo.com/oauth2/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=YOUR_REGISTERED_REDIRECT_URI

2、如果用户同意授权,页面跳转至 YOUR_REGISTERED_REDIRECT_URI/?code=CODE

3、换取Access Token:

https://api.weibo.com/oauth2/access_token?client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET&grant_type=authorization_code&redirect_uri=YOUR_REGISTERED_REDIRECT_URI&code=CODE

其中client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET可以使用basic方式加入header中,返回值

{``  ``"access_token"``: ``"SlAV32hkKG"``,``  ``"remind_in"``: 3600,``  ``"expires_in"``: 3600``}

4、使用获得的Access Token调用API

③ 测试如何得到access_token :

1、引导用户到达如下地址:client_id=App Key,redirect_uri=重定向地址

https://api.weibo.com/oauth2/authorize?client_id=2267840155&response_type=code&redirect_uri=http://gulimall.com/success

在登录页面点击微博头像就会引导用户到达上地址 :

<li>
   <a href="https://api.weibo.com/oauth2/authorize?client_id=2267840155&response_type=code&redirect_uri=http://gulimall.com/success">
      <img style="width: 50px;height: 18px" src="/static/login/JD_img/weibo.png" />
   </a>
</li>

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

2、如果用户同意授权,页面跳转至 YOUR_REGISTERED_REDIRECT_URI/?code=CODE

用户同意授权后,就会获得一个code,拿到code我们就可以换取access_token

在这里插入图片描述

3、换取Access Token:

使用postman测试:拿到code换取access_token

在这里插入图片描述

4、使用获得的Access Token调用API ,比如调用这个API:https://open.weibo.com/wiki/2/users/show

在这里插入图片描述

在这里插入图片描述

使用postman测试:通过这个接口API我们可以拿到微博用户的详细信息,总之,那么access_token之后就可以为所欲为了,所有开放的API我们都可以访问(所以API可以参考我的应用中的所有权限),只要有了这个access_token,我们相当于有了这个用户的权力。
在这里插入图片描述

1.9 整合微博社交登录

修改授权回调页地址:

在这里插入图片描述

① 引导需要授权的用户到达如下地址:在登录页点击微博会跳转到回调页面并带上一个请求参数code

<a href="https://api.weibo.com/oauth2/authorize?client_id=2267840155&response_type=code&redirect_uri=http://auth.gulimall.com/oauth2.0/weibo/success">
   <img style="width: 50px;height: 18px" src="/static/login/JD_img/weibo.png" />
</a>

② 处理回调页面的请求 :http://gulimall.com/oauth2.0/weibo/success

拿到code后,可以通过code换取access_token ,如果换取成功,就可以得到社交用户信息,远程调用gulimall-member服务,判断这个社交用户是否是第一次登录,如果第一次登录,就要进行注册流程,并给社交用户关联一个本系统的用户id。(保存社交用户uid和用户id之间的对应关系),如果已经登录过,给社交用户关联一个本系统的用户id,并更新access_token和expire_in。

为了方便,不再重新创建一张社交用户表,给ums_member表添加三个字段,用来保存社交用户信息
在这里插入图片描述

@Slf4j
@Controller
public class Oauth2Controller {
    @Autowired
    MemberFeignService memberFeignService;

    @GetMapping("/oauth2.0/weibo/success")
    public String weibo(@RequestParam("code") String code) throws Exception {
        Map<String,String> header = new HashMap<>();
        Map<String,String> query = new HashMap<>();
        Map<String,String> map = new HashMap<>();
        map.put("client_id","2267840155");
        map.put("client_secret","2b593efc574c587ee7b25191d1c31342");
        map.put("grant_type","authorization_code");
        map.put("redirect_uri","http://auth.gulimall.com/oauth2.0/weibo/success");
        map.put("code",code);
        //1、根据code换取accessToken;
        HttpResponse response = HttpUtils.doPost("https://api.weibo.com", "/oauth2/access_token", "POST", header, query, map);

        //2、处理
        if(response.getStatusLine().getStatusCode()==200){
            //获取到了 accessToken
            String json = EntityUtils.toString(response.getEntity());
            SocialUser socialUser = JSON.parseObject(json, SocialUser.class);
            //登录或者注册这个社交用户
            R oauthlogin = memberFeignService.oauthlogin(socialUser);
            if(oauthlogin.getCode() == 0){
                MemberRespVo data = oauthlogin
                    .getData("data", new TypeReference<MemberRespVo>() {});
                System.out.println("data:"+data.toString());
                //登录成功就跳回首页
                return "redirect:http://gulimall.com";
            }else {
                return "redirect:http://auth.gulimall.com/login.html";
            }
        }else {
            return "redirect:http://auth.gulimall.com/login.html";
        }
    }
}

③ 在gulimall-auth-server服务中对应的memberFeignService接口:

@FeignClient("gulimall-member")
public interface MemberFeignService {
    @PostMapping("/member/member/oauth2/login")
    R oauthlogin(@RequestBody SocialUser socialUser) throws Exception;
}

④ 在guilimall-member服务中被调用的方法:

@PostMapping("/oauth2/login")
public R oauthlogin(@RequestBody SocialUser socialUser) throws Exception {
    MemberEntity entity =  memberService.login(socialUser);
    if(entity!=null){
        //TODO 1、登录成功处理
        return R.ok().setData(entity);
    }else{
        return R.error(BizCodeEnume.LOGINACCT_PASSWORD_INVAILD_EXCEPTION.getCode(),
                       BizCodeEnume.LOGINACCT_PASSWORD_INVAILD_EXCEPTION.getMsg());
    }
}
/**
     * 社交登录
     * @param socialUser
     * @return
     * @throws Exception
*/
@Override
public MemberEntity login(SocialUser socialUser) throws Exception {
    //登录和注册合并逻辑
    String uid = socialUser.getUid();
    //1、判断当前社交用户是否已经登录过系统;
    MemberDao memberDao = this.baseMapper;
    MemberEntity memberEntity = memberDao.selectOne(new QueryWrapper<MemberEntity>().eq("social_uid", uid));
    if (memberEntity != null) {
        //这个用户已经注册
        MemberEntity update = new MemberEntity();
        update.setId(memberEntity.getId());
        update.setAccessToken(socialUser.getAccess_token());
        update.setExpiresIn(socialUser.getExpires_in());

        memberDao.updateById(update);

        memberEntity.setAccessToken(socialUser.getAccess_token());
        memberEntity.setExpiresIn(socialUser.getExpires_in());
        return memberEntity;
    }else{
        //2、没有查到当前社交用户对应的记录我们就需要注册一个
        MemberEntity regist = new MemberEntity();
        try{
            //3、查询当前社交用户的社交账号信息(昵称,性别等)
            Map<String,String> query = new HashMap<>();
            query.put("access_token",socialUser.getAccess_token());
            query.put("uid",socialUser.getUid());
            HttpResponse response = HttpUtils.doGet("https://api.weibo.com", "/2/users/show.json", "get", new HashMap<String, String>(), query);
            if(response.getStatusLine().getStatusCode() == 200){
                //查询成功
                String json = EntityUtils.toString(response.getEntity());
                JSONObject jsonObject = JSON.parseObject(json);
                //昵称
                String name = jsonObject.getString("name");
                String gender = jsonObject.getString("gender");
                //........
                regist.setNickname(name);
                regist.setGender("m".equals(gender)?1:0);
                //........
            }
        }catch (Exception e){}

        regist.setSocialUid(socialUser.getUid());
        regist.setAccessToken(socialUser.getAccess_token());
        regist.setExpiresIn(socialUser.getExpires_in());
        memberDao.insert(regist);

        return regist;
    }
}

⑤ 测试:

在这里插入图片描述

1.10 分布式Sesison问题

登录成功后,应该显示登录用户的昵称,而不是”你好,请登录“

① 在单体应用中,跨页面共享数据,我们可以使用session来存储,在浏览器打开到关闭期间,session 中存储的数据都能取出来,假如我们现在将登录的用户放在session中会有什么问题 ?

在这里插入图片描述

问题1:Sesison不能跨不同域名进行共享,即使不是分布式情况下,只是使用不同服务部署不同域名:

  • 我们在首页http://gulimall.com/下点击登录,跳转到登录页:http://auth.gulimall.com/login.html

  • 在http://auth.gulimall.com/login.html 下进行登录,域名为auth.gulimall.com(gulimall-auth-server服务),在这个域名下会保存服务器给浏览器响应的cookie(sessionID)

  • 登录成功后,会跳转到http://gulimall.com/ 下,域名为gulimall.com(guliamall-product服务),在这个域名下并没有服务器给浏览器响应的cookie(sessionID)

问题2:Session不同步问题

即使是同域名的情况下,在分布式部署下,会员服务不可能只部署到一台服务器上去,可能多台服务器同时都有会员服务,假设浏览器第一次登录请求发给了1号服务器,那么1号服务器就把我们的用户保存了,由于我们是分布式集群环境,那么下一次请求可能会落到2号服务器,2号服务器并没有用户数据。

② 分布式session情况下,Session不同步的四种解决方案 :

解决方案1:session复制

在这里插入图片描述

解决方案2:session存储在客户端

在这里插入图片描述

解决方案3 : hash一致性

在这里插入图片描述

解决方案4:统一存储 (我们项目中使用的方案)

我们将session数据统一存储在数据库DB或者redis中,解决sesison不同步的问题

在这里插入图片描述

③ Sesison不能跨不同域名进行共享的解决方案:

我们现在的问题是在auth.guilimall.com域名下会保存cookie,但是在guilimall.com中却没有,我们希望只要在子域名下的cookie,父域名也能感知到。

子域: gulimall.com auth.gulimall.com order.gulimall.com

父域: gulimall.com

在这里插入图片描述

1.11 整合SpringSession

解决问题1:

使用SpringSesion将session存储在redis中 ,这样所有的服务都能从redis中得到session从而得到用户信息了。

在gulimall-auth-server中 :

① 在gulimall-auth-server服务下导入springsession坐标依赖:

<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>

② 在application.properties中配置session :

# session保存的位置
spring.session.store-type=redis
# session的过期时间
server.servlet.session.timeout=30m

③ 在主启动类开启SpringSession的相关功能 : 整合redis作为session存储

@EnableRedisHttpSession
@EnableFeignClients
@EnableDiscoveryClient
@SpringBootApplication
public class GulimallAuthServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(GulimallAuthServerApplication.class, args);
    }
}

④ 登录成功后,将用户信息存储在redis中(Spring Session配置了存储位置为redis),添加下面一行代码:session.setAttribute("loginUser",data);

@Slf4j
@Controller
public class Oauth2Controller {
    @Autowired
    MemberFeignService memberFeignService;
    
    @GetMapping("/oauth2.0/weibo/success")
    public String weibo(@RequestParam("code") String code, HttpSession session) throws Exception {
        Map<String,String> header = new HashMap<>();
        Map<String,String> query = new HashMap<>();
        Map<String,String> map = new HashMap<>();
        map.put("client_id","2267840155");
        map.put("client_secret","2b593efc574c587ee7b25191d1c31342");
        map.put("grant_type","authorization_code");
        map.put("redirect_uri","http://auth.gulimall.com/oauth2.0/weibo/success");
        map.put("code",code);
        //1、根据code换取accessToken;
        HttpResponse response = HttpUtils.doPost("https://api.weibo.com", "/oauth2/access_token", "POST", header, query, map);

        //2、处理
        if(response.getStatusLine().getStatusCode()==200){
            //获取到了 accessToken
            String json = EntityUtils.toString(response.getEntity());
            SocialUser socialUser = JSON.parseObject(json, SocialUser.class);
            //登录或者注册这个社交用户
            R oauthlogin = memberFeignService.oauthlogin(socialUser);
            if(oauthlogin.getCode() == 0){
                MemberRespVo data = oauthlogin
                    .getData("data", new TypeReference<MemberRespVo>() {});
                log.info("登录成功:用户:{}",data.toString());
                // 将用户存放在session中,session存放在redis中
                session.setAttribute("loginUser",data);
                //登录成功就跳回首页
                return "redirect:http://gulimall.com";
            }else {
                return "redirect:http://auth.gulimall.com/login.html";
            }
        }else {
            return "redirect:http://auth.gulimall.com/login.html";
        }
    }
}

在gulimall-product 服务中 :

① 导入springsession坐标依赖:

<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>

② 在application.properties中配置session :

# session保存的位置
spring.session.store-type=redis

③ 在主启动类开启SpringSession的相关功能 : 整合redis作为session存储

@EnableRedisHttpSession
@EnableFeignClients(basePackages ="com.atguigu.gulimall.product.feign" )
@EnableDiscoveryClient
@SpringBootApplication
public class GulimallProductApplication {
    public static void main(String[] args) {
        SpringApplication.run(GulimallProductApplication.class,args);
    }
}

④ 在login.html中取出redis中存储的用户昵称:

<li>
  <a  th:if="${session.loginUser!=null}">欢迎:[[${session.loginUser==null?'':session.loginUser.nickname}]]</a>
  <a href="http://auth.gulimall.com/login.html" th:if="${session.loginUser==null}">欢迎,请登录</a>
</li>

我们在auth.gulimall.com(gulimall-auth-server服务)页面中进行授权登录后,会将用户数据保存在redis中,同时浏览器会有一个令牌,名称为SESSION :

在这里插入图片描述

但是登录成功后跳转的页面gulimall.com(gulimall-product服务)中却没有这个令牌SESSION :

在这里插入图片描述

通过redis客户端可以看到redis中已经存储了用户数据 :但是数据还需要序列化

在这里插入图片描述

所以我们现在解决了第一个问题,就是将用户数据存在了redis中,但是现在需要解决的第二个问题就是子域名下的令牌SESSION,希望父域名下也能感知到。

⑤ 假如我们现在手动的将auth.gulimall.com下的SESSION的作用域domain改为父域下(.gulimall.com):

在这里插入图片描述

可以看到在gulimall.com下就有了SESSION,并且可以取出Sesion中存放的用户数据 :

在这里插入图片描述

解决问题2:

自定义SpringSession完成子域session共享 :

原始的session中cookie是自动生成并响应给浏览器的,现在Spring Session我们可以配置cookie的生效路径,将其生效路径放大到父域名:

在gulimall-auth-server服务和gulimall-product服务下分别加上下面的配置:

@Configuration
public class GulimallSessionConfig {
    //配置cookie的生效路径
    @Bean
    public CookieSerializer cookieSerializer(){
        DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
        cookieSerializer.setDomainName(".gulimall.com");
        cookieSerializer.setCookieName("GULISESSION");
        return cookieSerializer;
    }

    //配置redis的序列化机制
    @Bean
    public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
        return new GenericJackson2JsonRedisSerializer();
    }
}

在这里插入图片描述

解决问题3:

当我们登陆过后,再访问登录页面http://auth.gulimall.com/login.html时,应该直接跳转到已登录后的首页

专门写一个Controller映射,处理再首页点击“欢迎,请登录” 的请求 :

在这里插入图片描述

@GetMapping("/login.html")
public String loginPage(HttpSession session){
    Object attribute = session.getAttribute(AuthServerConstant.LOGIN_USER);
    if(attribute==null){
        // 没登陆
        return "login";
    }else{
        // 已登录,重定向到首页
        return "redirect:http://gulimall.com";
    }
}

1.12 测试单点登录框架

整合Spring Session可以实现session共享和session同步的问题,只要我们将session存放在了redis中,并且设置cookie的生效路径为父域名下,那么在gulimall.com,auth.gulimall.com,item.gulimall.com,search.gulimall.com中都能取出session,也就是说实现了在一个服务中登录后,其他服务中也能取出登录用户的信息,但是session只能作用在guimall.com以及其子域名下,对于更大的系统,就不行了。

比如,在多系统中,我们有gulixueyuan.com,gulimall.com,gulifunding.com这三个系统,我们希望用户信息在这三个系统中生效,而不是gulimall.com及其子域名下,就需要使用单点登录。

单点登录:只需要登录一次就可以访问所有相互信任的应用系统

在这里插入图片描述

① 下载单点登录框架Demo :https://gitee.com/xuxueli0323/xxl-sso?_from=gitee_search,并设置三个不同域名

在这里插入图片描述

② 修改项目xxl-sso-server和xxl-sso-samples的applicaiton.properties中的redis服务器地址和登录服务器地址:

### xxl-sso
xxl.sso.server=http://ssoserver.com:8080/xxl-sso-server
xxl.sso.redis.address=redis://192.168.38.22:6379

③ 在整个项目文件夹xxl-sso下,将整个项目打包:mvn clean package -Dmaven.skip.test=true

在这里插入图片描述

④ 打包完成后,先启动xxl-sso-server服务器 :

在这里插入图片描述

启动完成后,在浏览器访问:http://ssoserver.com:8080/xxl-sso-server/login

在这里插入图片描述

⑤ 然后启动客户端1: xxl-sso-web-sample-springboot

在这里插入图片描述

启动完成后,访问客户端1:http://client1.com:8081/xxl-sso-web-sample-springboot

在这里插入图片描述

⑥ 启动客户端2 :xxl-sso-web-sample-springboot

在这里插入图片描述

启动完成后,访问客户端2:http://client2.com:8082/xxl-sso-web-sample-springboot

redirect_url是点击登录后回调的地址,即我们访问客户端2时会跳到server服务器登录,登录完成后再跳回客户端

在这里插入图片描述

⑦ 现在我们启动了三个系统:两个客户端和一个服务器,并且三个都没有登录,现在我们登录任意一个系统,发现一个登录后,其他两个系统也登录了,一个系统下线后,其他系统也下线了,实现了单点登录。

实现:三个系统即使域名不一样,想办法给三个系统同步用一个用户的票据。

  • 中央认证服务器:ssoserver.com

  • 其他系统想要登录,就去ssoserver.com登录,登录成功跳转回来

  • 只要有一个登录,其他都不用登录

  • 全系统唯一一个sso-sessionid

1.13 单点登录

在这里插入图片描述

单点登录流程1:

创建项目gulimall-test-sso-client,端口为8081 和 项目gulimall-test-sso-server,端口为8080

① 在gulimall-test-sso-client服务中,当我们访问http://client1.com:8081/employees时,首先会从session中取出用户信息,判断用户是否登录,如果用户登录了直接跳转到员工列表list.html页面,如果没有登录,我们需要让他跳到登录服务器的登录页面login.html,登录完成后,还要跳回原来client1客户端页面:

@Controller
public class HelloController  {
    //sso.server.url=http://sso.com:8080/login.html
    @Value("${sso.server.url}")
    String ssoServerUrl;

    @GetMapping("/employees")
    public String employees(Model model, HttpSession session){
        Object loginUser = session.getAttribute("loginUser");
        if(loginUser==null){
            //没登陆,重定向到登录服务器进行登录
            return "redirect:"+ssoServerUrl+"?redirect_url=http://client1.com:8081/employees";
        }else{
            List<String> emps = new ArrayList<String>();
            emps.add("张三");
            emps.add("李四");
            model.addAttribute("emps",emps);
            return "list";
        }
    }
}

② 如果用户没有登录跳转到gulimall-test-sso-server服务中下的登录页面login.html:

@Controller
public class LoginController {
    //处理登录页面的请求,跳转到登录页面
    @GetMapping("/login.html")
    public String loginPage(@RequestParam("redirect_url") String url, Model model){
        model.addAttribute("url",url);
        return "login";
    }
}

访问http://client1.com:8081/employees后由于没登录,跳转的地址:http://sso.com:8080/login.html?redirect_url=http://client1.com:8081/employees

在这里插入图片描述

我们现在要解决的是,输入用户名和密码后,如何跳转回与原来的请求处??

单点登录流程2:

① 输入账号和密码后点击登录就会提交表单:<form action="/doLogin" method="post">

@Controller
public class LoginController {

    @GetMapping("/login.html")
    public String loginPage(@RequestParam("redirect_url") String url, Model model){
        model.addAttribute("url",url);
        return "login";
    }

    @PostMapping("/doLogin")
    public String doLogin(@RequestParam("username") String username,
                          @RequestParam("password")String password,
                          @RequestParam("url")String url) {
        System.out.println("url:"+url);

        if (!StringUtils.isEmpty(username) && !StringUtils.isEmpty(password)) {
            //登录成功,跳回之前页面
            return "redirect:" + url;
        }
        //登录失败,展示登录页
        return "login";
    }
}

但是此时陷入了一个死循环:当我们点击登录后,会回到之前的请求处http://client1.com:8081/employees,在这个请求里再次判断用户有没有登录,因为用户还是没有登录,又重定向到了这个登录页面http://sso.com:8080/login.html?redirect_url=http://client1.com:8081/employees,所以我们需要解决这个问题

在这里插入图片描述

② 当用户登录成功后,我们就生成一个随机字符串uuid作为redis的key,使用redis来保存用户信息,当重定向回原来的地址时,我们带上这个uuid:

@Controller
public class LoginController {

    @Autowired
    StringRedisTemplate redisTemplate;

    @GetMapping("/login.html")
    public String loginPage(@RequestParam("redirect_url") String url, Model model){
        model.addAttribute("url",url);
        return "login";
    }

    @PostMapping("/doLogin")
    public String doLogin(@RequestParam("username") String username,
                          @RequestParam("password")String password,
                          @RequestParam("url")String url) {
        System.out.println("url:"+url);

        if (!StringUtils.isEmpty(username) && !StringUtils.isEmpty(password)) {
            //用户登录成功后,把登录成功的用户存起来。
            String uuid = UUID.randomUUID().toString().replace("-", "");
            redisTemplate.opsForValue().set(uuid, username);
            return "redirect:" + url + "?token=" + uuid;
        }
        //登录失败,展示登录页
        return "login";
    }
}

访问http://client1.com:8081/employees,可以看到此时重定向的请求处带上了uuid :

在这里插入图片描述

③ 当我们来到http://client1.com:8081/employees?token=503807b1e15b41e79bd8479d9c0ee013请求处就可以通过token从redis中获取用户的信息,从而将用户信息放在session中,这样再次判断用户有没有登录时,就会登录成功了,从而解决了死循环的问题:

@Controller
public class HelloController {
    //sso.server.url=http://sso.com:8080/login.html
    @Value("${sso.server.url}")
    String ssoServerUrl;

    @GetMapping("/employees")
    public String employees(Model model, HttpSession session,
                          @RequestParam(value = "token",required = false) String token){
		//TODO 去ssoserver获取当前token真正对应的用户信息
        if(!StringUtils.isEmpty(token)){
            session.setAttribute("loginUser","zhangsan");
        }

        Object loginUser = session.getAttribute("loginUser");
        if(loginUser==null){
            //没登录,跳转到登录服务器进行登录
            //跳转过去以后,使用url上的查询参数标识我们自己是哪个页面
            //redirect_url=http://client1.com:8080/employees
            return "redirect:"+ssoServerUrl+"? redirect_url=http://client1.com:8081/employees";
        }else{
            List<String> emps = new ArrayList<>();
            emps.add("张三");
            emps.add("李四");
            model.addAttribute("emps",emps);
            return "list";
        }
    }
}

在这里插入图片描述

单点登录流程3:

我们希望实现一处登录之后,处处登录,一处下线之后,处处下线,再创建一个项目gulimall-test-sso-client2 ,我们知道client1已经登录了,当我们访问client2时,希望能够做到免登录,但是此时client2还要我们登录,因为ssoserver没有记住client1已经登录了

在这里插入图片描述

所以我们现在就来实现一处登录后,其他服务都能免登录的功能,如何实现 ?

① 只要client1登录成功后,我们就将用户信息保存在redis中,重定向回client1系统时就会带上令牌token,为了下一次别的系统client2知道上一个系统已经登录了,我们要通过cookie将令牌token响应给浏览器,并保存在浏览器端:

@Controller
public class LoginController {

    @Autowired
    StringRedisTemplate redisTemplate;

    @GetMapping("/login.html")
    public String loginPage(@RequestParam("redirect_url") String url, Model model){
        model.addAttribute("url",url);
        return "login";
    }

    @PostMapping("/doLogin")
    public String doLogin(@RequestParam("username") String username,
                          @RequestParam("password")String password,
                          @RequestParam("url")String url,
                          HttpServletResponse response) {
        System.out.println("url:"+url);

        if (!StringUtils.isEmpty(username) && !StringUtils.isEmpty(password)) {
            //1、把登录成功的用户存起来。
            String uuid = UUID.randomUUID().toString().replace("-", "");
            redisTemplate.opsForValue().set(uuid, username);
            
            //2、将token通过cookie响应给浏览器,并保存在浏览器端
            Cookie sso_token = new Cookie("sso_token", uuid);
            response.addCookie(sso_token);
            
            return "redirect:" + url + "?token=" + uuid;
        }
        //登录失败,展示登录页
        return "login";
    }
}

给当前请求doLogin的域名sso.com下保存了一个cookie :

在这里插入图片描述

那么浏览器以后访问sso.com这个域名时就会带上之前保存的cookie :
在这里插入图片描述

当我们访问client2时,http://client2.com:8002/boss,就会跳转到登录页,在登录页上会带有cookie :

在这里插入图片描述

② 所以当我们访问client2时http://client2.com:8082/boss,会跳转到http://sso.com:8080/login.html?redirect_url=http://client2.com:8082/boss,从而在这个请求里可以判断是否带来了cookie(cookie中含有sso_token),如果带来了说明之前登录了,免登录,直接返回到之前的页面,如果没登录就会跳转到登录页面:

@Controller
public class LoginController {

    @Autowired
    StringRedisTemplate redisTemplate;

    @GetMapping("/login.html")
    public String loginPage(@RequestParam("redirect_url") String url, Model model,
                            @CookieValue(value = "sso_token",required = false) String sso_token){
        if(!StringUtils.isEmpty(sso_token)){
            //说明之前有人登录过,浏览器留下了痕迹,免登录,直接返回到之前的页面
            return "redirect:"+url+"?token="+sso_token;
        }
        //否则跳转到登录页面
        model.addAttribute("url",url);
        return "login";
    }
}

③ 测试:

首先我们让client1和client2 都退出登录,重新登录client1系统,由于三个系统都没登录过,因此会跳转到登录页面,也是因为别的系统没有登录过,所以这次请求也不会带有cookie(sso_token):

在这里插入图片描述

登录client1系统 ,登录成功后我们发现这个请求下(域名下)浏览器会保存cookie(sso_token):

在这里插入图片描述

接着登录client2系统http://client2.com:8082/boss,发现直接跳到了登录成功页面并步需要登录了,并且在重定向的请求中携带了之前保存在这个域名(sso.com)下的cookie:

在这里插入图片描述

至此,我们想要的功能就实现了。

2. 商城业务 - 购物车

2.1 环境搭建

① 创建服务gulimall-cart,将静态资源放到nginx中:

在这里插入图片描述

② 导入依赖:

<dependency>
    <groupId>com.atguigu.gulimall</groupId>
    <artifactId>gulimall-common</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <exclusions>
        <exclusion>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
        </exclusion>
        <exclusion>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

③ 配置端口和注册中心地址

server.port=30010
spring.application.name=gulimall-cart
spring.cloud.nacos.discovery.server-addr=127.0.0.1:8848

spring.redis.host=192.168.38.22
spring.redis.port=6379

④ 主启动类,开启nacos注册与发现功能,远程调用功能:

@EnableDiscoveryClient
@EnableFeignClients
@SpringBootApplication
public class GulimallCartApplication {
    public static void main(String[] args) {
        SpringApplication.run(GulimallCartApplication.class, args);
    }
}

⑤ 配置gulimall-cart的网关路由映射:

- id: gulimall_cart_route
  uri: lb://gulimall-cart
  predicates:
    - Host=cart.gulimall.com

2.2 购物车模型分析

① 需求描述 :

用户可以在登录状态下将商品添加到购物车【用户购物车/在线购物车】,登录以后, 会将临时购物车的数据全部合并过来, 并清空临时购物车:

  • 放入数据库(购物车是读写都高并发的操作,如果使用数据库,会造成数据库压力太大)
  • mongodb(性能并不能带来很大的提升)
  • 放入 redis(采用,redis拥有极高的数据读写并发性能,但是redis是内存数据库,一旦redis宕机,数据便没了,因此需要设置redis的持久化策略)

用户可以在未登录状态下将商品添加到购物车【游客购物车/离线购物车/临时购物车】,浏览器即使关闭, 下次进入, 临时购物车数据都在 :

  • 放入 localstorage(客户端存储, 后台不存)、cookie、WebSQL
  • 放入 redis(采用)

因此无论是临时购物车还是在线购物车,都将数据存放在redis中。

② 我们要开发的购物车功能 :

- 用户可以使用购物车一起结算下单
- 给购物车添加商品
- 用户可以查询自己的购物车
- 用户可以在购物车中修改购买商品的数量。
- 用户可以在购物车中删除商品。
- 选中不选中商品
- 在购物车中展示商品优惠信息
- 提示购物车商品价格变化

③ 购物车的数据结构选择 :

在这里插入图片描述

因此每一个购物项信息, 都是一个对象, 基本字段包括:

{
    skuId: 2131241,
    check: true,
    title: "Apple iphone.....",
    defaultImage: "...",
    price: 4999,
    count: 1,
    totalPrice: 4999,
    skuSaleVO: {...}
}

另外, 购物车中不止一条数据, 因此最终会是对象的数组。 即:

[
	{...},{...},{...}
]

Redis 有 5 种不同数据结构, 这里选择哪一种比较合适呢? Map<String, List<String>>
首先不同用户应该有独立的购物车, 因此购物车应该以用户的作为 key 来存储, Value 是用户的所有购物车信息。 这样看来基本的k-v结构就可以了。

但是, 我们对购物车中的商品进行增、 删、 改操作, 基本都需要根据商品 id 进行判断,为了方便后期处理, 我们的购物车也应该是k-v结构, key 是商品 id, value 才是这个商品的购物车信息。

综上所述, 我们的购物车结构是一个双层 Map: Map<String,Map<String,String>>

第一层 Map, Key 是用户 id
第二层 Map, Key 是购物车中商品 id, 值是购物项数据

在这里插入图片描述

④ VO编写 :

购物项(购物车中的每一件商品):

/**
 * 购物项(购物车中的每一件商品)
 */
public class CartItem {
    private Long skuId;
    // 是否选中加入购物车的商品
    private Boolean check=true;
    private String title;
    private String image;
    private List<String> skuAttr;
    private BigDecimal price;
    private Integer count;
    private BigDecimal totalPrice;

    /**
     * 计算当前项的总价
     */
    public BigDecimal getTotalPrice() {
        return this.price.multiply(new BigDecimal("" + this.count));
    }
}

购物车 :

/**
 * 整个购物车
 * 需要计算的属性,必须重写他的get方法,保证每次获取属性都会进行计算
 */
public class Cart {

    List<CartItem> items;
    private Integer countNum;//商品数量
    private Integer countType;//商品类型数量
    private BigDecimal totalAmount;//商品总价
    private BigDecimal reduce = new BigDecimal("0.00");//减免价格

    public List<CartItem> getItems() {
        return items;
    }

    public void setItems(List<CartItem> items) {
        this.items = items;
    }

    public Integer getCountNum() {
        int count = 0;
        if (items != null && items.size() > 0) {
            for (CartItem item : items) {
                count += item.getCount();
            }
        }
        return count;
    }


    public Integer getCountType() {
        int count = 0;
        if (items != null && items.size() > 0) {
            for (CartItem item : items) {
                count += 1;
            }
        }
        return count;
    }


    public BigDecimal getTotalAmount() {
        BigDecimal amount = new BigDecimal("0");
        //1、计算购物项总价
        if (items != null && items.size() > 0) {
            for (CartItem item : items) {
                if(item.getCheck()){
                    BigDecimal totalPrice = item.getTotalPrice();
                    amount = amount.add(totalPrice);
                }
            }
        }
        //2、减去优惠总价
        BigDecimal subtract = amount.subtract(getReduce());

        return subtract;
    }


    public BigDecimal getReduce() {
        return reduce;
    }

    public void setReduce(BigDecimal reduce) {
        this.reduce = reduce;
    }
}

2.3 ThreadLocal用户身份鉴别

① 需求分析 :

以京东网站为例,点击京东的”我的购物车“ ,就会看到有一个Cookie信息user-key,user-key 是随机生成的 id, 不管有没有登录都会有这个 cookie 信息。

浏览器有一个cookie:user-key; 标识用户身份,一个月后过期,如果第一次使用jd的购物车功能,都会给一个临时的购物车身份,浏览器以后保存,每次访问带上这个cookie 。
在这里插入图片描述

对于购物车的相关功能,比如新增商品到购物车、 查询购物车,需要判断用户是否登录:

新增商品: 判断是否登录
- 是: 则添加商品到后台 Redis 中, 把 user 的唯一标识符作为 key。
- 否: 则添加商品到后台 redis 中, 使用随机生成的 user-key 作为 key。

查询购物车列表: 判断是否登录
- 否: 直接根据 user-key 查询 redis 中数据并展示
- 是: 已登录, 则需要先根据 user-key 查询 redis 是否有数据。
- 有: 需要提交到后台添加到 redis, 合并数据, 而后查询。
- 否: 直接去后台查询 redis, 而后返回

② 怎么来判断用户是否登录呢 ?

我们需要判断用户是否登录,来执行购物车的相关功能,如果用户已经登录了,那么session中会有用户数据,如果没登录,按照cookie里面带来的user-key来做,第一次使用京东时,如果没有临时用户,创建一个临时用户。

对于项目中有些功能只有登录后才能访问,那么在访问这个功能时就需要判断用户是否登录了,判断的方法就是使用拦截器。(可以把user-key理解为一个token)

  • 因为不管用户是否登录,cookie中都会存在user-key,就可以从cookie中获取user-key(token)
  • 如果用户登录了,那么session中就会存在用户信息,通过session获取登录用户的sessionId
  • 如果用户第一次使用该网站,没有临时用户就创建一个临时用户的user-key,并将user-key保存在浏览器端,并设置过期时间。
@ToString
@Data
public class UserInfoTo {
    private Long userId;
    private String userKey; //一定封装
    private boolean tempUser = false;
}

将UserInfoTo存放在ThreadLocal中,ThreadLocal的作用就是同一个线程共享数据,同一个线程:

在这里插入图片描述

/**
 * 在执行目标方法之前需要判断用户的登录状态,并封装用户的登录状态传递给Controller的目标请求
 * 因为我们判断用户的登录状态并不是在拦截器中使用,而是在目标方法中使用,使用threadlocal
 */
@Component
public class CartInterceptor implements HandlerInterceptor {
    //因为我们判断用户的登录状态并不是在拦截器中使用,而是在目标方法中使用,因此使用threadlocal
    public static ThreadLocal<UserInfoTo> threadLocal = new ThreadLocal<>();

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        UserInfoTo userInfoTo = new UserInfoTo();

        //从cookie中获取user-key的值 (理解为从cookie要获取token)
        Cookie[] cookies = request.getCookies();
        if(cookies!=null && cookies.length>0){
            for(Cookie cookie:cookies){
                String name = cookie.getName();
                if(name.equals(CartConstant.TEMP_USER_COOKIE_NAME)){
                    userInfoTo.setUserKey(cookie.getValue());
                }
            }
        }

        //如果用户已经登录,那么session中就会存有用户信息
        HttpSession session = request.getSession();
        MemberRespVo member = (MemberRespVo) session
            						.getAttribute(AuthServerConstant.LOGIN_USER);
        if(member!=null){
            //登录了:获取登录用户的userId
            userInfoTo.setUserId(member.getId());
        }

        //如果没有临时用户,创建一个临时用户 
        if(StringUtils.isEmpty(userInfoTo.getUserKey())){
            String uuid = UUID.randomUUID().toString();
            userInfoTo.setUserKey(uuid);
        }
        //在目标方法执行之前,将用户信息userInfoVo,放入threadLocal
        threadLocal.set(userInfoTo);
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        //我们要将临时用户的user-key存放在浏览器,并设置这个cookie一个月过期
        UserInfoTo userInfoTo = threadLocal.get();
        //如果是临时用户信息,就给浏览器放置一个cookie
        if(!userInfoTo.isTempUser()){
            Cookie cookie = new Cookie(CartConstant.TEMP_USER_COOKIE_NAME,userInfoTo.getUserKey());
            cookie.setDomain("gulimall.com");
            //cookie的过期时间为1个月
            cookie.setMaxAge(CartConstant.TEMP_USER_COOKIE_TIMEOUT);
            response.addCookie(cookie);
        }
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        //为了防止threadLoacl内存泄露,需要在请求结束后,清除threadlocal中的userInfoVo信息
        threadLocal.remove();
    }
}

设置拦截器的拦截路径:

@Configuration
public class GulimallWebConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 拦截当前购物车的所有请求
        registry.addInterceptor(new CartInterceptor()).addPathPatterns("/**");
    }
}

③ 在Controller层中使用userInfoVo :

@Controller
public class CartController {
    /**
     * 浏览器有一个cookie:user-key; 标识用户身份,一个月后过期
     * 如果第一次使用jd的购物车功能,都会给一个临时的购物车身份,浏览器以后保存,每次访问带上这个cookie
     *
     * 登录:session有用户信息
     * 没登录:按照cookie里面带来的user-key来做
     * 第一次:如果没有临时用户,帮忙创建一个临时用户。
     */
    @GetMapping("/cart.html")
    public String cartListPage(){
        //我们可以从Thread Local中获取用户信息
        UserInfoTo userInfoTo = CartInterceptor.threadLocal.get();
        System.out.println(userInfoTo);
        return "cartList";
    }
}

2.4 添加商品到购物车

这里我们使用redis的hash结构来实现购物车功能:

如果用户已经登录使用,那么使用session中的userId作为hash的key,否则使用user-key作为hash的key。

如果购物车中此商品不存在,就添加新商品,如果购物车中此商品存在,就修改商品的数量。

@Controller
public class CartController {
    @Autowired
    CartService cartService;

    /**
     * 添加商品到购物车
     */
    @GetMapping("/addToCart")
    public String addToCart(@RequestParam("skuId")Long skuId, @RequestParam("num") Integer num, Model model) throws ExecutionException, InterruptedException {
        CartItem cartItem = cartService.addToCart(skuId,num);
        model.addAttribute("item",cartItem);
        return "success";
    }
}

对应的远程调用的feign接口 :

@FeignClient("gulimall-product")
public interface ProductFeignService {
    @RequestMapping("/product/skuinfo/info/{skuId}")
    R getSkuInfo(@PathVariable("skuId") Long skuId);

    @GetMapping("/product/skusaleattrvalue/stringlist/{skuId}")
    public List<String> getSkuSaleAttrValues(@PathVariable("skuId")Long skuId);
}
@Override
public CartItem addToCart(Long skuId, Integer num) throws ExecutionException, InterruptedException {
    BoundHashOperations<String, Object, Object> cartOps = getCartOps();

    //1、远程查询当前要添加的商品信息
    R skuInfo = productFeignService.getSkuInfo(skuId);
    SkuInfoVo skuInfoVo = skuInfo.getData("skuInfo", new TypeReference<SkuInfoVo>() {});

    String res = (String) cartOps.get(skuId.toString());

    if(StringUtils.isEmpty(res)){
        //2、新商品添加到购物车
        CartItem cartItem = new CartItem();
        // 使用异步编排来优化查询
        CompletableFuture<Void> getSkuInfoTask = CompletableFuture.runAsync(() -> {
            cartItem.setCheck(true);
            cartItem.setCount(num);
            cartItem.setImage(skuInfoVo.getSkuDefaultImg());
            cartItem.setPrice(skuInfoVo.getPrice());
            cartItem.setTitle(skuInfoVo.getSkuTitle());
            cartItem.setSkuId(skuId);
        },executor);

        //远程查询sku的销售属性信息
        CompletableFuture<Void> getSkuAttrValuesTask = CompletableFuture.runAsync(() -> {
            List<String> values = productFeignService.getSkuSaleAttrValues(skuId);
            cartItem.setSkuAttr(values);
        }, executor);

        CompletableFuture.allOf(getSkuAttrValuesTask,getSkuInfoTask).get();

        String s = JSON.toJSONString(cartItem);
        // 将商品添加至购物车
        cartOps.put(skuId.toString(),s);
        return cartItem;
    }else{
        //3、购物车中已经有这个商品,修改数量
        CartItem cartItem = JSON.parseObject(res, CartItem.class);
        cartItem.setCount(cartItem.getCount()+num);

        cartOps.put(skuId.toString(),JSON.toJSONString(cartItem));
        return cartItem;
    }
    
    public BoundHashOperations<String, Object, Object> getCartOps() {
        UserInfoTo userInfoTo = CartInterceptor.threadLocal.get();
        String cartKey = "";
        if(userInfoTo.getUserId()!=null){
            //用户登录了,使用用户userId,作为key
            cartKey = CART_PREFIX+userInfoTo.getUserId();
        }else{
            //用户没登录是临时用户,使用user-key作为key
            cartKey = CART_PREFIX +userInfoTo.getUserKey();
        }
//        redisTemplate.opsForHash().get(cartKey,"1");
        BoundHashOperations<String, Object, Object> operations = redisTemplate.boundHashOps(cartKey);
        return operations;
    }
}

现在有个问题,每次刷新添加商品的接口,购物车中商品的数量就在增加,所以希望添加商品成功后,能够直接跳转到购物车页面

在这里插入图片描述

@Controller
public class CartController {
    @Autowired
    CartService cartService;

    /**
     * 添加商品到购物车
     * RedirectAttributes ra
     *      ra.addFlashAttribute();将数据放在session里面可以在页面取出,但是只能取一次
     *      ra.addAttribute("skuId",skuId);将数据放在url后面
     */
    @GetMapping("/addToCart")
    public String addToCart(@RequestParam("skuId")Long skuId,
                            @RequestParam("num") Integer num,
                            RedirectAttributes redirectAttributes) throws ExecutionException, InterruptedException {
        CartItem cartItem = cartService.addToCart(skuId,num);
        redirectAttributes.addAttribute("skuId",skuId);
        return "redirect:http://cart.gulimall.com/addToCartSuccess.html";
    }

    //添加商品成功后,重定向到购物车页面,再次查询购物车
    @GetMapping("/addToCartSuccess.html")
    public String addToCartSuccessPage(@RequestParam("skuId") Long skuId,Model model){
        //重定向到成功页面,再次查询购物车即可
        CartItem cartItem = cartService.getCartItem(skuId);
        model.addAttribute("item",cartItem);
        return "success";
    }
}
//获取购物车中的某个购物项
@Override
public CartItem getCartItem(Long skuId) {
    BoundHashOperations<String, Object, Object> cartOps = getCartOps();
    String o = (String) cartOps.get(skuId.toString());
    CartItem cartItem = JSON.parseObject(o, CartItem.class);
    return cartItem;
}

2.5 获取购物车

@Controller
public class CartController {
    @Autowired
    CartService cartService;

    @Autowired
    StringRedisTemplate redisTemplate;

    @GetMapping("/cart.html")
    public String cartListPage(Model model) throws ExecutionException, InterruptedException {
        Cart cart = cartService.getCart();
        model.addAttribute("cart",cart);
        return "cartList";
    }
}

如果用户没有登录,我们获取到的是临时购物车的商品数据。

如果用户登录了,点击我的购物车页面,或者添加商品到购物车时,会先判断临时购物车中是否有商品数据,如果有数据需要合并到在线购物车中,合并后把临时购物车中的商品数据清除,然后再获取在线购物车中的商品数据。

@Override
public Cart getCart() throws ExecutionException, InterruptedException {
    Cart cart = new Cart();
    UserInfoTo userInfoTo = CartInterceptor.threadLocal.get();
    if(userInfoTo.getUserId()!=null){
        //登录,登录后需要将临时购物车的数据合并到在线购物车中

        //1、获取临时购物车中的购物项
        String cartkey = CART_PREFIX + userInfoTo.getUserId();

        //2、判断临时购物车中是否有数据
        String tempCartKey = CART_PREFIX + userInfoTo.getUserKey();
        List<CartItem> tempCartItems = getCartItems(tempCartKey);
        if(tempCartItems!=null){
            //临时购物车中有数据,需要合并
            for(CartItem cartItem:tempCartItems){
                addToCart(cartItem.getSkuId(),cartItem.getCount());
            }
            //清除临时购物车中的数据
            clearCart(tempCartKey);
        }

        //3、获取登录后的购物车的数据【包括临时购物车的数据和用户在线购物车的数据】
        List<CartItem> cartItems = getCartItems(cartkey);
        cart.setItems(cartItems);
    }else{
        //没登录
        String cartKey = CART_PREFIX + userInfoTo.getUserKey();
        List<CartItem> cartItems = getCartItems(cartKey);
        cart.setItems(cartItems);
    }
    return cart;
}

// 获取购物车中的购物项
private List<CartItem> getCartItems(String cartKey){
    BoundHashOperations<String, Object, Object> hashOperations = redisTemplate.boundHashOps(cartKey);
    //获cartKey对应的值
    List<Object> values = hashOperations.values();
    if(values!=null && values.size()>0){
        List<CartItem> collect = values.stream().map((item) -> {
            String item1 = (String) item;
            CartItem cartItem = JSON.parseObject(item1, CartItem.class);
            return cartItem;
        }).collect(Collectors.toList());
        return collect;
    }
    return null;
}

// 清除购物车(合并临时购物车后清除购物车)
@Override
public void clearCart(String cartkey){
    redisTemplate.delete(cartkey);
}

在这里插入图片描述

2.6 购物车操作

① 检查购物项 :

购物车中的商品一开始默认时选中的,如果我们勾选了购物车的状态,会首先跳转到检查购物车状态的请求路径上,然后重定向到购物车页面。

在这里插入图片描述

前端js :

$(".itemCheck").click(function(){
    var skuId = $(this).attr("skuId");
    var check = $(this).prop("checked");
    location.href = "http://cart.gulimall.com/checkItem?skuId="+skuId+"&check="+(check?1:0);
})

后端业务 :

@GetMapping("/checkItem")
public String checkItem(@RequestParam("skuId") Long skuId, @RequestParam("check") Integer check){
    cartService.checkItem(skuId,check);
    return "redirect:http://cart.gulimall.com/cart.html";
}
@Override
public void checkItem(Long skuId, Integer check) {
    CartItem cartItem = getCartItem(skuId);
    cartItem.setCheck(check==1?true:false);
    String jsonString = JSON.toJSONString(cartItem);
    BoundHashOperations<String, Object, Object> cartOps = getCartOps();
    cartOps.put(skuId.toString(),jsonString);
}

② 改变购物项数量:

在这里插入图片描述

前端js :

$(".countOpsBtn").click(function(){
   //1、skuId
   var skuId = $(this).parent().attr("skuId");
   var num = $(this).parent().find(".countOpsNum").text();
   location.href = "http://cart.gulimall.com/countItem?skuId="+skuId+"&num="+num;
});

后端业务 :

@GetMapping("/countItem")
public String countItem(@RequestParam("skuId")Long skuId,@RequestParam("num") Integer num){
    cartService.changeItemCount(skuId,num);
    return "redirect:http://cart.gulimall.com/cart.html";
}
@Override
public void changeItemCount(Long skuId, Integer num) {
    CartItem cartItem = getCartItem(skuId);
    cartItem.setCount(num);
    String jsonString = JSON.toJSONString(cartItem);
    BoundHashOperations<String, Object, Object> cartOps = getCartOps();
    cartOps.put(skuId.toString(),jsonString);
}

③ 删除购物项 :

在这里插入图片描述

前端js :

var deleteId = 0;
//删除购物项
function deleteItem(){
   location.href = "http://cart.gulimall.com/deleteItem?skuId="+deleteId;
}

$(".deleteItemBtn").click(function () {
   deleteId = $(this).attr("skuId");
});

后端业务 :

@GetMapping("/deleteItem")
public String deleteItem(@RequestParam("skuId")Long skuId){
    cartService.deleteItem(skuId);
    return "redirect:http://cart.gulimall.com/cart.html";
}
@Override
public void deleteItem(Long skuId) {
    BoundHashOperations<String, Object, Object> cartOps = getCartOps();
    cartOps.delete(skuId.toString());
}

3. 商城业务 - 消息队列

3.1 RabbitMQ简介与安装

① RabbitMQ简介:

RabbitMQ简介:RabbitMQ是一个由erlang开发的AMQP(Advanved Message Queue Protocol)的开源实现。

Message
消息,消息是不具名的,它由消息头和消息体组成。消息体是不透明的,而消息头则由一系列的可选属性组成,
这些属性包括routing-key(路由键)、priority(相对于其他消息的优先权)、delivery-mode(指出该消息可
能需要持久性存储)等。

Publisher
消息的生产者,也是一个向交换器发布消息的客户端应用程序。

Exchange
交换器,用来接收生产者发送的消息并将这些消息路由给服务器中的队列。
Exchange有4种类型:direct(默认),fanout, topic, 和headers,不同类型的Exchange转发消息的策略有所区别

Queue
消息队列,用来保存消息直到发送给消费者。它是消息的容器,也是消息的终点。一个消息可投入一个或多个队列。消息一直在队列里面,等待消费者连接到这个队列将其取走。

Binding
绑定,用于消息队列和交换器之间的关联。一个绑定就是基于路由键将交换器和消息队列连接起来的路由规则,所以可以将交换器理解成一个由绑定构成的路由表。Exchange 和Queue的绑定可以是多对多的关系。

Connection
网络连接,比如一个TCP连接。

Channel
信道,多路复用连接中的一条独立的双向数据流通道。信道是建立在真实的TCP连接内的虚拟连接,AMQP 命令都是通过信道发出去的,不管是发布消息、订阅队列还是接收消息,这些动作都是通过信道完成。因为对于操作系统来说建立和销毁 TCP 都是非常昂贵的开销,所以引入了信道的概念,以复用一条 TCP 连接。

Consumer
消息的消费者,表示一个从消息队列中取得消息的客户端应用程序。

Virtual Host
虚拟主机,表示一批交换器、消息队列和相关对象。虚拟主机是共享相同的身份认证和加密环境的独立服务器域。每个 vhost 本质上就是一个 mini 版的 RabbitMQ 服务器,拥有自己的队列、交换器、绑定和权限机制。vhost 是 AMQP 概念的基础,必须在连接时指定,RabbitMQ 默认的 vhost 是 / 。

Broker
表示消息队列服务器实体

在这里插入图片描述

② RabbitMQ安装 :

docker run -d --name rabbitmq -p 5671:5671 -p 5672:5672 -p 4369:4369 -p \
25672:25672 -p 15671:15671 -p 15672:15672 rabbitmq:management

docker update rabbitmq --restart=always

安装完成后访问:http://192.168.38.22:15672/#/

3.2 Exchange类型

AMQP 中消息的路由过程和 Java 开发者熟悉的 JMS 存在一些差别,AMQP 中增加了 Exchange 和Binding 的角色。生产者把消息发布到 Exchange 上,消息最终到达队列并被消费者接收,而 Binding 决定交换器的消息应该发送到那个队列。

在这里插入图片描述

Exchange分发消息时根据类型的不同分发策略有区别,目前共四种类型:direct、fanout、topic、headers

1、direct 直接路由

消息中的路由键(routing key)如果和Binding 中的 binding key 一致, 交换器就将消息发到对应的队列中。路由键与队列名完全匹配,如果一个队列绑定到交换机要求路由键为“dog”,则只转发 routing key 标记为“dog”的消息,不会转发“dog.puppy”,也不会转发“dog.guard”等等。它是完全匹配、单播的模式。

2、fanout 广播

每个发到 fanout 类型交换器的消息都会分到所有绑定的队列上去。fanout 交换器不处理路由键,只是简单的将队列绑定到交换器上,每个发送到交换器的消息都会被转发到与该交换器绑定的所有队列上。很像子网广播,每台子网内的主机都获得了一份复制的消息。fanout 类型转发消息是最快的。

3、topic 发布订阅

topic 交换器通过模式匹配分配消息的路由键属性,将路由键和某个模式进行匹配,此时队列需要绑定到一个模式上。它将路由键和绑定键的字符串切分成单词,这些单词之间用点隔开。它同样也会识别两个通配符:符号“#”和符号“*”。#匹配0个或多个单词,*匹配一个单词

在这里插入图片描述

下面要根据这个图创建交换机和队列:

在这里插入图片描述

① 创建四个队列 :

在这里插入图片描述

② 创建两个交换机:

在这里插入图片描述


将3个交换机都绑定四个队列:

exchange.directexchange.fanout
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Ici3uh9p-1612695342595)(imgs/image-20210101221440766.png)][外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-poRwBIQf-1612695342596)(imgs/image-20210101221521660.png)]

在这里插入图片描述

3.3 Spring Boot整合Rabbit MQ

① 引入RabbitMQ的坐标依赖:

<dependency>
    <groupId>com.atguigu.gulimall</groupId>
    <artifactId>gulimall-common</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <exclusions>
        <exclusion>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
        </exclusion>
    </exclusions>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>

② 在application.yml中配置属性:

spring.rabbitmq.host=192.168.38.22
spring.rabbitmq.port=5672
# 虚拟主机
spring.rabbitmq.virtual-host=/

③ 在主启动类上开启RabbitMQ的相关功能:

@EnableRabbit
@SpringBootApplication
public class GulimallOrderApplication {
    public static void main(String[] args) {
        SpringApplication.run(GulimallOrderApplication.class, args);
    }
}

④ 测试创建交换机,队列,将队列绑定交换机 :

@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest
public class GulimallOrderApplicationTests {

    @Autowired
    AmqpAdmin amqpAdmin;

    @Autowired
    RabbitTemplate rabbitTemplate;

    /**
     * 创建交换机
     */
    @Test
    public void createExchange() {
        //DirectExchange(String name, boolean durable, boolean autoDelete, Map<String, Object> arguments)
        // 创建交换机
        DirectExchange directExchange
                = new DirectExchange("hello-java-exchange",true,false);
        amqpAdmin.declareExchange(directExchange);
        log.info("Exchange[{}]创建成功", "hello-java-exchange");
    }

    /**
     * 创建队列
     */
    @Test
    public void createQueue(){
        //public Queue(String name, boolean durable, boolean exclusive, boolean autoDelete, Map<String, Object> arguments)
        Queue queue = new Queue("hello-java-queue",true,false,false);
        amqpAdmin.declareQueue(queue);
        log.info("Queue[{}]创建成功", "hello-java-queue");
    }

    /**
     * 将交换机和队列绑定
     */
    @Test
    public void createBinding(){
        // ( String destination【目的地】,
        //   DestinationType destinationType【目的地类型】,
        //   String exchange【交换机】,
        //   String routingKey【路由键】,
        //   Map<String, Object> arguments【自定义参数】)
        //将exchange指定的交换机和destination目的地进行绑定,使用routingKey作为指定的路由键
        Binding binding = new Binding("hello-java-queue",
                Binding.DestinationType.QUEUE, "hello-java-exchange",
                "hello.java", null);
        amqpAdmin.declareBinding(binding);
        log.info("Binding[{}]创建成功", "hello-java-binding");
    }
}

⑤ 测试发送消息 :

如果发送对象,需要指定MessageConverter,否则发送出去的就是字节数据:

@Configuration
public class MyRabbitConfig {
    @Bean
    public MessageConverter messageConverter(){
        return new Jackson2JsonMessageConverter();
    }
}
@Test
public void sendMessage(){
    // 发送消息
    // String msg = "Hello World!";
    // 发送消息,如果发送的消息是个对象,我们会使用序列化机制,将对象写出去。对象必须实现Serializable
    OrderReturnReasonEntity orderReturnReasonEntity = new OrderReturnReasonEntity();
    orderReturnReasonEntity.setId(1L);
    orderReturnReasonEntity.setCreateTime(new Date());
    orderReturnReasonEntity.setName("哈哈啥");
    rabbitTemplate.convertAndSend("hello-java-exchange","hello.java",orderReturnReasonEntity);
    log.info("消息发送完成{}");
}

⑥ 监听队列中的消息 :

监听消息:使用@RabbitListener;必须有@EnableRabbit
@RabbitListener: 类+方法上(监听哪些队列即可)
@RabbitHandler:标在方法上(重载区分不同的消息)

Queue:可以很多人都来监听。只要收到消息,队列删除消息,而且只能有一个收到此消息
1)、订单服务启动多个;同一个消息,只能有一个客户端收到
2)、 只有一个消息完全处理完,方法运行结束,我们就可以接收到下一个消息

发送消息 :

@RestController
public class RabbitController {

    @Autowired
    RabbitTemplate rabbitTemplate;

    @GetMapping("/sendMq")
    public String sendMq(@RequestParam(value = "num",defaultValue = "10") Integer num){
        for (int i=0;i<num;i++){
            if(i%2 == 0){
                OrderReturnReasonEntity reasonEntity = new OrderReturnReasonEntity();
                reasonEntity.setId(1L);
                reasonEntity.setCreateTime(new Date());
                reasonEntity.setName("哈哈-"+i);
                rabbitTemplate.convertAndSend("hello-java-exchange", "hello.java", reasonEntity); 
            }else {
                OrderEntity entity = new OrderEntity();
                entity.setOrderSn(UUID.randomUUID().toString());
                rabbitTemplate.convertAndSend("hello-java-exchange", "hello.java", entity);
            }
        }
        return "ok";
    }
}

监听队列:@RabbitListener(queues = {“hello-java-queue”}), @RabbitHandler:标在方法上(重载区分不同的消息)

@RabbitListener(queues = {"hello-java-queue"})
@Service("orderItemService")
public class OrderItemServiceImpl extends ServiceImpl<OrderItemDao, OrderItemEntity> implements OrderItemService {

    //@RabbitListener(queues = {"hello-java-queue"})
    @RabbitHandler
    public void receiveMessage(Message message, OrderReturnReasonEntity content){
        System.out.println("接收的消息:"+message);
        System.out.println("内容:"+content);
    }

    @RabbitHandler
    public void receiveMessage2(OrderEntity content){
        System.out.println("内容:"+content);
    }
}

访问:http://localhost:9000/sendMq

3.4 RabbitMQ消息确认机制

使用消息传递代理(例如RabbitMQ)的系统是分布式的。由于不能保证发送消息可以到达对等方或被其成功处理,因此发布者和使用者都需要一种机制来进行传递和处理确认。

保证消息不丢失,可靠抵达,可以使用事务消息,性能下降250倍,为此引入确认机制
• publisher confirmCallback 确认模式
• publisher returnCallback 未投递到 queue 退回模式
• consumer ack机制

在这里插入图片描述

① 可靠抵达 - ConfirmCallback : 只要消息抵达Broker就ack=true

• spring.rabbitmq.publisher-confirms=true

• 在创建 connectionFactory 的时候设置 PublisherConfirms(true) 选项,开启confirmcallback 。
• CorrelationData:用来表示当前消息唯一性。

• 消息只要被 broker 接收到就会执行 confirmCallback,如果是 cluster 模式,需要所有broker 接收到才会调用 confirmCallback。
• 被 broker 接收到只能表示 message 已经到达服务器,并不能保证消息一定会被投递到目标 queue 里。所以需要用到接下来的 returnCallback 。

# 开启发送者确认模式
spring.rabbitmq.publisher-confirms=true
@Configuration
public class MyRabbitConfig {
    @Autowired
    RabbitTemplate rabbitTemplate;

    /**
     * 定制rabbitMq
     */
    // Constructor(构造方法) -> @Autowired(依赖注入) -> @PostConstruct(注释的方法)
    @PostConstruct 
    public void initRabbitTemplate(){
           //设置确认回调 : 只要消息抵达Broker就ack=true
        rabbitTemplate.setConfirmCallback(new RabbitTemplate.ConfirmCallback() {
            /**
             * @param correlationData 当前消息的唯一关联数据
             * @param ack 消息是否成功收到
             * @param cause 失败的原因
             */
            @Override
            public void confirm(CorrelationData correlationData, boolean ack, String cause) {
                System.out.println("correlationData--》"+correlationData +"ack--》"+ack + "causer--》"+cause);
            }
        });
    }
}

发送消息,并指定correlationData,代表消息的唯一性

@RestController
public class RabbitController {

    @Autowired
    RabbitTemplate rabbitTemplate;

    @GetMapping("/sendMq")
    public String sendMq(@RequestParam(value = "num",defaultValue = "10") Integer num){
        for (int i=0;i<num;i++){
            if(i%2 == 0){
                OrderReturnReasonEntity reasonEntity = new OrderReturnReasonEntity();
                reasonEntity.setId(1L);
                reasonEntity.setCreateTime(new Date());
                reasonEntity.setName("哈哈-"+i);
                rabbitTemplate.convertAndSend("hello-java-exchange", "hello.java", reasonEntity,new CorrelationData(UUID.randomUUID().toString()));
            }else {
                OrderEntity entity = new OrderEntity();
                entity.setOrderSn(UUID.randomUUID().toString());
                rabbitTemplate.convertAndSend("hello-java-exchange", "hello2.java", entity,new CorrelationData(UUID.randomUUID().toString()));
            }
        }
        return "ok";
    }
}

② 可靠抵达 - ReturnCallback

• spring.rabbitmq.publisher-returns=true
• spring.rabbitmq.template.mandatory=true

• confrim 模式只能保证消息到达 broker,不能保证消息准确投递到目标 queue 里。在有些业务场景下,我们需要保证消息一定要投递到目标 queue 里,此时就需要用到return 退回模式。

• 这样如果未能投递到目标 queue 里将调用 returnCallback ,可以记录下详细到投递数据,定期的巡检或者自动纠错都需要这些数据 。

# 开启发送端消息抵达队列的确认
spring.rabbitmq.publisher-returns=true
# 只要抵达队列,以异步发送优先回调我们这个returnconfirm
spring.rabbitmq.template.mandatory=true
@Configuration
public class MyRabbitConfig {
    @Autowired
    RabbitTemplate rabbitTemplate;

    /**
     * 定制rabbitMq
     */
    @PostConstruct  
    public void initRabbitTemplate(){
        // 1、设置确认回调 : 只要消息抵达Broker就ack=true
        rabbitTemplate.setConfirmCallback(new RabbitTemplate.ConfirmCallback() {
            /**
             * @param correlationData 当前消息的唯一关联数据
             * @param ack 消息是否成功收到
             * @param cause 失败的原因
             */
            @Override
            public void confirm(CorrelationData correlationData, 
                                boolean ack, String cause) {
                System.out.println("correlationData--》"
                                   +correlationData +"ack--》"+ack + "causer--》"+cause);
            }
        });

        // 2、设置消息抵达队列的确认回调: 只有失败时才会调用这个消息
        rabbitTemplate.setReturnCallback(new RabbitTemplate.ReturnCallback() {
            /**
             * 只要消息没有投递给指定的队列,就触发这个失败回调
             * @param message   投递失败的消息详细信息
             * @param replyCode 回复的状态码
             * @param replyText 回复的文本内容
             * @param exchange  当时这个消息发给哪个交换机
             * @param routingKey 当时这个消息用哪个路由键
             */
            @Override
            public void returnedMessage(Message message, int replyCode, String replyText, String exchange, String routingKey) {
                System.out.println("Fail Message["+message+"]-->replyCode["+replyCode+"]-->replyText["+replyText+"]-->exchange["+exchange+"]-->routingKey["+routingKey+"]");
            }
        });
    }
}

③ 可靠抵达 - Ack消息确认机制

  • 消费者获取到消息,成功处理,可以回复Ack给Broker
    • basic.ack用于肯定确认;broker将移除此消息
    • basic.nack用于否定确认;可以指定broker是否丢弃此消息,可以批量
    • basic.reject用于否定确认;同上,但不能批量

  • 默认自动ack,消息被消费者收到,就会从broker的queue中移除;queue无消费者,消息依然会被存储,直到消费者消费

  • 消费者收到消息,默认会自动ack。但是如果无法确定此消息是否被处理完成,或者成功处理。我们可以开启手动ack模式

  • 消息确认的类型:

    channel.basicAck(deliveryTag, multiple);
    consumer处理成功后,通知broker删除队列中的消息,如果设置multiple=true,表示支持批量确认机制以减少网络流量。
    例如:有值为5,6,7,8 deliveryTag的投递
    如果此时channel.basicAck(8, true);则表示前面未确认的5,6,7投递也一起确认处理完毕。
    如果此时channel.basicAck(8, false);则仅表示deliveryTag=8的消息已经成功处理。

    channel.basicNack(deliveryTag, multiple, requeue);
    consumer处理失败后,例如:有值为5,6,7,8 deliveryTag的投递。
    如果channel.basicNack(8, true, true);表示deliveryTag=8之前未确认的消息都处理失败且将这些消息重新放回队列中。
    如果channel.basicNack(8, true, false);表示deliveryTag=8之前未确认的消息都处理失败且将这些消息丢弃。
    如果channel.basicNack(8, false, true);表示deliveryTag=8的消息处理失败且将该消息重新放回队列。
    如果channel.basicNack(8, false, false);表示deliveryTag=8的消息处理失败且将该消息直接丢弃。

    channel.basicReject(deliveryTag, requeue);
    相比channel.basicNack,除了没有multiple批量确认机制之外,其他语义完全一样。
    如果channel.basicReject(8, true);表示deliveryTag=8的消息处理失败且将该消息重新放回队列。
    如果channel.basicReject(8, false);表示deliveryTag=8的消息处理失败且将该消息直接丢弃。

# 设置为手动ack方式
spring.rabbitmq.listener.simple.acknowledge-mode=manual

发送消息 :

@RestController
public class RabbitController {

    @Autowired
    RabbitTemplate rabbitTemplate;

    @GetMapping("/sendMq")
    public String sendMq(@RequestParam(value = "num",defaultValue = "10") Integer num){
        for (int i=0;i<num;i++){
            if(i%2 == 0){
                OrderReturnReasonEntity reasonEntity = new OrderReturnReasonEntity();
                reasonEntity.setId(1L);
                reasonEntity.setCreateTime(new Date());
                reasonEntity.setName("哈哈-"+i);
                rabbitTemplate.convertAndSend("hello-java-exchange", "hello.java", reasonEntity,new CorrelationData(UUID.randomUUID().toString()));
            }else {
                OrderEntity entity = new OrderEntity();
                entity.setOrderSn(UUID.randomUUID().toString());
                rabbitTemplate.convertAndSend("hello-java-exchange", "hello.java", entity,new CorrelationData(UUID.randomUUID().toString()));
            }
        }
        return "ok";
    }
}

监听队列中的消息,并设置手动确认:

@RabbitListener(queues = {"hello-java-queue"})
@Service("orderItemService")
public class OrderItemServiceImpl extends ServiceImpl<OrderItemDao, OrderItemEntity> implements OrderItemService {

    @RabbitHandler
    public void recieveMessage(Message message,
                               OrderReturnReasonEntity content,
                               Channel channel) {
        // deliveryTag 是 channel内按顺序自增的。
        long deliveryTag = message.getMessageProperties().getDeliveryTag();
        System.out.println("deliveryTag==>"+deliveryTag);

        // 签收货物,非批量模式
        try {
            if(deliveryTag%2 == 0){
                //收货
                channel.basicAck(deliveryTag,false); //非批量模式
                System.out.println("签收了货物..."+deliveryTag);
            }else {
                // 退货
                // long deliveryTag, boolean multiple, boolean requeue
                // requeue=false 丢弃  requeue=true 发回服务器,服务器重新入队。
                channel.basicNack(deliveryTag,false,false); // 非批量模式
                System.out.println("没有签收货物..."+deliveryTag);
            }
        }catch (Exception e){
            //网络中断
        }
    }

    @RabbitHandler
    public void receiveMessage2(Message message,OrderEntity content,Channel channel){
        try {
            channel.basicNack(message.getMessageProperties()
                              .getDeliveryTag(),false,false);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

4. 商城业务 - 订单服务

4.1 环境搭建

① 配置域名:

在这里插入图片描述

② 配置网关 :

- id: gulimall_order_route
  uri: lb://gulimall-order
  predicates:
    - Host=order.gulimall.com

③ 将gulimall-order加入服务注册中心:

spring.session.store-type=redis
spring.redis.host=192.168.38.22
spring.redis.port=6379

gulimall.thread.core-size=20
gulimall.thread.max-size=200
gulimall.thread.keep-alive-time=10

④ 使用订单系统时,都需要用户是登录状态,因此需要整合Spring Session,修改前端页面

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    <exclusions>
        <exclusion>
            <groupId>io.lettuce</groupId>
            <artifactId>lettuce-core</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>
@EnableFeignClients
@EnableRedisHttpSession
@EnableDiscoveryClient
@EnableRabbit
@SpringBootApplication
public class GulimallOrderApplication {
    public static void main(String[] args) {
        SpringApplication.run(GulimallOrderApplication.class, args);
    }
}

⑤ 订单的基本概念 :

电商系统涉及到 3 流, 分别时信息流, 资金流, 物流, 而订单系统作为中枢将三者有机的集合起来。
订单模块是电商系统的枢纽, 在订单这个环节上需求获取多个模块的数据和信息, 同时对这些信息进行加工处理后流向下个环节, 这一系列就构成了订单的信息流通 。

在这里插入图片描述

订单创建与支付 :

(1) 、 订单创建前需要预览订单, 选择收货信息等
(2) 、 订单创建需要锁定库存, 库存有才可创建, 否则不能创建
(3) 、 订单创建后超时未支付需要解锁库存
(4) 、 支付成功后, 需要进行拆单, 根据商品打包方式, 所在仓库, 物流等进行拆单
(5) 、 支付的每笔流水都需要记录, 以待查账
(6) 、 订单创建, 支付成功等状态都需要给 MQ 发送消息, 方便其他系统感知订阅

4.2 订单登录拦截

订单系统只有登录后才能访问,没有登录,需要用户先去登录,因此需要先设置登录请求拦截判断用户是否登录:

@Component
public class LoginUserInterceptor implements HandlerInterceptor {

    public static ThreadLocal<MemberRespVo> loginUser = new ThreadLocal<>();

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        MemberRespVo attribute =(MemberRespVo) request.getSession().getAttribute(AuthServerConstant.LOGIN_USER);
        if(attribute!=null){
            // 登录了
            loginUser.set(attribute);
            return true;
        }else{
            // 没登录就去登录
            request.getSession().setAttribute("msg","请先进行登录:");
            response.sendRedirect("http://auth.gulimall.com/login.html");
            return false;
        }
    }
}

配置拦截器的拦截路径 :

@Configuration
public class OrderWebConfiguration implements WebMvcConfigurer {
    @Autowired
    LoginUserInterceptor interceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
         registry.addInterceptor(interceptor).addPathPatterns("/**");
    }
}

4.3 订单结算页

1、订单确认页Vo :

//订单确认页需要用的数据
public class OrderConfirmVo {

    // 收货地址,ums_member_receive_address表
    @Setter @Getter
    List<MemberAddressVo> address;

    // 所有选中的购物项
    @Setter @Getter
    List<OrderItemVo> items;

    // 优惠券信息
    @Setter @Getter
    Integer integration;

    //防重令牌
    @Setter @Getter
    String orderToken;

    public Integer getCount(){
        Integer i = 0 ;
        if(items!=null){
            for (OrderItemVo item : items) {
                i+=item.getCount();
            }
        }
        return i;
    }

    // 订单总额
    public BigDecimal getTotal() {
        BigDecimal sum = new BigDecimal("0");
        if(items!=null){
            for (OrderItemVo item : items) {
                BigDecimal multiply = item.getPrice().multiply(new BigDecimal(item.getCount().toString()));
                sum = sum.add(multiply);
            }
        }
        return sum;
    }

    // 应付价格
    public BigDecimal getPayPrice() {
       return  getTotal();
    }
}

2、订单确认页数据获取 :

① gulimall-order服务远程调用gulimall-member服务的feign接口 ,远程查询所有的收货地址列表 :

@GetMapping("/{memeberId}/addresses")
public List<MemberReceiveAddressEntity> getAddress(@PathVariable("memeberId") Long memberId){
    return memberReceiveAddressService.getAddress(memberId);
}
@FeignClient("gulimall-member")
public interface MemberFeignService {
    @GetMapping("/member/member/{memberId}/address")
    List<MemberAddressVo> getAddress(Long memberId);
}

② gulimall-order服务远程调用gulimall-cart服务的feign接口 ,远程查询购物车所有选中的购物项 :

@GetMapping("/currentUserCartItems")
@ResponseBody
public List<CartItem> getCurrentUserCartItems(){
    return cartService.getUserCartItems();
}
@FeignClient("gulimall-cart")
public interface CartFeignService {
    @GetMapping("/currentUserCartItems")
    List<OrderItemVo> getCurrentUserCartItems();
}

③ gulimall-cart远程调用gulimall-product服务的feign接口 ,远程查询商品的购物车信息 :

@GetMapping("/{skuId}/price")
public R getPrice(@PathVariable("skuId") Long skuId){
    SkuInfoEntity byId = skuInfoService.getById(skuId);
    return R.ok().setData(byId.getPrice().toString());
}
@FeignClient("gulimall-product")
public interface ProductFeignService {
    @GetMapping("/product/skuinfo/{skuId}/price")
    R getPrice(@PathVariable("skuId") Long skuId);
}

3、订单确认页数据获取 :

@Controller
public class OrderWebController {
    @Autowired
    OrderService orderService;

    @GetMapping("/toTrade")
    public String toTrade(Model model){
        OrderConfirmVo confirmVo = orderService.confirmOrder();
        model.addAttribute("orderConfirmData",confirmVo);
        return "confirm";
    }
}
@Service("orderService")
public class OrderServiceImpl extends ServiceImpl<OrderDao, OrderEntity> implements OrderService {
    @Autowired
    MemberFeignService memberFeignService;

    @Autowired
    CartFeignService cartFeignService;

    @Override
    public OrderConfirmVo confirmOrder() {
        OrderConfirmVo confirmVo = new OrderConfirmVo();

        MemberRespVo memberRespVo = LoginUserInterceptor.loginUser.get();
        //1、远程查询所有的收货地址列表
        List<MemberAddressVo> address = memberFeignService.getAddress(memberRespVo.getId());
        confirmVo.setAddress(address);

        //2、远程查询购物车所有选中的购物项
        List<OrderItemVo> currentCartItems = cartFeignService.getCurrentCartItems();
        confirmVo.setItems(currentCartItems);

        //3、查询用户积分
        Integer integration = memberRespVo.getIntegration();
        confirmVo.setIntegration(integration);

        //其他数据自动计算
        // TODO 防重令牌
        return confirmVo;
    }
}

4、Fiegn远程调用丢失请求头的问题:

问题描述:当远程调用gulimall-cart服务时,设置了拦截器判断用户是否登录,但是结果是即使用户登录了,也会显示用户没登录,原因在于cartFeignService.getCurrentCartItems();远程调用时,发送的请求是一个新的情求,请求中并不存在cookie,而http://order.gulimall.com/toTrade请求中是携带cookie的。

解决:编写远程调用拦截器

在这里插入图片描述

@Configuration
public class GuliFeignConfig {
    @Bean("requestInterceptor")
    public RequestInterceptor requestInterceptor(){
        return new RequestInterceptor(){
            @Override
            public void apply(RequestTemplate template) {
                //1、RequestContextHolder拿到刚进来的这个请求
                ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
                if(attributes!=null){
                    HttpServletRequest request = attributes.getRequest(); //老请求
                    if(request != null){
                        //同步请求头数据,Cookie
                        String cookie = request.getHeader("Cookie");
                        //给新请求同步了老请求的cookie
                        template.header("Cookie",cookie);
                    }
                }
            }
        };
    }
}

5、Feign异步情况丢失上下文问题 :

在这里插入图片描述

@Service("orderService")
public class OrderServiceImpl extends ServiceImpl<OrderDao, OrderEntity> implements OrderService {

    @Autowired
    MemberFeignService memberFeignService;

    @Autowired
    CartFeignService cartFeignService;

    @Autowired
    ThreadPoolExecutor threadPoolExecutor;

   @Override
   public OrderConfirmVo confirmOrder() throws ExecutionException, InterruptedException {
        OrderConfirmVo confirmVo = new OrderConfirmVo();

        MemberRespVo memberRespVo = LoginUserInterceptor.loginUser.get();
        System.out.println("主线程...." + Thread.currentThread().getId());

        //获取之前的请求
        RequestAttributes requestAttributes 
            					= RequestContextHolder.getRequestAttributes();
        CompletableFuture<Void> getAddressFuture = CompletableFuture.runAsync(() -> {
            //1、远程查询所有的收货地址列表
            System.out.println("member线程...." + Thread.currentThread().getId());
            //每一个线程都来共享之前的请求数据
            RequestContextHolder.setRequestAttributes(requestAttributes);
            List<MemberAddressVo> address 
                				= memberFeignService.getAddress(memberRespVo.getId());
            confirmVo.setAddress(address);
        }, threadPoolExecutor);

        CompletableFuture<Void> getCartItemsFuture = CompletableFuture.runAsync(() -> {
            //2、远程查询购物车所有选中的购物项
            System.out.println("cart线程...." + Thread.currentThread().getId());
            //每一个线程都来共享之前的请求数据
            RequestContextHolder.setRequestAttributes(requestAttributes);
            List<OrderItemVo> currentCartItems 
                				= cartFeignService.getCurrentUserCartItems();
            confirmVo.setItems(currentCartItems);
        }, threadPoolExecutor);

        //3、查询用户积分
        Integer integration = memberRespVo.getIntegration();
        confirmVo.setIntegration(integration);

        CompletableFuture.allOf(getAddressFuture,getCartItemsFuture).get();

        //其他数据自动计算
        // TODO 防重令牌
        return confirmVo;
    }
}

在这里插入图片描述

6、批量判断所有的购物项是否有库存:

gulimall-order服务远程调用gulimall-ware服务批量判断选中的购物项是否有货 :

@FeignClient("gulimall-ware")
public interface WmsFeignService {
    @PostMapping("/ware/waresku/hasstock")
    public R getSkusHasStock(@RequestBody List<Long> skuIds);
}
@Override
public OrderConfirmVo confirmOrder() throws ExecutionException, InterruptedException {
    OrderConfirmVo confirmVo = new OrderConfirmVo();

    MemberRespVo memberRespVo = LoginUserInterceptor.loginUser.get();
    System.out.println("主线程...." + Thread.currentThread().getId());

    //获取之前的请求
    RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
    CompletableFuture<Void> getAddressFuture = CompletableFuture.runAsync(() -> {
        //1、远程查询所有的收货地址列表
        System.out.println("member线程...." + Thread.currentThread().getId());
        //每一个线程都来共享之前的请求数据
        RequestContextHolder.setRequestAttributes(requestAttributes);
        List<MemberAddressVo> address = memberFeignService.getAddress(memberRespVo.getId());
        confirmVo.setAddress(address);
    }, threadPoolExecutor);

    CompletableFuture<Void> getCartItemsFuture = CompletableFuture.runAsync(() -> {
        //2、远程查询购物车所有选中的购物项
        System.out.println("cart线程...." + Thread.currentThread().getId());
        //每一个线程都来共享之前的请求数据
        RequestContextHolder.setRequestAttributes(requestAttributes);
        List<OrderItemVo> currentCartItems = cartFeignService.getCurrentUserCartItems();
        confirmVo.setItems(currentCartItems);
    }, threadPoolExecutor).thenRunAsync(()->{
        //得到所有的购物项
        List<OrderItemVo> items = confirmVo.getItems();
        //远程调用gulimall-ware服务查询每一个购物项是否有货
        List<Long> collect = items.stream().map(item -> item.getSkuId()).collect(Collectors.toList());
        R hasStock = wmsFeignService.getSkusHasStock(collect);
        List<SkuStockVo> data = hasStock.getData(new TypeReference<List<SkuStockVo>>() {
        });
        if(data!=null){
            Map<Long, Boolean> collect1 = data.stream().collect(Collectors.toMap(SkuStockVo::getSkuId, SkuStockVo::getHasStock));
            confirmVo.setStocks(collect1);
        }
    },threadPoolExecutor);

    //3、查询用户积分
    Integer integration = memberRespVo.getIntegration();
    confirmVo.setIntegration(integration);

    CompletableFuture.allOf(getAddressFuture,getCartItemsFuture).get();

    //其他数据自动计算
    // TODO 防重令牌
    return confirmVo;
}

7、根据收获地址计算运费 :

@RestController
@RequestMapping("ware/wareinfo")
public class WareInfoController {
    @Autowired
    private WareInfoService wareInfoService;

    @GetMapping("/fare")
    public R getFare(@RequestParam("addrId") Long addrId){
       BigDecimal fare = wareInfoService.getFare(addrId);
       return R.ok().setData(fare);
    }
}
@Override
public BigDecimal getFare(Long addrId) {
    //远程调用gulimall-member服务查询用户的收获地址
    R addrInfo = memberFeignService.addrInfo(addrId);
    MemberAddressVo data = addrInfo.getData("memberReceiveAddress",new TypeReference<MemberAddressVo>() {
    });
    if(data!=null){
        String phone = data.getPhone();
        //电话号码的最后一位作为运费
        String substring = phone.substring(phone.length() - 1, phone.length());
        return new BigDecimal(substring);
    }
    return null;
}

8、优化第7步:同时得到运费和收货人信息

@Data
public class FareVo {
    private MemberAddressVo address;
    private BigDecimal fare;
}
@GetMapping("/fare")
public R getFare(@RequestParam("addrId") Long addrId){
   FareVo fare = wareInfoService.getFare(addrId);
   return R.ok().setData(fare);
}
@Override
public FareVo getFare(Long addrId) {
    FareVo fareVo = new FareVo();
    //远程调用gulimall-member服务查询用户的收获地址
    R addrInfo = memberFeignService.addrInfo(addrId);
    MemberAddressVo data = addrInfo.getData("memberReceiveAddress",new TypeReference<MemberAddressVo>() {
    });
    if(data!=null){
        String phone = data.getPhone();
        //电话号码的最后一位作为运费
        String substring = phone.substring(phone.length() - 1, phone.length());
        BigDecimal bigDecimal = new BigDecimal(substring);
        fareVo.setAddress(data);
        fareVo.setFare(bigDecimal);
        return fareVo;
    }
    return null;
}

在这里插入图片描述

4.4 接口幂等性

1、 什么是幂等性

接口幂等性就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的, 不会因为多次点击而产生了副作用; 比如说支付场景, 用户购买了商品支付扣款成功, 但是返回结果的时候网络异常, 此时钱已经扣了, 用户再次点击按钮, 此时会进行第二次扣款, 返回结果成功, 用户查询余额返发现多扣钱了, 流水记录也变成了两条, 这就没有保证接口的幂等性。

2、 哪些情况需要防止

① 用户多次点击按钮
② 用户页面回退再次提交
③ 微服务互相调用, 由于网络问题, 导致请求失败。 feign 触发重试机制
④ 其他业务情况

3、 什么情况下需要幂等,以 SQL 为例, 有些操作是天然幂等的:

① SELECT * FROM table WHER id=?, 无论执行多少次都不会改变状态, 是天然的幂等。
② UPDATE tab1 SET col1=1 WHERE col2=2, 无论执行成功多少次状态都是一致的, 也是幂等操作。
③ delete from user where userid=1, 多次操作, 结果一样, 具备幂等性
④ insert into user(userid,name) values(1,‘a’) 如 userid 为唯一主键, 即重复操作上面的业务, 只会插入一条用户数据, 具备幂等性。
⑤ UPDATE tab1 SET col1=col1+1 WHERE col2=2, 每次执行的结果都会发生变化, 不是幂等的。
⑥ insert into user(userid,name) values(1,‘a’) 如 userid 不是主键, 可以重复, 那上面业务多次操作, 数据都会新增多条, 不具备幂等性

4、 幂等解决方案

① token 机制:

  • 服务端提供了发送 token 的接口。 我们在分析业务的时候, 哪些业务是存在幂等问题的,就必须在执行业务前, 先去获取 token, 服务器会把 token 保存到 redis 中 。
  • 然后调用业务接口请求时, 把 token 携带过去, 一般放在请求头部。
  • 服务器判断 token 是否存在 redis 中, 存在表示第一次请求, 然后删除 token,继续执行业务。
  • 如果判断 token 不存在 redis 中, 就表示是重复操作, 直接返回重复标记给 client, 这样就保证了业务代码, 不被重复执行

​ 先删除 token 还是后删除 token?

  • 先删除可能导致, 业务确实没有执行, 重试还带上之前 token, 由于防重设计导致,请求还是不能执行。
  • 后删除可能导致, 业务处理成功, 但是服务闪断, 出现超时, 没有删除 token, 别人继续重试, 导致业务被执行两边
  • 我们最好设计为先删除 token, 如果业务调用失败, 就重新获取 token 再次请求。

​ token 获取、 比较和删除必须是原子性 :

  • redis.get(token) 、 token.equals、 redis.del(token)如果这两个操作不是原子, 可能导致, 高并发下, 都 get 到同样的数据, 判断都成功, 继续业务并发执行
  • 可以在 redis 使用 lua 脚本完成这个操作
    if redis.call(‘get’, KEYS[1]) == ARGV[1] then return redis.call(‘del’, KEYS[1]) else return 0 end

② 各种锁机制 :

  • 数据库悲观锁

    select * from xxxx where id = 1 for update;
    悲观锁使用时一般伴随事务一起使用, 数据锁定时间可能会很长, 需要根据实际情况选用。另外要注意的是, id 字段一定是主键或者唯一索引, 不然可能造成锁表的结果, 处理起来会非常麻烦。

  • 数据库乐观锁

    这种方法适合在更新的场景中,
    update t_goods set count = count -1 , version = version + 1 where good_id=2 and version = 1
    根据 version 版本, 也就是在操作库存前先获取当前商品的 version 版本号, 然后操作的时候带上此 version 号。 我们梳理下, 我们第一次操作库存时, 得到 version 为 1, 调用库存服务version 变成了 2; 但返回给订单服务出现了问题, 订单服务又一次发起调用库存服务, 当订单服务传如的 version 还是 1, 再执行上面的 sql 语句时, 就不会执行; 因为 version 已经变为 2 了, where 条件就不成立。 这样就保证了不管调用几次, 只会真正的处理一次。乐观锁主要使用于处理读多写少的问题

  • 业务层分布式锁

    如果多个机器可能在同一时间同时处理相同的数据, 比如多台机器定时任务都拿到了相同数据处理, 我们就可以加分布式锁, 锁定此数据, 处理完成后释放锁。 获取到锁的必须先判断这个数据是否被处理过 。

③ 各种唯一约束:

  • 数据库唯一约束

    插入数据, 应该按照唯一索引进行插入, 比如订单号, 相同的订单就不可能有两条记录插入。我们在数据库层面防止重复。这个机制是利用了数据库的主键唯一约束的特性, 解决了在 insert 场景时幂等问题。 但主键的要求不是自增的主键, 这样就需要业务生成全局唯一的主键。如果是分库分表场景下, 路由规则要保证相同请求下, 落地在同一个数据库和同一表中, 要不然数据库主键约束就不起效果了, 因为是不同的数据库和表主键不相关。

  • redis set 防重

    很多数据需要处理, 只能被处理一次, 比如我们可以计算数据的 MD5 将其放入 redis 的 set,每次处理数据, 先看这个 MD5 是否已经存在, 存在就不处理。

④ 防重表 :

  • 使用订单号 orderNo 做为去重表的唯一索引, 把唯一索引插入去重表, 再进行业务操作, 且他们在同一个事务中。 这个保证了重复请求时, 因为去重表有唯一约束, 导致请求失败, 避免了幂等问题。 这里要注意的是, 去重表和业务表应该在同一库中, 这样就保证了在同一个事务, 即使业务操作失败了, 也会把去重表的数据回滚。 这个很好的保证了数据一致性。之前说的 redis 防重也算

⑤ 全局请求唯一 id :

  • 调用接口时, 生成一个唯一 id, redis 将数据保存到集合中(去重) , 存在即处理过。可以使用 nginx 设置每一个请求的唯一 id;proxy_set_header X-Request-Id $request_id;

4.5 令牌防止多次提交订单

订单结算页完成 :在订单结算页提交订单的相关数据

在这里插入图片描述

① 为了防止订单的多次提交,需要保证接口的幂等性。这里使用令牌机制,在订单确认页到达之前,为订单生成一个令牌保证幂等性。给服务器和浏览器分别存放一个防重令牌 :

@Override
public OrderConfirmVo confirmOrder() throws ExecutionException, InterruptedException {
    OrderConfirmVo confirmVo = new OrderConfirmVo();

    MemberRespVo memberRespVo = LoginUserInterceptor.loginUser.get();
    System.out.println("主线程...." + Thread.currentThread().getId());

    //获取之前的请求
    RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
    CompletableFuture<Void> getAddressFuture = CompletableFuture.runAsync(() -> {
        //1、远程查询所有的收货地址列表
        System.out.println("member线程...." + Thread.currentThread().getId());
        //每一个线程都来共享之前的请求数据
        RequestContextHolder.setRequestAttributes(requestAttributes);
        List<MemberAddressVo> address = memberFeignService.getAddress(memberRespVo.getId());
        confirmVo.setAddress(address);
    }, threadPoolExecutor);

    CompletableFuture<Void> getCartItemsFuture = CompletableFuture.runAsync(() -> {
        //2、远程查询购物车所有选中的购物项
        System.out.println("cart线程...." + Thread.currentThread().getId());
        //每一个线程都来共享之前的请求数据
        RequestContextHolder.setRequestAttributes(requestAttributes);
        List<OrderItemVo> currentCartItems = cartFeignService.getCurrentUserCartItems();
        confirmVo.setItems(currentCartItems);
    }, threadPoolExecutor).thenRunAsync(()->{
        //得到所有的购物项
        List<OrderItemVo> items = confirmVo.getItems();
        //远程调用gulimall-ware服务查询每一个购物项是否有货
        List<Long> collect = items.stream().map(item -> item.getSkuId()).collect(Collectors.toList());
        R hasStock = wmsFeignService.getSkusHasStock(collect);
        List<SkuStockVo> data = hasStock.getData(new TypeReference<List<SkuStockVo>>() {
        });
        if(data!=null){
            Map<Long, Boolean> collect1 = data.stream().collect(Collectors.toMap(SkuStockVo::getSkuId, SkuStockVo::getHasStock));
            confirmVo.setStocks(collect1);
        }
    },threadPoolExecutor);

    //3、查询用户积分
    Integer integration = memberRespVo.getIntegration();
    confirmVo.setIntegration(integration);

    CompletableFuture.allOf(getAddressFuture,getCartItemsFuture).get();

    //其他数据自动计算
    // TODO 防重令牌
    String token = UUID.randomUUID().toString().replace("_","");
    // 给服务器存储一个防重令牌
    redisTemplate.opsForValue()
        .set(OrderConstant.USER_ORDER_TOKEN_PREFIX+memberRespVo.getId(),token,30, TimeUnit.MINUTES);
    // 给页面存储一个防重令牌
    confirmVo.setOrderToken(token);
    return confirmVo;
}

② 订单提交数据的OrderSubmitVo:

@Data
public class OrderSubmitVo {
    private Long addrId;//收货地址的id
    private Integer payType;//支付方式
    //无需提交需要购买的商品,去购物车再获取一遍
    //优惠,发票
    private String orderToken;//防重令牌
    private BigDecimal payPrice;//应付价格  验价
    private String note;//订单备注
    //用户相关信息,直接去session取出登录的用户
}

③ 通过表单提交订单数据:

<form action="http://order.gulimall.com/submitOrder" method="post">
   <input id="addrIdInput" type="hidden" name="addrId">
   <input id="payPriceInput" type="hidden" name="payPrice">
   <input type="hidden" name="orderToken" th:value="${orderConfirmData.orderToken}"/>
   <button class="tijiao" type="submit">提交订单</button>
</form>

4.6 提交订单

@Controller
public class OrderWebController {
    @Autowired
    OrderService orderService;
    /**
     * 下单功能
     * @param vo
     * @return
     */
    @PostMapping("/submitOrder")
    public String submitOrder(OrderSubmitVo vo, Model model, RedirectAttributes redirectAttributes){
        try {
            SubmitOrderResponseVo responseVo = orderService.submitOrder(vo);
            //下单失败回到订单确认页重新确认订单信息
            System.out.println("订单提交的数据..."+vo);
            if(responseVo.getCode() == 0){
                //下单成功来到支付选择页
                model.addAttribute("submitOrderResp",responseVo);
                return  "pay";
            }else{
                String msg = "下单失败;";
                switch (responseVo.getCode()){
                    case 1: msg += "订单信息过期,请刷新再次提交"; break;
                    case 2: msg+= "订单商品价格发生变化,请确认后再次提交"; break;
                    case 3: msg+="库存锁定失败,商品库存不足"; break;
                }
                redirectAttributes.addFlashAttribute("msg",msg);
                return "redirect:http://order.gulimall.com/toTrade";
            }
        }catch (Exception e){
            if(e instanceof NoStockException){
                String message = ((NoStockException) e).getMessage();
                redirectAttributes.addFlashAttribute("msg",message);
            }
            return "redirect:http://order.gulimall.com/toTrade";
        }
    }
}

提交订单的整体逻辑 :

在这里插入图片描述

@Transactional
@Override
public SubmitOrderResponseVo submitOrder(OrderSubmitVo vo) {
    orderSubmitVoThreadLocal.set(vo);
    // 从session获取用户信息
    MemberRespVo memberRespVo = LoginUserInterceptor.loginUser.get();
    SubmitOrderResponseVo submitOrderResponseVo = new SubmitOrderResponseVo();
    submitOrderResponseVo.setCode(0);
    // 验证令牌【令牌的对比和删除必须保证原子性】
    // redis分布式锁的lura脚本: 获取指定的值,如果值不存在就返回0,存在就返回1,然后删除,删除成功就返回0,删除失败就返回1,0 令牌失败 - 1 删除成功
    String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
    String orderToken = vo.getOrderToken();
    // 执行lura脚本:原子验证令牌
    Long result = redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class),
                                        Arrays.asList(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberRespVo.getId()), orderToken);

    if(result==0L){
        //令牌验证失败
        submitOrderResponseVo.setCode(1);
        return submitOrderResponseVo;
    }else{
        //令牌验证通过后
        // 1、创建订单
        OrderCreateTo order = createOrder();
        // 2、验价
        BigDecimal payAmount = order.getOrder().getPayAmount();
        BigDecimal payPrice = vo.getPayPrice();
        if(Math.abs(payAmount.subtract(payPrice).doubleValue())<0.01){
            //金额对比成功
            // 3、保存订单
            saveOrder(order);
            // 4、锁库存,只要有异常,回滚订单数据
            WareSkuLockVo wareSkuLockVo = new WareSkuLockVo();
            wareSkuLockVo.setOrderSn(order.getOrder().getOrderSn());
            List<OrderItemVo> locks = order.getOrderItems().stream().map(item -> {
                OrderItemVo orderItemVo = new OrderItemVo();
                orderItemVo.setSkuId(item.getSkuId());
                orderItemVo.setCount(item.getSkuQuantity());
                orderItemVo.setTitle(item.getSkuName());
                return orderItemVo;
            }).collect(Collectors.toList());
            wareSkuLockVo.setLocks(locks);

            // 5、远程锁库存
            R r = wmsFeignService.orderLockStock(wareSkuLockVo);
            if(r.getCode()==0){
                //锁定成功
                submitOrderResponseVo.setOrder(order.getOrder());
                return submitOrderResponseVo;
            }else{
                //锁定失败
                String msg = (String)r.get("msg");
                throw new NoStockException(msg);
            }
        }else{
            submitOrderResponseVo.setCode(1);
            return submitOrderResponseVo;
        }
    }
}

远程锁定库存的逻辑 :

在这里插入图片描述

@Transactional(rollbackFor = NoStockException.class)
@Override
public Boolean orderLockStock(WareSkuLockVo vo) {
    // 1、按照下单的收获地址,找到一个就近仓库,锁定库存
    // 1、找到每个商品在哪个仓库都有库存
    List<OrderItemVo> orderItemVos = vo.getLocks();
    List<SkuWareHasStock> collect = orderItemVos.stream().map(item -> {
        SkuWareHasStock stock = new SkuWareHasStock();
        Long skuId = item.getSkuId();
        stock.setSkuId(skuId);
        stock.setNum(item.getCount());
        //查询这个商品在哪里有库存
        List<Long> wareIds = wareSkuDao.listWareIdHasSkuStock(skuId);
        stock.setWareId(wareIds);
        return stock;
    }).collect(Collectors.toList());

    Boolean allLock = true;
    //锁定库存
    for(SkuWareHasStock hasStock : collect){
        Boolean skuStocked = false;
        Long skuId = hasStock.getSkuId();
        List<Long> wareIds = hasStock.getWareId();
        if(wareIds==null || wareIds.size()==0){
            //没有任何仓库有这个商品的库存
            throw new NoStockException(skuId);
        }
        // 减库存
        for (Long wareId : wareIds) {
            Long count = wareSkuDao.lockSkuStock(skuId, wareId, hasStock.getNum());
            if(count==1){
                //成功
                skuStocked=true;
                break;
            }else{
                //失败,当前仓库锁定失败,重试下一个仓库
            }
        }
        if(skuStocked==false){
            //当前商品所有仓库都没锁住
            throw new NoStockException(skuId);
        }
    }
    // 全部锁定成功
    return true;
}

5. 商城业务 - 分布式事务

5.1 本地事务在分布式下的问题

在这里插入图片描述

业务描述:创建好订单后,需要进行下单,下单完成后远程调用gulimall-ware库存服务,远程锁库存,库存锁定成功后又需要远程调用gulimall-member服务,远程扣减积分,而且整个过程是处在本地事务@Transactional中的,那么这样会出现怎么样的问题?本地事务能解决什么问题??

@Transactional
@Override
public SubmitOrderResponseVo submitOrder(OrderSubmitVo vo) {
    // 省略。。。。。。。                        
    if(Math.abs(payAmount.subtract(payPrice).doubleValue())<0.01){
        //金额对比成功
        // 1、保存订单
        saveOrder(order);
        //省略 。。。。。。。。
        // 2、远程锁库存
        R r = wmsFeignService.orderLockStock(wareSkuLockVo);
        if(r.getCode()==0){
            //锁定成功
            submitOrderResponseVo.setOrder(order.getOrder());
            // 3、远程扣减积分
            int i = 10/0;
            return submitOrderResponseVo;
        }else{
            //锁定失败
            String msg = (String)r.get("msg");
            throw new NoStockException(msg);
        }
    }else{
        submitOrderResponseVo.setCode(1);
        return submitOrderResponseVo;
    }
}

① 订单服务异常,库存锁定不运行,全部回滚,撤销操作

② 库存服务处异常,库存服务事务自治,锁定失败全部回滚,订单感受到,继续回滚

问题:

③ 库存服务锁定成功了,但是网络原因返回数据途中出现问题,远程调用超时抛出异常,因此订单回滚,那么就会出现一个问题,库存扣除成功,但是订单没有下单成功。

④ 库存服务锁定成功了,库存服务下面的逻辑(远程扣积分)发生故障,订单回滚了,怎么处理 ?

订单服务连接的是订单数据库,这是一个连接,库存服务连接的是库存数据库,这是一个新的连接,会员服务链接的是会员数据库,这也是一个新的连接。远程调用实际上是一个新的连接,会员服务发生异常,库存服务是感知不到的,已经执行成功的请求是不能回滚的。

远程服务假失败:远程服务其实成功了,由于网络故障等没有返回,导致:订单回滚,库存却扣减
远程服务执行完成,下面的其他方法出现问题,导致:已执行的远程请求,肯定不能回滚

本地事务只能控制住在同一个连接中的异常,在分布式系统中,A服务远程调用B服务,B服务远程调用C服务,C服务远程调用D服务,任何一个远程服务出现问题,已经成功执行的远程服务没办法通过Transactional来实现事务的回滚,除非这几个服务不是远程服务,操作的是同一个数据库,在同一个连接内。

本地事务在分布式系统下,只能控制住自己数据库的回滚,控制不了其他服务的数据库的回滚。分布式事务的问题:网络问题+分布式机器(数据库不是同一个)。

5.2 本地事务

① 数据库事务的几个特性: 原子性 、 一致性 、 隔离性和持久性, 简称就是 ACID;

原子性: 一系列的操作整体不可拆分, 要么同时成功, 要么同时失败
一致性: 数据在事务的前后, 业务整体一致。
隔离性: 事务之间互相隔离。
持久性: 一旦事务成功, 数据一定会落盘在数据库

② 在以往的单体应用中, 我们多个业务操作使用同一条连接操作不同的数据表, 一旦有异常,我们可以很容易的整体回滚 :
在这里插入图片描述

比如买东西业务, 扣库存, 下订单, 账户扣款, 是一个整体; 必须同时成功或者失败,一个事务开始, 代表以下的所有操作都在同一个连接里面;

Business: 我们具体的业务代码

Storage: 库存业务代码; 扣库存、Order: 订单业务代码; 保存订单,Account: 账号业务代码; 减账户余额

③ 事务的隔离级别 :

READ UNCOMMITTED(读未提交)
该隔离级别的事务会读到其它未提交事务的数据, 此现象也称之为脏读。

READ COMMITTED( 读提交)
一个事务可以读取另一个已提交的事务, 多次读取会造成不一样的结果, 此现象称为不可重复读问题, Oracle 和 SQL Server 的默认隔离级别。

REPEATABLE READ( 可重复读)
该隔离级别是 MySQL 默认的隔离级别, 在同一个事务里, select 的结果是事务开始时时间点的状态, 因此, 同样的 select 操作读到的结果会是一致的, 但是, 会有幻读现象。 MySQL的 InnoDB 引擎可以通过 next-key locks 机制( 参考下文"行锁的算法"一节) 来避免幻读。

SERIALIZABLE( 序列化)
在该隔离级别下事务都是串行顺序执行的, MySQL 数据库的 InnoDB 引擎会给读操作隐式加一把读共享锁, 从而避免了脏读、 不可重读复读和幻读问题

④ 事务的传播行为:

1、 PROPAGATION_REQUIRED: 如果当前没有事务, 就创建一个新事务, 如果当前存在事务,就加入该事务, 该设置是最常用的设置。
2、 PROPAGATION_SUPPORTS: 支持当前事务, 如果当前存在事务, 就加入该事务, 如果当前不存在事务, 就以非事务执行。
3、 PROPAGATION_MANDATORY: 支持当前事务, 如果当前存在事务, 就加入该事务, 如果当前不存在事务, 就抛出异常。
4、 PROPAGATION_REQUIRES_NEW: 创建新事务, 无论当前存不存在事务, 都创建新事务。
5、 PROPAGATION_NOT_SUPPORTED: 以非事务方式执行操作, 如果当前存在事务, 就把当前事务挂起。
6、 PROPAGATION_NEVER: 以非事务方式执行, 如果当前存在事务, 则抛出异常。
7、 PROPAGATION_NESTED: 如果当前存在事务, 则在嵌套事务内执行。 如果当前没有事务,则执行与 PROPAGATION_REQUIRED 类似的操作。

@Transactional
public void a(){ // a事务的所有设置就会传播到和它共用一个事务的方法
    //在同一个类里面, 编写两个方法, 内部调用的时候, 会导致事务设置失效。 原因是没有用到代理对象的缘故。
    //事务设置失效,同一个对象内事务方法互相调用,原因是绕过了代理对象,事务是使用代理对象来控制的
    b();
    c();
    
    //事务设置不会失效,会使用代理对象
    aService.b(); // 和a用同一个事务,如果a事务出现异常回滚,b事务也回滚
    cService.c(); // 新事务,如果a事务回滚,c事务并不会回滚
}

@Transactional(propagation = Propagation.REQUIRED)
public void b(){
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void c(){
}

5.3 分布式事务理论

1、为什么有分布式事务 ?

分布式系统经常出现的异常:机器宕机、 网络异常、 消息丢失、 消息乱序、 数据错误、 不可靠的 TCP、 存储数据丢失 。。。。。由于以上问题都会导致分布式系统下,某一个服务的状态不能被其他服务感知到。

分布式事务是企业集成中的一个技术难点, 也是每一个分布式系统架构中都会涉及到的一个东西, 特别是在微服务架构中, 几乎可以说是无法避免。

在这里插入图片描述

2、CAP 定理

CAP 原则又称 CAP 定理, 指的是在一个分布式系统中

一致性 :在分布式系统中的所有数据备份, 在同一时刻是否都有同样的值。
可用性 :在集群中一部分节点故障后, 集群整体是否还能响应客户端的读写请求。
分区容错性 :大多数分布式系统都分布在多个子网络。 每个子网络就叫做一个区 。分区容错的意思是, 区间通信可能失败。 比如, 一台服务器放在中国, 另一台服务器放在美国, 这就是两个区, 它们之间可能无法通信。

CAP 原则指的是, 这三个要素最多只能同时实现两点, 不可能三者兼顾。

在这里插入图片描述

一般来说, 分区容错无法避免, 因此CAP 的 P 总是成立。 CAP 定理告诉我们,剩下的 C 和 A 无法同时做到。

分布式系统中实现一致性的 raft 算法:http://thesecretlivesofdata.com/raft/ (领导选举,日志复制)

3、base理论

对于多数大型互联网应用的场景, 主机众多、 部署分散, 而且现在的集群规模越来越大, 所以节点故障、 网络故障是常态, 而且要保证服务可用性达到 99.99999%(N 个 9) , 即保证P 和 A, 舍弃 C。

是对 CAP 理论的延伸, 思想是即使无法做到强一致性(CAP 的一致性就是强一致性) , 但可以采用适当的采取弱一致性, 即最终一致性

① 基本可用(Basically Available):

基本可用是指分布式系统在出现故障的时候, 允许损失部分可用性(例如响应时间、功能上的可用性) , 允许损失部分可用性。 需要注意的是, 基本可用绝不等价于系统不可用。

响应时间上的损失: 正常情况下搜索引擎需要在 0.5 秒之内返回给用户相应的查询结果, 但由于出现故障(比如系统部分机房发生断电或断网故障) , 查询结果的响应时间增加到了 1~2 秒。

功能上的损失: 购物网站在购物高峰(如双十一) 时, 为了保护系统的稳定性,部分消费者可能会被引导到一个降级页面。

② 软状态( Soft State):

软状态是指允许系统存在中间状态, 而该中间状态不会影响系统整体可用性。 分布式存储中一般一份数据会有多个副本, 允许不同副本同步的延时就是软状态的体现。 mysql replication 的异步复制也是一种体现。

③ 最终一致性( Eventual Consistency):

最终一致性是指系统中的所有数据副本经过一定时间后, 最终能够达到一致的状态。 弱一致性和强一致性相反, 最终一致性是弱一致性的一种特殊情况

从客户端角度, 多进程并发访问时, 更新过的数据在不同进程如何获取的不同策略, 决定了不同的一致性。 对于关系型数据库, 要求更新过的数据能被后续的访问都能看到, 这是强一致性。 如果能容忍后续的部分或者全部访问不到, 则是弱一致性。 如果经过一段时间后要求能访问到更新后的数据, 则是最终一致性 。

5.4 分布式事务常见解决方案

1、2PC 模式 :

数据库支持的 2PC【 2 phase commit 二阶提交】 , 又叫做 XA Transactions。其中, XA 是一个两阶段提交协议, 该协议分为以下两个阶段:
第一阶段: 事务协调器要求每个涉及到事务的数据库预提交(precommit)此操作, 并反映是否可以提交。
第二阶段: 事务协调器要求每个数据库提交数据。
其中, 如果有任何一个数据库否决此次提交, 那么所有数据库都会被要求回滚它们在此事务中的那部分信息 。

在这里插入图片描述

XA 协议比较简单, 而且一旦商业数据库实现了 XA 协议, 使用分布式事务的成本也比较低。
XA 性能不理想, 特别是在交易下单链路, 往往并发量很高, XA 无法满足高并发场景
XA 目前在商业数据库支持的比较理想, 在 mysql 数据库中支持的不太理想, mysql 的
XA 实现, 没有记录 prepare 阶段日志, 主备切换回导致主库与备库数据不一致。
许多 nosql 也没有支持 XA, 这让 XA 的应用场景变得非常狭隘。

2、柔性事务-TCC 事务补偿型方案 :

刚性事务: 遵循 ACID 原则, 强一致性。
柔性事务: 遵循 BASE 理论, 最终一致性;
与刚性事务不同, 柔性事务允许一定时间内, 不同节点的数据不一致, 但要求最终一致。

在这里插入图片描述

一阶段 prepare 行为: 调用 自定义 的 prepare 逻辑。
二阶段 commit 行为: 调用 自定义 的 commit 逻辑。
二阶段 rollback 行为: 调用 自定义 的 rollback 逻辑。
所谓 TCC 模式, 是指支持把 自定义 的分支事务纳入到全局事务的管理中。

在这里插入图片描述

3、柔性事务-最大努力通知型方案 :

按规律进行通知, 不保证数据一定能通知成功, 但会提供可查询操作接口进行核对。 这种方案主要用在与第三方系统通讯时, 比如: 调用微信或支付宝支付后的支付结果通知。 这种方案也是结合 MQ 进行实现, 例如: 通过 MQ 发送 http 请求, 设置最大通知次数。 达到通知次数后即不再通知。

案例: 银行通知、 商户通知等( 各大交易业务平台间的商户通知: 多次通知、 查询校对、 对 账文件) , 支付宝的支付成功异步回调

4、柔性事务-可靠消息+最终一致性方案( 异步确保型):

实现: 业务处理服务在业务事务提交之前, 向实时消息服务请求发送消息, 实时消息服务只记录消息数据, 而不是真正的发送。 业务处理服务在业务事务提交之后, 向实时消息服务确认发送。 只有在得到确认发送指令后, 实时消息服务才会真正发送。

5.5 分布式事务Seata

seata使用的2PC模式。

TC负责协调全局、TM用来控制整个大的事务、每一个微服务中使用RM这个资源管理器来控制的

① TM(下单业务)首先会告诉TC,准备开启一个全局事务

② TM调用远程服务后,不论是成功还是失败,TC都知道

③ 假如一个小事务出现异常回滚了,那么之前成功的事务也要回滚

在这里插入图片描述

1、给每一个服务创建一个undo_log表:

CREATE TABLE `undo_log` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `branch_id` bigint(20) NOT NULL,
  `xid` varchar(100) NOT NULL,
  `context` varchar(128) NOT NULL,
  `rollback_info` longblob NOT NULL,
  `log_status` int(11) NOT NULL,
  `log_created` datetime NOT NULL,
  `log_modified` datetime NOT NULL,
  `ext` varchar(100) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

2、在gulimall-common中导入坐标依赖:

<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>

3、启动服务器:安装事务协调器

因为导入依赖的版本为seata-all:0.7.1 ,所以需要从https://github.com/seata/seata/releases下载v0.7.1服务器软件包,将其解压缩,并运行:

在这里插入图片描述

在registry.conf文件中指明seata配置中心地址为nacos :

registry {
  # file 、nacos 、eureka、redis、zk、consul、etcd3、sofa 
  type = "nacos"

  nacos {
    serverAddr = "localhost:8848"
    namespace = "public"
    cluster = "default"
  }
}

4、给分布式的大事务的入口标注全局事务注解@GlobalTransactional ,每个小事务使用@Transactional即可:

@GlobalTransactional 
@Transactional
@Override
public SubmitOrderResponseVo submitOrder(OrderSubmitVo vo) {
}

5、所有想要使用分布式事务的微服务(gulimall-order和gulimall-ware),都需要注入 DataSourceProxy代理自己的数据源,因为 Seata 通过代理数据源实现分支事务,如果没有注入,事务无法成功回滚

@Configuration
public class MySeataConfig {

    @Autowired
    DataSourceProperties dataSourceProperties;

    @Bean
    public DataSource dataSource(DataSourceProperties dataSourceProperties){
        HikariDataSource dataSource = dataSourceProperties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
        if (StringUtils.hasText(dataSourceProperties.getName())) {
            dataSource.setPoolName(dataSourceProperties.getName());
        }
        return new DataSourceProxy(dataSource);
    }
}

6、每个需要用分布式事务的微服务都必须导入file.conf和registry.conf ,(gulimall-order和gulimall-ware)且 file.conf 的 service.vgroup_mapping 配置必须和spring.application.name一致

因为每个服务默认会使用 ${spring.application.name}-fescar-service-group作为服务名注册到 Seata Server上,如果和file.conf中的配置不一致,会提示 no available server to connect错误

也可以通过配置 spring.cloud.alibaba.seata.tx-service-group修改后缀,但是必须和file.conf中的配置保持一致

service {
  # vgroup->rgroup
  # vgroup_mapping.my_test_tx_group = "default"
  vgroup_mapping.gulimall-order-fescar-service-group = "default"
}
service {
  #vgroup->rgroup
  vgroup_mapping.gulimall-ware-fescar-service-group  = "default"
}

7、启动gulimallorder服务报错 :Error creating bean with name ‘globalTransactionScanner’ defined in class path resource [com/alibaba/cloud/seata/GlobalTransactionAutoConfiguration.class]: Bean instantiation via factory method failed; nested exception is org.springframework.beans.BeanInstantiationException: Failed to instantiate [io.seata.spring.annotation.GlobalTransactionScanner]: Factory method ‘globalTransactionScanner’ threw exception; nested exception is java.lang.NoClassDefFoundError: io/netty/util/HashedWheelTimer

这个错误花费了很长时间才解决,最后发现是因为netty-all-4.1.53.Final.jar使用maven镜像下载不下来的原来,再加上开始做项目的时候,我也没有指定自己的maven版本,因此这里我将maven改成了本地的,并且重新设置了仓库地址:

在这里插入图片描述

如果seata依赖不识别,需要再pom文件中加入 :

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-alibaba-dependencies</artifactId>
            <version>2.1.0.RELEASE</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>Greenwich.SR5</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

如果我们将依赖放在gulimall-common服务中,那么再启动其他服务时就会报错:

Exception in thread “main” io.seata.common.exception.NotSupportYetException: not support register type: null
at io.seata.config.ConfigurationFactory.buildConfiguration(ConfigurationFactory.java:80)
at io.seata.config.ConfigurationFactory.getInstance(ConfigurationFactory.java:65)
at io.seata.server.metrics.MetricsManager.init(MetricsManager.java:49)
at io.seata.server.Server.main(Server.java:56)

原因是需要将两个配置文件依次放在这各个服务中,所以我们可将用到分布式事务的seata依赖放在该服务中,在gulimall-order和gulimall-ware服务中导入依赖:

<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-seata</artifactId>
    <exclusions>
        <exclusion>
            <groupId>io.seata</groupId>
            <artifactId>seata-all</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>io.seata</groupId>
    <artifactId>seata-all</artifactId>
    <version>0.7.1</version>
</dependency>

8、订单服务远程调用仓储服务,仓储服务远程调用扣减积分服务,其中扣减积分这模拟一个异常,如果没有加@GlobalTransactional,那么出现异常时订单会回滚,但是仓库锁定会锁定失败,如果加了@GlobalTransactional,仓储服务和订单服务都会回滚:

@GlobalTransactional
@Transactional
@Override
public SubmitOrderResponseVo submitOrder(OrderSubmitVo vo) {
    orderSubmitVoThreadLocal.set(vo);
    // 从session获取用户信息
    MemberRespVo memberRespVo = LoginUserInterceptor.loginUser.get();
    SubmitOrderResponseVo submitOrderResponseVo = new SubmitOrderResponseVo();
    submitOrderResponseVo.setCode(0);
    // 1、验证令牌【令牌的对比和删除必须保证原子性】
    // 0 令牌失败 - 1 删除成功
    // redis分布式锁的lura脚本: 获取指定的值,如果值不存在就返回0,存在就返回1,然后删除,删除成功就返回0,删除失败就返回1
    String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
    String orderToken = vo.getOrderToken();
    // 执行lura脚本:原子验证令牌
    Long result = redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class),
            Arrays.asList(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberRespVo.getId()), orderToken);
    if(result==0L){
        //令牌验证失败
        submitOrderResponseVo.setCode(1);
        return submitOrderResponseVo;
    }else{
        //令牌验证通过后
        // 1、创建订单
        OrderCreateTo order = createOrder();
        // 2、验价
        BigDecimal payAmount = order.getOrder().getPayAmount();
        BigDecimal payPrice = vo.getPayPrice();
        if(Math.abs(payAmount.subtract(payPrice).doubleValue())<0.01){
            //金额对比成功
            // 3、保存订单
            saveOrder(order);
            // 4、锁库存,只要有异常,回滚订单数据
            WareSkuLockVo wareSkuLockVo = new WareSkuLockVo();
            wareSkuLockVo.setOrderSn(order.getOrder().getOrderSn());
            List<OrderItemVo> locks = order.getOrderItems().stream().map(item -> {
                OrderItemVo orderItemVo = new OrderItemVo();
                orderItemVo.setSkuId(item.getSkuId());
                orderItemVo.setCount(item.getSkuQuantity());
                orderItemVo.setTitle(item.getSkuName());
                return orderItemVo;
            }).collect(Collectors.toList());
            wareSkuLockVo.setLocks(locks);

            // 5、远程锁库存
            R r = wmsFeignService.orderLockStock(wareSkuLockVo);
            if(r.getCode()==0){
                //锁定成功
                // 6、远程扣减积分
                int i=10/0;
                submitOrderResponseVo.setOrder(order.getOrder());
                return submitOrderResponseVo;
            }else{
                //锁定失败
                String msg = (String)r.get("msg");
                throw new NoStockException(msg);
            }
        }else{
            submitOrderResponseVo.setCode(1);
            return submitOrderResponseVo;
        }
    }
}

5.6 最终一致性库存解锁逻辑

问题 :Seata的分布式事务使用的AT模式 ,下订单是一个高并发的操作,不太适合seata分布式的分布式事务(使用了各种锁,效率太低)。

在高并发场景下,库存的回滚使用 :柔性事务-最大努力通知型方案 或 柔性事务-可靠消息+最终一致性方案( 异步确保型)

在这里插入图片描述

5.7 RabbitMQ延时队列

场景:比如未付款订单,超过一定时间后,系统自动取消订单并释放占有物品。
常用解决方案:spring的 schedule 定时任务轮询数据库
缺点:消耗系统内存、增加了数据库的压力、存在较大的时间误差
解决:rabbitmq的消息TTL和死信Exchange结合

在这里插入图片描述

消息的TTL就是消息的存活时间 ,RabbitMQ可以对队列和消息分别设置TTL。

  • 对队列设置就是队列没有消费者连着的保留时间,也可以对每一个单独的消息做单独的设置。超过了这个时间,我们认为这个消息就死了,称之为死信。

  • 如果队列设置了,消息也设置了,那么会取小的。所以一个消息如果被路由到不同的队列中,这个消息死亡的时间有可能不一样(不同的队列设置)。这里单讲单个消息的TTL,因为它才是实现延迟任务的关键。可以通过设置消息的expiration字段或者xmessage-ttl属性来设置时间,两者是一样的效果

  • 一个消息在满足如下条件下,会进死信路由,记住这里是路由而不是队列,一个路由可以对应很多队列:

    • 一个消息被Consumer拒收了,并且reject方法的参数里requeue是false。也就是说不会被再次放在队列里,被其他消费者使用。(basic.reject/ basic.nack)requeue=false
    • 上面的消息的TTL到了,消息过期了。
    • 队列的长度限制满了。排在前面的消息会被丢弃或者扔到死信路由上。
  • Dead Letter Exchange其实就是一种普通的exchange,和创建其他exchange没有两样。只是在某一个设置Dead Letter Exchange的队列中有消息过期了,会自动触发消息的转发,发送到Dead Letter Exchange中去。

  • 我们既可以控制消息在一段时间后变成死信,又可以控制变成死信的消息被路由到某一个指定的交换机,结合二者,其实就可以实现一个延时队列

  • 手动ack&异常消息统一放在一个队列处理建议的两种方式

    • catch异常后,手动发送到指定队列,然后使用channel给rabbitmq确认消息已消费
    • 给Queue绑定死信队列,使用nack(requque为false)确认消息消费失败
      在这里插入图片描述

延时队列实现方式1 :设置队列的过期时间

消息首先交给交换机,交换机按照路由键发给指定的队列,这个队列设置了过期时间,以及死信路由键,由于没有消费者这监听这个队列,当消息死了扔给指定的队列:

在这里插入图片描述

延时队列实现方式2 :设置消息的过期时间

发送消息的时候,单独为消息设置过期时间,消息经过交换机发给延时队列,由于没有消费者这监听这个队列,消息过期之后就会发给死信交换机,通过交换机发给指定的队列。

在这里插入图片描述

模拟关单简单方式 :

创建两个交换机 :user.order.delay.exchange 和 user.order.exchange ,这两个交换机各绑定了一个队列,其中死信队列:user.order.delay.queue是没有消费者监听的,user.order.queue是有消费者监听的,当订单服务创建一个订单后会将消息发送给user.order.delay.exchange交换机,这个交换机经过指定的路由键order_delay发给user.order.delay.queue队列,由于队列的过期时间x-message-ttl=60000,即为1分钟,当1分钟之后,队列就会过期变为死信,交给x-dead-letter-exchange: user.order.exchange ,通过路由键x-dead-letter-routing-key: order 发给指定的队列user.order.queue。

在这里插入图片描述

模拟关单升级方式 :

消息创建成功后按照order.create.order 找到对应的队列order.delay.queue ,这个队列是一个延时队列,设置了三个参数:x-dead-letter-exchange: order-event-exchange ,当队列中的消息经过x-message-ttl: 60000 时间后变成死信,然后通过x-dead-letter-routing-key: order.release.order找到这个队列order.release.order,将死信交给它 。
在这里插入图片描述

① 创建交换机,队列,以及绑定关系 :

@Configuration
public class MyMQConfig {
    //监听延时队列
    @RabbitListener(queues = "order.release.order.queue")
    public void listener(OrderEntity orderEntity, Channel channel, Message message) throws IOException {
        System.out.println("收到过期的订单消息,准备关闭订单"+orderEntity.getOrderSn());
        channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
    }

    @Bean
    public Queue orderDelayOrderQueue(){
        Map<String,Object> arguments = new HashMap<>();
        /**
         * x-dead-letter-exchange: order-event-exchange
         * x-dead-letter-routing-key: order.release.order
         * x-message-ttl: 60000
         */
        arguments.put("x-dead-letter-exchange","order-event-exchange");
        arguments.put("x-dead-letter-routing-key","order.release.order");
        arguments.put("x-message-ttl",60000);
        //String name, boolean durable, boolean exclusive, boolean autoDelete, Map<String, Object> arguments
        Queue queue = new Queue("order.delay.queue", true, false, false,arguments);
        return queue;
    }

    @Bean
    public Queue orderReleaseOrderQueue(){
        Queue queue = new Queue("order.release.order.queue", true, false, false);
        return queue;
    }

    @Bean
    public Exchange orderEventExchange(){
        //String name, boolean durable, boolean autoDelete, Map<String, Object> arguments
        return new TopicExchange("order-event-exchange",true,false);
    }

    @Bean
    public Binding orderCreateOrderBinding(){
        //String destination, DestinationType destinationType, String exchange, String routingKey,Map<String, Object> arguments
        return new Binding("order.delay.queue",
                Binding.DestinationType.QUEUE,
                "order-event-exchange",
                "order.create.order",
                null);
    }

    @Bean
    public Binding orderReleaseOrderBinding(){
        return new Binding("order.release.order.queue",
                Binding.DestinationType.QUEUE,
                "order-event-exchange",
                "order.release.order",
                null);
    }
}

② 创建订单并给消息队列发送消息:

@Controller
public class HelloController {
    @Autowired
    RabbitTemplate rabbitTemplate;

    @ResponseBody
    @GetMapping("/test/createOrder")
    public String createOrderTest(){
        //订单下单成功
        OrderEntity entity = new OrderEntity();
        entity.setOrderSn(UUID.randomUUID().toString());
        entity.setModifyTime(new Date());

        //给MQ发送消息。
        rabbitTemplate.convertAndSend("order-event-exchange","order.create.order",entity);
        return "ok";
    }
}

访问 :http://order.gulimall.com/test/createOrder,发现消息从消息队列order.create.order中到了消息队列order.release.order.queue中。

6. 商城业务 - 库存的解锁

6.1 创建路由交换机和队列

在这里插入图片描述

库存锁定成功后,会根据路由键stock.locked根据交换机stock-event-exchange找到队列stock.delay.queue,并将消息发送到消息队列,由于这个队列是延时队列,50min之后队列中的消息变成死信,然后按照路由键stock.release根据交换机stock-event-exchange找到队列stock.release.stock.queue,将消息发送到该队列,然后接下来的解锁库存服务就来处理stock.release.stock.queue中的消息,因为这个队列中的信息都是超时的死信。

① gulimall-ware服务整合rabbitmq :

在appiliation.properties中配置rabbitmq的参数 :

spring.rabbitmq.host=192.168.38.22
spring.rabbitmq.virtual-host=/
spring.rabbitmq.listener.simple.acknowledge-mode=manual
@EnableRabbit
@EnableFeignClients
@EnableTransactionManagement
@EnableDiscoveryClient
@SpringBootApplication
public class GulimallWareApplication {
    public static void main(String[] args) {
        SpringApplication.run(GulimallWareApplication.class, args);
    }
}

② 创建路由交换机和队列:

@Configuration
public class MyRabbitConfig {
    /**
     * 使用JSON序列化机制,进行消息转换
     */
    @Bean
    public MessageConverter messageConverter(){
        return new Jackson2JsonMessageConverter();
    }

    @Bean
    public Exchange stockEventExchange(){
        //String name, boolean durable, boolean autoDelete, Map<String, Object> arguments
        return  new TopicExchange("stock-event-exchange",true,false);
    }

    @Bean
    public Queue stockReleaseStockQueue(){
        //String name, boolean durable, boolean exclusive, boolean autoDelete, Map<String, Object> arguments
        return new Queue("stock.release.stock.queue",true,false,false);
    }

    @Bean
    public Queue stockDelayQueue(){
        /**
         * x-dead-letter-exchange: stock-event-exchange
         * x-dead-letter-routing-key: order.release.order
         * x-message-ttl: 60000
         */
        Map<String,Object> args = new HashMap<>();
        args.put("x-dead-letter-exchange","stock-event-exchange");
        args.put("x-dead-letter-routing-key","stock.release");
        // 2min
        args.put("x-message-ttl",120000);
        return new Queue("stock.delay.queue",true,false,false,args);
    }

    @Bean
    public Binding stockReleaseBinding(){
        /**
         * String destination, DestinationType destinationType, String exchange, 
         * String routingKey,Map<String, Object> arguments
         */
        return  new Binding("stock.release.stock.queue",
                Binding.DestinationType.QUEUE,
                "stock-event-exchange",
                "stock.release.#",
                null);
    }

    @Bean
    public Binding stockLockedBinding(){
        /**
         * String destination, DestinationType destinationType, String exchange, 
         * String routingKey,Map<String, Object> arguments
         */
        return  new Binding("stock.delay.queue",
                Binding.DestinationType.QUEUE,
                "stock-event-exchange",
                "stock.locked",
                null);
    }
}

启动服务gulimall-ware,从而可以看到rabbitmq中创建了交换机和队列 :

在这里插入图片描述

6.2 库存自动解锁

在这里插入图片描述

库存解锁的场景 :

  • 下订单成功,订单过期没有支付被系统自动取消,被用户手动取消,都要解锁库存
  • 下订单成功,库存锁定成功,但是接下来的业务调用失败,导致订单回滚,之前锁定的库存就要解锁

锁库存----》保存库存工作单----》判断库存是否锁定成功,如果锁定成功保存保存库存工作单详情,然后将库存锁定成功的消息发给消息队列-----》如果有一个没有锁定成功,就要将之前锁定成功的库存全部解锁

/**
 * 库存解锁的场景 :
 * 1、下订单成功,订单过期没有支付被系统自动取消,被用户手动取消,都要解锁库存
 * 2、下订单成功,库存锁定成功,但是接下来的业务调用失败,导致订单回滚,之前锁定的库存就要解锁
 */
@Transactional(rollbackFor = NoStockException.class)
@Override
public Boolean orderLockStock(WareSkuLockVo vo) {
    // 保存库存工作单的详情
    WareOrderTaskEntity wareOrderTaskEntity = new WareOrderTaskEntity();
    wareOrderTaskEntity.setOrderSn(vo.getOrderSn());
    wareOrderTaskService.save(wareOrderTaskEntity);

    // 1、按照下单的收获地址,找到一个就近仓库,锁定库存
    // 1、找到每个商品在哪个仓库都有库存
    List<OrderItemVo> orderItemVos = vo.getLocks();
    List<SkuWareHasStock> collect = orderItemVos.stream().map(item -> {
        SkuWareHasStock stock = new SkuWareHasStock();
        Long skuId = item.getSkuId();
        stock.setSkuId(skuId);
        stock.setNum(item.getCount());
        //查询这个商品在哪里有库存
        List<Long> wareIds = wareSkuDao.listWareIdHasSkuStock(skuId);
        stock.setWareId(wareIds);
        return stock;
    }).collect(Collectors.toList());

    Boolean allLock = true;
    //锁定库存
    for(SkuWareHasStock hasStock : collect){
        Boolean skuStocked = false;
        Long skuId = hasStock.getSkuId();
        List<Long> wareIds = hasStock.getWareId();
        if(wareIds==null || wareIds.size()==0){
            //没有任何仓库有这个商品的库存
            throw new NoStockException(skuId);
        }
        // 减库存
        for (Long wareId : wareIds) {
            //
            Long count = wareSkuDao.lockSkuStock(skuId, wareId, hasStock.getNum());
            if(count==1){
                //成功
                skuStocked=true;
                // 保存库存工作单详情
                WareOrderTaskDetailEntity wareOrderTaskDetailEntity = new WareOrderTaskDetailEntity();
                wareOrderTaskDetailEntity.setSkuId(skuId);
                wareOrderTaskDetailEntity.setSkuNum(hasStock.getNum());
                wareOrderTaskDetailEntity.setTaskId(wareOrderTaskEntity.getId());
                wareOrderTaskDetailEntity.setWareId(wareId);
                wareOrderTaskDetailEntity.setLockStatus(1);
                wareOrderTaskDetailService.save(wareOrderTaskDetailEntity);

                //将库存锁定成功的消息发给消息队列
                StockLockedTo stockLockedTo = new StockLockedTo();
                stockLockedTo.setId(wareOrderTaskEntity.getId());
                StockDetailTo stockDetailTo = new StockDetailTo();
                BeanUtils.copyProperties(wareOrderTaskDetailEntity,stockDetailTo);
                stockLockedTo.setDetail(stockDetailTo);
                rabbitTemplate.convertAndSend("stock-event-exchange","stock.locked",stockLockedTo);
                break;
            }else{
                //失败,当前仓库锁定失败,重试下一个仓库
            }
        }
        if(skuStocked==false){
            //当前商品所有仓库都没锁住
            throw new NoStockException(skuId);
        }
    }
    // 全部锁定成功
    return true;
}

在这里插入图片描述

/**
 * 下订单成功,库存锁定成功,但是接下来的业务调用失败,导致订单回滚,之前锁定的库存就要解锁
 * 下订单成功,库存锁定失败,导致订单回滚,之前锁定的库存就要解锁
*/
@Override
public void unlockStock(StockLockedTo stockLockedTo) {
    System.out.println("收到解锁库存的消息");
    StockDetailTo detail = stockLockedTo.getDetail();
    Long skuId =detail.getSkuId();
    Long detailId = detail.getId();
    /**
     * 去库存锁定工作单详情查询数据库关于这个订单的锁定库存信息
     *     如果有这个信息,说明这个商品的库存锁定成功了,由于其他业务的失败导致订单回滚了
     *     如果没有这个信息,说明库存锁定失败了,这个商品的库存锁定回滚了,就不需要解锁
    */
    WareOrderTaskDetailEntity wareOrderTaskDetailEntity = wareOrderTaskDetailService.getById(detailId);
    if(wareOrderTaskDetailEntity!=null){
        /**
          * 解锁:判断订单情况
          *    没有这个订单,必须解锁
          *    有这个订单,判断订单状态:
          *          订单状态为已取消,解锁库存,
          *          没取消订单,不用解锁
        */
        // 库存工作单id
        Long id = stockLockedTo.getId();
        WareOrderTaskEntity wareOrderTaskEntity = wareOrderTaskService.getById(id);
        //订单号
        String orderSn = wareOrderTaskEntity.getOrderSn();
        //根据订单号查询订单的状态
        R r = orderFeignService.getOrderStatus(orderSn);
        if(r.getCode()==0){
            OrderVo data = r.getData(new TypeReference<OrderVo>() {});
            if(data==null || data.getStatus()==4){
                //订单不存在 或 订单已经被取消,解锁库存
                if(wareOrderTaskDetailEntity.getLockStatus()==1){
                    //当前库存工作单详情,状态1已锁定但是未解锁才可以解锁
                    unLockStock(skuId,detail.getWareId(),detail.getSkuNum(),detailId);
                }
            }
        }else{
            //消息拒绝之后重新放到队列,让别人继续消费解锁
            throw new RuntimeException("远程服务失败");
        }
    }else{
        //不需要解锁
    }
}

private void unLockStock(Long skuId,Long wareId,Integer num,Long taskDetailId) {
    wareSkuDao.unlockStock(skuId,wareId,num);
}

监听stock.release.stock.queue队列,对库存进行解锁:

@Service
@RabbitListener(queues = "stock.release.stock.queue")
public class StockReleaseListener {
    @Autowired
    WareSkuService wareSkuService;

    @RabbitHandler
    public void handleStockLockedRelease(StockLockedTo stockLockedTo, Message message, Channel channel) throws IOException {
        try {
            wareSkuService.unlockStock(stockLockedTo);
            channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
        } catch (Exception e) {
            //消息拒绝之后重新放到队列,让别人继续消费解锁
            channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
        }
    }
}

6.3 定时关单和手动库存解锁

订单创建成功后,如果30分钟没有支付,那么系统就要自动取消订单

首先订单创建成功后,先给交换机发送一个消息(创建成功的订单消息),交换机通过路由键将消息发送给延时队列,消息30min之后过期,过期的消息会通过交换机和路由键发送给队列order.release.order.queue,从而让没有支付的订单消息关闭。

在这里插入图片描述

① 订单创建成功后就会给order-event-exchange交换机发送消息 ,通过路由键发送给延时队列order.delay.queue,延时队列中的消息一旦过过期就会通过交换机的路由键发送给order.release.order.queue队列,从而实现关闭订单。

@Transactional
@Override
public SubmitOrderResponseVo submitOrder(OrderSubmitVo vo) {
    orderSubmitVoThreadLocal.set(vo);
    // 从session获取用户信息
    MemberRespVo memberRespVo = LoginUserInterceptor.loginUser.get();
    SubmitOrderResponseVo submitOrderResponseVo = new SubmitOrderResponseVo();
    submitOrderResponseVo.setCode(0);
    String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
    String orderToken = vo.getOrderToken();
    // 执行lura脚本:原子验证令牌
    Long result = redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class),
            Arrays.asList(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberRespVo.getId()), orderToken);
    if(result==0L){
        //令牌验证失败
        submitOrderResponseVo.setCode(1);
        return submitOrderResponseVo;
    }else{
        //令牌验证通过后
        // 1、创建订单
        OrderCreateTo order = createOrder();
        // 2、验价
        BigDecimal payAmount = order.getOrder().getPayAmount();
        BigDecimal payPrice = vo.getPayPrice();
        if(Math.abs(payAmount.subtract(payPrice).doubleValue())<0.01){
            //金额对比成功
            // 3、保存订单
            saveOrder(order);
            // 4、锁库存,只要有异常,回滚订单数据
            WareSkuLockVo wareSkuLockVo = new WareSkuLockVo();
            wareSkuLockVo.setOrderSn(order.getOrder().getOrderSn());
            List<OrderItemVo> locks = order.getOrderItems().stream().map(item -> {
                OrderItemVo orderItemVo = new OrderItemVo();
                orderItemVo.setSkuId(item.getSkuId());
                orderItemVo.setCount(item.getSkuQuantity());
                orderItemVo.setTitle(item.getSkuName());
                return orderItemVo;
            }).collect(Collectors.toList());
            wareSkuLockVo.setLocks(locks);

            // 5、远程锁库存
            R r = wmsFeignService.orderLockStock(wareSkuLockVo);
            if(r.getCode()==0){
                //锁定成功
                //订单创建成功,发送消息给消息队列
                rabbitTemplate.convertAndSend("order-event-exchange","order.create.order",order.getOrder());
                submitOrderResponseVo.setOrder(order.getOrder());
                return submitOrderResponseVo;
            }else{
                //锁定失败
                String msg = (String)r.get("msg");
                throw new NoStockException(msg);
            }
        }else{
            submitOrderResponseVo.setCode(1);
            return submitOrderResponseVo;
        }
    }
}

② 监听@RabbitListener(queues = “order.release.order.queue”)这个队列中的消息,实现关闭订单功能 :

@RabbitListener(queues = "order.release.order.queue")
@Service
public class OrderCloseListener {

    @Autowired
    OrderService orderService;

    @RabbitHandler
    public void listener(OrderEntity orderEntity,Channel channel,Message message) throws IOException {
        System.out.println("收到过期的订单消息:准备关闭订单"+orderEntity.getOrderSn());
        try {
            orderService.closeOrder(orderEntity);
            channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
        } catch (IOException e) {
            channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
        }
    }
}
//关闭订单即修改订单的状态
@Override
public void closeOrder(OrderEntity entity) {
    //查询当前这个订单的最新状态
    OrderEntity orderEntity = getById(entity.getId());
    if(orderEntity.getStatus()==OrderStatusEnum.CREATE_NEW.getCode()){
        OrderEntity update = new OrderEntity();
        update.setId(entity.getId());
        update.setStatus(OrderStatusEnum.CANCLED.getCode());
        this.updateById(update);
    }
}

③ 库存解锁在订单解锁之后,只要订单解锁成功了,那么库存解锁时看订单已经关单了,库存就自动解锁了:
在这里插入图片描述

@Service
@RabbitListener(queues = "stock.release.stock.queue")
public class StockReleaseListener {
    @Autowired
    WareSkuService wareSkuService;

    @RabbitHandler
    public void handleStockLockedRelease(StockLockedTo stockLockedTo, Message message, Channel channel) throws IOException {
        try {
            wareSkuService.unlockStock(stockLockedTo);
            channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
        } catch (Exception e) {
            channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
        }
    }
}
/**
 * 下订单成功,库存锁定成功,但是接下来的业务调用失败,导致订单回滚,之前锁定的库存就要解锁
 * 下订单成功,库存锁定失败,导致订单回滚,之前锁定的库存就要解锁
 */
@Override
public void unlockStock(StockLockedTo stockLockedTo) {
    System.out.println("收到解锁库存的消息");
    StockDetailTo detail = stockLockedTo.getDetail();
    Long skuId =detail.getSkuId();
    Long detailId = detail.getId();
    /**
     * 去库存锁定工作单详情查询数据库关于这个订单的锁定库存信息
     *     如果有这个信息,说明这个商品的库存锁定成功了,由于其他业务的失败导致订单回滚了
     *     如果没有这个信息,说明库存锁定失败了,这个商品的库存锁定回滚了,就不需要解锁
     */
    WareOrderTaskDetailEntity wareOrderTaskDetailEntity = wareOrderTaskDetailService.getById(detailId);
    if(wareOrderTaskDetailEntity!=null){
        /**
         * 解锁:判断订单情况
         *    没有这个订单,必须解锁
         *    有这个订单,判断订单状态:
         *          订单状态为已取消,解锁库存,
         *          没取消订单,不用解锁
         */
        // 库存工作单id
        Long id = stockLockedTo.getId();
        WareOrderTaskEntity wareOrderTaskEntity = wareOrderTaskService.getById(id);
        //订单号
        String orderSn = wareOrderTaskEntity.getOrderSn();
        //根据订单号查询订单的状态
        R r = orderFeignService.getOrderStatus(orderSn);
        if(r.getCode()==0){
            OrderVo data = r.getData(new TypeReference<OrderVo>() {});
            if(data==null || data.getStatus()==4){
                //订单不存在 或 订单已经被取消,解锁库存
                if(wareOrderTaskDetailEntity.getLockStatus()==1){
                    //当前库存工作单详情,状态1已锁定但是未解锁才可以解锁
                    unLockStock(skuId,detail.getWareId(),detail.getSkuNum(),detailId);
                }
            }
        }else{
            //消息拒绝之后重新放到队列,让别人继续消费解锁
            throw new RuntimeException("远程服务失败");
        }
    }else{
        //不需要解锁
    }
}

④ 但是还有一种情况,如果订单创建成功后由于机器卡顿,消息延迟等原因,订单还没解锁,库存解锁就先执行了,那么库存就没办法解锁了,因为已经解锁一次了,就不会走解锁逻辑了:

在这里插入图片描述

解决方法:除了订单创建完成后等待它自动解锁库存之外,我们在订单解锁成功后也应该主动的发送一个消息到交换机,交换机通过路由键order.release.other会将消息发送给stock.release.stock.queue队列,从而实现手动解锁库存

在这里插入图片描述

添加一个绑定关系 :

@Configuration
public class MyMQConfig {

    /**
     * 订单释放直接和库存释放进行绑定
     * @return
     */
    @Bean
    public Binding orderReleaseOtherBingding() {
        return new Binding("stock.release.stock.queue",
                Binding.DestinationType.QUEUE,
                "order-event-exchange",
                "order.release.other.#",
                null);
    }
}

订单解锁成功后给交换机发送一个消息,交换机通过路由键将消息发送给stock.release.stock.queue队列,监听这个队列实现库存的解锁:

//关闭订单即修改订单的状态
@Override
public void closeOrder(OrderEntity entity) {
    //查询当前这个订单的最新状态
    OrderEntity orderEntity = getById(entity.getId());
    if(orderEntity.getStatus()==OrderStatusEnum.CREATE_NEW.getCode()){
        OrderEntity update = new OrderEntity();
        update.setId(entity.getId());
        update.setStatus(OrderStatusEnum.CANCLED.getCode());
        this.updateById(update);
    }
    //发送给MQ一个
    rabbitTemplate.convertAndSend("order-event-exchange", "order.release.other", orderEntity);
}
@RabbitHandler
public void handleOrderCloseRelease(OrderTo orderTo, Message message, Channel channel) throws IOException {
    System.out.println("订单关闭准备解锁库存...");
    try{
        wareSkuService.unlockStock(orderTo);
        channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
    }catch (Exception e){
        channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
    }
}
//防止订单服务卡顿,导致订单状态消息一直改不了,库存消息优先到期。查订单状态新建状态,什么都不做就走了。
//导致卡顿的订单,永远不能解锁库存
@Transactional
@Override
public void unlockStock(OrderTo orderTo) {
    String orderSn = orderTo.getOrderSn();
    //查一下最新库存的状态,防止重复解锁库存
    WareOrderTaskEntity task = orderTaskService.getOrderTaskByOrderSn(orderSn);
    Long id = task.getId();
    //按照工作单找到所有 没有解锁的库存,进行解锁
    List<WareOrderTaskDetailEntity> entities = orderTaskDetailService.list(
            new QueryWrapper<WareOrderTaskDetailEntity>()
                    .eq("task_id", id)
                    .eq("lock_status", 1));

    //Long skuId, Long wareId, Integer num, Long taskDetailId
    for (WareOrderTaskDetailEntity entity : entities) {
        unLockStock(entity.getSkuId(),entity.getWareId(),entity.getSkuNum(),entity.getId());
    }
}

6.4 消息丢失、积压、重复等问题

1、如何保证消息可靠性-消息丢失

  • 消息发送出去,由于网络问题没有抵达服务器

    • 做好容错方法(try-catch),发送消息可能会网络失败,失败后要有重试机制,可记录到数据库,采用定期扫描重发的方式

      //关闭订单即修改订单的状态
      @Override
      public void closeOrder(OrderEntity entity) {
          //查询当前这个订单的最新状态
          OrderEntity orderEntity = getById(entity.getId());
          if(orderEntity.getStatus()==OrderStatusEnum.CREATE_NEW.getCode()){
              OrderEntity update = new OrderEntity();
              update.setId(entity.getId());
              update.setStatus(OrderStatusEnum.CANCLED.getCode());
              this.updateById(update);
          }
          try{
              //TODO 保证消息一定发送出去,每一个消息都可以做好日志记录(给数据库保存每一个消息的详细信息)。
              //TODO 定期扫描数据库将失败的消息再发送一遍;
              rabbitTemplate.convertAndSend("order-event-exchange", "order.release.other", orderEntity);
          }catch (Exception e){
              //TODO 将没法送成功的消息进行重试发送。
          }
      }
      
    • 做好日志记录,每个消息状态是否都被服务器收到都应该记录

    • 做好定期重发,如果消息没有发送成功,定期去数据库扫描未成功的消息进行重发

  • 消息抵达Broker,Broker要将消息写入磁盘(持久化)才算成功。此时Broker尚未持久化完成,宕机。

    • publisher也必须加入确认回调机制,确认成功的消息,修改数据库消息状态。

      @Configuration
      public class MyRabbitConfig {
          @Autowired
          RabbitTemplate rabbitTemplate;
      
          /**
           * 定制RabbitTemplate
           * 1、服务器收到消息就回调
           *      1、spring.rabbitmq.publisher-confirms=true
           *      2、设置确认回调ConfirmCallback
           * 2、消息正确抵达队列进行回调
           *      1、 spring.rabbitmq.publisher-returns=true
           *          spring.rabbitmq.template.mandatory=true
           *      2、设置确认回调ReturnCallback
           *
           * 3、消费端确认(保证每个消息被正确消费,此时才可以broker删除这个消息)。
           *      spring.rabbitmq.listener.simple.acknowledge-mode=manual 手动签收
           *      1、默认是自动确认的,只要消息接收到,客户端会自动确认,服务端就会移除这个消息
           *          问题:
           *              我们收到很多消息,自动回复给服务器ack,只有一个消息处理成功,宕机了。就会发生消息丢失;
           *              消费者手动确认模式。只要我们没有明确告诉MQ,货物被签收。没有Ack,
           *                  消息就一直是unacked状态。即使Consumer宕机。消息不会丢失,会重新变为Ready,下一次有新的Consumer连接进来就发给他
           *      2、如何签收:
           *          channel.basicAck(deliveryTag,false);签收;业务成功完成就应该签收
           *          channel.basicNack(deliveryTag,false,true);拒签;业务失败,拒签
           */
          @PostConstruct // 配置文件MyRabbitConfig对象创建完后,再来执行这个方法
          public void initRabbitTemplate(){
              //设置确认回调 : 只要消息抵达Broker就ack=true
              rabbitTemplate.setConfirmCallback(new RabbitTemplate.ConfirmCallback() {
                  /**
                   * @param correlationData 当前消息的唯一关联数据
                   * @param ack 消息是否成功收到
                   * @param cause 失败的原因
                   */
                  @Override
                  public void confirm(CorrelationData correlationData, boolean ack, String cause) {
                      /**
                       * 1、做好消息确认机制(pulisher,consumer【手动ack】)
                       * 2、每一个发送的消息都在数据库做好记录。定期将失败的消息再次发送一遍
                       */
                      //服务器收到了;
                      //修改消息的状态
                      System.out.println("correlationData--》"+correlationData +"ack--》"+ack + "causer--》"+cause);
                  }
              });
      
              // 设置消息抵达队列的确认回调: 只有失败时才会调用这个消息
              rabbitTemplate.setReturnCallback(new RabbitTemplate.ReturnCallback() {
                  /**
                   * 只要消息没有投递给指定的队列,就触发这个失败回调
                   * @param message   投递失败的消息详细信息
                   * @param replyCode 回复的状态码
                   * @param replyText 回复的文本内容
                   * @param exchange  当时这个消息发给哪个交换机
                   * @param routingKey 当时这个消息用哪个路由键
                   */
                  @Override
                  public void returnedMessage(Message message, int replyCode, String replyText, String exchange, String routingKey) {
                      System.out.println("Fail Message["+message+"]-->replyCode["+replyCode+"]-->replyText["+replyText+"]-->exchange["+exchange+"]-->routingKey["+routingKey+"]");
                  }
              });
          }
      }
      
  • 自动ACK的状态下。消费者收到消息,但没来得及消息然后宕机

    • 一定开启手动ACK,消费成功才移除,失败或者没来得及处理就noAck并重新入队

2、消息重复

  • 消息消费成功,事务已经提交,ack时,机器宕机。导致没有ack成功,Broker的消息重新由unack变为ready,并发送给其他消费者。
  • 消息消费失败,由于重试机制,自动又将消息发送出去。
  • 成功消费,ack时宕机,消息由unack变为ready,Broker又重新发送。
    • 消费者的业务消费接口应该设计为幂等性的。比如扣库存有工作单的状态标志。
    • 使用防重表(redis/mysql),发送消息每一个都有业务的唯一标识,处理过就不用处理。
    • rabbitMQ的每一个消息都有redelivered字段,可以获取是否是被重新投递过来的,而不是第一次投递过来的。

3、消息积压

  • 消费者宕机积压
  • 消费者消费能力不足积压
  • 发送者发送流量太大
    • 上线更多的消费者,进行正常消费
    • 上线专门的队列消费服务,将消息先批量取出来,记录数据库,离线慢慢处理

7. 商城业务 - 支付

7.1 支付成功

支付宝开放平台:https://open.alipay.com/platform/home.htm

电脑网站支付文档; 下载 demo :https://opendocs.alipay.com/open/270/106291/

① 使用沙箱环境获得商户私钥和公钥以及支付宝公钥:

支付宝沙箱环境配置:https://openhome.alipay.com/platform/appDaily.htm?tab=info

在这里插入图片描述

https://opendocs.alipay.com/open/009zj5,下载密钥生成助手,生成商户的私钥和公钥:

在这里插入图片描述

将商户公钥交给支付宝:

在这里插入图片描述

从而就可以得到支付宝的公钥:

在这里插入图片描述

② 内网穿透,续断: www.zhexi.tech ,给localhost:8080主机和端口设置域名:

在这里插入图片描述

③ 整合支付功能:

<!--导入支付宝的SDK-->
<dependency>
    <groupId>com.alipay.sdk</groupId>
    <artifactId>alipay-sdk-java</artifactId>
    <version>4.9.28.ALL</version>
</dependency>

将支付页面在浏览器显示出来:

@Controller
public class PayWebController {
    @Autowired
    AlipayTemplate alipayTemplate;

    @Autowired
    OrderService orderService;
    /**
     * 1、将支付页让浏览器展示。
     * 2、支付成功以后,我们要跳到用户的订单列表页
     * @param orderSn
     * @return
     * @throws AlipayApiException
     */
    @ResponseBody
    @GetMapping(value = "/payOrder",produces = "text/html")
    public String payOrder(@RequestParam("orderSn") String orderSn) throws AlipayApiException {
        PayVo payVo = orderService.getOrderPay(orderSn);
        //调用支付宝的支付功能,返回的是一个页面。将此页面直接交给浏览器就行
        String pay = alipayTemplate.pay(payVo);
        //将支付页让浏览器展示。
        return pay;
    }
}
@Override
public PayVo getOrderPay(String orderSn) {
    PayVo payVo = new PayVo();
    //订单号
    OrderEntity order = this.getOrderByOrderSn(orderSn);
    //订单总金额
    BigDecimal bigDecimal = order.getPayAmount().setScale(2, BigDecimal.ROUND_UP);
    payVo.setTotal_amount(bigDecimal.toString());
    //对外交易号即订单号
    payVo.setOut_trade_no(order.getOrderSn());

    //将第一个订单项的订单名称作为主题
    // 所有订单项
    List<OrderItemEntity> order_sn = orderItemService.list(new QueryWrapper<OrderItemEntity>().eq("order_sn", orderSn));
    // 第一个订单项
    OrderItemEntity entity = order_sn.get(0);
    payVo.setSubject(entity.getSkuName());

    // 订单备注
    payVo.setBody(entity.getSkuAttrsVals());
    return payVo;
}

在这里插入图片描述

在这里插入图片描述

7.2 支付成功同步回调

① 支付成功后跳转到用户的订单列表页:

@ConfigurationProperties(prefix = "alipay")
@Component
@Data
public class AlipayTemplate {
    // 同步通知,支付成功,一般跳转到成功页,这里支付成功后跳转到会员订单列表页
    public static String return_url = "http://member.gulimall.com/memberOrder.html";
}
@Controller
public class MemberWebController {
    @GetMapping("/memberOrder.html")
    public String memberOrderPage(){

        //查询当前登录用户的所有订单列表数据
        return "orderList";
    }
}

② 在gulimall-member服务中配置登录拦截器和拦截路径:

@Component
public class LoginUserInterceptor implements HandlerInterceptor {

    public static ThreadLocal<MemberRespVo> loginUser = new ThreadLocal<>();

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        
        String uri = request.getRequestURI();
        boolean match = new AntPathMatcher().match("/member/**", uri);
        if(match){
            return true;
        }

        MemberRespVo memberRespVo =(MemberRespVo) request.getSession().getAttribute(AuthServerConstant.LOGIN_USER);
        if(memberRespVo!=null){
            // 登录了
            loginUser.set(memberRespVo);
            return true;
        }else{
            // 没登录就去登录
            request.getSession().setAttribute("msg","请先进行登录:");
            response.sendRedirect("http://auth.gulimall.com/login.html");
            return false;
        }
    }
}
@Configuration
public class MemberWebConfig implements WebMvcConfigurer {

    @Autowired
    LoginUserInterceptor loginUserInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(loginUserInterceptor).addPathPatterns("/**");
    }
}

③ 因为做了登录检查,需要配置SpringSession:

Spring Session的相关依赖和相关配置,并且在主配置类中开启SpringSession的相关功能:

spring.session.store-type=redis
spring.redis.host=192.168.38.22
@EnableRedisHttpSession
@Configuration
public class GulimallSessionConfig {
    //配置cookie的生效路径
    @Bean
    public CookieSerializer cookieSerializer(){
        DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
        cookieSerializer.setDomainName("gulimall.com");
        cookieSerializer.setCookieName("GULISESSION");
        return cookieSerializer;
    }

    //配置redis的序列化机制
    @Bean
    public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
        return new GenericJackson2JsonRedisSerializer();
    }
}
@EnableRedisHttpSession
@EnableFeignClients(basePackages = "com.atguigu.gulimall.member.feign")
@EnableDiscoveryClient
@SpringBootApplication
public class GulimallMemberApplication {
    public static void main(String[] args) {
        SpringApplication.run(GulimallMemberApplication.class, args);
    }
}

在这里插入图片描述

7.3 订单列表页渲染

① 在gulimall-order服务的OrderController类中分页查询当前登录用户的所有订单以及对应订单项:

/**
 * 分页查询当前登录用户的所有订单
 * @param params
 * @return
 */
@PostMapping("/listWithItem")
public R listWithItem(@RequestBody Map<String, Object> params){
    PageUtils page = orderService.queryPageWithItem(params);
    return R.ok().put("page", page);
}
@Override
public PageUtils queryPageWithItem(Map<String, Object> params) {
    MemberRespVo memberRespVo = LoginUserInterceptor.loginUser.get();
    // 查询当前用户的所有订单
    IPage<OrderEntity> page = this.page(
        new Query<OrderEntity>().getPage(params),
        new QueryWrapper<OrderEntity>().eq("member_id", memberRespVo.getId()).orderByDesc("id")
    );
    List<OrderEntity> order_sn = page.getRecords().stream().map(order -> {
        //当前订单的所有订单项
        List<OrderItemEntity> itemEntities = orderItemService.list(new QueryWrapper<OrderItemEntity>().eq("order_sn", order.getOrderSn()));
        order.setItemEntities(itemEntities);
        return order;
    }).collect(Collectors.toList());
    page.setRecords(order_sn);

    return new PageUtils(page);
}

② 在gulimall-member服务中编写远程调用gulimall-order服务的fiegn接口:

@FeignClient("gulimall-order")
public interface OrderFeignService {
    @PostMapping("/order/order/listWithItem")
    public R listWithItem(@RequestBody Map<String, Object> params);
}
@Controller
public class MemberWebController {

    @Autowired
    OrderFeignService orderFeignService;

    /**
     * 订单分页查询
     * @param pageNum
     * @param model
     * @return
     */
    @GetMapping("/memberOrder.html")
    public String memberOrderPage(@RequestParam(value = "pageNum",defaultValue = "1") Integer pageNum,
                                  Model model){
        // 获取到支付宝给我们传来的所有请求数据;
        // request。验证签名,如果正确可以去修改。

        //查出当前登录的用户的所有订单列表数据
        Map<String,Object> page =new HashMap<>();
        page.put("page",pageNum.toString());
        R r = orderFeignService.listWithItem(page);
        model.addAttribute("orders",r);
        return "orderList";
    }
}

7.4 异步通知

对于 PC 网站支付的交易,在用户支付完成之后,支付宝会根据 API 中商户传入的 notify_url,通过 POST 请求的形式将支付结果作为参数通知到商户系统。

@ConfigurationProperties(prefix = "alipay")
@Component
@Data
public class AlipayTemplate {
    // 支付宝会悄悄的给我们发送一个请求,告诉我们支付成功的信息
    // 服务器异步通知页面路径
    public static String notify_url = "http://hqqxjyc0q7.52http.tech/payed/notify";
}
@RestController
public class OrderPayedListener {
    @Autowired
    AlipayTemplate alipayTemplate;

    @Autowired
    OrderService orderService;
    /**
     * 支付宝成功异步通知
     * @param request
     * @return
     */
    @PostMapping("/payed/notify")
    public String handleAlipayed(PayAsyncVo vo, HttpServletRequest request) throws AlipayApiException, UnsupportedEncodingException {
        Map<String,String[]> map = request.getParameterMap();
        System.out.println(map);
        return "success";
    }
}

配置内网穿透主机和端口:

在这里插入图片描述

配置nginx:

server {
    listen       80;
    server_name  gulimall.com  *.gulimall.com hqqxjyc0q7.52http.tech;

    location /static {
        root    /usr/share/nginx/html;
    }

    location /payed/ {
        proxy_set_header Host order.gulimall.com;
        proxy_pass http://gulimall;
    }

    location / {
        proxy_set_header Host $host;
        proxy_pass http://gulimall;
    }
}

在这里插入图片描述

@RestController
public class OrderPayedListener {
    @Autowired
    AlipayTemplate alipayTemplate;

    @Autowired
    OrderService orderService;
    /**
     * 支付宝成功异步通知
     * @param request
     * @return
     */
    @PostMapping("/payed/notify")
    public String handleAlipayed(PayAsyncVo vo, HttpServletRequest request) throws AlipayApiException, UnsupportedEncodingException {
        //验签
        Map<String,String> params = new HashMap<String,String>();
        Map<String,String[]> requestParams = request.getParameterMap();
        for (Iterator<String> iter = requestParams.keySet().iterator(); iter.hasNext();) {
            String name = (String) iter.next();
            String[] values = (String[]) requestParams.get(name);
            String valueStr = "";
            for (int i = 0; i < values.length; i++) {
                valueStr = (i == values.length - 1) ? valueStr + values[i]
                        : valueStr + values[i] + ",";
            }
            // 乱码解决,这段代码在出现乱码时使用
			// valueStr = new String(valueStr.getBytes("ISO-8859-1"), "utf-8");
            params.put(name, valueStr);
        }

        boolean signVerified = AlipaySignature.rsaCheckV1(params, alipayTemplate.getAlipay_public_key(), alipayTemplate.getCharset(), alipayTemplate.getSign_type()); //调用SDK验证签名
        if(signVerified){
            System.out.println("签名验证成功...");
            String result = orderService.handlePayResult(vo);
            return result;
        }else {
            System.out.println("签名验证失败...");
            return "error";
        }
    }
}
/**
 * 处理支付宝的支付结果
 *
 * @param vo
 *
 * @return
 */
@Override
public String handlePayResult(PayAsyncVo vo) {
    //1、保存交易流水
    PaymentInfoEntity infoEntity = new PaymentInfoEntity();
    infoEntity.setAlipayTradeNo(vo.getTrade_no());
    infoEntity.setOrderSn(vo.getOut_trade_no());
    infoEntity.setPaymentStatus(vo.getTrade_status());
    infoEntity.setCallbackTime(vo.getNotify_time());
    paymentInfoService.save(infoEntity);

    //2、修改订单的状态信息
    if (vo.getTrade_status().equals("TRADE_SUCCESS") || vo.getTrade_status().equals("TRADE_FINISHED")) {
        //支付成功状态
        String outTradeNo = vo.getOut_trade_no();
        this.baseMapper.updateOrderStatus(outTradeNo,OrderStatusEnum.PAYED.getCode());
    }
    return "success";
}
已标记关键词 清除标记
相关推荐
©️2020 CSDN 皮肤主题: 酷酷鲨 设计师:CSDN官方博客 返回首页