nestjs—创建短链服务

829 阅读7分钟

前言

短链服务是一种将长网址转换成更短、更易于分享的形式的服务。短链通常由一个短域名加上一串短字符组成,它通过重定向将用户从短链引导到原始的长网址上。

像购物商品分享 498a966acee4411f8b068836053c64a2.jpg 还是一些短信服务推送 919d2ce36eb310fcdff5a415d5af8812.jpg 都能看到短链使用的场景 如果我们将上述文本中的url复制到浏览器,打开查看

链接302重定向到了很长的url地址中 image.png

为什么会使用到短链

  1. 便于转发分享,使用短链可节省空间,使分享更简洁
  2. 在短信消息,短信是按照字符数量收费,短链接节省一定费用,用户体验也会更好
  3. 可以对访问链接的来源进行数据追踪和分析

实现思路分析

在前面我们提到短链是由以下部分构成 image.png 而当我们访问短链时访问重定向到了原始的长网址上,服务端是如何知道重定向到哪一个链接的呢?很显然通过短链最后一串短字符查询获取得来的,所以原始网址和短标识符是有映射关系的,而短标识符对应的原始网址应该是唯一的,每个短标识符都应该映射到一个特定的长网址,以确保当用户点击该短链时,能够被正确地重定向到相应的长网址

我们来绘制个简单的流程图 image.png 下面我们用代码去实现短链服务

短链服务实现

创建项目

使用@nestjs/cli创建项目,如果没有安装的 运行以下命令npm i -g @nestjs/cli 并运行nest new project-name来构建

nest new url-shortener-serve

image.png 默认3000端口访问

我们在 nest-cli.json中加下 "spec": false,暂时不去生成测试文件

"generateOptions": {
    "spec": false
  }

环境搭建

我们使用 PostgreSQL数据库 Prisma 作为与 PostgreSQL 数据库交互的 ORM

  • 集成prisma

Prisma 提供的命令行工具(CLI),用于管理和操作你的 Prisma 项目

## Prisma 提供的命令行工具(CLI),用于管理和操作你的 Prisma 项目
pnpm i -D prisma

## 与数据库交互 提供一些查询和操作数据库的API
pnpm install @prisma/client

npx prisma init

初始化 Prisma 会在根目录创建.env 文件以及prisma 目录和 schema.prisma 文件

npx prisma init

image.png

调整.env 文件里的配置内容

API_URL = http://localhost:3000


POSTGRES_USER=postgres
POSTGRES_PASSWORD=example
POSTGRES_DB=url-shortener-db


DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?schema=public

接下来我们在根目录创建docker-compose.yml用于配置容器服务 没有安装docker的,在(www.docker.com/) 下载docker desktop

version: '3.1'
services:
  postgres:
    image: postgres:latest
    container_name: postgresprisma
    restart: always
    ports:
      - 5432:5432
    env_file:
      - .env
    volumes:
      - postgres:/var/lib/postgresql/data
volumes:
  postgres:
    name: nest-url-shortener-docker-db

docker-compose up -d 启动配置的镜像服务

image.png

使用navicat链接并创建数据库

image.png

image.png

测试连接成功,数据库服务就创建好了

回到nest项目中 在prisma/schema.prisma 定义数据模型 分别是 ShortLink 短链信息 Visit访问记录,以及UniqueShortCode短链码 其中 ShortLink和Visit数据模型是一对多的关系

model ShortLink {
  id          String   @id @default(cuid())
  title       String?
  originalUrl String
  shortCode   String   @unique
  shortLink   String
  visits      Visit[]
  visitTimes  Int      @default(0)
  createdAt   DateTime @default(now())
  updateAt    DateTime @updatedAt()
  desc        String?
}

model Visit {
  id          String    @id @default(uuid())
  shortLink   ShortLink @relation(fields: [shortLinkId], references: [id])
  shortLinkId String
  timestamp   DateTime  @default(now())
  ip          String?
  province    String?
  city        String?
  district    String?
  locationLng Float?
  locationLat Float?
  browser     String?
  device      String?
  os          String?
}

model UniqueShortCode {
  id        String   @id @default(uuid())
  code      String   @unique
  isEnabled Boolean  @default(false)
  createdAt DateTime @default(now())
  updateAt  DateTime @updatedAt()
}

执行npx prisma db push 同步到数据库并生成client 代码

image.png

就可以看到新的表了,正好对应我们上面创建的三个模型 image.png

在nestjs项目如何去使用prisma呢

我们可以创建 prisma的服务文件,然后在app模块中导入 Prisma

import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
  async onModuleInit() {
    await this.$connect();
  }
  async onModuleDestroy() {
    await this.$disconnect();
  }
}

