【后端新手谈09】深入浅出短链接:从原理到实战开发

在日常分享链接的场景中,长串的 URL 不仅不美观,还容易出现复制错误、平台字符限制等问题,短链接应运而生成为解决这些痛点的最优解。本文将从 "是什么、为什么、怎么办" 三个维度解析短链接,并手把手教你基于 Spring Boot + 前端技术搭建一个轻量级短链接服务。

一、短链接是什么?

短链接(Short URL)是将原始长 URL 通过特定算法映射生成的简短字符串链接,核心特征是:

  • 长度短:通常仅包含数字、大小写字母,长度控制在 10-20 个字符;
  • 可跳转:访问短链接时,服务器会将其重定向到原始长 URL;
  • 唯一性:每个短链接对应唯一的原始 URL(也可支持一对多,但主流是一对一)。

比如将 https://www.example.com/article/2024/05/12/spring-boot-best-practices 转换为 http://localhost:8080/u/8a7b9,后者就是短链接。

二、为什么需要短链接?

  1. 场景适配:社交平台(如微博、短信)有字符数限制,长 URL 会占用大量空间,短链接能节省字符;
  2. 美观易记:短链接简洁易读,便于口头传播或印刷展示;
  3. 降低错误率:长 URL 包含大量特殊字符,复制 / 输入时易出错,短链接出错概率大幅降低;
  4. 统一管理:可基于短链接做访问统计、有效期控制、批量失效等扩展功能(本文基础版暂不实现);
  5. 隐私保护:隐藏原始 URL 中的敏感参数(如推广码、用户 ID),降低信息泄露风险。

三、短链接的核心实现逻辑(怎么办)

短链接的核心是 "映射关系"------ 将原始 URL 与短码(Short Code)绑定,核心流程分为两步:

3.1 生成流程

用户提交原始 URL → 服务器验证 URL 有效性 → 生成唯一短码 → 存储 "短码 - 原始 URL" 映射 → 返回短链接;

3.2 跳转流程

用户访问短链接 → 服务器解析短码 → 查询映射关系获取原始 URL → 302 重定向到原始 URL。

