Сократитель URL: Java и Spring полное руководство


Введение

Чтобы получить больше сообщений, подобных этому, следуйте за мной в Twitter

URL Shortener – это сервис, который используется для создания коротких ссылок из очень длинных URL. Обычно короткие ссылки имеют размер в одну треть или даже одну четвертую часть оригинального URL, что делает их более удобными для ввода, представления или твита. При нажатии на короткую ссылку пользователь автоматически перенаправляется на исходный URL.

В Интернете существует множество служб сокращения URL-адресов, таких как tiny.cc, bitly.com, cutt.ly и др. Реализация службы сокращения URL не является сложной задачей, и она часто является частью собеседований при проектировании системы. В этой заметке я постараюсь объяснить процесс внедрения сервиса.

Теория

Перед внедрением всегда полезно записать то, что необходимо сделать, в виде функциональных и нефункциональных требований.

Функциональные требования:

  • Пользователи должны иметь возможность вводить длинный URL. Наш сервис должен сохранить этот URL и сгенерировать короткую ссылку.
  • Пользователи должны иметь возможность ввести дату истечения срока действия. По истечении этого срока короткая ссылка должна стать недействительной.
  • Нажатие на короткую ссылку должно перенаправлять пользователя на исходный длинный URL.
  • Для использования услуги пользователи должны создать учетную запись. Сервис может иметь лимит использования для одного пользователя*.
  • Пользователь может создавать свои собственные короткие ссылки*.
  • Сервис должен иметь метрики, например, наиболее посещаемые ссылки*.

Нефункциональные требования:

  • Сервис должен работать 100% времени.
  • Перенаправление не должно длиться дольше двух секунд.

*Требования являются необязательными

Конвертация урлов:

Допустим, мы хотим иметь короткую ссылку с максимальной длиной 7. Самое важное в сократителе URL – это алгоритм преобразования. Преобразование URL может быть реализовано несколькими различными способами, и каждый из них имеет свои плюсы и минусы.

Одним из способов генерации коротких ссылок является хэширование исходного URL с помощью какой-либо хэш-функции (например, MD5 или SHA-2). При использовании хэш-функции можно быть уверенным, что разные входные данные приведут к разным выходным. Результат хэширования длиннее семи символов, поэтому нам нужно будет взять первые семь символов. Но в этом случае может произойти коллизия, поскольку первые семь символов могут уже использоваться в качестве короткой ссылки. Затем мы берем следующие семь символов, пока не найдем короткую ссылку, которая не используется.

Второй способ генерации короткой ссылки – это использование UUID. Вероятность того, что UUID будет продублирован, не равна нулю, но она достаточно близка к нулю, чтобы ею можно было пренебречь. Поскольку UUID состоит из 36 символов, это означает, что у нас та же проблема, что и выше. Мы должны взять первые семь символов и проверить, не используется ли уже эта комбинация.

Третий способ – преобразование чисел из основания 10 в основание 62. Основание – это количество цифр или знаков, которые могут быть использованы для представления определенного числа. Основание 10 – это цифры [0-9], которые мы используем в повседневной жизни, а основание 62 – [0-9][a-z][A-Z]. Это означает, что, например, число в базе 10 с четырьмя цифрами будет тем же числом в базе 62, но с двумя знаками.

Использование базы 62 в преобразовании url с максимальной длиной в семь символов позволяет нам иметь 62^7 уникальных значений для коротких ссылок.

Как работает преобразование по основанию 62?

У нас есть число по основанию 10, которое мы хотим преобразовать в основание 62. Мы будем использовать следующий алгоритм:

    while(number > 0)
    remainder = number % 62
    number = number / 62
    attach remainder to start of result collection
Войти в полноэкранный режим Выход из полноэкранного режима

После этого нам просто нужно перевести числа из коллекции результатов в основание 62 Алфавит = [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] в основание 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.

Мы собираемся использовать функцию автоинкремента нашей базы данных. Автоинкрементное число будет использоваться для преобразования базы 62. Вы можете использовать любую другую базу данных, которая имеет функцию автоинкремента.

Сначала зайдите в Spring initializr и выберите Spring Web и MySql Driver. После этого нажмите на кнопку Generate и скачайте zip-файл. Распакуйте файл и откройте проект в вашей любимой IDE.
Каждый раз, когда я начинаю новый проект, мне нравится создавать несколько папок для логического разделения кода. В данном случае это папки controller, entity, service, repository, dto и config.

Внутри папки entity создадим класс Url.java с четырьмя атрибутами: id, longUrl, createdDate, expiresDate.

Обратите внимание, что нет атрибута короткой ссылки. Мы не будем сохранять короткие ссылки. Мы будем преобразовывать атрибут id из основания 10 в основание 62 каждый раз, когда выполняется запрос GET. Таким образом, мы сэкономим место в нашей базе данных.