也可以使用nestjs-prisma这个包

它提供了一个模块来配置 Prisma ,并将其注入到 NestJS 中,简化了与prisma的配置过程,同时prisma的错误处理与nest的异常过滤器集成,也就是说数据库错误可以被捕获并转换为HTTP响应

安装

pnpm install nestjs-prisma

在app模块中导入

...
import { PrismaModule } from 'nestjs-prisma';

@Module({
...
  imports: [
    PrismaModule.forRoot({
      isGlobal: true,
    }),
  ],
 ...
})

在 AppController 注入,并在uniqueShortCode中创建一条数据

constructor(private readonly prisma: PrismaService) {}
...
this.prisma.uniqueShortCode.create({
      data: {
        code: 'DHCNSI',
      },
  });

pnpm run start:dev启动项目并访问3000端口 image.png 在数据库表中,正是我们创建的一条数据 image.png

创建短链模块

使用以下命令创建短链模块

nest generate resource shortLink

生成短链码

生成短链码的方式有很多:如对原始长链接进行哈希截取,随机字符串,递增的方法计数器,uuid,雪花 id等。短链码不能过长,过长违背短链服务的本意了,短链码要唯一,对应指定的长链接,为了避免碰撞的可能性,在生成后查询该短链码是否已经存在。

在这里我们使用随机字符串

在ShortLinkService中添加generateHash方法如下

  /**
   * 生成指定长度的短链码
   * @param length 生成的短链码的长度
   * @returns 生成的短链码
   */
  async generateShortCode(length = 8): Promise<string> {
    // 去除几个相似性的字符
    const characters =
      '23456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ';

    let result = '';
    for (let i = 0; i < length; i++) {
      result += characters.charAt(
        Math.floor(Math.random() * characters.length),
      );
    }
    return result;
  }

注入PrismaService使用generateHash方法创建短链码,生成完成之后并检查短链码是否存在,在进行创建保存

...
private readonly prisma: PrismaService;
  /**
   * 生成并检查唯一的短链码
   * @returns 唯一的短链码
   */
  async createUniqueShortCode() {
    const code = await this.generateShortCode(8);
    // 检查该短链码是否已经存在
    const isExist = this.prisma.uniqueShortCode.findUnique({
      where: {
        code,
      },
    });
    if (isExist) {
      this.createUniqueShortCode;
    }
    await this.prisma.uniqueShortCode.create({
      data: {
        code,
      },
    });
    return code;
  }

可以使用定时任务的方式来去创建短链码

安装pnpm install --save @nestjs/schedule

在appModule中导入

import { ScheduleModule } from '@nestjs/schedule';

ScheduleModule.forRoot()

添加到 createUniqueShortCode 方法上

// 每5秒时执行一次
@Cron(CronExpression.EVERY_5_SECONDS)

image.png

数据库中可以看到生成了一批未使用的短链码

image.png

也可以写个批量创建的方法

   /任务在凌晨4点执行
  @Cron(CronExpression.EVERY_DAY_AT_4AM)
  async batchCreateUniqueShortCode() {
    for (let i = 0; i < 1000; i++) {
      this.createUniqueShortCode();
    }
  }

至此短链码创建这块儿暂时告一段落,接下来开始创建短链接接口

创建短链接接口

新增dto/create-short-link.dto.ts文件 并安装 对传入的参数进行转化校验

pnpm install class-transformer class-validator
import { IsNotEmpty, IsUrl } from 'class-validator';
export class CreateShortLinkDto {
  @IsNotEmpty({
    message: '标题不能为空',
  })
  title: string;
  @IsNotEmpty({
    message: 'URL 不能为空',
  })
  @IsUrl({}, { message: 'URL 格式不正确' })
  originalUrl: string;
  desc: string;
}

short-link.service.ts中新增创建短链方法,首先从uniqueShortCode中找到未被使用的短链码 ,更新短链码的使用状态,并创建shortLink;如果没有找到可使用的短链码,就重新生成并创建, (不要忘记安装 @nestjs/config 并注入 获取API_URL .env里定义的值)

    @Inject(ConfigService)
  private readonly configService: ConfigService;

  async createShortLink(createShortLinkDto: CreateShortLinkDto) {
    // 找到没有被使用的短链码
    let shortCode = await this.prisma.uniqueShortCode.findFirst({
      where: {
        isEnabled: false,
      },
    });

    const API_URL = this.configService.get('API_URL');

    if (!shortCode) {
    // createUniqueShortCode返回值不在时code
      shortCode = await this.createUniqueShortCode();
    }
    // 更新短链码状态
    await this.prisma.uniqueShortCode.update({
      where: {
        id: shortCode.id,
        code: shortCode.code,
      },
      data: {
        isEnabled: true,
      },
    });
    await this.prisma.shortLink.create({
      data: {
        desc: createShortLinkDto.desc,
        title: createShortLinkDto.title,
        originalUrl: createShortLinkDto.originalUrl,
        shortCode: shortCode.code,
        shortLink: `/short-link/${shortCode.code}`,
      },
    });
    return {
      message: '短链接生成成功',
      data: `${API_URL}/short-link/${shortCode.code}`,
    };
  }