3.3 核心技术点

  • 短码生成:常用 Base62(数字 + 大小写字母)算法,将自增 ID 转换为短字符串(避免纯数字易猜);
  • 存储方案:基础版用内存 Map,生产环境可替换为 Redis/Mysql;
  • URL 标准化 :自动补全协议头(如用户输入example.com自动转为https://example.com);
  • 重定向:使用 HTTP 302 状态码实现跳转(支持统计访问量)。

四、实战开发:搭建短链接服务

接下来基于 Spring Boot 3.2 + 原生 HTML/JS 实现一个完整的短链接服务,包含前端页面和后端接口。

4.1 技术栈

  • 后端:Spring Boot 3.2、Spring Validation(参数校验);
  • 前端:原生 HTML/CSS/JS(无框架);
  • 构建工具:Maven;
  • JDK 版本:17。

4.2 后端开发

application.properties

复制代码
server.port=8080
# 生成短链时拼进响应里的前缀(可按部署域名修改)
app.short-url.base=http://localhost:8080
步骤 1:创建 Maven 项目,配置 pom.xml
复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.5</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>short-url</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>short-url</name>
    <description>玩具短链接服务</description>

    <properties>
        <java.version>17</java.version>
    </properties>

    <dependencies>
        <!-- Web核心依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- 参数校验 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <!-- 测试依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
步骤 2:定义请求 / 响应 DTO
CreateLinkRequest.java(请求参数)
复制代码
package com.example.shorturl.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

// 记录类,简化POJO编写
public record CreateLinkRequest(
        @NotBlank(message = "url 不能为空")
        @Size(max = 2048, message = "url 过长")
        String url
) {}
CreateLinkResponse.java(响应结果)
复制代码
package com.example.shorturl.dto;

public record CreateLinkResponse(String code, String shortUrl, String originalUrl) {}
步骤 3:核心服务层(ShortUrlService)

实现 URL 标准化、Base62 编码、映射存储核心逻辑:

复制代码
package com.example.shorturl.service;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.net.URI;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

@Service
public class ShortUrlService {

    // Base62字符集:数字+小写字母+大写字母,共62个字符
    private static final String BASE62 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";

    // 自增序列,用于生成唯一ID
    private final AtomicLong seq = new AtomicLong(0);
    // 内存存储短码-原始URL映射,生产环境替换为Redis/Mysql
    private final Map<String, String> codeToUrl = new ConcurrentHashMap<>();

    // 短链接基础地址,可通过配置文件覆盖
    @Value("${app.short-url.base:http://localhost:8080}")
    private String shortUrlBase;

    /**
     * 生成短链接
     * @param rawUrl 原始URL
     * @return 包含短码、短链接、原始URL的结果对象
     */
    public CreateLinkResult shorten(String rawUrl) {
        // 标准化URL(补全协议头、校验有效性)
        String normalized = normalizeUrl(rawUrl);
        // 生成短码(自增ID转Base62)
        String code = toBase62(seq.incrementAndGet());
        // 存储映射关系
        codeToUrl.put(code, normalized);
        // 拼接完整短链接(处理base地址末尾的/)
        String base = shortUrlBase.endsWith("/") ? shortUrlBase.substring(0, shortUrlBase.length() - 1) : shortUrlBase;
        return new CreateLinkResult(code, base + "/u/" + code, normalized);
    }

    /**
     * 解析短码,获取原始URL
     * @param code 短码
     * @return 原始URL(不存在返回null)
     */
    public String resolve(String code) {
        return codeToUrl.get(code);
    }

    /**
     * URL标准化:补全协议头、校验有效性
     */
    private static String normalizeUrl(String raw) {
        String t = raw.trim();
        // 自动补全https协议头
        if (!t.matches("(?i)^https?://.*")) {
            t = "https://" + t;
        }
        // 校验URL有效性
        URI uri = URI.create(t);
        if (uri.getScheme() == null || uri.getHost() == null) {
            throw new IllegalArgumentException("无效的 URL");
        }
        return uri.toString();
    }

    /**
     * 自增ID转Base62编码(核心算法)
     */
    private static String toBase62(long n) {
        if (n == 0) {
            return String.valueOf(BASE62.charAt(0));
        }
        StringBuilder sb = new StringBuilder();
        while (n > 0) {
            sb.append(BASE62.charAt((int) (n % 62)));
            n /= 62;
        }
        return sb.reverse().toString();
    }

    // 内部结果记录类
    public record CreateLinkResult(String code, String shortUrl, String originalUrl) {}
}
步骤 4:控制器(ShortUrlController)

提供生成短链接和跳转的接口:

复制代码
package com.example.shorturl.web;

import com.example.shorturl.dto.CreateLinkRequest;
import com.example.shorturl.dto.CreateLinkResponse;
import com.example.shorturl.service.ShortUrlService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

@RestController
public class ShortUrlController {

    private final ShortUrlService shortUrlService;

    // 构造器注入服务
    public ShortUrlController(ShortUrlService shortUrlService) {
        this.shortUrlService = shortUrlService;
    }

    /**
     * 生成短链接接口
     * @param req 包含原始URL的请求体
     * @return 短链接响应结果
     */
    @PostMapping("/api/shorten")
    public CreateLinkResponse shorten(@Valid @RequestBody CreateLinkRequest req) {
        try {
            var result = shortUrlService.shorten(req.url());
            return new CreateLinkResponse(result.code(), result.shortUrl(), result.originalUrl());
        } catch (IllegalArgumentException e) {
            // 抛出400异常,返回错误信息
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
        }
    }

    /**
     * 短链接跳转接口
     * @param code 短码
     * @return 302重定向到原始URL
     */
    @GetMapping("/u/{code}")
    public ResponseEntity<Void> redirect(@PathVariable String code) {
        String target = shortUrlService.resolve(code);
        if (target == null) {
            // 短码不存在,返回404
            throw new ResponseStatusException(HttpStatus.NOT_FOUND, "短链不存在");
        }
        // 302重定向(支持统计访问量)
        return ResponseEntity.status(HttpStatus.FOUND).location(java.net.URI.create(target)).build();
    }
}
步骤 5:启动类(ShortUrlApplication)
复制代码
package com.example.shorturl;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class ShortUrlApplication {

    public static void main(String[] args) {
        SpringApplication.run(ShortUrlApplication.class, args);
    }
}

4.3 前端开发

创建src/main/resources/static/index.html(Spring Boot 默认静态资源目录),实现用户交互页面:

复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>短链接</title>
  <style>
    :root {
      --bg: #0f1419;
      --surface: #1a2332;
      --border: #2d3a4d;
      --text: #e7ecf3;
      --muted: #8b9cb3;
      --accent: #5b9cf5;
      --accent-hover: #7eb0f7;
      --danger: #f07178;
      --radius: 12px;
      --font: "Segoe UI", system-ui, sans-serif;
    }
    * { box-sizing: border-box; }
    body {
      margin: 0;
      min-height: 100vh;
      font-family: var(--font);
      background: radial-gradient(ellipse 120% 80% at 50% -20%, #1e3a5f 0%, var(--bg) 55%);
      color: var(--text);
      display: flex;
      align-items: center;
      justify-content: center;
      padding: 24px;
    }
    .card {
      width: 100%;
      max-width: 440px;
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: var(--radius);
      padding: 28px 28px 32px;
      box-shadow: 0 24px 48px rgba(0, 0, 0, 0.35);
    }
    h1 {
      margin: 0 0 8px;
      font-size: 1.5rem;
      font-weight: 600;
      letter-spacing: -0.02em;
    }
    .sub {
      margin: 0 0 24px;
      font-size: 0.875rem;
      color: var(--muted);
      line-height: 1.5;
    }
    label {
      display: block;
      font-size: 0.8125rem;
      color: var(--muted);
      margin-bottom: 8px;
    }
    input[type="url"] {
      width: 100%;
      padding: 12px 14px;
      font-size: 1rem;
      font-family: inherit;
      color: var(--text);
      background: var(--bg);
      border: 1px solid var(--border);
      border-radius: 8px;
      outline: none;
      transition: border-color 0.15s;
    }
    input[type="url"]:focus {
      border-color: var(--accent);
    }
    input[type="url"]::placeholder {
      color: var(--muted);
      opacity: 0.7;
    }
    .row {
      margin-top: 18px;
    }
    button[type="submit"] {
      width: 100%;
      margin-top: 20px;
      padding: 12px 16px;
      font-size: 1rem;
      font-weight: 600;
      font-family: inherit;
      color: #0a0e14;
      background: var(--accent);
      border: none;
      border-radius: 8px;
      cursor: pointer;
      transition: background 0.15s;
    }
    button[type="submit"]:hover:not(:disabled) {
      background: var(--accent-hover);
    }
    button[type="submit"]:disabled {
      opacity: 0.55;
      cursor: not-allowed;
    }
    .result {
      margin-top: 24px;
      padding-top: 24px;
      border-top: 1px solid var(--border);
      display: none;
    }
    .result.visible { display: block; }
    .result-label {
      font-size: 0.8125rem;
      color: var(--muted);
      margin-bottom: 8px;
    }
    .short-row {
      display: flex;
      gap: 10px;
      align-items: stretch;
    }
    .short-row a {
      flex: 1;
      min-width: 0;
      padding: 10px 12px;
      font-size: 0.9375rem;
      color: var(--accent);
      background: var(--bg);
      border: 1px solid var(--border);
      border-radius: 8px;
      text-decoration: none;
      word-break: break-all;
      line-height: 1.4;
    }
    .short-row a:hover {
      text-decoration: underline;
    }
    .btn-copy {
      flex-shrink: 0;
      padding: 10px 16px;
      font-size: 0.875rem;
      font-family: inherit;
      font-weight: 500;
      color: var(--text);
      background: transparent;
      border: 1px solid var(--border);
      border-radius: 8px;
      cursor: pointer;
      transition: border-color 0.15s, background 0.15s;
    }
    .btn-copy:hover {
      border-color: var(--muted);
      background: rgba(255, 255, 255, 0.04);
    }
    .btn-copy.copied {
      border-color: #3dad6b;
      color: #7fd9a0;
    }
    .err {
      margin-top: 16px;
      padding: 10px 12px;
      font-size: 0.875rem;
      color: var(--danger);
      background: rgba(240, 113, 120, 0.08);
      border: 1px solid rgba(240, 113, 120, 0.25);
      border-radius: 8px;
      display: none;
    }
    .err.visible { display: block; }
    .orig {
      margin-top: 14px;
      font-size: 0.8125rem;
      color: var(--muted);
      word-break: break-all;
      line-height: 1.45;
    }
  </style>
</head>
<body>
  <main class="card">
    <h1>短链接生成</h1>
    <p class="sub">输入网址,一键生成短链;访问短链会跳转到原地址(数据仅在内存中,重启后失效)。</p>
    <form id="form" novalidate>
      <label for="url">目标地址</label>
      <input
        id="url"
        name="url"
        type="url"
        inputmode="url"
        autocomplete="url"
        placeholder="https://example.com 或 example.com"
        required
      />
      <div class="row">
        <button type="submit" id="submit">生成短链接</button>
      </div>
    </form>
    <div id="err" class="err" role="alert"></div>
    <section id="result" class="result" aria-live="polite">
      <div class="result-label">你的短链接</div>
      <div class="short-row">
        <a id="shortLink" href="#" target="_blank" rel="noopener noreferrer"></a>
        <button type="button" class="btn-copy" id="copy">复制</button>
      </div>
      <p class="orig"><span id="origLabel"></span></p>
    </section>
  </main>
  <script>
    (function () {
      const form = document.getElementById("form");
      const urlInput = document.getElementById("url");
      const submitBtn = document.getElementById("submit");
      const errEl = document.getElementById("err");
      const resultEl = document.getElementById("result");
      const shortLinkEl = document.getElementById("shortLink");
      const copyBtn = document.getElementById("copy");
      const origLabel = document.getElementById("origLabel");

      // 隐藏错误提示
      function hideErr() {
        errEl.classList.remove("visible");
        errEl.textContent = "";
      }

      // 显示错误提示
      function showErr(msg) {
        errEl.textContent = msg;
        errEl.classList.add("visible");
      }

      // 隐藏结果区域
      function hideResult() {
        resultEl.classList.remove("visible");
      }

      // 解析接口错误信息
      async function parseError(res) {
        const text = await res.text();
        try {
          const j = JSON.parse(text);
          if (j.message) return j.message;
          if (Array.isArray(j.errors) && j.errors[0]?.defaultMessage) {
            return j.errors[0].defaultMessage;
          }
        } catch (_) {}
        return text || res.statusText || "请求失败";
      }

      // 表单提交事件
      form.addEventListener("submit", async function (e) {
        e.preventDefault();
        hideErr();
        hideResult();
        copyBtn.classList.remove("copied");
        copyBtn.textContent = "复制";

        const url = urlInput.value.trim();
        if (!url) {
          showErr("请输入网址");
          return;
        }

        submitBtn.disabled = true;
        try {
          // 调用后端生成短链接接口
          const res = await fetch("/api/shorten", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ url }),
          });
          if (!res.ok) {
            showErr(await parseError(res));
            return;
          }
          const data = await res.json();
          // 渲染短链接结果
          shortLinkEl.href = data.shortUrl;
          shortLinkEl.textContent = data.shortUrl;
          origLabel.textContent = "原始链接:" + data.originalUrl;
          resultEl.classList.add("visible");
        } catch (err) {
          showErr("网络异常,请稍后重试");
        } finally {
          submitBtn.disabled = false;
        }
      });

      // 复制短链接功能
      copyBtn.addEventListener("click", async function () {
        const text = shortLinkEl.textContent;
        if (!text) return;
        try {
          await navigator.clipboard.writeText(text);
          copyBtn.textContent = "已复制";
          copyBtn.classList.add("copied");
          // 2秒后恢复按钮状态
          setTimeout(function () {
            copyBtn.textContent = "复制";
            copyBtn.classList.remove("copied");
          }, 2000);
        } catch (_) {
          // 兼容旧浏览器
          shortLinkEl.select?.();
          document.execCommand("copy");
        }
      });
    })();
  </script>
