前言
短链服务是一种将长网址转换成更短、更易于分享的形式的服务。短链通常由一个短域名加上一串短字符组成,它通过重定向将用户从短链引导到原始的长网址上。
像购物商品分享
还是一些短信服务推送
都能看到短链使用的场景
如果我们将上述文本中的url复制到浏览器,打开查看
链接302重定向到了很长的url地址中
为什么会使用到短链
- 便于转发分享,使用短链可节省空间,使分享更简洁
- 在短信消息,短信是按照字符数量收费,短链接节省一定费用,用户体验也会更好
- 可以对访问链接的来源进行数据追踪和分析
实现思路分析
在前面我们提到短链是由以下部分构成
而当我们访问短链时访问重定向到了原始的长网址上,服务端是如何知道重定向到哪一个链接的呢?很显然通过短链最后一串短字符查询获取得来的,所以原始网址和短标识符是有映射关系的,而短标识符对应的原始网址应该是唯一的,每个短标识符都应该映射到一个特定的长网址,以确保当用户点击该短链时,能够被正确地重定向到相应的长网址
我们来绘制个简单的流程图
下面我们用代码去实现短链服务
短链服务实现
创建项目
使用@nestjs/cli创建项目,如果没有安装的 运行以下命令npm i -g @nestjs/cli
并运行nest new project-name来构建
nest new url-shortener-serve
默认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
调整.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 启动配置的镜像服务
使用navicat链接并创建数据库
测试连接成功,数据库服务就创建好了
回到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 代码
就可以看到新的表了,正好对应我们上面创建的三个模型
在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端口
在数据库表中,正是我们创建的一条数据
创建短链模块
使用以下命令创建短链模块
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)
数据库中可以看到生成了一批未使用的短链码
也可以写个批量创建的方法
/任务在凌晨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);
}
使用接口测试工具测试
参数不正确时无法创建
当输入对的url时就创建成功了
查看数据库也有我们刚刚创建的这条数据
获取短链码重定向跳转
短链创建完成之后,平台推送给用户,访问时通过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,
};
}
}
生成个短链测试下,跟预期的一致
接下来对每次访问的信息进行关联记录
短链访问记录
- 对于访问设备之类的信息,可以通过 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信息如下
新增 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,
};
}
再次访问短链,设备信息就保存在数据库中了
- 对于访问者的区域地址 可以通过第三方服务解析获取ip地址来处理
创建 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测试, 接口成功的调用了
更新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值测试的~)
总结
短链服务是一种将长网址转换成更短、更易于分享的形式的服务。短链通常由一个短域名加上一串短字符组成,它通过重定向将用户从短链引导到原始的长网址上。 为什么会使用到短链
- 便于转发分享:使用短链可节省空间,使分享更简洁。
- 节省短信字符数:短信是按照字符数量收费,使用短链接可以节省费用,提升用户体验。
- 数据追踪和分析:可以对访问链接的来源进行数据追踪和分析,帮助企业进行数据统计和用户行为分析。 实现思路分析 短链服务的核心思想是将长网址与短字符之间建立映射关系,当用户访问短链时,服务端通过查询短字符找到对应的长网址,并重定向到该长网址。 短链服务的基本构成包括以下几个部分:
- 短链生成:通过一定的算法生成唯一的短链码。
- 数据存储:将原始网址和生成的短链码进行存储,确保短链码和原始网址的映射关系是唯一的。
- 访问重定向:用户访问短链时,服务器根据短链码查找对应的原始网址,并进行302(临时重定向,可记录每一次短链访问情况,便于进行统计和分析)重定向