short-link.controller.ts 新增一个post方法,并使用nestjs内置的管道,用于在请求进入controller,处理逻辑之前对请求数据进行验证

  @Post()
  @UsePipes(new ValidationPipe({ transform: true }))
  async create(@Body() createShortLinkDto: CreateShortLinkDto) {
    return await this.shortLinkService.createShortLink(createShortLinkDto);
  }

使用接口测试工具测试

参数不正确时无法创建 image.png

当输入对的url时就创建成功了 image.png 查看数据库也有我们刚刚创建的这条数据

image.png

获取短链码重定向跳转

短链创建完成之后,平台推送给用户,访问时通过url中的短链码,来获取对应的原始链接数据,并获取访问者的一些基础访问信息,ip,访问地区,访问设备游览器,访问次数,等等。如果数据不存在 则返回跳转指定页面或提示页面不存在。

先来实现基础的一些功能 我们使用@Redirect() 装饰器重定向 @Redirect() 接受两个参数, url 和 statusCode ,两者都是可选的。如果省略,则 statusCode 的默认值为 302。 可以在方法里动态返回,会覆盖@Redirect()里设置的值

/**
   * 通过短链获取原始链接
   * @param shortCode 短链
   * @returns 原始链接
   */
  @Get('/:shortCode')
  @Redirect()
  async getOriginalUrl(@Param('shortCode') shortCode: string) {
    const originalUrl = await this.prisma.shortLink.findUnique({
      where: {
        shortCode,
      },
    });

    if (!originalUrl) {
      return {
        url: '/',
        statusCode: HttpStatus.NOT_FOUND,
      };
    }

    await this.prisma.shortLink.update({
      where: {
        id: originalUrl.id,
      },
      data: {
        visitTimes: originalUrl.visitTimes + 1,
      },
    });
    return {
      url: originalUrl.originalUrl,
      statusCode: HttpStatus.FOUND,
    };
  }
}

生成个短链测试下,跟预期的一致 image.png

image.png

接下来对每次访问的信息进行关联记录

短链访问记录

  • 对于访问设备之类的信息,可以通过 headers中的user-agent来解析

安装 pnpm install express-useragent

使用

import * as useragent from 'express-useragent';

 const userAgent = request.headers['user-agent'];
 const ua = useragent.parse(userAgent);

打印 ua信息如下

image.png

新增 utils.ts文件内新增getUseragentInfo方法对 ua信息 进行整理

import * as useragent from 'express-useragent';

export const getUseragentInfo = (UserAgent: string | 'unknown') => {
  if (UserAgent === 'unknown') return {};

  const ua = useragent.parse(UserAgent) as any;
  const browser = {
    Chrome: ua.isChrome,
    Firefox: ua.isFirefox,
    Safari: ua.isSafari,
    Edge: ua.isEdge,
    Opera: ua.isOpera,
    IE: ua.isIE,
    UC: ua.isUC,
    WeChat: ua.isWechat,
  };

  const os = {
    Android: ua.isAndroid,
    IOS: ua.isiPhone,
    Windows: ua.isWindows,
    Mac: ua.isMac,
    Linux: ua.isLinux,
  };

  const device = {
    Tablet: ua.isTablet,
    Mobile: ua.isMobile,
    Desktop: ua.isDesktop,
  };

  const getKeyWithValue = (obj: Record<string, boolean>) =>
    Object.keys(obj).find((key) => obj[key]) || 'Other';

  return {
    browser: getKeyWithValue(browser),
    os: getKeyWithValue(os),
    device: getKeyWithValue(device),
  };
};

getOriginalUrl方法里新增以下内容

...
  @Get('/:shortCode')
  @Redirect()
  async getOriginalUrl(
    @Param('shortCode') shortCode: string,
    @Req() request: Request,
  ) {
    const userAgent = request.headers['user-agent'];
    const ua = getUseragentInfo(userAgent);

    // 更新记录访问
    await this.prisma.visit.create({
      data: {
        shortLinkId: originalUrl.id,
        ...ua,
      },
    });
    return {
      url: originalUrl.originalUrl,
      statusCode: HttpStatus.FOUND,
    };
  }