</body>
</html>

4.4 运行与测试

  1. 启动 Spring Boot 应用(运行ShortUrlApplication的 main 方法);
  2. 访问http://localhost:8080,进入前端页面;
  3. 输入任意合法 URL(如www.baidu.com),点击 "生成短链接";
  4. 页面会显示生成的短链接(如http://localhost:8080/u/1),点击可跳转至原始 URL;
  5. 点击 "复制" 按钮可复制短链接到剪贴板。

五、扩展与优化(生产环境建议)

本文实现的是基础版短链接服务,生产环境需做以下优化:

  1. 存储层:将内存 Map 替换为 Redis(支持过期时间、分布式部署)或 Mysql(持久化);
  2. 短码生成:增加随机数 / 哈希算法,避免自增 ID 易被遍历;
  3. 防重复:对相同原始 URL 返回相同短链接(增加 URL→短码的反向映射);
  4. 访问统计:在跳转接口中记录访问量、IP、时间等信息;
  5. 限流防刷:增加接口限流(如 Spring Cloud Gateway + Sentinel);
  6. HTTPS:配置 HTTPS 证书,保证传输安全;
  7. 自定义短码:支持用户自定义短码(需校验唯一性);
  8. 异常监控:接入日志框架(如 Logback)、监控平台(如 Prometheus)。

六、总结

短链接的核心是 "映射关系 + 重定向",通过 Base62 算法将自增 ID 转换为短码,结合 HTTP 302 实现跳转。本文从原理到实战,完整实现了一个轻量级短链接服务,涵盖前端交互、后端接口、参数校验、URL 标准化等核心环节。在此基础上,你可以根据业务需求扩展更多功能,打造符合生产标准的短链接系统。

相关推荐
yuki_uix2 小时前
当 reduce 遇到二维数据:从"聚合直觉"到"复合 Map"的思维跃迁
前端·javascript·面试
DeepModel2 小时前
通俗易懂讲透随机梯度下降法(SGD)
人工智能·python·算法·机器学习
我叫黑大帅2 小时前
Vue3中的computed 与 watch 的区别
前端·javascript·面试
满满和米兜2 小时前
【Java基础】- 集合-HashSet与TreeSet
java·开发语言·算法
yuki_uix2 小时前
前端解题的 6 个思维模型:比记答案更有用的东西
前端·面试
无尽的罚坐人生2 小时前
hot 100 73. 矩阵置零
线性代数·算法·矩阵
goodluckyaa2 小时前
thread block grid模型
算法
武帝为此2 小时前
【Rabbit加密算法介绍】
算法·安全
m0_716765232 小时前
数据结构三要素、时间复杂度计算详解
开发语言·数据结构·c++·经验分享·笔记·算法·visual studio