ThreadLocal这么用,老大直呼委瑞古德

803 阅读6分钟

能有幸点进来看,想必大家对ThreadLocal都有一定的了解,那么关于它究竟是个什么东东,我就不照本宣科了,在这篇文章里,我主要分享ThreadLocal相关的应用,作为开发2C项目的后端同学可能都需要考虑到一些公共参数的传递和使用,比如客户端IP、APK版本、渠道Code、用户账号等信息,最挫的方式就是在各DTO对象中定义这些属性,但聪明的你,坑定不会这么做,至少做一个公共的Base类:

public class PublicParam { 
    /**用户ID*/ 
    private Long userId; 
    /**手机号*/ 
    private String phone; 
    /**MAC*/
    private String mac; 
    /**客户端SN序列号*/ 
    private String sn; 
    /**IP*/ 
    private String ip; 
    /**版本号*/ 
    private String version; 
    /**客户端类型:IOS ANDROID*/ 
    private String client; 
}

然后再需要传递这些参数的时候使用 extands 继承一下,比如一个电商系统,创建购物车、订单DTO对象,示例就可以这样子写啦:
购物车DTO创建对象:

public class CreateCartDTO extends PublicParam { 
    /**产品ID*/ 
    private Long productId; 
    /**产品skuID*/ 
    private Long skuId;
    /**购买数量*/ 
    private Integer num = 1;
}

订单DTO创建对象:

public class CreateOrderDTO extends PublicParam{
/**产品ID*/
private Long productId;
/**产品skuID*/
private Long skuId;
/**购买数量 */
private Integer num = 1;
/**其它属性...*/
}

好的,这么做稍微会好一些,至少代码会少一点,然后我们就带上这些DTO参数对象开始在Controller、Service方法中穿梭,比如我要下单啦:

public boolean buyDirect(CreateOrderDTO orderDTO) {
    // 保存订单
    saveOrder(orderDTO.getUserId(), orderDTO.getProductId(), orderDTO.getSkuId());
    // 扣减库存
    ProductReduceDTO reduceDTO = new ProductReduceDTO(orderDTO.getProductId(), orderDTO.getSkuId(), orderDTO.getNum());
    reduceStock(reduceDTO);
    return false;
}

我们在保存订单和扣减库存操作后,尤其是失败后,一般需要日志打印一下结果,以便于线上问题定位等需要,比如想要把信息记录详细一些,如用户的IP,客户端版本version等。需要将参数reduceStock中将这些参数传递过去,或封装成一个新DTO对象,又或者直接是最开始的那个orderDTO对象,

boolean reduceStock(@RequestBody ProductReduceDTO reduceDTO) {
    try {
        log.info("-------- 用户下单扣减库存开始:");
        log.info("-------- userId: {}, ip: {}, client: {}, version: {}",
                reduceDTO.getUserId(), reduceDTO.getIp(),
                reduceDTO.getClient(), reduceDTO.getVersion());
        log.info("-------- 商品:{}, 总量减少:{}", reduceDTO.getProductId(), reduceDTO.getNum());
        log.info("-------- SKU:{}, 减少:{}", reduceDTO.getProductId(), reduceDTO.getNum());
        log.info("-------- 用户下单扣减库存成功!");
        return true;
    } catch (Exception e) {
        log.error("userId: {}, ip:{}, client:{}, version:{}, reduceDTO: {} 扣减失败!",
                reduceDTO.getUserId(),
                reduceDTO.getIp(),
                reduceDTO.getClient(),
                reduceDTO.getVersion(),
                JsonUtils.toJsonString(reduceDTO),
                e);
        return false;
    }
}

当然实际的业务场景远比上面描述的复杂,或者有较长的调用链路,各个方法参数封装到新的DTO对象都冗余定义公共参数。

5466db08a2cf653b74e8d35b1c95dcd.jpg

如果你也觉得不方便,那么可以继续往下面看了,嚯嚯。 基于Spring的Web应用,想必AOP的大家都不再陌生,在这主要使用AOP+ThreadLocal,便可实现公共参数的统一设置和回收,思想并不复杂, 由于我们并不知道系统中什么地方会用到这些公共参数,那么最好的办法就是都进行拦截设置, 定义公共参数存储对象定义ParamContext,用于存储ThreadLocal线程级的缓存信息:

