Сокращаем ссылки в домашних условиях: бесплатно и без СМС

Давным-давно, кажется, в прошлом десятилетии, мне показали гугловский сервис для сокращения ссылок. Goo.gl, есличо. И я упорно не понимал смысла, пока не начал бросаться расшаренными документами в своих друзей-коллег-товарищей. А это URL на 200+ символов, и, если тебе приходится перепечатывать его с менее электронных источников — ты ненавидишь весь мир. Гугловский сокращатель превращает этот процесс во что-то более-менее приятное: ссылочка — короткая, копировать и бросаться — удобно, необходимость перенабрать — не вгоняет в депрессию. Но один фатальный недостаток у их сокращалки все-таки есть: его писали не мы. Поэтому, сейчас мы с вами запасемся мужеством и будем героически этот недостаток исправлять — трепещите!

Как это работает?

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

Настоящая База Данных, между прочим — никаких подделок. Все просто: есть оригинальная ссылка, есть — ее маппинг на какой-то околослучайный, но всегда уникальный набор символов. И весь процесс поиска настоящей ссылки по ее сокращенному представлению можно свести к такому SQL — запросу

SELECT origin_url from urls WHERE short_url = 'HBUVFE';

Разумеется, пускать юзера в БДшку никто не собирается, поэтому к ней аккуратно приписывается небольшой сервер, который умеет не только доставать оригинальнальную ссылку по ее алиасу, но и создавать новые пары, генеря случайные сокращения так, чтобы они никогда не повторялись. А еще — такой сервер  должен уметь обрабатывать запросы к сокращенным ссылкам и редиректить юзера на оригинальную — иначе в нем вообще никакого смысла. Ну и пусть стату какую-нибудь собирает, это сейчас стильно-модно-молодежно

Чо, прям SQL?

Не, нафиг. У нас тут как-никак 2017 год заканчиваться собирается, давно пора пользовать что-нибудь покруче. Например, MongoDB, давно хотел поиграться. А код будем писать на Джаве и свежей версии спринга — должны же в мире хоть где-то быть традиции. Ну, фронт накодим на JS, тут без вариантов — оно уже повсюду

Пилим сервак

Java, Spring, JPA — ничего нового. Берем и создаем сущности

package org.grakovne.url.shortener.entity;

import org.springframework.data.annotation.Id;

public class SourceUrl implements UrlShortenerEntity {

    @Id
    private String url;

    public SourceUrl() {
    }

    public SourceUrl(String url) {
        this.url = url;
    }

    public String getUrl() {
        return url;
    }

    public void setUrl(String url) {
        this.url = url;
    }

    @Override
    public String toString() {
        return "SourceUrl{" +
                "url='" + url + '\'' +
                '}';
    }
}
package org.grakovne.url.shortener.entity;

import org.springframework.data.annotation.Id;

public class ShortenUrl implements UrlShortenerEntity {

    @Id
    private String url;

    public ShortenUrl() {
    }

    public ShortenUrl(String url) {
        this.url = url;
    }

    public String getUrl() {
        return url;
    }

    public void setUrl(String url) {
        this.url = url;
    }

    @Override
    public String toString() {
        return "ShortenUrl{" +
                "url='" + url + '\'' +
                '}';
    }
}
package org.grakovne.url.shortener.entity;

import org.springframework.data.annotation.Id;

import java.time.LocalDateTime;

public class UrlMapped implements UrlShortenerEntity {

    @Id
    private String id;

    private SourceUrl sourceUrl;

    private ShortenUrl shortenUrl;

    private LocalDateTime createdDate;

    private Integer clickCounter;

    public UrlMapped() {
    }

    public UrlMapped(SourceUrl sourceUrl, ShortenUrl shortenUrl) {
        this.shortenUrl = shortenUrl;
        this.sourceUrl = sourceUrl;
        this.clickCounter = 0;
        this.createdDate = LocalDateTime.now();
    }

    public SourceUrl getSourceUrl() {
        return sourceUrl;
    }

    public void setSourceUrl(SourceUrl sourceUrl) {
        this.sourceUrl = sourceUrl;
    }

    public ShortenUrl getShortenUrl() {
        return shortenUrl;
    }

    public void setShortenUrl(ShortenUrl shortenUrl) {
        this.shortenUrl = shortenUrl;
    }

    public LocalDateTime getCreatedDate() {
        return createdDate;
    }

