最近用了接近两个月的时间,完成了一个分布式微服务项目,本篇博文将会对项目中涉及到的重点技术进行总结。
项目中使用的一些中间件、服务软件,都是使用 docker 进行安装、配置的,如果不会 docker 安装和配置这些软件的可以参考下面这篇博文:
Docker 常用软件安装
在项目中使用到的技术:
其中一些网关、服务监控、服务熔断降级限流,这些用的都是 SpringBoot、SpringCloudAlibaba 中的技术,就不再一一列举了。
--------------------------------------分割线--------------------------------------
下面,我会以一个客户进入购物网站,从登录、检索商品、加入购物车、下订单、去支付、秒杀商品,这样的流程将各个部分所用到的关键技术进行讲解,并且每个流程会附有相应的业务逻辑图,便于了解整个电商项目的流程。
用户登录有比较重要的两点:
在项目中,博主使用的是微博的社交账号登录,具体可以参考微博OAuth2.0 文档使用:
微博OAuth2.0登录 - 使用接口深度开发,适合后端开发人员,在使用前需要创建一个应用项目,获取 App Key、App Secret。
社交登录大致流程如下:
使用 Access Token 通过微博API 获取用户信息:微博API
在用户登录之后,对于用户本机来说,无论访问网站的哪一个页面,都应该各个微服务都应该知道当前用户的登录信息。博主在项目中,使用SpringSession 来解决 Session 共享问题。
但是还有一个问题,将用户登录信息存储到 Session 中,Session 底层也是通过 Cookie 向服务器获取数据的,但是网站的不同服务,域名都不相同,为了解决这个问题,需要将保存用户信息的 Cookie 扩大域名范围到父域,这样就可以保证每个服务都可以获取到 Cookie。
使用 SpringSession 还是和 Session 一样,获取到 HttpSession 调用 session.setAttribute()
方法进行数据存储,但 SpringSession 会将数据存储到 Redis 中,保证了分布式服务可以共享 Session。
SpringSession 配置类:
@Configuration
public class RedisSessionConfig {
/**
* cooike 序列化器:自定义 cookie 作用域
*/
@Bean
public CookieSerializer cookieSerializer() {
DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
// 设置 cookie 的作用域,设置为项目的父域,所有域都可访问
cookieSerializer.setDomainName("mall.com");
return cookieSerializer;
}
/**
* Redis 序列化器:设置存入 Redis 中的序列化机制
*/
@Bean
public RedisSerializer<Object> redisSerializer() {
return new GenericJackson2JsonRedisSerializer();
}
}
检索商品主要就是对 ElasticSearch 的使用,项目中有以下两点:
将商品加入购物车有一个关注点:
为了解决这个业务问题,使用了两个购物车数据结构来分别将 临时购物车、用户购物车 存储到 Redis 中,并且在用户登录查看购物车时,会合并临时购物车的商品到用户购物车中。
解决方法:创建拦截器,对于每一个没有登录的用户,在使用购物车服务时,会分配一个 user-key
并保存到 Cookie 中,设置 Cookie 保存1个月。这样就可以保证1个月内,临时用户可以查看自己添加过的商品。
CartInterceptor 拦截器类:
public class CartInterceptor implements HandlerInterceptor {
public static ThreadLocal<UserInfoTo> threadLocal = new ThreadLocal<>();
/**
* 业务执行之前:检测用户登录状态,如果是临时用户分配一个 user-key
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
UserInfoTo userInfoTo = new UserInfoTo();
HttpSession session = request.getSession();
MemberRespVo member = (MemberRespVo) session.getAttribute(AuthServerConstant.LOGIN_USER);
// 1.用户登录:
if (member != null) {
userInfoTo.setId(member.getId());
}
// 2.临时用户:如果之前已经存在就从浏览器中获取 user-key
Cookie[] cookies = request.getCookies();
if (cookies != null && cookies.length > 0) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals(CartConstant.TEMP_USER_COOKIE_NAME)) {
userInfoTo.setUserKey(cookie.getValue());
userInfoTo.setTempUser(true); // 标记已经是临时用户,没有必要保存 cookie
}
}
}
// 3.临时用户:在第一次使用购物车时,自动生成一个 user-key,并会在 postHandle 中将 user-key 保存到浏览器的 cookie 中
if (StringUtils.isEmpty(userInfoTo.getUserKey())) {
String userKey = UUID.randomUUID().toString();
userInfoTo.setUserKey(userKey);
}
// 在方法执行之前,将用户的登录状态存入到 ThreadLocal 中,方便后面 controller 的执行
threadLocal.set(userInfoTo);
return true;
}
/**
* 业务执行之后:
* 如果第一次访问购物车,保存临时用户的 user-key 到浏览器 cookie 中,保证以后每次访问都会携带这个 user-key
*/
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
UserInfoTo userInfoTo = threadLocal.get();
// 如果是第一次使用购物车,才会在浏览器 cookie 中保存一个 user-key
if (!userInfoTo.isTempUser()) {
Cookie cookie = new Cookie(CartConstant.TEMP_USER_COOKIE_NAME, userInfoTo.getUserKey());
cookie.setDomain("mall.com");
cookie.setMaxAge(CartConstant.TEMP_USER_COOKIE_TIMEOUT); // 设置1个月的过期时间
response.addCookie(cookie);
}
}
}
在 Redis 保存每个购物车的商品信息时,使用的是 hash 类型,使用 hash 类型可以使查看购物车项、结算等业务逻辑都非常快速。
订单服务所涉及到的就是多服务的分布式事务问题,如何保证订单的创建、库存的扣减、订单的回滚、库存的回滚是下订单关注的问题:
博主在项目中的下订单以及库存扣减,都是采用 RabbitMQ 来保证订单服务与库存服务之间事务的一致性。
这里先插入一张图,可以简单了解一下 RabbitMQ 的工作流程:
在消息队列中,可以使用死信队列来控制订单的有效时间,如果订单在有效时间内没有完成支付,死信队列就会向库存服务发送解锁库存的消息,在库存服务手动将库存数量将会回滚。
因为只是一个简单的项目,所以我使用的是第三方支付宝沙箱环境进行的支付测试,不了解支付宝沙箱环境可以参考:沙箱环境
在支付过程中,支付宝使用了 RSA 非对称加密算法来保证支付过程的安全,对于 RSA 加密算法可以通过下面的图简单了解一下:
所以在使用支付接口时,我们需要给支付宝上传一个商户公钥(商户私钥自己安全保管),支付宝会给我们一个支付宝公钥。在支付过程中,我们使用商户私钥对数据进行加签加密,支付宝通过商户公钥进行验签解密,将响应再次通过支付宝私钥加签加密发送给我们,我们在使用支付宝公钥解签解密,才能获取到支付的响应结果。
调用支付宝支付的模板类:
@ConfigurationProperties(prefix = "alipay")
@Component
@Data
public class AlipayTemplate {
//在支付宝创建的应用的id
private String app_id = "2016102600763438";
// 商户私钥,您的PKCS8格式RSA2私钥
private String merchant_private_key = xxx;
// 支付宝公钥,查看地址:https://openhome.alipay.com/platform/keyManage.htm 对应APPID下的支付宝公钥。
private String alipay_public_key = xxx;
// 服务器[异步通知]页面路径 需http://格式的完整路径,不能加?id=123这类自定义参数,必须外网可以正常访问
// 支付宝会悄悄的给我们发送一个请求,告诉我们支付成功的信息
private String notify_url;
// 页面跳转同步通知页面路径 需http://格式的完整路径,不能加?id=123这类自定义参数,必须外网可以正常访问
//同步通知,支付成功,一般跳转到成功页
private String return_url;
// 签名方式
private String sign_type = "RSA2";
// 字符编码格式
private String charset = "utf-8";
// 订单超时时间
private String timeout = "30m";
// 支付宝网关; https://openapi.alipaydev.com/gateway.do
private String gatewayUrl = "https://openapi.alipaydev.com/gateway.do";
public String pay(PayVo vo) throws AlipayApiException {
//AlipayClient alipayClient = new DefaultAlipayClient(AlipayTemplate.gatewayUrl, AlipayTemplate.app_id, AlipayTemplate.merchant_private_key, "json", AlipayTemplate.charset, AlipayTemplate.alipay_public_key, AlipayTemplate.sign_type);
//1、根据支付宝的配置生成一个支付客户端
AlipayClient alipayClient = new DefaultAlipayClient(gatewayUrl,
app_id, merchant_private_key, "json",
charset, alipay_public_key, sign_type);
//2、创建一个支付请求 //设置请求参数
AlipayTradePagePayRequest alipayRequest = new AlipayTradePagePayRequest();
alipayRequest.setReturnUrl(return_url);
alipayRequest.setNotifyUrl(notify_url);
//商户订单号,商户网站订单系统中唯一订单号,必填
String out_trade_no = vo.getOut_trade_no();
//付款金额,必填
String total_amount = vo.getTotal_amount();
//订单名称,必填
String subject = vo.getSubject();
//商品描述,可空
String body = vo.getBody();
alipayRequest.setBizContent("{\"out_trade_no\":\"" + out_trade_no + "\","
+ "\"total_amount\":\"" + total_amount + "\","
+ "\"subject\":\"" + subject + "\","
+ "\"body\":\"" + body + "\","
+ "\"timeout_express\":\"" + timeout + "\","
+ "\"product_code\":\"FAST_INSTANT_TRADE_PAY\"}");
String result = alipayClient.pageExecute(alipayRequest).getBody();
//会收到支付宝的响应,响应的是一个页面,只要浏览器显示这个页面,就会自动来到支付宝的收银台页面
System.out.println("支付宝的响应:" + result);
return result;
}
}
如果支付完成,支付宝会有两个返回(直接返回、异步回调),直接返回跳转到用户的订单列表,而异步回调则可以对订单进行后续处理(解锁库存、修改订单状态等等),而且异步回调的速度快与直接返回。 使用异步回调需要先支付宝服务器返回一个 success,否则支付宝服务器会一直发送这个异步请求(具体参考:支付宝异步回调)。
处理异步回调方法:
/**
* 支付宝异步回调通知:https://opendocs.alipay.com/open/270/105902
*/
@PostMapping("/payed/notify")
public String handleAilpayed(HttpServletRequest request, PayAsyncVo vo) throws AlipayApiException, UnsupportedEncodingException {
System.out.println("进入支付异步回调...");
// 只要我们收到了支付宝给我们异步的通知,告诉我们订单支付成功,返回 success,支付宝不在通知
// 1.验签
// 获取支付宝POST过来反馈信息
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验证签名
// 2.签名验证成功,处理订单
if (signVerified) {
String result = orderService.handlePayResult(vo);
System.out.println("签名验证成功..");
return result;
}
return "error";
}
商品秒杀可以说是比较经典的高并发场景,如何让自己的应用承受百万级的并发是非常重要的,下面提出几个高并发场景需要注意的问题:
有了上面的这些方法,再加上一些集群的部署,处理百万并发也不是不可能。博主在项目中也是尽量满足了上面的部分条件:
还有一个需要注意的就是对于秒杀商品的上架,这里博主使用了 Spring 自带的定时任务注解@EnableScheduling @Scheduled(cron = "0 0 3 * * ?")
,定时任务在每天的3点进行秒杀商品上架,上架最近3天的秒杀场数据。
秒杀业务梳理:
在秒杀流程中,这些操作都没有数据库操作、服务调用等,每个步骤执行速度非常快,提高秒杀业务处理吞吐量。
问题原因:由于使用 Feign 进行远程调用时,会创建代理对象重新封装一个 RequestTemplate,但是这个请求里面并没有携带原 Request 的请求头 Cookie 信息,导致远程调用访问 Session 内容为空,导致服务之间无法获取到 Session 中存储的用户信息。
解决方法:在 Feign 的代理对象构建 RequestTemplate 过程中会提供一个 RequestInterceptor 拦截器处理,我们可以利用这个机制来对RequestTemplate 进行修改,将原 Request 请求中的 Cookie 信息,添加到新的 RequestTemplate 中。
Feign 的配置类:
@Configuration
public class MyFeignConfig {
@Bean
public RequestInterceptor requestInterceptor() {
return template -> {
// 1.使用 RequestContextHolder 拿到原请求
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
// 2.将原请求中的 Cookie 信息添加到新的 RequestTemplate 中
String cookie = request.getHeader("Cookie");
template.header("Cookie", cookie);
}
};
}
}
上面为了解决 OpenFeign 远程调用出现的消息头丢失问题,我们可以从RequestAttributes
中获取到原请求,将原请求的 Cookie 设置给 Feign RequestTemplate
,但是在异步线程 Feign 拦截器调用 RequestContextHolder.getRequestAttributes()
时,只是新线程的 RequestAttributes
,在新线程的 RequestAttributes
中并没有存储主线程的 Request 请求,所以在异步任务 Feign 调用时会出现上下文丢失的情况。
main -> ThreadLocal (RequestAttributes(main) -> "/toTrade"请求的相关信息)
main("/toTrade") --- confirmOrder() ------------------------
thread1 -> ThreadLocal (RequestAttributes(thread1) -> null)
thread1:addressFuture ----------
thread2 -> ThreadLocal (RequestAttributes(thread2) -> null)
thread2:cartItemsFuture --------
解决方法:在开启异步任务之前,将主线程的 RequestAttributes(main) 存储到异步线程的 RequestAttributes 中进行线程间共享,这样别的线程就可以通过主线程的 RequestAttributes 获取到请求上下文信息。
// 获取主前线程的 RequestAttributes
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
// 1.远程查询所有的收货列表
CompletableFuture<Void> addressFuture = CompletableFuture.runAsync(() -> {
// 将主线程的 RequestAttributes 设置到自己 ThreadLocal 中
RequestContextHolder.setRequestAttributes(requestAttributes);
List<MemberAddressVo> address = memberFeignService.getAddress(memberId);
confirmVo.setAddressVos(address);
}, executor);
花了2个月的时间完成一个分布式项目还是非常有意义的,在这个项目中学到了非常多的东西,也巩固了之前学过的东西,总的来说对自己帮助还是非常大的,继续淦!
项目 GitHub 仓库地址:
https://github.com/zk-kiger/Shopping-Mall