服务实现
容器拦截器链实现
容器的拦截器需要实现org.springframework.web.server.WebFilter
(WebFlux
的Filter
接口),主要有四个实现(顺序如下):
MappedDiagnosticContextFilter
:引入transmittable-thread-local
通过MDC
做TraceId
的请求上下文绑定,WebFlux
的线程模型和常见的Servlet
容器的线程模型不一样,这里不能直接使用ThreadLocal
或者Slf4j
中原有的MDC
实现BlockIpFilter
:判断客户端请求IP
是否命中黑名单AccessDomainFilter
:判断域名是否命中短链域名白名单(可选的,因为外部已经通过NGINX
做了一次拦截,这个实现是可有可无的)ExcludeUriFilter
:判断当前请求的URI
是否命中了URI
黑名单
这里简单展示一下MappedDiagnosticContextFilter
的实现:
@Order(value = Integer.MIN_VALUE) @Component public class MappedDiagnosticContextFilter implements WebFilter { @Override public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) { String uuid = UUID.randomUUID().toString(); MDC.put("TRACE_ID", uuid); return chain.filter(exchange).then(Mono.fromRunnable(() -> MDC.remove("TRACE_ID"))); } } 复制代码
上面的TRACE_ID
是配合项目的logback.xml
中的pattern
使用。另外需要参考https://github.com/alibaba/transmittable-thread-local/blob/master/docs/requirement-scenario.md
中logback
与transmittable-thread-local
做集成的场景:
这里为了方便管理和升级版本,笔者直接把logback-mdc-ttl
的源码实现改造好后放到项目中。
服务内部拦截器链实现
服务内部的拦截器链主要负责请求参数解析、URL
映射转换、重定向和访问转换结果记录,顶层接口设计如下:
public interface TransformFilter { default int order() { return 1; } default void init(TransformContext context) { } void doFilter(TransformFilterChain chain, TransformContext context); } 复制代码
TransformContext
是一个属性承载类,本质是一个普通的JavaBean
,设计如下:
目前内置了4
个拦截器实现,包括:
ExtractRequestHeaderTransformFilter
:请求头解析UrlTransformFilter
:URL
转换RedirectionTransformFilter
:重定向处理TransformEventProcessTransformFilter
:转换事件记录
以UrlTransformFilter
为例子,源码如下:
@Slf4j @Scope(scopeName = ConfigurableBeanFactory.SCOPE_PROTOTYPE) @Component public class UrlTransformFilter implements TransformFilter { @Autowired private UrlMapCacheManager urlMapCacheManager; @Override public int order() { return 2; } @Override public void init(TransformContext context) { } @Override public void doFilter(TransformFilterChain chain, TransformContext context) { String compressionCode = context.getCompressionCode(); UrlMap urlMap = urlMapCacheManager.loadUrlMapCacheByCompressCode(compressionCode); context.setTransformStatus(TransformStatus.TRANSFORM_FAIL); if (Objects.nonNull(urlMap)) { context.setTransformStatus(TransformStatus.TRANSFORM_SUCCESS); context.setParam(TransformContext.PARAM_LONG_URL_KEY, urlMap.getLongUrl()); context.setParam(TransformContext.PARAM_SHORT_URL_KEY, urlMap.getShortUrl()); chain.doFilter(context); } else { log.warn("压缩码[{}]不存在或异常,终止TransformFilterChain执行,并且重定向到404页面......", compressionCode); throw new RedirectToErrorPageException(String.format("[c:%s]", compressionCode)); } } } 复制代码
所有的服务内拦截器的scope
都是prototype
,意味着每次初始化拦截器链都会重新创建对应的Bean
。
主控制器实现
因为octopus
只做短链访问的入口,后台管理的功能交给另外的服务实现,此服务只有一个控制器,控制器里面只有一个方法:
@RequiredArgsConstructor @RestController public class OctopusController { private final UrlMapService urlMapService; @GetMapping(path = "/{compressionCode}") @ResponseStatus(HttpStatus.FOUND) public Mono<Void> dispatch(@PathVariable(name = "compressionCode") String compressionCode, ServerWebExchange exchange) { ServerHttpRequest request = exchange.getRequest(); TransformContext context = new TransformContext(); context.setCompressionCode(compressionCode); context.setParam(TransformContext.PARAM_SERVER_WEB_EXCHANGE_KEY, exchange); if (Objects.nonNull(request.getRemoteAddress())) { context.setParam(TransformContext.PARAM_REMOTE_HOST_NAME_KEY, request.getRemoteAddress().getHostName()); } HttpHeaders httpHeaders = request.getHeaders(); Set<String> headerNames = httpHeaders.keySet(); if (!CollectionUtils.isEmpty(headerNames)) { headerNames.forEach(headerName -> { String headerValue = httpHeaders.getFirst(headerName); context.setHeader(headerName, headerValue); }); } // 处理转换 urlMapService.processTransform(context); // 这里有一个技巧,flush用到的线程和内部逻辑处理的线程不是同一个线程,所以要用到TTL -- 和Servlet容器不一样,所以目前写的比较别扭 return Mono.fromRunnable(context.getRedirectAction()); } } 复制代码
这个主控制的分发压缩码方法只负责封装参数调用服务内部拦截器链进行后续的处理。然后添加一个全局的异常处理器,把所有的异常或者非法操作引导到一个自定义的404
页面(甚至可以在上面挂一点广告):
Dubbo契约实现
octopus-contract
是一个完全独立的模块,甚至可以说它是一个完全独立的项目,主要作用是提供契约API
,让其他服务引入,让octopus-server
模块进行实现。契约接口定义如下:
public interface OctopusApi { Response<CreateUrlMapResponse> createUrlMap(CreateUrlMapRequest request); } 复制代码
基于Dubbo
的实现如下:
@DubboService(retries = -1) public class DefaultOctopusApi implements OctopusApi { @Autowired private UrlMapService urlMapService; @Value("${default.octopus.domain}") private String domain; @Override public Response<CreateUrlMapResponse> createUrlMap(CreateUrlMapRequest request) { UrlMap urlMap = new UrlMap(); urlMap.setUrlStatus(UrlMapStatus.AVAILABLE.getValue()); urlMap.setLongUrl(request.getLongUrl()); urlMap.setDescription(request.getDescription()); String shortUrl = urlMapService.createUrlMap(domain, urlMap); return Response.succeed(new CreateUrlMapResponse(request.getRequestId(), shortUrl)); } } 复制代码
生产中契约模块做了比较多的特性定制,这里只举一个简单实现的例子。
部署架构
octopus
服务集群单独部署,支持无限添加节点,部署架构的关键在于网络架构,内层的负载均衡使用了Nginx
,最外层的负载均衡使用了云负载均衡,如阿里云的SLB
或者UCloud
的ULB
。添加或者移除短链域名,关键在于修改Nginx
的配置。基本的架构如下:
只要保证负载均衡池指向octopus
集群即可,短链的域名可能动态增删,操作完之后只需要nginx -s -reload
刷新一下Nginx
的配置即可。
使用短链服务
先在domain_conf
表写入一条本地域名和端口的数据:
编写一个集成测试类,创建一个短链映射:
@Slf4j @SpringBootTest(classes = OctopusServerApplication.class, properties = "spring.profiles.active=local") @RunWith(SpringRunner.class) public class UrlMapServiceTest { @Autowired private UrlMapService urlMapService; @Test public void createUrlMap() { String domain = "localhost:9099"; UrlMap urlMap = new UrlMap(); urlMap.setUrlStatus(UrlMapStatus.AVAILABLE.getValue()); urlMap.setLongUrl("https://throwx.cn/2020/08/24/canal-ha-cluster-guide"); urlMap.setDescription("测试短链"); String url = urlMapService.createUrlMap(domain, urlMap); log.info("生成的短链:{}", url); } } // 某次执行的结果如下:生成的短链:http://localhost:9099/Myt8qW 复制代码
基于本地配置启动项目,然后访问http://localhost:9099/Myt8qW
,效果如下:
日志如下:
[2020-12-27 19:29:22,285] [INFO] cn.throwx.octopus.server.application.consumer.TransformEventConsumer [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [1c603903-e8d8-4072-aa97-6abf614b9411] - 接收到URL转换事件,内容:{"clientIp":"192.168.211.113","compressionCode":"Myt8qW","userAgent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36","cookieValue":"Webstorm-734c3b68=9b8b3560-41f5-478a-93d0-b02128b1022f; __gads=ID=28121bd829638f67-2286c86e7fc400d3:T=1604132165:RT=1604132165:S=ALNI_MbsMQROv6swaC8kf4ux2suZm_GZXA; Hm_lvt_4df6907aebab752244c3ca1432b4ff57=1605930058,1607228133","timestamp":1609068562262,"shortUrlString":"http://localhost:9099/Myt8qW","longUrlString":"https://throwx.cn/2020/08/24/canal-ha-cluster-guide","transformStatusValue":3}...... [2020-12-27 19:29:22,353] [INFO] cn.throwx.octopus.server.application.consumer.TransformEventConsumer [org.springframework.amqp.rabbit.RabbitListenerEndpointContainer#0-1] [1c603903-e8d8-4072-aa97-6abf614b9411] - 记录URL转换事件完成...... 复制代码
查看转换事件记录表的数据:
后续功能迭代
前期方案有一个安全隐患:没有做压缩码的白名单,容易被基于短链域名,伪造压缩码拼接短链接的方法进行攻击。解决方案是在容器的拦截器链添加或者替换一个基于布隆过滤器实现的压缩码(短链接)白名单拦截器,这样就能在前期拦截了绝大部分恶意伪造的压缩码,让极少量命中了错误率部分的恶意压缩码流到后面的处理逻辑中进行判断。另外,可以引入Caffeine
配合Redis
做两级缓存,毕竟本地缓存的速度更快。
小结
octopus
初版是一个4
小时紧急迭代出来的一个微型项目,到现在为止更新了很多次,生产上已经基本稳定。文中描述的版本是公司生产版本的移植版,精简了大量代码同时移除了一些业务耦合的设计,这里把源码开放出来,让一些有可能用到短链服务的场景提供一个可参考但尽可能不要复制的解决思路。源码仓库:
代码都在main
分支。
彩蛋
最近鸽了很长一段时间,原因是年底比较多业务功能迭代,内部的一个标签服务重构花了大量时间。笔者一直在摸索着通过"分片"、"异步"等等思想,在时间可控的前提下,对小数据量(百万和千万级别)前提下,通过常用的关系型数据库、缓存、消息队列等非大数据平台架构替代实现《用户画像方法论与工程化解决方案》里面提到的解决方案。
标签服务内部的代号是"千寻",取自于辛弃疾《青玉案元夕》中的"众里寻他千百度",项目名来自于宫崎骏的动漫《千与千寻》的女主千寻(千寻罗马音是chihiro
):
待后面项目上线一段时间稳定后,应该会抽时间写一个系列谈谈怎么不用大数据那套体系,提供用户画像的工程化解决方案。
(本文完 c-10-d e-a-20201227)