    public void setCreatedDate(LocalDateTime createdDate) {
        this.createdDate = createdDate;
    }

    public Integer getClickCounter() {
        return clickCounter;
    }

    public void setClickCounter(Integer clickCounter) {
        this.clickCounter = clickCounter;
    }

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    @Override
    public String toString() {
        return "UrlMapped{" +
                "id='" + id + '\'' +
                ", sourceUrl=" + sourceUrl +
                ", shortenUrl=" + shortenUrl +
                ", createdDate=" + createdDate +
                ", clickCounter=" + clickCounter +
                '}';
    }

    public void incrementClicks() {
        this.clickCounter++;
    }
}

Мда, профессиональная деформация в действии: была одна коллекция, сделали три. Зачем — не знаю, но верю, что так надо. Потом разберемся. А пока — набросаем слой доступа к данным

package org.grakovne.url.shortener.repository;

import org.grakovne.url.shortener.entity.UrlMapped;

public interface UrlMappedRepository extends UrlShortenerRepository<UrlMapped> {

}

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

Сокращая — сокращай!

Первое, с чем нужно определиться — что мы будем выдавать за уникальную часть сокращенной ссылки. Теория вероятностей, которая плещется в моей голове со второго курса универа и здравый смысл, который там не плещется уже давно, голосуют за результат какой-нибудь коротенькой и устойчивой к коллизиям хеш-функции: одна ссылка — один хеш. Удобно. Природная лень же предыдущих ораторов не одобряет и предлагает просто генерить рандомную последовательность из 5 символов до тех пор, пока не получим уникальную комбинацию буковок и говорить, что это — результат сокращения

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

private ShortenUrl generateShortenUrl() {
    String proposalUrl = RandomStringUtils.randomAlphanumeric(5);

    if (shortenUrlRepository.findOneByUrl(proposalUrl).isPresent()) {
        return generateShortenUrl();
    }

    return shortenUrlRepository.save(new ShortenUrl(proposalUrl));
}

Ну круто же, чего морщитесь? Зато — у нас есть возможность создать аж 60 с лишним миллионов ссылок! На недельку хватит, там посмотрим

Следующее, чего мы хотим не хотим, так это создавать в БДшке мусор. Ну, на самом деле, сокращенные ссылки — всегда уникальные, это ладно. Но оригинальные — то почему? Зачем нам хранить в БДшке 5 тысяч ссылок на одну и ту же картинку с одним и тем же котиком? Котик должен быть один! И вот — в нашем сервисе живет такой метод

private SourceUrl findOrCreateSourceUrl(String sourceUrl) {
    return sourceUrlRepository
            .findOneByUrl(sourceUrl)
            .orElseGet(() ->
                    sourceUrlRepository.save(new SourceUrl(sourceUrl))
            );
}

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

public UrlMapped shortUrl(SourceUrl sourceUrlDto) {

     SourceUrl sourceUrl = findOrCreateSourceUrl(sourceUrlDto.getUrl());
     return urlMappedRepository.save(new UrlMapped(sourceUrl, generateShortenUrl()));
}

Отдай мою ссылку обратно, бездушная машина!

Да, это надо, а то как-то неудобненько получается. Впрочем, делается это несложно

public String getOriginLink(String shortenedLink) {
    ShortenUrl shortenUrl = getShortenUrl(shortenedLink);
    return urlMappedRepository
            .findOneByShortenUrl(shortenUrl)
            .map(urlMapped -> urlMapped.getSourceUrl().getUrl())
            .orElseThrow((Supplier<RuntimeException>) () ->
                    new EntityNotFoundException("urlMapped")
            );
}

С тем, почему мы хотим принимать обычную строчку вместо DTO-шки, разберемся потом, а пока пойдем писать апишечку

Будем творить REST!

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

@RequestMapping(value = "", method = RequestMethod.POST)
public UrlMapped shortUrl(@RequestBody SourceUrl sourceUrl) {
    return urlService.shortUrl(sourceUrl);
}
@RequestMapping(value = "", method = RequestMethod.GET)
public UrlMapped getUrl(@RequestParam("sourceUrl") String sourceUrlDto) {
    return urlService.getShortenUrl(new SourceUrl(sourceUrlDto));
}

Крудятина, ничего интересного. С редиректом все немного интереснее

