项目概述
构建类 bit.ly 的短链接服务:接收长 URL、生成唯一短码、重定向并记录点击,同时提供统计、过期和限流。
架构与数据模型
Express 处理 HTTP,服务层承载业务规则,Prisma 访问 PostgreSQL,Redis 保存限流计数。核心表包含短码唯一约束、原始 URL、点击数和过期时间。
实施阶段
先初始化 TypeScript、Prisma 和 Docker 基础设施;校验目标 URL 仅允许 http/https;生成短码并在冲突时重试;重定向时先检查过期再原子增加点击;实现统计、统一错误和 Redis 固定窗口限流;最后编写集成测试。
验证与限制
测试覆盖创建、重定向、统计、过期 404 和 429;预期所有用例通过。内存与单库设计不适合多区域;固定窗口在边界允许突刺,生产应使用滑动窗口并监控短码碰撞。
代码示例
mkdir url-shortener && cd url-shortener
pnpm init
pnpm add express @prisma/client zod ioredis nanoid
pnpm add -D typescript @types/express @types/node tsx vitest supertest prisma
npx tsc --init
npx prisma init --datasource-provider postgresql
docker compose up -d
echo 'DATABASE_URL="postgresql://dev:devpass@localhost:5432/shortener"' >> .env
echo 'REDIS_URL="redis://localhost:6379"' >> .env
npx prisma migrate dev --name init
import { z } from 'zod';
const ShortenInput = z.object({
url: z.string().url().max(2048),
});
export function normalizeUrl(input: unknown): string {
const { url } = ShortenInput.parse(input);
const parsed = new URL(url);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('only http and https URLs are supported');
}
if (parsed.username || parsed.password) {
throw new Error('credentials in URLs are not allowed');
}
return parsed.toString();
}
import { nanoid } from 'nanoid';
import { Prisma } from '@prisma/client';
import { prisma } from '../lib/prisma';
export async function createShortUrl(longUrl: string, ttlDays = 30) {
const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000);
for (let attempt = 0; attempt < 5; attempt++) {
const code = nanoid(8);
try {
return await prisma.shortUrl.create({
data: { code, longUrl, expiresAt },
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
continue;
}
throw error;
}
}
throw new Error('could not allocate a unique code');
}
app.get('/:code', async (req, res) => {
const record = await prisma.shortUrl.findUnique({
where: { code: req.params.code },
});
if (!record || (record.expiresAt && record.expiresAt <= new Date())) {
return res.status(404).json({ error: 'Not found or expired' });
}
await prisma.shortUrl.update({
where: { id: record.id },
data: { clicks: { increment: 1 } },
});
return res.redirect(302, record.longUrl);
});
export function createRateLimiter(store: RateLimitStore, max = 10, windowSeconds = 60) {
return async (req, res, next) => {
const key = `rl:ip:${req.ip}`;
const count = await store.increment(key, windowSeconds);
if (count > max) {
res.setHeader('Retry-After', String(windowSeconds));
return res.status(429).json({ error: 'Too many requests' });
}
next();
};
}