短鏈服務?用 Nest 自己寫一個
生活中我們經常遇到需要短鏈的場景。
比如一段很長的 url:
分享出去很不方便。
這時候就可以通過短鏈服務把它縮短:
點擊短鏈會跳轉到原鏈接:
這種在短信裏很常見:
因爲短信是按照字數收費的,太長不但閱讀體驗不好,費用也高。
所以都會生成短鏈之後再加到短信裏。
那短鏈是怎麼實現的呢?
很容易想到的思路是這樣的:
用 0、1、2、3、4、5 的遞增 id 標識每個 url,把映射關係存到數據庫裏。
這樣訪問短鏈的時候從數據庫中查出對應的長鏈接,返回 302 重定向即可。
比如剛纔的短鏈服務就是通過 302 把短鏈重定向到長鏈:
這裏也可以用 301。
301 是永久重定向,就是重定向一次之後,下次瀏覽器就不會再訪問短鏈,會直接訪問長鏈接。
302 是臨時重定向,下次訪問短鏈依然會先訪問短鏈服務,返回 302 後再重定向到長鏈。
這兩種都可以,301 的話,短鏈服務壓力小,不過 302 每次都會先訪問短鏈服務,這樣可以記錄鏈接的訪問次數等數據。
比如剛纔我們用的短鏈服務,就會記錄這個鏈接的訪問記錄:
還可以做一些分析:
所以訪問記錄也挺有價值的。
一般短鏈服務都是用 302 來重定向。
每個 url 的 id 我們會用 Base64 或者 Base62 編碼:
const data = '123456';
const buff = Buffer.from(data);
const base64data = buff.toString('base64');
console.log(base64data);
base64 就是 26 個大寫字母、26 個小寫字母、10 個數字、2 個特殊字符,一共 64 個字符。
而 base62 則是去掉了兩個特殊字符,一共 62 個字符。
做短鏈的話,我們用 base62 比較多。
安裝用到的包:
npm install base62
測試下:
const base62 = require("base62/lib/ascii");
const res = base62.encode(123456);
console.log(res);
按照這個思路,我們就能實現一個短鏈服務。
在 mysql 裏創建壓縮碼和長鏈接的對應關係的表,用 mysql 的自增 id 然後進行 base62 之後作爲壓縮碼。
訪問短鏈的時候,根據壓縮碼查詢這個表,找到長鏈接,通過 302 重定向到這個鏈接,並且記錄短鏈訪問記錄。
這樣是可以的,但有個問題:
用自增 id 作爲壓縮碼,那別人很容易拿到上一個、下一個壓縮碼,從而拿到別的短鏈,萬一這個短鏈是用來兌獎之類的呢?
這樣就會有安全問題。
所以自增 id 的方案不太好。
那如果我們對 url 做 hash 呢?
也就是這樣:
const crypto = require('crypto');
function md5(str) {
const hash = crypto.createHash('md5');
hash.update(str);
return hash.digest('hex');
}
console.log(md5('111222'))
這樣太長了,有 32 位呢:
倒是可以每 4 位取一個數字,然後組成一個 8 位的壓縮碼。
但是,這樣是有碰撞的可能的。
也就是兩個不同的 url 生成的壓縮碼一樣。
所以,hash 的方案也不行。
還有一種方案,就是通過隨機數的方式生成壓縮碼。
比如這樣:
const base62 = require("base62/lib/ascii");
function generateRandomStr(len) {
let str = '';
for(let i = 0; i < len; i++) {
const num = Math.floor(Math.random() * 62);
str += base62.encode(num);
}
return str;
}
console.log(generateRandomStr(6));
隨機生成 0-61 的數字,然後轉成字符。
62 的 6 次方,範圍有 580 億,足夠用了:
當然,隨機數也是有碰撞的可能的,這個可以在生成之後查下表,看下是否有重複的,有的話就再重新生成。
不過每次生成都查表的話性能會不好,那有啥優化的方案呢?
我們可以提前生成一批壓縮碼,用的時候直接取!
可以用個定時任務來跑,每天凌晨 4 點生成一批。
這樣,生成壓縮碼的方案就完美了。
小結下:
用遞增 id + base62 作爲壓縮碼,可以保證唯一,但是容易被人拿到其它短碼,不安全。
用 url 做 hash 之後取一部分然後 base62 做爲壓縮碼,有碰撞的可能,不唯一。
隨機生成字符串再查表檢測是否重複,可以保證唯一且不連續,但是性能不好。用提前批量生成的方式可以解決。
有的同學可能提到 uuid、雪花 id 之類的,那些都太長了,不適合用來做壓縮碼:
思路理清了,我們來寫下代碼。
創建個 nest 項目:
npm install -g @nestjs/cli
nest new short-url
先進入項目,把它跑起來:
npm run start:dev
瀏覽器看到 hello world,代表 nest 服務跑成功了:
然後我們用 docker 把 mysql 跑起來:
從 docker 官網下載 docker desktop,這個是 docker 的桌面端:
跑起來後,搜索 mysql 鏡像(這步需要科學上網),點擊 run:
輸入容器名、端口映射、以及掛載的數據卷,還要指定一個環境變量:
端口映射就是把宿主機的 3306 端口映射到容器裏的 3306 端口,這樣就可以在宿主機訪問了。
數據卷掛載就是把宿主機的某個目錄映射到容器裏的 /var/lib/mysql 目錄,這樣數據是保存在本地的,不會丟失。
而 MYSQL_ROOT_PASSWORD 的密碼則是 mysql 連接時候的密碼。
跑起來後,我們用 GUI 客戶端連上,這裏我們用的是 mysql workbench,這是 mysql 官方提供的免費客戶端:
連接上之後,點擊創建 database:
指定名字、字符集爲 utf8mb4,然後點擊右下角的 apply。
創建成功之後在左側就可以看到這個 database 了:
當然,現在還沒有表。
我們在 Nest 裏用 TypeORM 連接 mysql。
安裝用到的包:
npm install --save @nestjs/typeorm typeorm mysql2
mysql2 是數據庫驅動,typeorm 是我們用的 orm 框架,而 @nestjs/tyeporm 是 nest 集成 typeorm 用的。
在 AppModule 裏引入 TypeORM,指定數據庫連接配置:
然後創建個 entity:
src/entities/UniqueCode.ts
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
@Entity()
export class UniqueCode {
@PrimaryGeneratedColumn()
id: number;
@Column({
length: 10,
comment: '壓縮碼'
})
code: string;
@Column({
comment: '狀態, 0 未使用、1 已使用'
})
status: number;
}
在 AppModule 引入:
保存之後,TypeORM 會自動建表:
表創建好了,接下來插入一些數據:
nest g service unique-code --flat --no-spec
生成 service 類,--flat 是不生成目錄 --no-spec 是不生成測試代碼:
然後創建 src/utils.ts 來放生成隨機壓縮碼的代碼:
import * as base62 from "base62/lib/ascii";
export function generateRandomStr(len: number) {
let str = '';
for(let i = 0; i < len; i++) {
const num = Math.floor(Math.random() * 62);
str += base62.encode(num);
}
return str;
}
安裝用到的包:
npm install base62
然後在 UniqueCodeService 添加下插入壓縮碼的方法:
import { Injectable } from '@nestjs/common';
import { InjectEntityManager } from '@nestjs/typeorm';
import { EntityManager } from 'typeorm';
import { generateRandomStr } from './utils';
import { UniqueCode } from './entities/UniqueCode';
@Injectable()
export class UniqueCodeService {
@InjectEntityManager()
private entityManager: EntityManager;
async generateCode() {
let str = generateRandomStr(6);
const uniqueCode = await this.entityManager.findOneBy(UniqueCode, {
code: str
});
if(!uniqueCode) {
const code = new UniqueCode();
code.code = str;
code.status = 0;
return await this.entityManager.insert(UniqueCode, code);
} else {
return this.generateCode();
}
}
}
就是生成隨機的長度爲 6 的字符串,查下數據庫,如果沒查到,就插入數據,否則重新生成。
我們用定時任務的方式來跑:
安裝用到的包:
npm install --save @nestjs/schedule
在 AppModule 註冊下:
然後在 service 方法上聲明,每 5s 執行一次:
@Cron(CronExpression.EVERY_5_SECONDS)
然後就可以看到一直在打印 insert 語句:
數據庫中也可以看到插入的未使用的壓縮碼:
當然,一個個這麼插入可太費勁了。
我們一般是在凌晨 4 點左右批量插入一堆,比如一次性插入 10000 個。
@Cron(CronExpression.EVERY_DAY_AT_4AM)
async batchGenerateCode() {
for(let i = 0; i< 10000; i++) {
this.generateCode();
}
}
這裏我們是每次 insert 一個,你也可以每次 insert 10 個 20 個這種。
批量插入性能會好,因爲執行的 sql 語句少。這裏我們就先不優化了。
壓縮碼有了,接下來生成 url 和壓縮碼的對應關係就好了。
同樣需要創建 entity:
src/entities/ShortLongMap.ts
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from "typeorm";
@Entity()
export class ShortLongMap {
@PrimaryGeneratedColumn()
id: number;
@Column({
length: 10,
comment: '壓縮碼'
})
shortUrl: string;
@Column({
length: 200,
comment: '原始 url'
})
longUrl: string;
@CreateDateColumn()
createTime: Date;
}
在 entities 引入:
同樣會自動建表:
生成短鏈就是往這個表裏添加記錄。
我們加一個生成短鏈的 service:
nest g service short-long-map --flat --no-spec
實現生成短鏈的方法:
import { UniqueCodeService } from './unique-code.service';
import { Inject, Injectable } from '@nestjs/common';
import { InjectEntityManager } from '@nestjs/typeorm';
import { EntityManager } from 'typeorm';
import { ShortLongMap } from './entities/ShortLongMap';
import { UniqueCode } from './entities/UniqueCode';
@Injectable()
export class ShortLongMapService {
@InjectEntityManager()
private entityManager: EntityManager;
@Inject(UniqueCodeService)
private uniqueCodeService: UniqueCodeService;
async generate(longUrl: string) {
let uniqueCode = await this.entityManager.findOneBy(UniqueCode, {
status: 0
})
if(!uniqueCode) {
uniqueCode = await this.uniqueCodeService.generateCode();
}
const map = new ShortLongMap();
map.shortUrl = uniqueCode.code;
map.longUrl = longUrl;
await this.entityManager.insert(ShortLongMap, map);
await this.entityManager.update(UniqueCode, {
id: uniqueCode.id
}, {
status: 1
});
return uniqueCode.code;
}
}
這裏就是先從 unique-code 表裏取一個壓縮碼來用,如果沒有可用壓縮碼,那就生成一個。
然後在 short-long-map 表裏插入這條新的短鏈映射,並且把用到的壓縮碼狀態改爲 1。
我們在 AppController 裏添加一個接口:
import { ShortLongMapService } from './short-long-map.service';
import { Controller, Get, Inject, Query } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Inject(ShortLongMapService)
private shortLongMapService: ShortLongMapService;
@Get()
getHello(): string {
return this.appService.getHello();
}
@Get('short-url')
async generateShortUrl(@Query('url') longUrl) {
return this.shortLongMapService.generate(longUrl);
}
}
在瀏覽器裏測試下:
可以看到,打印了 4 條 sql:
首先 select 查出一個壓縮碼來,然後 insert 插入壓縮碼和 url 的映射,之後再把它 select 出來返回。
最後 update 更新壓縮碼狀態。
看 sql 來說,是符合我們的預期的。
然後看下數據:
也是對的。
那剩下的事情就很簡單了,只要加一個重定向就好:
首先在 service 裏添加根據壓縮碼查詢 longUrl 的方法:
async getLongUrl(code: string) {
const map = await this.entityManager.findOneBy(ShortLongMap, {
shortUrl: code
});
if(!map) {
return null;
}
return map.longUrl;
}
然後在 AppController 裏添加一個重定向的接口:
@Get(':code')
@Redirect()
async jump(@Param('code') code) {
const longUrl = await this.shortLongMapService.getLongUrl(code);
if(!longUrl) {
throw new BadRequestException('短鏈不存在');
}
return {
url: longUrl,
statusCode: 302
}
}
測試下:
這樣,我們的短鏈服務就完成了。
其他的非核心功能,比如記錄每次訪問記錄,做一些分析:
這些比較簡單,就不實現了。
案例代碼上傳了 github: https://github.com/QuarkGluonPlasma/nestjs-course-code/tree/main/short-url
總結
我們經常用短鏈服務把長的 url 縮短,在短信裏的鏈接一般都是這種。
我們用 Nest 自己實現了一個。
核心是壓縮碼的生成,我們分析了自增 id + base62,這樣容易被人拿到其它短鏈,不安全。hash + base62 會有衝突的可能,所以最終用的是自己生成隨機數 + base62 的方案。
當然,這個隨機字符串最好是提前生成,比如用定時任務在低峯期批量生成一堆,之後直接用就好了。
短鏈的重定向使用 302 臨時重定向,這樣可以記錄短鏈訪問記錄,做一些分析。
市面上的短鏈服務,基本都是這樣實現的。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/qI8yH6w5GxeOKYY1DVj0ZQ