Атрибут LongUrl – это URL, на который мы должны перенаправить пользователя после перехода по короткой ссылке. Дата создания нужна только для того, чтобы увидеть, когда длинная ссылка была сохранена (она не важна), а expiresDate нужна, если пользователь хочет сделать короткую ссылку недоступной через некоторое время.

Далее, давайте создадим BaseService.java в папке service. BaseService содержит методы для преобразования из основания 10 в основание 62 и наоборот.

    private static final String allowedString = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
    private char[] allowedCharacters = allowedString.toCharArray();
    private int base = allowedCharacters.length;
Вход в полноэкранный режим Выход из полноэкранного режима

Как я уже говорил, если мы хотим использовать преобразования по основанию 62, нам необходимо иметь алфавит основания 62, который в данном случае называется allowedCharacters. Кроме того, значение переменной base вычисляется из длины разрешенных символов на случай, если мы захотим изменить разрешенные символы.

Метод encode принимает на вход число и возвращает короткую ссылку. Метод декодирования принимает на вход строку (короткую ссылку) и возвращает число. Алгоритмы должны быть реализованы так, как они были описаны выше.

После этого в папке репозитория создадим файл UrlRepository.java, который является просто расширением JpaRepository и дает нам множество методов, таких как ‘findById’, ‘save’ и т.д. Нам не нужно добавлять сюда ничего другого.

Затем создадим файл UrlController.java в папке контроллера. Контроллер должен иметь один метод POST для создания коротких ссылок и один метод GET для перенаправления на исходный URL.

    @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 преобразуется в короткую ссылку base 62 и возвращается контроллеру.

GetOriginalUrl – это метод, используемый методом GET из контроллера. Сначала он преобразует строку в base 10, в результате чего получается id. Затем он получает запись из базы данных по этому id и выбрасывает исключение, если она не существует. После этого он возвращает контроллеру исходный URL.

И это все для первой части. В следующей части я сосредоточусь на более “продвинутых” вещах.

Продвинутые” темы

В этой части я расскажу о документации Swagger, докеризации приложения, кэше приложения и запланированном событии MySql.

Пользовательский интерфейс Swagger

Каждый раз, когда вы разрабатываете API, полезно каким-то образом документировать его. Документация облегчает понимание и использование API. API в этом проекте документирован с помощью Swagger UI.

Swagger UI позволяет любому человеку визуализировать ресурсы API и взаимодействовать с ними, не имея при этом никакой логики реализации. Он генерируется автоматически, а визуальная документация упрощает реализацию на задней стороне и использование на стороне клиента.

Для включения Swagger UI в проект необходимо выполнить несколько шагов.

Во-первых, нам нужно добавить зависимости Maven в файл pom.xml:

    <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 для создания определений бобов и запросов на обслуживание этих бобов во время выполнения.

@EnableSwagger2 указывает, что поддержка Swagger должна быть включена.

Далее мы должны добавить боб Docket, который обеспечивает первичную конфигурацию API с разумными значениями по умолчанию и удобными методами для настройки.

Метод apiInfo() принимает объект ApiInfo, в котором мы можем настроить всю необходимую информацию об API – в противном случае он использует некоторые значения по умолчанию. Чтобы сделать код чище, мы должны сделать частный метод, который будет конфигурировать и возвращать объект ApiInfo и передавать его в качестве параметра методу apiInfo(). В данном случае это метод metadata().

Метод apis() позволяет нам фильтровать пакеты, которые документируются.

Теперь Swagger UI настроен, и мы можем приступить к документированию нашего API. Внутри UrlController, над каждой конечной точкой, мы можем использовать аннотацию @ApiOperation для добавления описания. В зависимости от ваших потребностей вы можете использовать и другие аннотации.

Также можно документировать DTO и с помощью @ApiModelProperty, что позволяет добавлять допустимые значения, описания и т.д.

Кэширование

Согласно Википедии, кэш – это аппаратный или программный компонент, который хранит данные, чтобы будущие запросы на эти данные обслуживались быстрее; данные, хранящиеся в кэше, могут быть результатом предыдущего вычисления или копией данных, хранящихся в другом месте.

Наиболее часто используемым типом кэша является кэш в памяти, который хранит кэшированные данные в оперативной памяти. Когда данные запрашиваются и находятся в кэше, они предоставляются из оперативной памяти, а не из базы данных. Таким образом, мы избегаем обращения к дорогостоящему бэкенду, когда пользователь запрашивает данные.

Укоротитель URL – это тип приложения, которое имеет больше запросов на чтение, чем на запись, что означает, что это идеальное приложение для использования кэша.

