短鏈服務?用 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