如何缩短 URL:Java 和 Spring 分步指南

实现 URL 缩短服务并不是一项复杂的任务,它通常是系统设计面试的一部分。在这篇文章中,我将尝试解释实现该服务的过程。URL 缩短器是一种用于从非常长的 URL 创建短链接的服务。

通常,短链接的大小是原始 URL 的三分之一甚至四分之一,这使得它们更容易输入、呈现或发推文。单击短链接,用户将被自动重定向到原始 URL。在线提供许多 URL 缩短服务,例如 tiny.cc、bitly.com、cutt.ly 等。

理论

在实施之前,以功能性和非功能性需求的形式写下需要做的事情总是一个好主意。

功能要求

  • 用户需要能够输入长 URL。我们的服务应该保存那个 URL 并生成一个短链接。
  • 单击短链接应将用户重定向到原始长 URL。
  • 用户应该可以选择输入到期日期。一旦该日期过去,短链接应该是无效的。
  • 用户应创建一个帐户以使用该服务。服务可以有每个用户的使用限制(可选)
  • 允许用户创建自己的短链接 – 服务应该有指标,例如,访问最多的链接(可选)

非功能性需求

  • 服务应在 100% 的时间内启动并运行
  • 重定向的持续时间不应超过两秒

网址转换

假设我们想要一个最大长度为 7 的短链接。URL 缩短器中最重要的是转换算法。URL转换可以通过几种不同的方式实现,每种方式都有其优点和缺点。

生成短链接的一种方法是使用一些散列函数(例如MD5SHA-2)对原始 URL 进行散列。使用哈希函数时,可以肯定的是,不同的输入会导致不同的输出。哈希的结果超过七个字符,所以我们需要取前七个字符。但是,在这种情况下,可能会发生冲突,因为前七个字符可能已经用作短链接。然后,我们取接下来的七个字符,直到找到一个未使用的短链接。

生成短链接的第二种方法是使用UUID。UUID 将被复制的概率不是零,但它足够接近于零,可以忽略不计。由于 UUID 有 36 个字符,这意味着我们遇到了与上述相同的问题。我们应该取前七个字符并检查该组合是否已被使用。

第三种选择是将数字从以 10 为基数转换为以 62 为基数。基数是可用于表示特定数字的数字或字符数。以 10 为底的是我们在日常生活中使用的数字 [0-9],而以 62 为底的数字是 [0-9][az][AZ]。这意味着,例如,以 10 为基数的四位数字将与以 62 为基数的数字相同,但具有两个字符。

在最大长度为 7 个字符的 URL 转换中使用 base 62 允许我们为短链接拥有 62^7 个唯一值。

那么 base 62 转换是如何工作的呢?

我们有一个以 10 为底的数字,我们想将其转换为以 62 为底的数字。我们将使用以下算法:

    while(number > 0)
    remainder = number % 62
    number = number / 62
    attach remainder to start of result collection

之后,我们只需要将结果集合中的数字映射到基数为 62 的 Alphabet = [0,1,2,…,a,b,c…,A,B,C,…]。

让我们用一个真实的例子来看看它是如何工作的。在此示例中,让我们将 1000 从基数 10 转换为基数 62。

    1st iteration:
        number = 1000
        remainder = 1000 % 62 = 8
        number = 1000 / 62 = 16
        result list = [8]
    2nd iteration:
        number = 16
        remainder = 16 % 62 = 16
        number = 16 / 62 = 0
        result list = [16,8]
        There is no more iterations since number = 0 after 2nd iteration

将 [16,8] 映射到 base 62 将是 g8。这意味着 1000base10 = g8base62。

从基数 62 转换为基数 10 也很简单:

    i = 0
    while(i < inputString lenght)
        counter = i + 1
        mapped = base62alphabet.indexOf(inputString[i]) // map character to number based on its index in alphabet
        result = result + mapped * 62^(inputString lenght - counter)
        i++

真实例子:

    inputString = g8
    inputString length = 2
    i = 0
    result = 0
    1st iteration
        counter = 1
        mapped = 16 // index of g in base62alphabet is 16
        result = 0 + 16 * 62^1 = 992
    2nd iteration
        counter = 2
        mapped = 8 // index of 8 in base62alphabet is 8
        result = 992 + 8 * 62^1 = 1000

执行

注意:整个解决方案在我的Github上。我使用 Spring Boot 和 MySQL 实现了这项服务。

我们将使用数据库的自动增量功能。自动递增的数字将用于 base 62 转换。您可以使用任何其他具有自动增量功能的数据库。

首先,访问Spring initializr并选择 Spring Web 和 MySql Driver。之后,单击“生成”按钮并下载 zip 文件。解压缩文件并在您喜欢的 IDE 中打开项目。每次我开始一个新项目时,我都喜欢创建一些文件夹来逻辑划分我的代码。在这种情况下,我的文件夹是控制器、实体、服务、存储库、dto 和配置。