再次访问短链,设备信息就保存在数据库中了

image.png

  • 对于访问者的区域地址 可以通过第三方服务解析获取ip地址来处理

image.png

创建 ip-query 模块

nest generate resource ipQuery

需要请求第三方服务接口,所以需要安装 docs.nestjs.com/techniques/…

pnpm i --save @nestjs/axios axios

导入 HttpModule再使用 HttpService

// IpQueryModule
....
import { HttpModule } from '@nestjs/axios';
@Global()
@Module({
  imports: [HttpModule],
  ...
  exports: [IpQueryService],
})

参考第三方接口服务配置 在IpQueryService加入,使用rxjs处理数据

import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { catchError, map } from 'rxjs/operators';
import { HttpService } from '@nestjs/axios';
import { AxiosResponse } from 'axios';
import { Observable } from 'rxjs';

@Injectable()
export class IpQueryService {
  private readonly host = 'https://qryip.market.alicloudapi.com';
  private readonly path = '/lundear/qryip';
  private readonly appCode = 'XXXXX';

  constructor(private readonly HttpService: HttpService) {}

  queryIp(ip: string): Observable<any> {
    const headers = {
      Authorization: `APPCODE ${this.appCode}`,
    };
    const params = {
      ip,
    };
    const response = this.HttpService.get(`${this.host}${this.path}`, {
      headers,
      params,
    }).pipe(
      map((response: AxiosResponse) => {
        // return response.data;
        const { status, message, result } = response.data || {};
        if (status === 0) {
          return {
            // lat:纬度
            // lng:经度
            // nation:国家
            // province:省
            // city:市
            // district:区
            status,
            message,
            data: {
              ip: result?.ip,
              province: result.ad_info?.province,
              city: result?.ad_info?.city,
              district: result?.ad_info?.district,
              locationLat: result?.location?.lat,
              locationLng: result?.location?.lng,
            },
          };
        }
        return {
          status,
          message,
          data: {},
        };
      }),
      catchError((error) => {
        console.error('Error querying IP:', error);
        throw new HttpException(
          '查询IP信息失败',
          HttpStatus.INTERNAL_SERVER_ERROR,
        );
      }),
    );

    return response;
  }

  async getIpInfo(ip: string): Promise<any> {
    return new Promise((resolve, reject) => {
      this.queryIp(ip).subscribe(
        (data) => resolve(data),
        (error) => reject(error),
      );
    });
  }
}

在IpQueryController添加IP查询接口

@Get()
  queryIp(@Query('ip') ip: string): Observable<AxiosResponse<any>> {
    return this.ipQueryService.queryIp(ip);
  }

随便找个ip测试, 接口成功的调用了

image.png 更新ShortLinkController里getOriginalUrl方法

  @Inject(IpQueryService)
  private readonly ipQueryService: IpQueryService;
    ...
  @Get('/:shortCode')
  @Redirect()
  async getOriginalUrl(
    @Param('shortCode') shortCode: string,
    @Req() request: Request,
  ) {
    const ipAddress =
      request.ip ||
      request.headers['x-forwarded-for'] ||
      request.connection.remoteAddress;
    const ipInfo =await this.ipQueryService.getIpInfo(ipAddress as string);
    ...
    // 更新记录访问
    await this.prisma.visit.create({
      data: {
        shortLinkId: originalUrl.id,
        ...ipInfo.data,
      },
    });
   
  }

再次访问测试 就可以看到根据ip解析后的信息,就被保存下来了(本地测试是拿不到ip的,有数据的是固定ip值测试的~)

image.png

总结

短链服务是一种将长网址转换成更短、更易于分享的形式的服务。短链通常由一个短域名加上一串短字符组成,它通过重定向将用户从短链引导到原始的长网址上。 为什么会使用到短链

  1. 便于转发分享:使用短链可节省空间,使分享更简洁。
  2. 节省短信字符数:短信是按照字符数量收费,使用短链接可以节省费用,提升用户体验。
  3. 数据追踪和分析:可以对访问链接的来源进行数据追踪和分析,帮助企业进行数据统计和用户行为分析。 实现思路分析 短链服务的核心思想是将长网址与短字符之间建立映射关系,当用户访问短链时,服务端通过查询短字符找到对应的长网址,并重定向到该长网址。 短链服务的基本构成包括以下几个部分:
  • 短链生成:通过一定的算法生成唯一的短链码。
  • 数据存储:将原始网址和生成的短链码进行存储,确保短链码和原始网址的映射关系是唯一的。
  • 访问重定向:用户访问短链时,服务器根据短链码查找对应的原始网址,并进行302(临时重定向,可记录每一次短链访问情况,便于进行统计和分析)重定向