首页
/ freeCodeCamp 后端认证实战:基于 URL Shortener Microservice 挑战规格构建全栈 URL 短链接服务

freeCodeCamp 后端认证实战:基于 URL Shortener Microservice 挑战规格构建全栈 URL 短链接服务

2026-09-06 14:45:00作者:霍妲思

本篇基于 freeCodeCamp 课程仓库中 URL Shortener Microservice 挑战定义文件,完整拆解这个后端认证项目的 API 契约、官方测试逻辑与关键技术点(body 解析中间件、dns.lookup URL 校验、重定向机制),并给出可复制运行的完整实现方案,帮助你在本地交付一个能通过官方自动测试的全栈短链接微服务。

一、项目定位:它在 freeCodeCamp 课程体系中的位置

URL Shortener Microservice 是 Back-End Development and APIs(后端开发与 API)认证下的第 3 个项目。从课程组织结构文件可以确认它的归属链路:

挑战文件本身 curriculum/challenges/english/blocks/back-end-development-and-apis-projects/bd7158d8c443edefaeb5bd0e.md 的 frontmatter 中 challengeType: 4,在 freeCodeCamp 中代表“项目类挑战”——你需要部署一个可公开访问的服务,官方测试会直接对你的线上地址发起 HTTP 请求来验证功能。

二、项目目标与两种开工方式

挑战描述要求:构建一个功能上类似于官方示例(URL Shortener Microservice)的全栈 JavaScript 应用。官方给出了两种标准做法:

  1. 克隆官方样板仓库完成项目:freeCodeCamp 为该项目提供独立的 boilerplate 仓库 boilerplate-project-urlshortener,包含预置好的项目骨架(Express 应用、测试脚本、package.json 等),将其克隆到本地后在此基础上下手。
  2. 使用任意托管平台(site builder)完成项目:可以部署在免费云平台,但要求把 boilerplate 仓库中的所有文件都纳入项目——这是硬性要求,因为官方测试代码就是依赖这些文件运行的。

无论哪种方式,交付物都是一个公网可访问的 Node.js 服务。

官方给出的两条关键提示(HINT)

挑战文档的 instructions 部分明确提示:

  • 不要忘记使用 body 解析中间件来处理 POST 请求:接收表单数据的 Express 应用必须挂接 bodyParser(或 Express 内置的 express.json / express.urlencoded),否则 req.body 为空,POST 接口无法拿到用户提交的 URL。
  • 可以使用 Node.js 核心模块 dns 中的 dns.lookup(host, cb) 来校验提交的 URL:这是判断一个主机名是否真实可解析的标准做法——短链接服务在生成短码前,先对目标地址做 DNS 查询,解析失败即视为非法 URL。

三、API 契约:三个接口的完整行为规范

官方测试基于三条行为定义,任何实现都必须满足。

3.1 POST /api/shorturl:提交长 URL,换取短码

请求方式:application/x-www-form-urlencoded 表单提交,字段为 url。响应必须是一个包含 original_urlshort_url 两个属性的 JSON 对象,官方示例响应为:

{ "original_url": "https://freeCodeCamp.org", "short_url": 1 }

测试代码(直接摘自挑战文档,可原样运行于 Node 环境):

const url = code; // 你部署的服务地址
const urlVariable = Date.now();
const fullUrl = `${url}/?v=${urlVariable}`;
const res = await fetch(url + '/api/shorturl', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: `url=${fullUrl}`
});
if (res.ok) {
  const { short_url, original_url } = await res.json();
  assert.isNotNull(short_url);
  assert.strictEqual(original_url, `${url}/?v=${urlVariable}`);
} else {
  throw new Error(`${res.status} ${res.statusText}`);
}

两个断言点:short_url 必须非空(数值或字符串短码均可),original_url 必须原样等于你提交的全量 URL——注意测试提交的 URL 带上了 ?v=${Date.now()} 查询串,因此你的服务必须保留完整 query string 做回显,不能只截取 origin。

