Пишем - сервис коротких ссылок

Пишем - сервис коротких ссылок

Друзья привет, наверняка вы встречались с короткими ссылками по типу этих:
goo.gl,
bit.ly,
vc.com/cc,
youtu.be
и еще куча других, причем заметим, что первый сервис представленный в Google, компания закрыли переводят всех на использование их API, которое в свою очередь использует ресурсы Firebase API.

Так вот - здесь есть интересное описание этих сервисов и модели их работы
https://texterra.ru/blog/kak-sokratit-ssylku-obzor-12-servisov.html на русском

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

  • если вам нужно отправлять СМС с ссылкой
  • возможно вы вставляете ссылку в twitter, где соц сеть вводит ограничение на число символов
  • может ваша ссылка может быть короткой и нести смысл, прям как этаyoudomain.ru/s/ideas - (ideas это короткое и запоминаемое имя)

а ведь в случае с СМС, если ссылка длинная, то вам может придется платить за 2 смс-ки вместо 1-ой или больше.
Собственно на работе первый кейс приближен к реальности.
Кейс такой, у нас есть продающая landing страница с формой ->
при заполнении формы пользователь отправляет запрос к нам в корзину с применением, промокода и бизнес задача отправить на телефон пользователю по СМС и на электронную почту ссылку на покупку или подписку. Если говорить про электронную почту, то проблемы в ней нет, но с СМС дела обстоят совсем иначе.

Поэтому я подумал, и решил, а давайте вместе реализуем его!

На работе мы используем менее популярный стек технологий языка Java  ->
PlayFramework в связке с MyBatis-ом, здесь же я предложу вам другой вариант(попса) и расскажу, с какими ограничениями реализации столкнулись мы, по ходу разработки приложения.

Ну что хватит лить воду, давайте перейдем к описанию цели, а цель у нас есть:

Написать сервис коротких ссылок, который:

  • а) принимает на вход длинную ссылку и возвращает сгенерированную ссылку типа - короткая ссылка
  • b) ссылку можно вводить вручную, если такой еще нету
  • c) установить срок жизни
  • d) сгенерировать QR code
  • e) добавить возможность блокирования
  • f) вести статистику - язык.Ip.адрес.referer.utm-метки.facebook-pixel
  • g) возможность AI-генерации короткого имени ссылки - slug-а удобочитаемого

И в рамках этой статьи рассмотрим только пункт a), если же Вам интересно рассмотреть и остальные варианты, напишите об этом пожалуйста в комментариях по постом.


Как уже принято опишем стек технологий Java, который будем использовать, а именно:
Spring Web, Spring Data, Spring Boot, Java 8+, H2

Переходим на сайт start.spring.io и выбираем необходимые зависимости:

не забываем добавить зависимость h2database(H2)!

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

@SpringBootApplication
public class ShorterUrlApplication {

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

}

Создаем контроллер, в нем у нас будет 2 основных метода для работы нашего сервиса,
1 ый - сохранение оригинальной ссылки и генерация короткого кода, назовем его hash
2 ой - при переходе на нашу короткую ссылку мы должны перенаправлять пользователя на оригинальную ссылку

@RestController
@RequestMapping
class ShorterController {
    Logger logger = LoggerFactory.getLogger(ShorterController.class.getSimpleName());

    @PostMapping(path = "/")
    public String createShortUrl(String original) {
        //TODO generate hash of original URL
        // and return it
        return null;
    }

    @GetMapping(path = "/{hash}")
    public ResponseEntity redirectShorter(@PathVariable("hash") String hash) {
        //TODO find hash in DB and redirect to original URL
		return null;
    }

}

Создаем ShorterRepository интерфейс для работы с Spring Data через CrudRepository<ID, Model> и добавляем в него метод поиска по hash- коду
findByHash

interface ShorterRepository extends CrudRepository<Shorter, Long> {
    Shorter findByHash(String hash);
}

Что такое Shorter - это наша модель, в которой у нас хранится:
id -  идентификатор записи
hash - код сокращенный соответсвует уникальной ссылке
originalUrl - оригинальная ссылка куда осуществляем переход
createdAt - время создания
модель можно доработать в зависимости от сложности сервиса, например мы хотим хранить количество переходов по ссылке, а также другую информацию о том откуда пользователь перешел, возможно там есть utm метки или еще какая интересная доп. информация. Но пока продолжаем кодить на легке. Итак наша модель:

@Entity
@AllArgsConstructor
@RequiredArgsConstructor
@Getter
@Setter
class Shorter {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;
    @Column
    private String hash;
    @Column(name = "original_url")
    private String originalUrl;
    @Column(name = "created_at", columnDefinition = "TIMESTAMP")
    private ZonedDateTime createdAt;
}

Детали модели мы не описываем, это не цель, но если возникнут вопросы пишите в комментарии👇
Продолжим, чтобы протестировать наличие записи в БД нам нужно пре-инициализировать структуру и залить данные:
- есть целый ряд возможностей инициализации БД и накатки миграции, который тянет на отдельный пост - пишим коммент если интересно!
В примере создаем schema.sql и сохраняем в папку src/main/resources

DROP TABLE IF EXISTS shorter;

CREATE TABLE IF NOT EXISTS shorter
(
    id           SERIAL PRIMARY KEY,
    hash         varchar(20) not null unique,
    original_url varchar,
    created_at   timestamp
);

наполняем данными, для этого создаем файл data.sql в той же директории

INSERT INTO shorter (id, hash, original_url, created_at)
VALUES (NULL, '1', 'https://facebook.com', current_timestamp);