Чтобы включить кэширование в приложении Spring Boot, нам просто нужно добавить аннотацию @EnableCaching в класс UrlShortenerApiApplication.

После этого в контроллере необходимо установить аннотацию @Cachable над методом GET. Эта аннотация автоматически сохраняет результат вызова метода в кэш. В аннотации @Cachable мы задаем параметр value, который является именем кэша, и параметр key, который является ключом кэша. В данном случае для ключа кэша мы будем использовать ‘shortUrl’, поскольку уверены в его уникальности. Параметр Sync имеет значение true, чтобы гарантировать, что только один поток создает значение кэша.

Вот и все – наш кэш создан, и когда мы впервые загрузим URL с короткой ссылкой, результат будет сохранен в кэше, и при любом последующем обращении к конечной точке с той же короткой ссылкой результат будет получен из кэша, а не из базы данных.

Докеризация

Докеризация – это процесс упаковки приложения и его зависимостей в контейнер Docker. Как только мы настроим контейнер 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 файл из нашего проекта, чтобы команда COPY в Dockerfile работала правильно. Для создания исполняемого файла .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, а тег – latest. Тегирование используется для версионирования изображений. После выполнения этой команды мы можем убедиться, что создали изображение, с помощью команды

    docker images  
Войти в полноэкранный режим Выйти из полноэкранного режима

В результате мы получим что-то вроде этого

На последнем этапе мы должны создать наши образы. Я говорю “образы”, потому что мы также запустим сервер MySQL в контейнере docker. Контейнер базы данных будет изолирован от контейнера приложения. Чтобы запустить сервер MySQL в контейнере docker, просто выполните следующие действия

    $ 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’.

Поскольку мы внесли некоторые изменения в наш проект, необходимо упаковать наш проект в файл .jar с помощью Maven и снова собрать образ Docker из Dockerfile.

Теперь, когда у нас есть образ 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.

Многоэтапная сборка

При многоэтапной сборке вы используете несколько операторов FROM в вашем Dockerfile. Каждое указание FROM может использовать разную базу, и каждое из них начинает новый этап сборки. Вы можете выборочно копировать артефакты с одного этапа на другой, оставляя в конечном образе все, что вам не нужно.

Многоэтапные сборки хороши тем, что позволяют нам избежать ручного создания .jar-файлов каждый раз, когда мы вносим какие-то изменения в наш код. При многоэтапной сборке мы можем определить один этап сборки, который будет выполнять команду Maven package, а другой этап будет копировать результат первой сборки в файловую систему контейнера Docker.

Полный Dockerfile можно посмотреть здесь.

Docker-compose

Compose – это инструмент для определения и запуска многоконтейнерных приложений Docker. С помощью Compose вы используете YAML-файл для настройки сервисов вашего приложения. Затем, с помощью одной команды, вы создаете и запускаете все сервисы из вашей конфигурации.

С помощью docker-compose мы упакуем наше приложение и базу данных в один конфигурационный файл, а затем запустим все сразу. Таким образом, мы избежим запуска контейнера MySQL и последующего связывания его с контейнером приложения каждый раз.

Docker-compose.yml практически не требует пояснений – сначала мы конфигурируем контейнер MySQL, устанавливая образ mysql v8.0 и учетные данные для сервера MySSQL. После этого мы конфигурируем контейнер приложения, задавая параметры сборки, поскольку нам нужно собрать образ, а не тянуть его, как мы делали с MySQL. Также нам нужно установить, что контейнер приложения зависит от контейнера MySQL.

Теперь мы можем запустить весь проект с помощью всего одной команды:
docker-compose up

Запланированное событие MySql

Эта часть необязательна, но я думаю, что кому-то она все равно пригодится. Я говорил о дате истечения срока действия короткой ссылки, которая может быть задана пользователем или иметь значение по умолчанию. Для этой проблемы мы можем установить запланированное событие внутри нашей базы данных. Это событие будет запускаться каждые x минут и удалять все строки из базы данных, где срок действия меньше текущего времени. Все просто. Это хорошо работает при небольшом количестве данных в базе данных.

Теперь я должен предупредить вас о паре проблем с этим решением.

Первая – это событие удалит записи из базы данных, но не удалит данные из кэша. Как мы уже говорили, кэш не будет заглядывать в базу данных, если он может найти там подходящие данные. Поэтому даже если данные больше не существуют в базе данных, потому что мы их удалили, мы все равно сможем получить их из кэша.

Второе – В моем примере скрипта я установил, что событие запускается каждые 2 минуты. Если наша база данных станет огромной, то может случиться так, что событие не завершит выполнение в течение своего интервала планирования, в результате чего несколько экземпляров события могут выполняться одновременно.

Оцените статью
Procodings.ru
Добавить комментарий