public class ParamContext {
    public static final String USER_ID = "userId";
    public static final String IP = "ip";
    public static final String VERSION = "version";
    public static final String CLIENT = "client";
    /**
     * 参数缓存
     */
    private static final ThreadLocal<HashMap<String, Object>> cache =
    ThreadLocal.withInitial(HashMap::new);

    /**
     * 数据清理
     */
    public static void clean() {
        cache.remove();
    }
    public static Long getUserId() {
        return null != cache.get().get(USER_ID) ? Long.valueOf(toString(cache.get().get(USER_ID))) : null;
    }
    public static void setUserId(String userId) {
        if (!StringUtils.hasLength(userId)) return;
        cache.get().put(USER_ID, Long.valueOf(userId));
    }
    // ...... 其它属性设置和获取类似,省略
    private static String toString(Object o) {
        if (null == o) {
            return null;
        }
        return String.valueOf(o);
    }
}

定义参数拦截AOP切面类:

@Aspect
@Component
public class PublicParamAspect {
    //申明一个切点 里面是excution表达式
    @Pointcut("execution(* com.ddoubuy..*Controller.*(..))")
    private void paramPointCut() {
    }
    @Around(value = "paramPointCut()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        try {
            ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
            HttpServletRequest request = Objects.requireNonNull(attributes).getRequest();
            // 设置各参数到ThreadLocal的cache中
            ParamContext.setUserId(request.getHeader(USER_ID));
            ParamContext.setIp(request.getHeader(IP));
            ParamContext.setClient(request.getHeader(CLIENT));
            ParamContext.setVersion(request.getHeader(VERSION));
            return joinPoint.proceed();
        } finally {
            // very import,必须调用clean方法进行ThreadLocal变量的remove
            ParamContext.clean();
        }
    }
}

只需要在具体需要使用的地方,直接获取ParamContext缓存中设置的属性值即可,如上面的扣减库存日志,公共参数就不用通过reduceDTO传递啦:

boolean reduceStock(ProductReduceDTO reduceDTO) {
    try {
        log.info("-------- 用户下单扣减库存开始:");
        log.info("-------- userId: {}, ip: {}, client: {}, version: {}",
                ParamContext.getUserId(), ParamContext.getIp(),
                ParamContext.getClient(), ParamContext.getVersion());
        log.info("-------- 商品:{}, 总量减少:{}", reduceDTO.getProductId(), reduceDTO.getNum());
        log.info("-------- SKU:{}, 减少:{}", reduceDTO.getProductId(), reduceDTO.getNum());
        log.info("-------- 用户下单扣减库存成功!");
        return true;
    } catch (Exception e) {
        log.error("userId: {}, ip:{}, client:{}, version:{}, reduceDTO: {} 扣减失败!",
                ParamContext.getUserId(),
                ParamContext.getIp(),
                ParamContext.getClient(),
                ParamContext.getVersion(),
                JsonUtils.toJsonString(reduceDTO),
                e);
        return false;
    }
}

扣减库存对应的执行结果正常打印,表明AOP拦截设置的缓存读取成功:

 -------- 用户下单扣减库存开始:
 -------- userId: 1, ip: 1.1.1.1, client: IOS, version: 1.0.1
 -------- 商品:1, 总量减少:1
 -------- SKU:1, 减少:1
 -------- 用户下单扣减库存成功!

上面的demo只是简单示例,实际项目中用起来其实挺爽的,没试过的小伙伴可以实践一下,不过一定要注意做好ThreadLocal变量的remove,尽量避免所谓的内存泄漏问题。

那么,好了,本期的内容继续.....

c1abc6a0cfd0b7d221a4bceb48ff203.jpg

到这儿,你可能就要吐槽了,单机版应用,没毛病,可以这么玩,但都2022年了,谁还不玩微服务、分布式,你这套行?比如微服务A调用微服务B,B调用C,后面的服务怎么使用这些公共变量呢?

为了装13,我花费了至少10根头发的代价,搭建了一套基于SpringCloud Alibaba微服务调用Demo来演示一波。 使用技术说明,实际工作中也是基于这一套技术,实践过的小伙伴得心应手了,没实践过的小伙伴了噶了噶