INSERT INTO shorter (id, hash, original_url, created_at)
VALUES (NULL, '2', 'https://mail.google.com/mail/u/0/', current_timestamp);

INSERT INTO shorter (id, hash, original_url, created_at)
VALUES (NULL, '3', 'https://instagram.com', current_timestamp);

Есть подводные камни. Во-первых, если вы используете JPA и встроенную БД
ваш schema.sql не запустится, потому что выше в приориете генерация Hibernate DDL.
Чтобы разрешить эту проблему нужно в application.properties написать:

spring.jpa.hibernate.ddl-auto=none

Дополнительно к этому с версии Spring boot 2, схема инициализируется по умолчанию только для встроенных БД. Чтобы разрешить загрузку для всех типов БД, вам нужно установить следующие свойства:

spring.datasource.initialization-mode=always

Итак мы настроили БД, мы настроили наш инфраструктурный слой хранения данных  - Repository еще его называют DAO - data access objects  
Проинжектим его в контроллер:

@RestController
@RequestMapping
class ShorterController {
    Logger logger = LoggerFactory.getLogger(ShorterController.class.getSimpleName());
    
    private final ShorterRepository repository;

	//инжектим репозиторий, в более крупных сервисах и где есть бизнес-логика
    //добавляем слой service-сервис, а уже он взаимодействует с repository
    @Autowired
    public ShorterController(final ShorterRepository repository) {
        this.repository = repository;
    }

    @PostMapping(path = "/")
    public String createShortUrl(String original) {
        //TODO generate hash of original URL
        // and return it
        return null;
    }

    @GetMapping(path = "/{hash}")
    public ResponseEntity redirectShorter(@PathVariable("hash") String hash) {
        //TODO find hash in DB and redirect to original URL
    	return null;
	}
}

Реализуем метод 'redirectShorter':

    @GetMapping(path = "/{hash}")
    public ResponseEntity redirectShorter(@PathVariable("hash") String hash) {
        Shorter shorter = repository.findByHash(hash);
        if (shorter != null) {
        	//если мы нашли код, то делаем редирект на ссылку 
            HttpHeaders headers = new HttpHeaders();
            headers.add("Location", shorter.getOriginalUrl());
            return new ResponseEntity<String>(headers, HttpStatus.FOUND);
        } else {
			//не нашли, выкидываем ошибку не найден 404
            return ResponseEntity.notFound().build();
        }
	}

Ура! Теперь у нас есть реализация редиректа и если вы зайдете на ссылку:

localhost:8080/1 - то попадете на https://facebook.com
localhost:8080/2 - то попадете на https://mail.google.com/mail/u/0/
localhost:8080/3 - то попадете на https://instagram.com

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

[a-zA-Z0-9_-+]

- "a-z"  -  26 
- "A-Z"  -  26
- "0-9"  -  10
- "_-+"  -  3 
= 65 в степени 6(длина кода) 

Например: fh_ds+-Q - это хеш код в 8ой степени
Если подитожить и посчитать кол-во комбинаций: 65 в степени 6 = 75 million вариаций) то что сможете найти в исходниках этого проекта - если захотите увеличить вариативность, достаточно будет увеличить длину генерируемой строки хеш кода

Создадим утилитарный класс CodeGenerator, согласно правилу описанному выше, добавим в pom.xml утилитарную библиотеку для генерации случайной строки

<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-text</artifactId>
    <version>1.9</version>
</dependency>

и сам класс:

class CodeGenerator {
    private RandomStringGenerator randomStringGenerator;

    public CodeGenerator() {
        this.randomStringGenerator = new RandomStringGenerator
                .Builder().filteredBy(c -> isLatinLetterOrDigit(c))
                .build();
    }

    public String generate(int length) {
        return randomStringGenerator.generate(length);
    }

    //проверяем
    private static boolean isLatinLetterOrDigit(int codePoint) {
        return ('a' <= codePoint && codePoint <= 'z')
                || ('A' <= codePoint && codePoint <= 'Z')
                || ('0' <= codePoint && codePoint <= '9')
                || ('+' == codePoint)
                || ('_' == codePoint)
                || ('-' == codePoint);

    }

}

проинжектим private final CodeGenerator codeGenerator; в контроллере и вызовим генерацию, сохраним нашу модель:

@PostMapping(path = "/", consumes = APPLICATION_JSON_VALUE)
    public Shorter createShortUrl(@RequestBody Shorter shorter) {
        String hash = codeGenerator.generate(shorterLength);
        logger.info(hash);
        if(shorter != null) {
            String shorterString = URLDecoder.decode(shorter.getOriginalUrl());
            logger.info(shorterString);
            shorter = new Shorter(null, hash, shorterString, ZonedDateTime.now());
            return repository.save(shorter);
        } else {
            return null;
        }
    }

Та-да-дам! У нас получается - проверим через cURL создание и получение ссылки

Создание через POST запрос

curl -X POST -d '{"originalUrl": "https://mail.google.com/mail/u/0/#spam"}' "http://localhost:8080/" -H "Content-Type:application/json"

И переход по короткой ссылке, подчеркиваем, что нужно добавить -v оператор, а то иначе мы не поймет прозошел переход или нет:

curl -X GET localhost:8080/5YuuXT -v
Note: Unnecessary use of -X or --request, GET is already inferred.
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> GET /5YuuXT HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.64.1
> Accept: */*
> 
< HTTP/1.1 302 
< Location: https://mail.google.com/mail/u/0/#spam
< Content-Length: 0
< Date: Tue, 08 Dec 2020 20:57:22 GMT
< 
* Connection #0 to host localhost left intact
* Closing connection 0

Исходный код размещен на GitHub-e:
https://github.com/isatimur/shorter-url