在实体文件夹中,我们创建一个具有四个属性的Url.java类:id、longUrl、createdDate、expiresDate。

请注意,没有短链接属性。我们不会保存短链接。每次有 GET 请求时,我们都会将 id 属性从 base 10 转换为 base 62。这样,我们就节省了数据库中的空间。

LongUrl 属性是用户访问短链接后我们应该重定向到的 URL。创建日期只是为了查看 longUrl 的保存时间(这并不重要),如果用户希望在一段时间后使短链接不可用,则 expiresDate 存在。

接下来,让我们在服务文件夹中创建一个BaseService .java 。BaseService 包含从 base 10 转换为 base 62 的方法,反之亦然。

    private static final String allowedString = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
    private char[] allowedCharacters = allowedString.toCharArray();
    private int base = allowedCharacters.length;

正如我之前提到的,如果我们想使用 base 62 转换,我们需要有一个 base 62 字母表,在这种情况下,它被称为 allowedCharacters。此外,如果我们想要更改允许的字符,则基本变量的值是根据允许的字符的长度计算的。

encode 方法将一个数字作为输入并返回一个短链接。decode 方法将字符串(短链接)作为输入并返回一个数字。算法应该按照上面的解释来实现。

之后,在存储库文件夹中,我们创建UrlRepository .java文件,它只是 JpaRepository 的扩展,它为我们提供了很多方法,如 ‘findById’、’save’ 等。我们不需要添加任何其他内容对此。

然后,让我们在控制器文件夹中创建一个 UrlController.java 文件。控制器应该有一种用于创建短链接的 POST 方法和一种用于重定向到原始 URL 的 GET 方法。

    @PostMapping("create-short")
    public String convertToShortUrl(@RequestBody UrlLongRequest request) {
        return urlService.convertToShortUrl(request);
    }

    @GetMapping(value = "{shortUrl}")
    public ResponseEntity<Void> getAndRedirect(@PathVariable String shortUrl) {
        var url = urlService.getOriginalUrl(shortUrl);
        return ResponseEntity.status(HttpStatus.FOUND)
        .location(URI.create(url))
        .build();
    }

POST 方法将UrlLongRequest作为其请求正文。它只是一个具有 longUrl 和 expiresDate 属性的类。

GET 方法将一个短 URL 作为路径变量,然后获取并重定向到原始 URL。在控制器的顶部,将 UrlService 作为依赖注入,接下来会解释。

UrlService .java是大多数逻辑所在的地方,也是控制器使用的服务。

ConvertToShortUrl 由控制器的 POST 方法使用。它只是在数据库中创建一条新记录并获取一个 ID。然后将 ID 转换为 base 62 短链接并返回给控制器。

GetOriginalUrl 是控制器的 GET 方法使用的方法。它首先将一个字符串转换为以 10 为底,结果是一个 id。然后它从数据库中获取具有该 ID 的记录,如果不存在则抛出异常。之后,它将原始 URL 返回给控制器。

“高级”主题

在这一部分中,我将讨论 swagger 文档、应用程序的docker化、应用程序缓存和 MySql 计划事件。

招摇用户界面

每次开发 API 时,最好以某种方式记录它。文档使 API 更易于理解和使用。此项目的 API 使用 Swagger UI 记录。

Swagger UI允许任何人在没有任何实现逻辑的情况下可视化 API 的资源并与之交互。

它是自动生成的,带有可视化文档,便于后端实施和客户端使用。

我们需要采取几个步骤才能在项目中包含 Swagger UI。

首先,我们需要在 pom.xml 文件中添加 Maven 依赖项:

    <dependency>
        <groupId>io.springfox</groupId>
        <artifactId>springfox-swagger2</artifactId>
        <version>2.9.2</version>
    </dependency>
    <dependency>
        <groupId>io.springfox</groupId>
        <artifactId>springfox-swagger-ui</artifactId>
        <version>2.9.2</version>
    </dependency>

供您参考,您可以在此处查看完整的 pom.xml 文件。添加 Maven 依赖后,是时候添加 Swagger 配置了。在 config 文件夹中,我们需要创建一个新类——SwaggerConfig .java

    @Configuration
    @EnableSwagger2
    public class SwaggerConfig {

    @Bean    
    public Docket apiDocket() {   
        return new Docket(DocumentationType.SWAGGER_2)  
            .apiInfo(metadata())    
            .select()    
            .apis(RequestHandlerSelectors.basePackage("com.amarin"))    
            .build();    
    }

    private ApiInfo metadata(){
        return new ApiInfoBuilder()
        .title("Url shortener API")    
        .description("API reference for developers")    
        .version("1.0")    
        .build();    
        }  
    }

在类的顶部,我们需要添加几个注释。