使用技术简介
SpringCloud Alibaba是阿里巴巴提供的微服务开发一站式解决方案,是阿里巴巴开源中间件与 Spring Cloud 体系的融合。
Spring Cloud Gateway网关
Nacos服务注册、发现和配置中心
OpenFeignRest方式服务调用其它微服务应用,自带负载能力

贴一下项目的目录:

微信图片编辑_20220529000915.jpg

在gateway网关自定义一个全局过滤器RequestFilter,主要解析参数请求body体中的参数,并设置到request的header中,便于在参数在各个微服务组件中传递:

@Slf4j
@Configuration
public class RequestFilter implements GlobalFilter, Ordered {
    private static final List<HttpMethod> bodyMethods = Arrays.asList(HttpMethod.POST, HttpMethod.PUT, HttpMethod.PATCH);
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        // 无body体直接放行
        if (!bodyMethods.contains(request.getMethod())) {
            return chain.filter(exchange);
        }
        return DataBufferUtils.join(exchange.getRequest().getBody())
                .flatMap(dataBuffer -> {
                    byte[] bytes = new byte[dataBuffer.readableByteCount()];
                    dataBuffer.read(bytes);
                    String bodyString = new String(bytes, StandardCharsets.UTF_8);
                    PublicParam publicParam = JsonUtils.parseObject(bodyString, PublicParam.class);
                    // 五*,设置公共参数到header中方便传输
                    HttpHeaders httpHeaders = new HttpHeaders();
                    httpHeaders.addAll(request.getHeaders());
                    httpHeaders.add(PUBLIC_PARAM, JsonUtils.toJsonString(publicParam));
                    addHeader(httpHeaders, USER_ID, publicParam.getUserId());
                    addHeader(httpHeaders, IP, publicParam.getIp());
                    addHeader(httpHeaders, CLIENT, publicParam.getClient());
                    addHeader(httpHeaders, VERSION, publicParam.getVersion());
                    DataBufferUtils.release(dataBuffer);
                    Flux<DataBuffer> cachedFlux = Flux.defer(() -> {
                        DataBuffer buffer = exchange.getResponse().bufferFactory()
                                .wrap(bytes);
                        return Mono.just(buffer);
                    });
                    ServerHttpRequest mutatedRequest = new ServerHttpRequestDecorator(exchange.getRequest()) {
                        @Override
                        public HttpHeaders getHeaders() {
                            if (bytes.length > 0) {
                                httpHeaders.setContentLength(bytes.length);
                            } else {
                                httpHeaders.set(HttpHeaders.TRANSFER_ENCODING, "chunked");
                            }
                            return httpHeaders;
                        }

                        @Override
                        public Flux<DataBuffer> getBody() {
                            return cachedFlux;
                        }
                    };
                    return chain.filter(exchange.mutate().request(mutatedRequest)
                            .build());
                });
    }

    @Override
    public int getOrder() {
        return Integer.MIN_VALUE + 1;
    }

    private void addHeader(HttpHeaders httpHeaders, String key, Object val) {
        if (!StringUtils.hasLength(key) || null == val) {
            return;
        }
        httpHeaders.add(key, String.valueOf(val));
    }
}

我们使用OpenFeign提供的RequestInterceptor进行拦截,将需要的参数设置到Header中,传递给下游被调用的微服务:

@Component
public class FeignHeaderInterceptor implements RequestInterceptor {
    @Override
    public void apply(RequestTemplate requestTemplate) {
        // 所有的header都传递,建议根据自己的需求传递需要的参数
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        Enumeration<String> headerNames = request.getHeaderNames();
        if (headerNames != null) {
            while (headerNames.hasMoreElements()) {
                String name = headerNames.nextElement();
                // 跳过 content-length,否则会报java.io.IOException: Incomplete output stream
                if (name.equals("content-length")){
                    continue;
                }
                String values = request.getHeader(name);
                requestTemplate.header(name, values);
            }
        }
    }
}

然后在订单服务order-service中将扣减库存的拆分到商品服务product-service ,并通过OpenFeign方式进行调用:

1653754350(1).png

商品服务 product-service中提供对应的PRC接口,主要就是是扣减库存对应的逻辑:

1653754377(1).png

就这样,其它的不用做什么改动,我们就可以在各个微服务中传递这些公共参数,非常丝滑。 大家后面对ThreadLocal有什么高级应用和不同理解可以一起分享分享。

f11aa58bb13da9f64d1929806bb3b61.jpg