短链接服务Octopus的实现与源码开放(下)

本文涉及的产品
传统型负载均衡 CLB,每月750个小时 15LCU
EMR Serverless StarRocks,5000CU*H 48000GB*H
应用型负载均衡 ALB,每月750个小时 15LCU
简介: 半年前(2020-06)左右,疫情触底反弹,公司的业务量不断提升,运营部门为了方便短信、模板消息推送等渠道的投放,提出了一个把长链接压缩为短链接的功能需求。当时为了快速推广,使用了一些比较知名的第三方短链压缩平台,存在一些问题

服务实现



容器拦截器链实现


容器的拦截器需要实现org.springframework.web.server.WebFilterWebFluxFilter接口),主要有四个实现(顺序如下):

  • MappedDiagnosticContextFilter:引入transmittable-thread-local通过MDCTraceId的请求上下文绑定,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.mdlogbacktransmittable-thread-local做集成的场景:


微信截图_20220513182754.png


这里为了方便管理和升级版本,笔者直接把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,设计如下:


微信截图_20220513181909.png


目前内置了4个拦截器实现,包括:

  • ExtractRequestHeaderTransformFilter:请求头解析
  • UrlTransformFilterURL转换
  • 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页面(甚至可以在上面挂一点广告):


微信截图_20220513181921.png


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或者UCloudULB。添加或者移除短链域名,关键在于修改Nginx的配置。基本的架构如下:

微信截图_20220513181930.png


只要保证负载均衡池指向octopus集群即可,短链的域名可能动态增删,操作完之后只需要nginx -s -reload刷新一下Nginx的配置即可。


使用短链服务



先在domain_conf表写入一条本地域名和端口的数据:


微信截图_20220513181939.png


编写一个集成测试类,创建一个短链映射:


@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,效果如下:


微信截图_20220515184551.png

日志如下:


[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转换事件完成......
复制代码


查看转换事件记录表的数据:


微信截图_20220513182217.png


后续功能迭代



前期方案有一个安全隐患:没有做压缩码的白名单,容易被基于短链域名,伪造压缩码拼接短链接的方法进行攻击。解决方案是在容器的拦截器链添加或者替换一个基于布隆过滤器实现的压缩码(短链接)白名单拦截器,这样就能在前期拦截了绝大部分恶意伪造的压缩码,让极少量命中了错误率部分的恶意压缩码流到后面的处理逻辑中进行判断。另外,可以引入Caffeine配合Redis做两级缓存,毕竟本地缓存的速度更快。


小结



octopus初版是一个4小时紧急迭代出来的一个微型项目,到现在为止更新了很多次,生产上已经基本稳定。文中描述的版本是公司生产版本的移植版,精简了大量代码同时移除了一些业务耦合的设计,这里把源码开放出来,让一些有可能用到短链服务的场景提供一个可参考但尽可能不要复制的解决思路。源码仓库:



代码都在main分支。


彩蛋



最近鸽了很长一段时间,原因是年底比较多业务功能迭代,内部的一个标签服务重构花了大量时间。笔者一直在摸索着通过"分片"、"异步"等等思想,在时间可控的前提下,对小数据量(百万和千万级别)前提下,通过常用的关系型数据库、缓存、消息队列等非大数据平台架构替代实现《用户画像方法论与工程化解决方案》里面提到的解决方案。


微信截图_20220513182227.png


标签服务内部的代号是"千寻",取自于辛弃疾《青玉案元夕》中的"众里寻他千百度",项目名来自于宫崎骏的动漫《千与千寻》的女主千寻(千寻罗马音是chihiro):


微信截图_20220513182234.png


待后面项目上线一段时间稳定后,应该会抽时间写一个系列谈谈怎么不用大数据那套体系,提供用户画像的工程化解决方案。


(本文完 c-10-d e-a-20201227)


相关文章
|
6月前
|
搜索推荐 数据挖掘 数据管理
短链接系统精选:打造高效网络分享体验
在互联网时代,短链接系统扮演着重要角色,将长网址转化为简洁、易记的字符串。本文介绍了四款知名服务:行业标准的Bitly,提供详细统计和定制功能;简洁的TinyURL,操作简便;品牌化的Rebrandly,支持自定义域名以增强营销效果;以及DZ_tech/ShortURL,提供轻量级的私有部署方案。选择合适的短链接服务能优化用户体验,助力数据分析和营销。
|
5月前
|
安全 API 开发工具
微信开发:API接口与ipad协议的深度比较及最佳选择
微信开发:API接口与ipad协议的深度比较及最佳选择
|
6月前
|
安全 Java Linux
如何实现无公网IP及服务器实现公网环境企业微信网页应用开发调试
如何实现无公网IP及服务器实现公网环境企业微信网页应用开发调试
109 2
|
6月前
|
NoSQL Redis Docker
揭秘Github火爆的开源IP代理池秘密!
爬虫新利器:揭秘Github火爆的开源IP代理池秘密!
346 0
|
6月前
|
缓存 人工智能 API
【Python+微信】【企业微信开发入坑指北】2. 如何利用企业微信API主动给用户发应用消息
【Python+微信】【企业微信开发入坑指北】2. 如何利用企业微信API主动给用户发应用消息
233 0
|
搜索推荐 数据安全/隐私保护
直播程序源码OAuth协议:开放授权的重要性
在直播程序源码平台,需要OAuth协议这样的协议,OAuth协议保证了用户在使用直播程源码平台结合第三方应用程序的技术功能时的安全性与方便性,也为直播程序源码平台的用户提供了许多互动功能,是让直播程源码平台成为更高质量、更好的平台。
直播程序源码OAuth协议:开放授权的重要性
直播网站源码社区功能部署开发:连接世界的互动形式!
直播网站源码社区功能如何去实现from flask import Flask, request app = Flask(__name__) posts = [] @app.route('/post', methods=['POST'])
直播网站源码社区功能部署开发:连接世界的互动形式!
|
小程序 安全 定位技术
微信小程序学习实录4(开发前准备、认证必备资料、公众号关联小程序、小程序发布、开发配置、服务器域名、业务域名、位置接口设置)
微信小程序学习实录4(开发前准备、认证必备资料、公众号关联小程序、小程序发布、开发配置、服务器域名、业务域名、位置接口设置)
333 0
|
UED
怎么集成短链接生成,短链接的作用
短链接也称为短网址,是指长度较短的URL链接
485 0