@RequestMapping(value = "{shortenedUrl}", method = RequestMethod.GET)
public void sendRedirect(@PathVariable String shortenedUrl, HttpServletResponse response) throws IOException {
    response.sendRedirect(urlService.getOriginLink(shortenedUrl));
}

Немного Спринговской магии: взяли запрос, положили туда 301 код c новой ссылочкой и — швырнули в пользователя. А заодно поняли, почему метод getOriginLink хочет строчку, а не объект. Потому что лень создать, да 🙂

Даешь интерфейсик

Сервер — это хорошо, но пользоваться апишечкой без UI не круто и никто не будет. Поэтому идем, кодим простенькую SPA-шечку и живем долго-счастливо

<div id="content-container" class="center-vertical">
    <div class="page-title text-center">
        <h1>TRUNCATE YOUR URL</h1>
    </div>

    <div class="input-group col-sm-8 col-sm-offset-2 input-group-lg input-item">
        <span id="shorten-status" class="input-group-addon">URL:</span>
        <input id="url-container" type="text" class="form-control" placeholder="http://grakovne.org">
        <div id="copy-to-clipboard-button" class="input-group-addon"><i class="custom-button fa fa-clipboard" aria-hidden="true"></i></div>
    </div>

    <div id="short-url-button" class="input-group col-sm-8 col-sm-offset-2">
        <button type="button" class="btn btn-primary btn-lg col-sm-4 col-sm-offset-4">TRUNCATE IT</button>
    </div>
</div>

Разметка — до ужаса простая: формочка, кнопочка, иконочка — что еще нужно от одноразового сервиса? А, да. Нужно, чтобы эта фигня хоть как-то работала

function startShortAction() {
    var sourceUrl = url_container.val();

    if (!validateUrl(sourceUrl)) {
        return;
    }

    $.ajax({
        type: "POST",
        url: "http://grakovne.org:3030/api/v1/url/",
        data: JSON.stringify({"url": sourceUrl}),
        contentType: 'application/json',
        success: function (data) {
            shortenUrlCount++;

            setDoneToStatus();
            setShortenUrlToContainer(buildAbsoluteShortenUrl(data['shortenUrl']['url']));
            showCopyToClipBoardButton();
        }
    });
}

JQuery, ага. Я джавапрограммист — у меня лапки мне можно. Сделали запрос к апишечке, получили короткую ссылку, показали, перестали прятать от народа удобненькие кнопочки — красота. Все остальное не так интересно, поэтому — давайте уже запустим в прод?

Сверлим — красим — деплоим!

С тем, чтобы отправить джавабекендик на сервер никаких проблем никогда не было. У нас есть крутой Дженкинс, который и билд мавена соберет и фатджарник куда надо положит и серис передернет, чтобы он этот джарник сожрал. Фронт — тоже не сложнее: клоним репозиторий в папочку — получаем готовый бандл

Остается только правильно настроить роутинг: если юзер приходит на главную страничку сервиса, он должен видеть красивый UI. если — на любую другую — запрос должен редиректиться к эндпоинту апишки и обрабатываться им, чтобы в итоге юзер попал куда хотел. Так что нам потребуется Апач (помним про лапки же, да?) и немного магии

Создаем новый конфиг виртуального хоста

<VirtualHost *:80>
  ServerName  short.grakovne.org
  ServerAlias short.grakovne.org
  ServerAdmin grakovne@gmail.com
  DocumentRoot /var/www/short
  ErrorDocument 404 http://grakovne.org:3030%{REQUEST_URI}
</VirtualHost>

Не круто, зато работает: все, что смогли найти в домашней папочке — покажем, все остальное будем считать короткой ссылкой, которую надо скормить беку — сам упадет, есличо — не маленький

Проверяем?

Я люблю бросаться в хороших людей своей личной, законнособранной библиотекой. Ты же хороший? На, получай!

Теперь сократим этот длинный кошмар и получим вполне себе читабельную ссылочку: http://short.grakovne.org/t1Tk8. Классно? Еще бы! Осталось завернуть это в докер, распилить на микросервисы и можно будет с чистой совестью скрыться в закат на гироскутере, оставив после себя еще один никому не нужный сокращатель ссылок!

P.S. Чуть не забыл про ссылку на репозиторий: http://short.grakovne.org/Vqd9K. Не удержался 🙂

Искренне Ваш, сокращающий ссылки на сокращатели ссылок, GrakovNe