短連結服務Octopus的實現與原始碼開放
阿新 • • 發佈:2020-12-28
## 前提
半年前(`2020-06`)左右,疫情觸底反彈,公司的業務量不斷提升,運營部門為了方便簡訊、模板訊息推送等渠道的投放,提出了一個把長連結壓縮為短連結的功能需求。當時為了快速推廣,使用了一些比較知名的第三方短鏈壓縮平臺,存在一些問題:
- 收費貴
- 一些情況下,短鏈域名在部分第三方平臺例如微信會被封殺
- 回源資料沒有辦法定製處理方案,無法打通整個業務鏈路進行資料分析和跟蹤
基於此類問題,決定自研一個(長連結壓縮為)短連結服務,當時剛好同步進行微服務拆分,內部很多微服務需要重新命名,組內的一個妹子說不如就用`Github`的吉祥物去命名`octopus cat`(章魚貓)去命名,但是考慮到版權問題,去掉了她最喜歡的貓,剩下章魚,以`octopus`命名:
![](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202012/o-c-g-w-1.png)
(專案的描述還打錯字了,應該是"短連結")因為實現的功能並不複雜,初版於`2020-06`月底就釋出。`octopus`的實現參考了網際網路中幾篇關於"短鏈服務實現"瀏覽量比較高的文章,下面從實現原理、服務實現和部署架構等方面展開談談。
## 基本原理
短鏈服務的核心就是構建短連結和長連結的唯一對映關係,依賴到一個高效能、排列組合數量大而且破解難度大的對映標識生成演算法。
### 構建唯一對映關係
![](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202012/o-c-g-w-4.png)
上圖是筆者收到的京東白條分期還款結果提醒簡訊,簡訊內容也包含了一個短鏈`https://3.cn/j/xxxxxxx`,把它拷貝到瀏覽器中開啟,發現客戶端會重定向到長鏈`https://jrmkt.jd.com/ptp/wl/vouchers.html?activityId=${activityId}&uep_p=${uep_p}&uep_template_id=${uep_template_id}&uep_timestamp=${uep_timestamp}`,然後跳入一個`H5`的登入頁,登入後再跳進一個白條攻略頁面。這裡其實一個長鏈其實可以壓成多個短鏈,短鏈可以相同域名,也可以使用不同的域名:
![](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202012/o-c-g-w-5.png)
訪問`https://3.cn/j/xxxxxxx`短連結具體的互動流程猜測如下:
![](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202012/o-c-g-w-8.png)
> jrmkt.jd.com和3.cn查證都是doge東的域名
構建唯一對映關係其實就是基於一個固定的長連結,對映到一個或者多個可以動態生成的短連結,這個唯一對映關係,要求生成的短連結滿足:
- 不容易被破解(使用數字例如資料庫的自增主鍵作為唯一對映標識容易被人遍歷出來進行惡意呼叫)
- 不能重複(一個短連結只能對應一個長連結,當然一個長連結可以對應多個短連結)
- 長度儘可能短,這是因為第三方推送的報文內容一般有長度限制,如果短鏈過長,會導致不容易傳輸,還會令到推送內容字數受限(試想運營商簡訊投放內容最大長度為`30`個字元長度,短鏈已經佔了`20`個字元長度,剩下只有`10`個字元長度讓運營同事去發揮,顯然不合理)
- 如果連結過長,生成的二維碼裡面的"碼點"會十分密集,不利於客戶端識別和傳輸,剛好筆者公司運營有使用二維碼的場景,所以必須儘可能縮短連結的長度
總的來說,這個唯一對映關係中的對映標識需要像`Hash`演算法生成的`Hash`碼那樣具備高唯一性和低碰撞頻率,同時具備短小易傳輸的特點,具體如何去生成對映唯一標識見下一節"壓縮碼生成演算法"。
### 壓縮碼生成演算法
這裡的"壓縮碼"(`compression_code`)是筆者杜撰出來的名詞,在本文中它的含義是短連結`URL`的路徑部分(為了節省長度,除了協議和域名部分,短鏈的`URL`只有第一段路徑):
![](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202012/o-c-g-w-2.png)
其中,協議部分基本是固定為`https://`(從安全性來看不建議使用`http://`),短鏈域名可以購買儘可能長度短的域名如`t.cn`,不過有先見之明的資本家一般會把所有優質的短域名買下並且把價格提到很高,所以域名的長度基本也是很難控制的因素,剩下可控的就是壓縮碼部分。壓縮碼部分是可控的,但因為它是`URL`的一部分,只要確保所使用的字元不會被`URL`編碼轉義,那麼長度是人為可控的。假設我們使用的是`26`個字母的大小寫,加上`10`個數字,那麼對於`N`位壓縮碼可以表示的最大組合數量為:
- `N = 4`,組合數為`62 ^ 4 = 14_776_336`,`147`萬接近`148`萬
- `N = 5`,組合數為`62 ^ 5 = 916_132_832`,`9.16`億左右
- `N = 6`,組合數為`62 ^ 6 = 56_800_235_584`,`568`億左右
一般來說,組合數越小破解的難度就越小,組合數越大,要求壓縮碼長度越大,所以常用的長度就是`4`、`5`和`6`,而且後期可以對失效的長鏈進行壓縮碼回收或者禁用,這三個長度對於絕大對數生產短鏈的應用場景都能滿足。`octopus`在實現的時候選用的是`6`位長度的壓縮碼,無他,因為有現成的成熟的參考方案:`62`進位制數剛好由字元`0-9 a-z A-Z`組成,生成壓縮碼的時候,只需要生成一個唯一的`10`進位制數,然後再基於此`10`進位制數轉換為`62`進位制數數即可。說到這裡,看起來的方案如下:
![](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202012/o-c-g-w-6.png)
虛線部分一般依賴一種高效而且低衝突的摘要演算法,如`MurmurHash`,而第`(1)`步的實線部分就是生成一個全域性唯一的`10`進位制序列,常用的手法有:
- 資料庫自增序列(如自增主鍵)
- `Snowflake`演算法
- 自研的類似`UUID`演算法生成全域性唯一的序列值
考慮到之前筆者鑽研過`Snowflake`演算法的原理,這裡簡單使用`Snowflake`演算法生成自增序列,使用了下面的流程進行壓縮碼生成和分配:
![](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202012/o-c-g-w-7.png)
因為運營部門對短鏈生成的批量不大,而且短鏈域名只有一個,**所以簡單起見,一次壓縮操作直接消耗掉一個壓縮碼,不考慮不同短鏈域名對同一個壓縮碼進行共享,也不考慮壓縮碼的回收問題**。
## 服務實現
短鏈服務的主訪問入口一般`QPS`極高,因此需要想盡一切辦法降低該入口的耗時,考慮可以用`Redis`做快取承載入口的流量,基礎架構選型如下:
- `JDK1.8+`:生產部署使用`JDK11`
- `MVC`框架與容器:`spring-boot-starter-webflux`或者`spring-cloud-gateway`,主要是必須使用`Netty`作為底層通訊容器
- 內部`RPC`框架:`Dubbo`
- 服務註冊與發現:`Nacos`
- 可選`APM`工具:`Pinpoint`
中介軟體依賴(因為之前整個服務叢集都上雲了,低負載的服務共用了部分中介軟體):
- `MySQL8.x`
- `Redis5.x`普通主從或者哨兵叢集
- `RabbitMQ3.8.x`叢集,使用映象佇列
服務的設計圖如下:
![](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202012/o-c-g-w-3.png)
最新的版本考慮把黑白名單的攔截器去掉,**替換成一個基於布隆過濾器現實的攔截器**。服務使用了兩個攔截器(雖然`Filter`翻譯是過濾器,但是出於習慣,下文稱為攔截器)鏈,容器提供的攔截器組成的攔截器鏈主要是負責服務安全、呼叫鏈跟蹤的功能,而服務內部自定義的攔截器鏈主要是實現請求引數解析、`URL`轉換、重定向和非同步事件記錄等功能。
模組劃分:
```shell
- (ROOT) octopus
- octopus-contract
- octopus-server
```
`octopus-contract`模組必須脫離父`POM`的管理,方便單獨迭代更新。
### 資料庫設計
一共使用了`5`個表:
![](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202012/o-c-g-w-10.png)
具體的初始化`DDL`如下:
```sql
CREATE DATABASE `db_octopus` CHARSET 'utf8mb4' COLLATE 'utf8mb4_unicode_520_ci';
USE `db_octopus`;
CREATE TABLE `url_map`
(
`id` BIGINT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT COMMENT '主鍵',
`short_url` VARCHAR(32) NOT NULL COMMENT '短鏈URL',
`long_url` VARCHAR(768) NOT NULL COMMENT '長鏈URL',
`short_url_digest` VARCHAR(128) NOT NULL COMMENT '短鏈摘要',
`long_url_digest` VARCHAR(128) NOT NULL COMMENT '長鏈摘要',
`compression_code` VARCHAR(16) NOT NULL COMMENT '壓縮碼',
`description` VARCHAR(256) COMMENT '描述',
`url_status` TINYINT NOT NULL DEFAULT 1 COMMENT 'URL狀態,1:正常,2:已失效',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '建立時間',
`edit_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新時間',
`creator` VARCHAR(32) NOT NULL DEFAULT 'admin' COMMENT '建立者',
`editor` VARCHAR(32) NOT NULL DEFAULT 'admin' COMMENT '更新者',
`deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '軟刪除標識',
`version` BIGINT NOT NULL DEFAULT 1 COMMENT '版本號',
UNIQUE uniq_compression_code (`compression_code`),
INDEX idx_short_url (`short_url`),
INDEX idx_short_url_digest (`short_url_digest`),
INDEX idx_long_url_digest (`long_url_digest`)
) COMMENT 'URL對映表';
CREATE TABLE `domain_conf`
(
`id` BIGINT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT COMMENT '主鍵',
`domain_value` VARCHAR(16) NOT NULL COMMENT '域名',
`protocol` VARCHAR(8) NOT NULL DEFAULT 'https' COMMENT '協議,https或者http',
`domain_status` TINYINT NOT NULL DEFAULT 1 COMMENT '域名狀態,1:正常,2:已失效',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '建立時間',
`edit_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新時間',
`creator` VARCHAR(32) NOT NULL DEFAULT 'admin' COMMENT '建立者',
`editor` VARCHAR(32) NOT NULL DEFAULT 'admin' COMMENT '更新者',
`deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '軟刪除標識',
`version` BIGINT NOT NULL DEFAULT 1 COMMENT '版本號',
UNIQUE uniq_domain (`domain_value`)
) COMMENT '域名配置';
CREATE TABLE `compression_code`
(
`id` BIGINT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT COMMENT '主鍵',
`compression_code` VARCHAR(16) NOT NULL COMMENT '壓縮碼',
`code_status` TINYINT NOT NULL DEFAULT 1 COMMENT '壓縮碼狀態,1:未使用,2:已使用,3:已失效',
`sequence_value` VARCHAR(64) NOT NULL COMMENT '序列(鹽)',
`strategy` VARCHAR(8) NOT NULL DEFAULT 'sequence' COMMENT '策略,sequence或者hash',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '建立時間',
`edit_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新時間',
`creator` VARCHAR(32) NOT NULL DEFAULT 'admin' COMMENT '建立者',
`editor` VARCHAR(32) NOT NULL DEFAULT 'admin' COMMENT '更新者',
`deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '軟刪除標識',
`version` BIGINT NOT NULL DEFAULT 1 COMMENT '版本號',
UNIQUE uniq_compression_code (`compression_code`)
) COMMENT '壓縮碼';
CREATE TABLE `visit_statistics`
(
`id` BIGINT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT COMMENT '主鍵',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '建立時間',
`edit_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新時間',
`creator` VARCHAR(32) NOT NULL DEFAULT 'admin' COMMENT '建立者',
`editor` VARCHAR(32) NOT NULL DEFAULT 'admin' COMMENT '更新者',
`deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '軟刪除標識',
`version` BIGINT NOT NULL DEFAULT 1 COMMENT '版本號',
`statistics_date` DATE NOT NULL DEFAULT '1970-01-01' COMMENT '統計日期',
`pv_count` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '頁面流量數',
`uv_count` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '獨立訪客數',
`ip_count` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '獨立IP數',
`effective_redirection_count` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '有效跳轉數',
`ineffective_redirection_count` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '無效跳轉數',
`compression_code` VARCHAR(16) NOT NULL COMMENT '壓縮碼',
`short_url_digest` VARCHAR(128) NOT NULL COMMENT '短鏈摘要',
`long_url_digest` VARCHAR(128) NOT NULL COMMENT '長鏈摘要',
UNIQUE uniq_date_code_digest (`statistics_date`, `compression_code`)
) COMMENT '訪問資料統計';
CREATE TABLE `transform_event_record`
(
`id` BIGINT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT COMMENT '主鍵',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '建立時間',
`edit_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新時間',
`creator` VARCHAR(32) NOT NULL DEFAULT 'admin' COMMENT '建立者',
`editor` VARCHAR(32) NOT NULL DEFAULT 'admin' COMMENT '更新者',
`deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '軟刪除標識',
`version` BIGINT NOT NULL DEFAULT 1 COMMENT '版本號',
`unique_identity` VARCHAR(128) NOT NULL COMMENT '唯一身份標識,SHA-1(客戶端IP-UA)',
`client_ip` VARCHAR(64) NOT NULL COMMENT '客戶端IP',
`short_url` VARCHAR(32) NOT NULL COMMENT '短鏈URL',
`long_url` VARCHAR(768) NOT NULL COMMENT '長鏈URL',
`short_url_digest` VARCHAR(128) NOT NULL COMMENT '短鏈摘要',
`long_url_digest` VARCHAR(128) NOT NULL COMMENT '長鏈摘要',
`compression_code` VARCHAR(16) NOT NULL COMMENT '壓縮碼',
`record_time` DATETIME NOT NULL COMMENT '記錄時間戳',
`user_agent` VARCHAR(2048) COMMENT 'UA',
`cookie_value` VARCHAR(2048) COMMENT 'cookie',
`query_param` VARCHAR(2048) COMMENT 'URL引數',
`province` VARCHAR(32) COMMENT '省份',
`city` VARCHAR(32) COMMENT '城市',
`phone_type` VARCHAR(64) COMMENT '手機型號',
`browser_type` VARCHAR(64) COMMENT '瀏覽器型別',
`browser_version` VARCHAR(128) COMMENT '瀏覽器版本號',
`os_type` VARCHAR(32) COMMENT '作業系統型號',
`device_type` VARCHAR(32) COMMENT '裝置型號',
`os_version` VARCHAR(32) COMMENT '作業系統版本號',
`transform_status` TINYINT NOT NULL DEFAULT 0 COMMENT '轉換狀態,1:轉換成功,2:轉換失敗,3:重定向成功,4:重定向失敗',
INDEX idx_record_time (`record_time`),
INDEX idx_compression_code (`compression_code`),
INDEX idx_short_url_digest (`short_url_digest`),
INDEX idx_long_url_digest (`long_url_digest`),
INDEX idx_unique_identity (`unique_identity`)
) COMMENT '轉換事件記錄';
```
### 壓縮碼生成模組實現
壓縮碼生成的方法比較簡單:
```java
private final SequenceGenerator sequenceGenerator; # <------------- 雪花演算法序列生成器
@Value("${compress.code.batch:100}")
private Integer compressCodeBatch;
......
private void generateBatchCompressionCodes() {
for (int i = 0; i < compressCodeBatch; i++) {
long sequence = sequenceGenerator.generate();
CompressionCode compressionCode = new CompressionCode();
compressionCode.setSequenceValue(String.valueOf(sequence));
String code = ConversionUtils.X.encode62(sequence); # <-------------- 10進位制轉62進位制
code = code.substring(code.length() - 6);
compressionCode.setCompressionCode(code);
compressionCodeDao.insertSelective(compressionCode);
}
}
```
總是批量生成可用的壓縮碼,查詢的時候只需要查出當前未被使用的第一個壓縮碼即可。
### 容器攔截器鏈實現
容器的攔截器需要實現`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`的實現:
```java
@Order(value = Integer.MIN_VALUE)
@Component
public class MappedDiagnosticContextFilter implements WebFilter {
@Override
public Mono 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`做整合的場景:
![](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202012/o-c-g-w-12.png)
這裡為了方便管理和升級版本,筆者直接把`logback-mdc-ttl`的原始碼實現改造好後放到專案中。
### 服務內部攔截器鏈實現
服務內部的攔截器鏈主要負責請求引數解析、`URL`對映轉換、重定向和訪問轉換結果記錄,頂層介面設計如下:
```java
public interface TransformFilter {
default int order() {
return 1;
}
default void init(TransformContext context) {
}
void doFilter(TransformFilterChain chain,
TransformContext context);
}
```
`TransformContext`是一個屬性承載類,本質是一個普通的`JavaBean`,設計如下:
![](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202012/o-c-g-w-13.png)
目前內建了`4`個攔截器實現,包括:
- `ExtractRequestHeaderTransformFilter`:請求頭解析
- `UrlTransformFilter`:`URL`轉換
- `RedirectionTransformFilter`:重定向處理
- `TransformEventProcessTransformFilter`:轉換事件記錄
以`UrlTransformFilter`為例子,原始碼如下:
```java
@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`只做短鏈訪問的入口,後臺管理的功能交給另外的服務實現,此服務只有一個控制器,控制器裡面只有一個方法:
```java
@RequiredArgsConstructor
@RestController
public class OctopusController {
private final UrlMapService urlMapService;
@GetMapping(path = "/{compressionCode}")
@ResponseStatus(HttpStatus.FOUND)
public Mono 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 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`頁面(甚至可以在上面掛一點廣告):
![](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202012/o-c-g-w-11.png)
### Dubbo契約實現
`octopus-contract`是一個完全獨立的模組,甚至可以說它是一個完全獨立的專案,主要作用是提供契約`API`,讓其他服務引入,讓`octopus-server`模組進行實現。契約介面定義如下:
```java
public interface OctopusApi {
Response createUrlMap(CreateUrlMapRequest request);
}
```
基於`Dubbo`的實現如下:
```java
@DubboService(retries = -1)
public class DefaultOctopusApi implements OctopusApi {
@Autowired
private UrlMapService urlMapService;
@Value("${default.octopus.domain}")
private String domain;
@Override
public Response 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`的配置。基本的架構如下:
![](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202012/o-c-g-w-19.png)
只要保證負載均衡池指向`octopus`叢集即可,短鏈的域名可能動態增刪,操作完之後只需要`nginx -s -reload`重新整理一下`Nginx`的配置即可。
## 使用短鏈服務
先在`domain_conf`表寫入一條本地域名和埠的資料:
![](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202012/o-c-g-w-14.png)
編寫一個整合測試類,建立一個短鏈對映:
```java
@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`,效果如下:
![](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202012/o-c-g-w-15.gif)
日誌如下:
```shell
[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轉換事件完成......
```
檢視轉換事件記錄表的資料:
![](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202012/o-c-g-w-16.png)
## 後續功能迭代
前期方案有一個安全隱患:沒有做壓縮碼的白名單,容易被基於短鏈域名,偽造壓縮碼拼接短連結的方法進行攻擊。解決方案是在容器的攔截器鏈新增或者替換一個基於布隆過濾器實現的壓縮碼(短連結)白名單攔截器,這樣就能在前期攔截了絕大部分惡意偽造的壓縮碼,讓極少量命中了錯誤率部分的惡意壓縮碼流到後面的處理邏輯中進行判斷。另外,可以引入`Caffeine`配合`Redis`做兩級快取,畢竟本地快取的速度更快。
## 小結
`octopus`初版是一個`4`小時緊急迭代出來的一個微型專案,到現在為止更新了很多次,生產上已經基本穩定。文中描述的版本是公司生產版本的移植版,精簡了大量程式碼同時移除了一些業務耦合的設計,這裡把原始碼開放出來,讓一些有可能用到短鏈服務的場景提供一個可參考但儘可能不要複製的解決思路。原始碼倉庫:
- `Gitee`:`https://gitee.com/throwableDoge/octopus`
- `Github`:`https://github.com/zjcscut/octopus`
程式碼都在`main`分支。
## 彩蛋
最近鴿了很長一段時間,原因是年底比較多業務功能迭代,內部的一個標籤服務重構花了大量時間。筆者一直在摸索著通過"分片"、"非同步"等等思想,在時間可控的前提下,對小資料量(百萬和千萬級別)前提下,通過常用的關係型資料庫、快取、訊息佇列等非大資料平臺架構替代實現《使用者畫像方法論與工程化解決方案》裡面提到的解決方案。
![](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202012/o-c-g-w-17.png)
標籤服務內部的代號是"千尋",取自於辛棄疾《青玉案元夕》中的"眾裡尋他千百度",專案名來自於宮崎駿的動漫《千與千尋》的女主千尋(千尋羅馬音是`chihiro`):
![](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202012/o-c-g-w-18.png)
待後面專案上線一段時間穩定後,應該會抽時間寫一個系列談談怎麼不用大資料那套體系,提供使用者畫像的工程化解決方案。
## 個人部落格
- [Throwable's Blog](https://throwx.cn/2020/12/27/octopus)
(本文完 c-10-d e-a-202