Introducción

Qué es un URL Shortener

URL shortener es usado para crear alias/nombres (short links) cortos para URLs largas. Estos links cortos te redireccionan a la URL original. Los links cortos salvan mucho espacio cuando

  • Imprimimos.
  • Mostramos imagenes.
  • Dentro de mensajes de texto.
  • Tweets.
  • Memoria en sistemas (base de datos, etc).

Un ejemplo, una URL original luce:

https://www.educative.io/courses/grokking-the-system-design-interview/m2ygV4E81AR

Y utilizando un sistema como TinyURL:

https://tinyurl.com/rxcsyr3r

Tecnologías a utilizar

  • IDE; Para este proyecto quise explorar con Visual Studio Code.
  • Spring-boot.
  • Gradle.
  • Swagger
  • Docker.
  • MySQL.
  • Bootstrap.
  • AJAX.

MySQL.

MySQL from Docker

Install MySQL using a docker image.

docker run -d -p 33060:3306 --name mysql-db -e MYSQL_ROOT_PASSWORD=secret mysql
-d: Deatached Mode es la forma en que indicamos que corra en background.
-p : puerto, el contenedor corre en el puerto 3306 pero hacemos un bind para que lo escuchemos en Host el puerto 33061.
–name : para no tener que hacer referencia al hash le asignamos un nombre.
-e : environment le asignamos la contraseña.
  • Manipular MySQL
docker exec -it mysql-db mysql -p
-exec: indicamos que vamos a pasar un comando.
-it Modo interactivo.
-mysql -p: es el comando para entrar a la consola de mysql con el usuario root(si has trabajado con mysql en consola es lo mismo).
  • Montar un volumen Hasta este punto no persistimos los datos que se realicen en nuestro contenedor lo que significa que cuando terminamos con el proceso los cambios se perderán, para eso Docker nos dice que hay que utilizar volúmenes que no es otra cosa que una parte del disco Host se reserve para los datos generados en el contenedor(no el contenedor).

Para eso seguimos los siguientes pasos:

  • Eliminamos el proceso que corre el contenedor creado.
$ docker rm -f mysql-db
  • Eliminamos todos los volúmenes ya que Docker crea volúmenes temporales sin pedirte permiso.
$ docker volume prune
  • Creamos un volumen
$ docker volume create mysql-db-data
$ docker volume ls

DRIVER              VOLUME NAME
local               mysql-db-data
Levantamos nuevamente el Docker y agregamos el volumen con la opcion --mount

$  docker run -d -p 33060:3306 --name mysql-db  -e MYSQL_ROOT_PASSWORD=secret --mount src=mysql-db-data,dst=/var/lib/mysql mysql

// Entramos al contenedor de forma interactiva o desde el Workbench y creamos una base de datos
$ docker exec -it mysql-db mysql -p
...
mysql> create database demo;         
Query OK, 1 row affected (0.32 sec)  
                                    
mysql> show databases;               
+--------------------+               
| Database           |               
+--------------------+               
| demo               |               
| information_schema |               
| mysql              |               
| performance_schema |               
| sys                |               
+--------------------+               
5 rows in set (0.00 sec)             
                                    
mysql>                          

// Terminamos el proceso tal como en el paso 1
$ docker rm -f mysql-db

// Lanzamos nuevamente el proceso como en el paso 5
$  docker run -d -p 33060:3306 --name mysql-db  -e MYSQL_ROOT_PASSWORD=secret --mount src=mysql-db-data,dst=/var/lib/mysql mysql

#Entramos nuevamente al contenedor de forma interactiva y podemos ver que la base de datos que creamos se encuentra
$ docker exec -it mysql-db mysql -p
...
mysql> create database demo;         
Query OK, 1 row affected (0.32 sec)  
                                    
mysql> show databases;               
+--------------------+               
| Database           |               
+--------------------+               
| demo               |               
| information_schema |               
| mysql              |               
| performance_schema |               
| sys                |               
+--------------------+               
5 rows in set (0.00 sec)             
                                    
mysql> create database shorturl;     

mysql> show databases;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
| shorturl           |
| sys                |
+--------------------+
5 rows in set (0.00 sec)

De esta forma ya estamos trabajando con volúmenes donde persistimos los datos el el Host de forma que si queremos utilizar la base de datos solo hay que montar el volumen.

Inicializando nuestro proyecto

Esto va a variar dependiendo de nuestro IDE, en general son los mismos pasos en Eclipse, IntelliJ o desde la web.

Visual Studio Code, una vez descargado los plugins necesarios para web y Spring-Boot presionamos las teclas Cntrl + Shift + P y nos debería aparecer el menú para crear proyectos:

  • Spring Initializr: Create a gradle project....
  • Specify Spring Boot version: 2.5.2
  • Language: Java
  • Group Id (package base): com.c4cydonia.shortener.url
  • Artifact Id (Project name): UrlShort