3.2 GET /api/shorturl/<short_url>:访问短链接被重定向回原始地址

拿到 short_url 后,GET /api/shorturl/{short_url} 必须把客户端重定向到原始 URL。测试用 fetchredirect 选项做了双重验证:

const url = code;
const urlVariable = Date.now();
const fullUrl = `${url}/?v=${urlVariable}`;
let shortenedUrlVariable;
const postResponse = await fetch(url + '/api/shorturl', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: `url=${fullUrl}`
});
if (postResponse.ok) {
  const { short_url } = await postResponse.json();
  shortenedUrlVariable = short_url;
} else {
  throw new Error(`${postResponse.status} ${postResponse.statusText}`);
}
// 自动跟随重定向,确认最终到达新 URL
const getResponse = await fetch(
  url + '/api/shorturl/' + shortenedUrlVariable, {redirect:'follow'}
);
if (getResponse) {
  const { url } = getResponse; // status is always 200 for some reason
  assert.strictEqual(url, fullUrl);
} else {
  throw new Error(`${getResponse.status} ${getResponse.statusText}`);
}

// 关闭自动跟随,确认服务器端确实在做重定向
const getManualResponse = await fetch(
  url + '/api/shorturl/' + shortenedUrlVariable, {redirect:'manual'}
);
if (getManualResponse) {
  const { status } = getManualResponse; // if a redirect happens, it won't reach the new resource
  assert.strictEqual(status, 0);
} else {
  throw new Error(`${getManualResponse.status} ${getManualResponse.statusText}`);
}

这段测试值得逐行理解,它实际上验证了两个层面:

  1. redirect: 'follow'fetch 自动跟随 301/302 跳转,最终落在 fullUrl 上——证明你的短链接“真的能跳转过去”;
  2. redirect: 'manual':不跟随跳转时,Response.status0opaqueredirect 响应),证明重定向发生在你的服务器侧,而不是你直接返回了一个 200 页面或客户端 JS 跳转。如果你的实现是返回 200 + HTML <meta refresh> 或前端路由跳转,这个断言必然失败——必须用 HTTP 层重定向(res.redirect(url) / res.status(301).redirect(url))。

3.3 非法 URL 必须返回 { error: 'invalid url' }

当提交的地址不符合 http://www.example.com 这样的合法格式时,POST 接口应返回包含 error 字段的 JSON:

const url = code;
const res = await fetch(url + '/api/shorturl', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: `url=ftp:/john-doe.invalidTLD`
});
if (res.ok) {
  const { error } = await res.json();
  assert.isNotNull(error);
  assert.strictEqual(error.toLowerCase(), 'invalid url');
} else {
  throw new Error(`${res.status} ${res.statusText}`);
}

测试用例提交的是 ftp:/john-doe.invalidTLD:非 http(s) 协议、且域名不存在。断言只要求 error 字段存在且内容(忽略大小写)等于 'invalid url',因此错误信息可以直接写死为该字符串。

3.4 提交物约束:不能是官方示例地址

挑战文档的第一条 hint 明确约束:

assert(
  !/.*\/url-shortener-microservice\.freecodecamp\.rocks/.test(code)
);

即你提交的服务地址(code)不得匹配官方示例部署域名——必须是你自己部署的项目,直接提交官方示例 URL 会被判不通过。

四、源码级实现要点:从契约到完整代码

结合上述契约与官方提示,一个能跑通全部测试的最小 Express 实现如下(基于 boilerplate 仓库的常见骨架结构):

const express = require('express');
const { DNSResultCode, dnsPromises } = require('dns');
const app = express();

// 提示 1:body 解析中间件,缺失则 POST 拿不到数据
app.use(express.urlencoded({ extended: false }));
app.use(express.json());

// 内存存储;boilerplate 默认骨架即为此形式,生产可换 MongoDB/Mongoose
const db = new Map();
let counter = 0;