@Configuration表示一个类声明了一个或多个@Beans方法,并且可以由Spring 容器处理以在运行时为这些bean 生成bean 定义和服务请求。

@EnableSwagger2表示应启用 Swagger 支持。

接下来,我们应该添加Docket bean 它为主要的 API 配置提供合理的默认值和方便的配置方法。

apiInfo ()方法采用 ApiInfo 对象,我们可以在其中配置所有必要的 API 信息——否则,它使用一些默认值。为了使代码更简洁,我们应该创建一个私有方法来配置和返回 ApiInfo 对象,并将该方法作为参数传递给apiInfo()方法。在这种情况下,它是metadata()方法。

apis()方法允许我们过滤正在记录的包。

Swagger UI 已配置,我们可以开始记录我们的 API。在UrlController中,在每个端点之上,我们可以使用@ApiOperation注释来添加描述。根据您的需要,您可以使用其他一些注释

也可以使用@ApiModelProperty记录DTO,它允许您添加允许的值、描述等。

缓存

根据 Wikipedia,[缓存](https://en.wikipedia.org/wiki/Cache_(computing)是一种硬件或软件组件,用于存储数据,以便可以更快地处理未来对该数据的请求;数据存储在缓存可能是早期计算的结果或存储在其他地方的数据副本。

最常用的缓存类型是内存缓存,它将缓存的数据存储在 RAM 中。当数据被请求并在缓存中找到时,它是从 RAM 而不是从数据库中提供的。这样,当用户请求数据时,我们避免调用昂贵的后端。

URL 缩短器是一种读取请求多于写入请求的应用程序,这意味着它是使用缓存的理想应用程序。

要在 Spring Boot 应用程序中启用缓存,我们只需要在UrlShortenerApiApplication类中添加@EnableCaching注解即可。

之后,在控制器中,我们需要在GET 方法上方设置@Cachable注解。此注解自动存储称为缓存的方法的结果。在@Cachable注解中,我们设置value参数是缓存的名字,key参数是缓存的key。

在这种情况下,对于缓存键,我们将使用“shortUrl”,因为我们确定它是唯一的。同步参数设置为 true 以确保只有单个线程正在构建缓存值。

就是这样——我们的缓存已经设置好了,当我们第一次加载带有一些短链接的 URL 时,结果将被保存到缓存中,并且对具有相同短链接的端点的任何额外调用都将从缓存中检索结果,而不是从数据库。

码头化

Docker 化是将应用程序及其依赖项打包到 [Docker](https://en.wikipedia.org/wiki/Docker_(software) 容器中的过程。一旦我们配置了 Docker 容器,我们就可以轻松地在任何支持 Docker 的服务器或计算机。

我们需要做的第一件事是创建一个 Dockerfile。

Dockerfile是一个文本文件,其中包含用户可以在命令行上调用以组装映像的所有命令

    FROM openjdk:13-jdk-alpine   
    COPY ./target/url-shortener-api-0.0.1-SNAPSHOT.jar /usr/src/app/url-shortener-api-0.0.1-SNAPSHOT.jar    
    EXPOSE 8080    
    ENTRYPOINT ["java","-jar","/usr/src/app/url-shortener-api-0.0.1-SNAPSHOT.jar"]

FROM – 这是我们为构建基础设置基础镜像的地方。我们将使用 OpenJDK v13,它是 Java 的免费开源版本。您可以在Docker hub上为您的基础镜像找到其他镜像,这是一个共享 docker 镜像的地方。

COPY – 此命令将文件从本地文件系统(您的计算机)复制到我们指定路径的容器文件系统。我们要将 JAR 文件从目标文件夹复制到容器中的 /usr/src/app 文件夹。稍后我将解释创建 JAR 文件。

EXPOSE – 通知 Docker 容器在运行时侦听指定网络端口的指令。默认协议是 TCP,您可以指定是否要使用 UDP。

ENTRYPOINT – 此指令允许您配置将作为可执行文件运行的容器。在这里,我们需要指定 Docker 将如何耗尽应用程序。

从 .jar 文件运行应用程序的命令是

    java -jar <app_name>.jar

所以我们把这三个词放在一个数组中,就是这样。

现在我们有了 Dockerfile,我们应该从中构建镜像。但是就像我之前提到的,我们首先需要从我们的项目中创建 .jar 文件,这样 Dockerfile 中的 COPY 命令才能正常工作。要创建可执行的 .jar,我们将使用maven

我们需要确保我们的pom .xml中有 Maven 。如果缺少 Maven,我们可以添加它

<build>    
    <plugins>    
        <plugin>    
            <groupId>org.springframework.boot</groupId>    
            <artifactId>spring-boot-maven-plugin</artifactId>    
        </plugin>    
    </plugins>    
</build>

之后,我们应该只运行命令

    mvn clean package

完成后,我们可以构建 Docker 映像。我们需要确保我们在 Dockerfile 所在的同一文件夹中,以便我们可以运行此命令

    docker build -t url-shortener:latest .

-t用于标记图像。在我们的例子中,这意味着存储库的名称将是 url-shortener 并且标签将是最新的。标记用于图像的版本控制。完成该命令后,我们可以确保我们使用该命令创建了一个图像

    docker images

这会给我们这样的东西

最后一步,我们应该构建我们的图像。我说图像是因为我们还将在 docker 容器中运行 MySQL 服务器。数据库容器将与应用程序容器隔离。要在 docker 容器中运行 MySQL 服务器,只需运行

    $ docker run --name shortener -e MYSQL_ROOT_PASSWORD=my-secret-pw -d -p 3306:3306 mysql:8

您可以在Docker hub上查看文档。

当我们在容器内运行数据库时,我们需要配置我们的应用程序以连接到该 MySQL 服务器。在application.properties中设置 spring.datasource.url 以连接到“shortener”容器。

由于我们对项目进行了一些更改,因此需要使用 Maven 将项目打包到 .jar 文件中,并再次从 Dockerfile 构建 Docker 映像。

现在我们有了一个 Docker 镜像,我们需要运行我们的容器。我们将使用命令执行此操作

    docker run -d --name url-shortener-api -p 8080:8080 --link shortener url-shortener

-d表示 Docker 容器在终端后台运行。–name让您设置容器的名称

-p host-port:docker-port – 这只是将本地计算机上的端口映射到容器内的端口。在这种情况下,我们在容器中暴露了 8080 端口,并决定将其映射到我们的本地端口 8080。

–link与此链接我们将我们的应用程序容器与数据库容器链接起来,以允许容器相互发现并将有关一个容器的信息安全地传输到另一个容器。

重要的是要知道这个标志现在是一个遗产,它将在不久的将来被删除。我们需要创建一个网络来促进两个容器之间的通信,而不是链接。

url-shortener – 是我们要运行的 docker 镜像的名称。

至此,我们就完成了——在浏览器中访问http://localhost:8080/swagger-ui.html

现在,您可以将图像发布到 DockerHub 并轻松地在任何计算机或服务器上运行您的应用程序。

为了改善我们的 Docker 体验,我还想谈两件事。一个是多阶段构建,另一个是 docker-compose。

多阶段构建

通过多阶段构建,您可以在 Dockerfile中使用多个FROM语句。每个FROM指令都可以使用不同的基础,并且它们中的每一个都开始构建的新阶段。您可以选择性地将工件从一个阶段复制到另一个阶段,从而在最终图像中留下您不想要的一切。

多阶段构建有利于我们避免每次对代码进行一些更改时手动创建 .jar 文件。对于多阶段构建,我们可以定义一个构建阶段来执行 Maven 打包命令,而另一个阶段将把第一个构建的结果复制到 Docker 容器的文件系统中。

您可以在此处查看完整的 Dockerfile 。

码头工人组成

Compose是一个用于定义和运行多容器 Docker 应用程序的工具。使用 Compose,您可以使用 YAML 文件来配置应用程序的服务。然后,使用一个命令,您可以从您的配置中创建并启动所有服务。

使用 docker-compose,我们会将我们的应用程序和数据库打包到一个配置文件中,然后一次运行所有内容。这样我们就避免了每次运行 MySQL 容器然后将其链接到应用程序容器。

Docker – compose.yml几乎是不言自明的——首先,我们通过设置镜像 mysql v8.0 和 MySQL 服务器的凭据来配置 MySQL 容器。之后,我们通过设置构建参数来配置应用程序容器,因为我们需要构建一个镜像,而不是像使用 MySQL 那样拉取它。另外,我们需要设置应用程序容器依赖于 MySQL 容器。

现在我们可以只用一个命令运行整个项目:

docker-compose up

MySQL 预定事件

这部分是可选的,但我认为无论如何有人可能会觉得这很有用。我谈到了短链接的到期日期,它可以是用户定义的或一些默认值。对于这个问题,我们可以在我们的数据库中设置一个预定的事件。此事件将每 x 分钟运行一次,并将从数据库中删除到期日期低于当前时间的所有行。就那么简单。这适用于数据库中的少量数据。

现在我需要警告您有关此解决方案的几个问题。

  • First – 此事件将从数据库中删除记录,但不会从缓存中删除数据。就像我们之前说的,如果缓存可以找到匹配的数据,它就不会在数据库内部查找。因此即使数据因为我们删除而不再存在于数据库中,我们仍然可以从缓存中获取它。
  • 第二——在我的示例脚本中,我将该事件设置为每 2 分钟运行一次。如果我们的数据库变得很大,那么事件可能没有在其调度间隔内完成执行,结果可能是事件的多个实例同时执行。