Gradle y su configuración

Tengo mas experiencia con Maven, sin embargo, quise experimentar con gradle, en un principio tuve ciertos problemas, pero son bastante similares y fue fácil pasar de una tecnología a otra, explicaré a grandes rasgos aquello que esta en el build.gradle.

  • Dependencias
dependencies {
	// implementation group: 'mysql', name: 'mysql-connector-java', version: '8.0.25'
	implementation group: 'org.springframework.boot', name: 'spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation group: 'io.springfox', name: 'springfox-swagger2', version: '2.9.2'
	implementation group: 'io.springfox', name: 'springfox-swagger-ui', version: '2.9.2'
	runtimeOnly 'mysql:mysql-connector-java'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

De arriba hacia abajo. - En este momento ignoren la dependencia comentada, la utilizaremos mas adelante con docker. - JPA starter: Librerías para persistencia con Spring boot. - Thymeleaf: Librería para la interfaz de usuario (UI). - web: Todo lo relacionado a REST. - swagger: Librerías para las anotaciones generales. - swagger-ui: Visualización web.

  • Plugins section

    • Agreguen el id application para que puedan inicializar la aplcación con el comando de gradle ./gradlew run
plugins {
	id 'org.springframework.boot' version '2.5.2'
	id 'io.spring.dependency-management' version '1.0.11.RELEASE'
	id 'application'
	id 'java'
}
  • MainClass name Debajo de la sección de plugins agreguen la clase principal a ejecutarse en el proyecto.
mainClassName = 'com.c4cydonia.shortener.url.UrlShortApplication'

El archivo en general lo pueden encontrar en github.

La estructura del proyecto.

project-structure
ProjectStructure

Código

Package base es com.c4cydonia.shortener.url, durante el proyecto se hará referencia como /{packagePath}

El modelo o entidad (Model o entity)

Crea el paquete model y una clase llamada Url.

/{packagePath}/model/Url.java

Esta clase contiene los campos que buscamos persistir en la base de datos. Nota; Cuando iniciemos nuestro servidor de base de datos, no es necesario crear la tabla, esta es generada automáticamente.

Anotaciones JPA (Java Persistance API)

  • @Entity -> Indica que es una entidad.
  • @Table -> Es el nombre que hace referencia en la base de datos.
  • @Id -> Se pone arriba del campo que es el primary key en la tabla.
  • @GeneratedValue(strategy = GenerationType.AUTO) -> Indica que es un valor autoincrementable.
  • @Column(nullable = false) -> El valor no debe ser nulo.
  • @Temporal(TemporalType.DATE) ->
@Entity
@Table(name = "url")
public class Url {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private long id;

    @Column(nullable = false)
    private String fullUrl;

    @Column(nullable = false)
    @Temporal(TemporalType.DATE)
    private Date createdDate;

    @Temporal(TemporalType.DATE)
    private Date expiresDate;

    @Column(nullable = true)
    private String webHook;

    public long getId() {
        return id;
    }

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

    public String getFullUrl() {
        return fullUrl;
    }

    public void setFullUrl(String fullUrl) {
        this.fullUrl = fullUrl;
    }

    public Date getCreatedDate() {
        return createdDate;
    }

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

    public Date getExpiresDate() {
        return expiresDate;
    }

    public void setExpiresDate(Date expireDate) {
        this.expiresDate = expireDate;
    }

    public String getWebHook() {
        return webHook;
    }

    public void setWebHook(String webHook) {
        this.webHook = webHook;
    }
}

Repositorio (Repository)

En el paquete base /{packagePath} crea el paquete url y la clase UrlRepository.java /{packagePath}/url/UrlRepository.java

El repositorio te da las funcionalidades básicas para interactuar a la base de datos; Guarda, buscar, eliminar y actualizar.

@Repository
public interface UrlRepository extends JpaRepository<Url, Long> {}

Utilidades o clases comunes

En el paquete base /{packagePath} crea el paquete common y la clase BaseConversion.java /{packagePath}/common/BaseConversion.java.

Aquí aplicamos el algoritmo que genera el identificador único descrito al principio de este árticulo.

@Service
public class BaseConversion {
    private static final String allowedString = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
    private char[] allowedCharacters = allowedString.toCharArray();
    private int base = allowedCharacters.length;

    public String encode(long input){
        var encodedString = new StringBuilder();

        if(input == 0) {
            return String.valueOf(allowedCharacters[0]);
        }

        while (input > 0) {
            encodedString.append(allowedCharacters[(int) (input % base)]);
            input = input / base;
        }

        return encodedString.reverse().toString();
    }

    public long decode(String input) {
        var characters = input.toCharArray();
        var length = characters.length;

        var decoded = 0;

        //counter is used to avoid reversing input string
        var counter = 1;
        for (int i = 0; i < length; i++) {
            decoded += allowedString.indexOf(characters[i]) * Math.pow(base, length - counter);
            counter++;
        }
        return decoded;
    }
}

Clase de servicio

En el paquete base /{packagePath} crea el paquete service y la clase UrlService.java /{packagePath}/service/UrlService.java.

  • @Autowired -> Esta anotación utiliza la inyección de dependencias (Dependency Injection) caracteristica de Spring boot y otros frameworks que utilizan la inversión de control (Inversion of Control), en donde permites al framework cargue las clases necesarias utilizando otras anotaciones de Spring (Entity, Repository, etc).
  • La clase UrlFullRequest la vamos a generar el siguiente paso, primero quiero explicar la razón de tener un DTO (Data Transformation Object).

El método convertToShortUrl realiza lo siguiente:

  1. Realizamos una instancia de la clase Url.
  2. Obtenemos los datos request; La URL, si tiene fecha de expiración, la fecha de creación la asignamos en este momento.
  3. Guardamos el registro en la base de datos utilizando el repositorio y el método save; Esto genera identificador único.
  4. Una vez que tenemos el Id único que fue autogenerado, lo codificamos en bas64 y lo agisnamos como la url corta (shortUrl); La diferencia entre el modelo (model) y el DTO, son aquellos datos que no necesitamos guardar en la base de datos, pueden también tener transformaciones, agregaciones, información de otras fuentes, etc.
@Service
public class UrlService {
    @Autowired
    private UrlRepository urlRepository;

    @Autowired
    private BaseConversion conversion;

    public UrlFullRequest convertToShortUrl(UrlFullRequest request) {
        var url = new Url();
        url.setFullUrl(request.getFullUrl());
        url.setExpiresDate(request.getExpiresDate());
        url.setCreatedDate(new Date());
        var entity = urlRepository.save(url);

        var shortUrl = conversion.encode(entity.getId()); //unique id, it will be a unique conversion
        request.setShortUrl(shortUrl);
        return request;
    }

    public String getOriginalUrl(String shortUrl) {
        var id = conversion.decode(shortUrl);
        var url = urlRepository.findById(id).orElseThrow(() -> new EntityNotFoundException("There is no entity with " + shortUrl));

        if (url.getExpiresDate() != null && url.getExpiresDate().before(new Date())){
            urlRepository.delete(url);
            throw new EntityNotFoundException("Link expired!");
        }

        return url.getFullUrl();
    }
}

DTO - Data Transformation Object

En el paquete base /{packagePath} crea el paquete dto y la clase UrlFullRequest.java /{packagePath}/dto/UrlFullRequest.java.

@ApiModel(description = "Request object for POST method")
public class UrlFullRequest {
    @ApiModelProperty(required = true, notes = "Url to convert to short")
    private String fullUrl;

    @ApiModelProperty(required = true, notes = "Short url")
    private String shortUrl;

    @ApiModelProperty(notes = "Expiration datetime of url")
    private Date expiresDate;

    @ApiModelProperty(notes = "Action to execute when full url is requested")
    private String webHook;

    public String getFullUrl() {
        return fullUrl;
    }

    public void setFullUrl(String fullUrl) {
        this.fullUrl = fullUrl;
    }

    public String getShortUrl() {
        return shortUrl;
    }

    public void setShortUrl(String shortUrl) {
        this.shortUrl = shortUrl;
    }

    public Date getExpiresDate() {
        return expiresDate;
    }

    public void setExpiresDate(Date expiresDate) {
        this.expiresDate = expiresDate;
    }

    public String getWebHook() {
        return webHook;
    }

    public void setWebHook(String webHook) {
        this.webHook = webHook;
    }
}

Controlador (Controller)

En el paquete base /{packagePath} crea el paquete controller y la clase UrlController.java /{packagePath}/controller/UrlController.java.

  • @RequestMapping -> Indica a Spring-boot que es el punto de entrada para un request HTTP a través de una URI.
  • /api/shorturl/v1 -> Es la URI que vamos a utilizar.
  • @GetMapping(value="/") -> Es interpretada por Spring-boot para una llamada HTTP GET, postman o curl sería GET {host}:{port}/api/shorturl/v1/; Primero registra una llamada a través del logger y finalmente regresamos el nombre de la página que buscamos cargar, en este caso es index.
  • @PostMapping("/shortUrl") -> Código HTTP POST para la generación/creación de registros, enviamos los datos necesarios; URL original que buscamos recortar, fecha de expiración si aplica, webhook que puede ser utilizado para indicar acciones cuando la URL es consultada. La clase URLValidator será creada en el siguiente paso.
  • @GetMapping(value = "/{shortUrl}") -> Envió la URL corta, la buscamos en nuestra base de datos a través de la clase de servicio y si el registro existe en la base de datos y utilizando la URL original, re-dirigimos al usuario a esa página.
  • @Cacheable(value = "urls", key = "#shortUrl", sync = true)
@RestController
@RequestMapping("/api/shorturl/v1")
public class UrlController {
    private static final Logger LOGGER = LoggerFactory.getLogger(UrlController.class);

    @Autowired
    private UrlService urlService;

    @GetMapping(value="/")
    public String loadIndex() {
        LOGGER.info("Load index page");
        return "index";
    }

    @ApiOperation(value = "Convert new url", notes = "Converts long url to short url")
    @PostMapping("/shortUrl")
    public ResponseEntity<Object> convertToShortUrl(@RequestBody UrlFullRequest request) throws Exception {
        LOGGER.info("Received url to shorten: " + request.getFullUrl());

        var fullUrl = request.getFullUrl();
        if (URLValidator.INSTANCE.validateURL(fullUrl)) {
            return new ResponseEntity<Object>(urlService.convertToShortUrl(request), HttpStatus.OK);
        }
        throw new Exception("Please enter a valid URL");
    }

    @ApiOperation(value = "Redirect", notes = "Finds original url from short url and redirects")
    @GetMapping(value = "/{shortUrl}")
    @Cacheable(value = "urls", key = "#shortUrl", sync = true)
    public ResponseEntity<Void> getAndRedirect(@PathVariable String shortUrl) {
        LOGGER.info("Received shortened url to redirect: " + shortUrl);
        var url = urlService.getOriginalUrl(shortUrl);
        return ResponseEntity.status(HttpStatus.FOUND)
                .location(URI.create(url))
                .build();
    }
}

Validar la URL

En el paquete base /{packagePath} crea el paquete common y la clase URLValidator.java /{packagePath}/common/URLValidator.java.

Esta clase es un singleton, su objetivo es validar que una URL tenga el formato correcto.

public class URLValidator {
    public static final URLValidator INSTANCE = new URLValidator();
    private static final String URL_REGEX = "^(http:\\/\\/www\\.|https:\\/\\/www\\.|http:\\/\\/|https:\\/\\/)?[a-z0-9]+([\\-\\.]{1}[a-z0-9]+)*\\.[a-z]{2,5}(:[0-9]{1,5})?(\\/.*)?$";

    private static final Pattern URL_PATTERN = Pattern.compile(URL_REGEX);

    private URLValidator() {
    }

    public boolean validateURL(String url) {
        Matcher matcher = URL_PATTERN.matcher(url);
        return matcher.matches();
    }
}

Validar la URL

En el paquete base /{packagePath} en la clase principal del proyecto UrlShortApplication.

En esta clase:

  • Activamos el cache
  • Indicamos que es aplicación de SpringBoot
  • Indicamos el paquete del proyecto.
  • Activamos swagger.
  • El método api activa la configuración de swagger.
@EnableCaching
@SpringBootApplication
@ComponentScan(basePackages={"com.c4cydonia.shortener.url"})
@Configuration
@EnableSwagger2
public class UrlShortApplication {
	
	@Bean
	public Docket api() { 
        return new Docket(DocumentationType.SWAGGER_2)  
          .select()                                  
          .apis(RequestHandlerSelectors.any())              
          .paths(PathSelectors.any())                          
          .build();                                           
    }

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

}

application.properties

El proyecto esta dividido en 2 fases,

  1. La primera utiliza una imagen de docker de MySQL, la inicializamos de forma manual y nuestro proyecto por separado.
  2. La segunda (esta comentada), utiliza docker-compose y lo exploraremos en la siguiente fase.
##Manual execution
spring.datasource.url = jdbc:mysql://127.0.0.1:33060/shorturl
spring.datasource.username = root
spring.datasource.password = root

##Docker
#spring.datasource.url = jdbc:mysql://mysql-local:3306/shorturl
#spring.datasource.username = admin
#spring.datasource.password = admin

## Hibernate Properties
# The SQL dialect makes Hibernate generate better SQL for the chosen database
spring.jpa.properties.hibernate.dialect = org.hibernate.dialect.MySQL8Dialect

# Hibernate ddl auto (create, create-drop, validate, update)
spring.jpa.hibernate.ddl-auto = update

Pendiente

  • Refrescar una vez que que cambie la URL o se presione un botón.
  • Cloud deployment -> AWS Fargate, AWS Elastic Bench
  • No-SQL -> DynamoDB o MongoDB.
  • Serverless.

Troubleshooting

  • Check the logs
  • Delete docker images
  • Delete docker base images
  • Delete docker volumnes, ser muy cuidadoso

Autor

Gustavo Leyva.

Un proyecto desarrollado con GatsbyJS Starter y Netlify CMS, Hecho con por Stackrole.com. Personalizado por Tavo Leyva