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

本文涉及的产品
网络型负载均衡 NLB,每月750个小时 15LCU
传统型负载均衡 CLB,每月750个小时 15LCU
应用型负载均衡 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)


相关文章
|
安全 网络安全 数据安全/隐私保护
百度搜索:蓝易云【网络通信协议-HTTPS协议详解!】
总之,HTTPS是一种基于加密的安全通信协议,用于在计算机网络中安全地传输超文本和其他资源。通过使用SSL/TLS协议进行加密和身份验证,HTTPS确保了通信的机密性和完整性。使用HTTPS可以有效防止数据被窃听和篡改,提供了更安全的网络通信环境。
249 1
|
9月前
|
搜索推荐 数据挖掘 数据管理
短链接系统精选:打造高效网络分享体验
在互联网时代,短链接系统扮演着重要角色,将长网址转化为简洁、易记的字符串。本文介绍了四款知名服务:行业标准的Bitly,提供详细统计和定制功能;简洁的TinyURL,操作简便;品牌化的Rebrandly,支持自定义域名以增强营销效果;以及DZ_tech/ShortURL,提供轻量级的私有部署方案。选择合适的短链接服务能优化用户体验,助力数据分析和营销。
|
6月前
|
存储 安全 搜索推荐
想要解析邮件?IMAP协议轻松助你,不再烦恼!
电子邮件仍是关键的通讯工具,利用编程语言自动化处理能显著提高效率。本文介绍使用Go语言从IMAP服务器读取、解析邮件及处理相关信息。首先概述POP3/IMAP/SMTP协议的作用,强调IMAP协议的优势及其在邮件客户端与服务器间双向同步的特点。接着,指导如何获取授权码以连接第三方服务。通过实战演示,展示使用`go-imap`库连接服务器、读取邮件详情(如主题、收件人等)、标记邮件为已读的过程。最后,对比`Store`与`UidStore`方法的区别,指出使用`UidStore`更安全可靠。本文提供了一段详细的Go语言示例代码,帮助读者快速上手。
131 4
|
9月前
|
Web App开发 网络协议 安全
IP地址无所遁形!试试这个3k星热门开源查询神器!
IP地址无所遁形!试试这个3k星热门开源查询神器!
180 0
|
9月前
|
网络协议 JavaScript 前端开发
百度搜索:蓝易云【WebSocket是什么,怎么用?】
综上所述,WebSocket是一种在Web浏览器和服务器之间实现双向通信的协议,通过创建WebSocket对象、处理WebSocket事件、发送和接收消息以及关闭连接来使用WebSocket。它提供了实时、持久且高效的通信方式,适用于各种实时应用场景。
67 7
|
9月前
|
网络协议
百度搜索:蓝易云【基于TCP/UDP的Socket编程】
通过使用上述示例,您可以基于TCP或UDP协议进行Socket编程,实现网络通信功能。根据您的需求,可以进一步扩展和定制这些示例代码。
58 1
|
9月前
|
网络协议
百度搜索:蓝易云【基于TCP/UDP的Socket编程。】
以上是基于TCP/UDP的Socket编程的基本步骤和函数调用。通过理解和掌握这些概念和操作,可以实现网络应用程序的数据传输和通信功能。
83 1
|
JavaScript 前端开发
百度搜索:蓝易云【Nodejs快速搭建简单的HTTP服务器详细教程。】
恭喜!您已成功搭建了一个简单的Node.js HTTP服务器。您可以根据需要修改服务器代码,添加其他路由和功能。有关更多详细信息,请参考Express框架的官方文档([https://expressjs.com](https://expressjs.com/))。
532 1
直播网站源码社区功能部署开发:连接世界的互动形式!
直播网站源码社区功能如何去实现from flask import Flask, request app = Flask(__name__) posts = [] @app.route('/post', methods=['POST'])
直播网站源码社区功能部署开发:连接世界的互动形式!
|
Web App开发 前端开发 JavaScript
前端开发学习常用网站网址及API接口()免费)
前端开发学习常用网站网址及API接口()免费)