app.post('/api/shorturl', async (req, res) => {
  const { url } = req.body;
  if (!url) return res.json({ error: 'invalid url' });

  let parsed;
  try {
    parsed = new URL(url);
  } catch {
    return res.json({ error: 'invalid url' });
  }
  if (!['http:', 'https:'].includes(parsed.protocol)) {
    return res.json({ error: 'invalid url' });
  }

  // 提示 2:dns.lookup 校验主机真实可解析(文档建议的写法是回调版
  // dns.lookup(host, cb),这里展示其 Promise 等价形式 dnsPromises.lookup)
  try {
    await dnsPromises.lookup(parsed.hostname);
  } catch (err) {
    if (err.code === DNSResultCode.NODATA) {
      return res.json({ error: 'invalid url' });
    }
    throw err;
  }

  const short_url = ++counter;
  db.set(short_url, url);
  res.json({ original_url: url, short_url });
});

app.get('/api/shorturl/:short', (req, res) => {
  const target = db.get(Number(req.params.short));
  if (!target) return res.json({ error: 'invalid url' });
  res.redirect(target); // 302 重定向,满足 redirect:'manual' 时 status 为 0 的断言
});

app.listen(process.env.PORT || 3000);

几个实现细节与测试断言直接对应:

  • original_url 原样回显new URL(url).toString() 会规范化(补斜杠、重排 query 等),导致 3.1 节中 assert.strictEqual(original_url, fullUrl) 失败,所以存取都应使用用户提交的原始字符串URL 解析仅用于校验。
  • dns.lookup 的行为边界:它对不存在的域名(如 john-doe.invalidTLD)会返回 ENOTFOUND/NODATA 错误,这正是把“主机不存在”归类为 invalid url 的依据。挑战文档建议的 dns.lookup(host, cb) 是回调风格;核心模块 dns 同时提供同步版 lookupSync 与 Promise 版 dnsPromises.lookup,语义一致。
  • 重定向用 res.redirect() 而非 200 响应,这是 3.2 节 redirect: 'manual' 断言(status === 0)通过的前提。

五、课程侧的配套机制:测试如何跑起来

理解挑战在平台上的运行方式有助于排查问题。从课程仓库结构看:

  • 挑战文件 frontmatter 中的 forumTopicId: 301509 指向官方论坛的对应版块,遇到“我的部署为什么没通过测试”类问题可在该版块检索既有讨论;
  • 该 block 的 blockLayoutproject-list(见 curriculum/structure/blocks/back-end-development-and-apis-projects.json),即五个项目以清单形式呈现,各自独立提交、独立判分;
  • 文档中的测试代码以 code(你提交的服务地址)为入参,fetch + assert 的组合说明判分逻辑就是标准的 Node.js HTTP 客户端断言脚本,运行在官方测试环境而非你的服务器上——因此服务必须保持公网可达且长期在线,测试是异步发起的。

六、小结与检查清单

交付前对照以下清单逐项确认,即可覆盖挑战文档中的全部行为约束:

检查项 对应契约
挂接 express.urlencoded(或等效)body 解析中间件 POST 能读取 url 字段
POST /api/shorturl 返回 { original_url, short_url }original_url 与提交串完全一致 测试 1
GET /api/shorturl/<short_url> 使用 HTTP 301/302 重定向 测试 2(follow 到达原地址;manual 时 status 为 0)
非法 URL(非 http(s) 或域名不可解析)返回 { error: 'invalid url' } 测试 3
提交的是自己的部署地址,而非官方示例域名 hint 1 正则断言
项目文件基于 boilerplate 仓库完整构建 挑战描述的两条开工方式约束

URL Shortener Microservice 体量不大,但它串起了后端认证前三块的核心能力:Express 路由与中间件(body 解析)、核心模块(dns)、状态存储(内存 Map 起步、Mongoose 进阶)与 HTTP 语义(重定向)。按上文契约逐项实现并自测,即可顺利拿下该认证项目。

登录后查看全文
热门项目推荐
相关项目推荐