Пишем - сервис коротких ссылок
Друзья привет, наверняка вы встречались с короткими ссылками по типу этих:
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