← 中文资源

简体中文版本

用 Express 与 Prisma 构建 URL 短链接 API

完整的 Express、Prisma、PostgreSQL 和 Redis 短链接 API,包含校验、分析、限流、测试和部署。

阅读英文原文

项目概述

构建类 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();
  };
}