Back GuiaCompleta-Autentia
Back GuiaCompleta-Autentia
Back GuiaCompleta-Autentia
GUÍA COMPLETA
G U I A P A R A D I R E C T I V O S Y T É C N IC O S
V.1
Back
Guía completa
Si alguna vez, estando con amigos con celeridad y que siempre está
o familiares preguntan a qué disponible; se da por supuesto
me dedico exactamente, suelo que la información se intercambia
contestar que mi trabajo consiste entre distintos sistemas con
en colaborar en la construcción fiabilidad y registrando en todo
de la parte de las aplicaciones que momento qué, quién y cuándo se
hace que salgas en los periódicos accedió a esa información, etc. En
únicamente cuando lo haces mal. definitiva, se da por supuesto que
Eso es el Backend, eso que no funciona.
se ve pero que todo el mundo da
por supuesto: se da por supuesto “El Backend es
que los datos se intercambian
la parte de las
de manera segura, que no se
pierden o corrompen y que son aplicaciones que
vistos o modificados únicamente el usuario percibe
por los que tienen el permiso
para hacerlo; se da por supuesto únicamente cuando
que el sistema debe responder NO funciona”
BACK - GUÍA COMPLETA
Índice
Tipos de aplicaciones
Escritorio Web
Aplicaciones de escritorio
Son las aplicaciones más tradicionales que podemos instalar en nuestro
equipo. Las aplicaciones móviles también pertenecen a este grupo, aunque
su planteamiento dista de aquellas que se desarrollaban en décadas
pasadas.
Aplicaciones Web
Son la piedra angular de internet. Se encargan de gestionar las peticiones
de millones de clientes a lo largo de todo el mundo. Mantienen la
coherencia y la seguridad de los datos. Intercambian información con otras
aplicaciones para ofrecernos servicios de interés.
En un inicio, se basaban en una arquitectura cliente-servidor que contenía
todo lo necesario para funcionar, incluida una interfaz web para acceder y
manipular la información. Esta visión ha evolucionado hacia aplicaciones
que se distribuyen a lo largo de varios servidores o instancias en la nube, y
se enfocan a actuar como back para atender peticiones de múltiples
clientes a través de servicios SOAP o REST. Incluso, en muchas ocasiones,
encontramos que esta relación se establece entre varias aplicaciones web,
donde una no puede funcionar sin acceso a las otras.
Lenguajes de programación
Un lenguaje de programación no es más que un conjunto de reglas
gramaticales que son usadas para decirle a un ordenador cómo llevar a
cabo una determinada tarea.
Otra forma de categorizarlos es por paradigma. Existen varios paradigmas
de programación y los lenguajes pueden adoptar uno o varios de estos
paradigmas. A veces, está en la mano del programador escribir el código
usando un paradigma u otro dentro del mismo lenguaje o incluso
combinando varios paradigmas. Por ejemplo, a partir de Java 8 podemos
escribir programas usando mayormente la programación orientada a
objetos pero aprovechando algunas de las ventajas de la programación
funcional.
● Programación imperativa.
● Programación declarativa.
● Programación lógica.
● Programación funcional.
● Programación estructurada.
● Programación orientada a objetos.
● Programación reactiva.
Existen casos un poco más especiales como el de Java, que aunque es
compilado, no es compilado a código máquina si no a bytecode, un lenguaje
intermedio que solo la JVM (Java Virtual Machine) es capaz de interpretar,
siendo necesario disponer de una para poder ejecutar el programa.
Paradigmas
Este paradigma representa entidades del mundo real o internas del sistema
mediante objetos, que son estructuras que tienen datos, normalmente
llamados propiedades o atributos, y a la vez comportamientos (funciones),
normalmente llamados métodos.
En la mayoría de los lenguajes orientados a objetos, los objetos son
creados a partir de clases. Llamaremos instancia de una clase a un objeto
creado a partir de la misma. Las clases definen qué atributos y métodos
tendrán sus objetos.
Herencia
La herencia es uno de los recursos principales para reutilizar código en
POO, aunque no siempre el más recomendado. Consiste en la posibilidad
de heredar desde una clase, métodos y propiedades de otra. Por lo general,
definimos una clase como una subclase de otra, esto significa que todos
los objetos de la subclase son también objetos de la clase padre. Por
ejemplo, una clase Trabajador podría heredar de una clase Persona y
diríamos, por lo tanto, que un Trabajador e
s una Persona.
Abstracción
La herencia a veces se nos queda corta. Cuando queremos que todos los
hijos tengan cierto comportamiento, cierta funcionalidad pero no queremos
dar una implementación de la misma, entonces usamos Abstracción. La
abstracción nos permite obligar a que nuestros hijos o sus sucesivos hijos,
se vean obligados a implementar cierta funcionalidad. Es posible, incluso,
utilizar esa funcionalidad desde otras funciones de una clase abstracta.
Esto es así porque el lenguaje se asegura de que esa funcionalidad va a
estar implementada cuando se use. No se deja instanciar objetos de clases
que tengan alguna funcionalidad abstracta. Se tiene que haber
implementado para poder instanciar un objeto de esa clase.
Polimorfismo
La idea es que cualquier referencia de una subclase puede ser utilizada
donde la superclase (clase de la que se hereda) pueda ser usada. De esta
forma, el comportamiento de la subclase en concreto será ejecutado.
Es decir, volviendo al ejemplo de un Trabajador que hereda de Persona,
podremos utilizar un objeto de la clase Trabajador en cualquier otro sitio
donde una Persona pueda ser utilizada. Ya que un trabajador es también
una persona.
Encapsulación
Principio de ocultación
Se ocultan las propiedades del objeto de forma que estos solo puedan ser
accedidos a través de sus métodos.
Uno de los objetivos de la POO es conseguir una alta cohesión y un bajo
acoplamiento.
Una alta cohesión consiste en que una clase o módulo tenga un propósito
claro y los conceptos que son parecidos o iguales se mantengan juntos.
Un bajo acoplamiento se refiere a que las clases o módulos tienen que
depender y conocer el funcionamiento lo menos posible de otros módulos
o clases del software.
Programación funcional
● Funciones de orden superior: una función puede recibir una o más
funciones por parámetro y a su vez, podría retornar otra. Además, las
funciones pueden ser asignadas a una variable.
● “Qué” en vez de “Cómo”: su enfoque principal es "qué resolver", en
contraste con el estilo imperativo donde el enfoque es "cómo
resolver".
● No soporta estructuras de control: aplica la recursividad para
resolver problemas que en lenguajes imperativos se resolverían con
bucles o condicionales.
● Funciones puras: el valor retornado por una función será el mismo
siempre que los parámetros de entrada sean iguales. Esto significa
que durante el proceso no va a haber efectos secundarios que muten
el estado de otras funciones. Esto ayuda a reducir los bugs en los
programas y facilita su testeo y depuración.
Programación reactiva
Java
A mediados de la década de los 90, Sun Microsystems definió Java como
“un lenguaje de programación ‘sencillo’ orientado a objetos, distribuido, con
una arquitectura neutra y portable, seguro y concurrente”.
La evolución de Java se lleva a cabo por el Java community Process (JCP)
a través de Java Specification Request (JSR). Su desarrollo ha pasado por
varias manos, empezando en Sun Microsystems que fue comprada por
Oracle en 2009. Sin embargo, también se han realizado implementaciones
open source de la plataforma. Hoy en día tienen más relevancia que nunca.
Classpath
El classpath indica a Java dónde debe buscar las clases de usuario; esto es,
aquellas que no pertenecen al JRE y que son necesarias para poder
compilar o ejecutar la aplicación. Por defecto, el classpath se limita al
● Mediante la opción -cp en la línea de comandos. Es el método
preferido ya que especifica un classpath diferente para cada
aplicación, sin que afecte al resto.
● Declarándolo como una variable de entorno.
Paquetes
En Java, el código se organiza en paquetes. Cada paquete forma un
namespace propio, de forma que se evitan los conflictos de nombres entre
elementos de distintos paquetes.
Los paquetes se corresponden con estructuras de árbol de directorios. Por
convención, se utiliza un dominio del que tengamos la propiedad como
prefijo de los paquetes, aunque a la inversa. Por ejemplo, si estamos
desarrollando MyApp y tenemos en propiedad el dominio
www.example.com, podríamos nombrar nuestro paquete como
com.example.myApp y se correspondería con la siguiente estructura de
directorios:
com
└── example
└── myApp
Dentro del código fuente de nuestra clase, también deberemos indicar el
paquete al que pertenece. Si no coincide con la estructura de directorios, el
compilador lanzará errores, pues no encontrará las clases que necesita.
Esto se hace al principio del fichero con la siguiente sentencia:
package com.example.myApp;
Si queremos referenciar una clase dentro de un paquete, debemos escribir
todo el nombre completo. No obstante, Java proporciona un método de
importación que permite abreviar esta nomenclatura en nuestro código,
siempre que no haya conflicto entre dos nombres de diferentes paquetes.
Compilar
Podemos encontrar el compilador javac dentro del directorio bin de nuestra
instalación. Ejecutaremos el comando de la siguiente forma:
$ javac MyApp.java
Este comando generará uno o varios ficheros .class a partir de nuestro
archivo fuente .java. Estos son los archivos que puede ejecutar la máquina
virtual de Java.
Ejecutar
Para ejecutar una aplicación, usaremos java, que lo podemos encontrar en
el directorio bin del JRE o JDK. Para ello, debemos hacer referencia a una
clase que contenga un método estático main, el cual es siempre el punto
de entrada de las aplicaciones en Java. Además, si forma parte de un
paquete, deberemos escribir la ruta completa desde la base del árbol.
$ java com.example.myApp.MyApp
Como se aprecia, no es necesario incluir la extensión .class. Podemos
declarar algunas opciones adicionales, como el classpath en caso de
necesitarlo o ampliar la memoria disponible para la máquina virtual, si
encontramos que la aplicación es pesada y no funciona o tiene un
rendimiento bajo.
La opción c indica que se desea crear el archivo y la opción f especifica el
nombre del archivo. Este comando genera un comprimido .jar que contiene
todas las clases que indiquemos, incluyendo directorios de forma recursiva.
Además, genera un archivo de manifiesto.
Si el archivo de manifiesto especifica el header Main-Class, podremos
La Máquina Virtual de Java, en inglés Java Virtual Machine (JVM), es un
componente dentro de JRE (Java Runtime Environment) necesario para la
ejecución del código desarrollado en Java, es decir, es la máquina virtual la
que permite ejecutar código Java en cualquier sistema operativo o
arquitectura. De aquí que se conozca Java como un lenguaje
multiplataforma.
Cuando una clase Java necesita ser ejecutada, existe un componente
llamado Java Class Loader Subsystem que se encarga de cargar, vincular e
inicializar de forma dinámica y en tiempo de ejecución las distintas clases
en la JVM. Se dice que el proceso es dinámico porque la carga de los
ficheros se hace gradualmente, según se necesiten.
Existen tres tipos de Loaders y cada uno tiene una ruta predefinida desde
donde cargar las clases:
Linking es el proceso de añadir los bytecodes cargados de una clase en el
Java Runtime System para que pueda ser usado por la JVM. Existen 3
pasos en el proceso de Linking, aunque el último es opcional.
inferior) dependiendo de su tipo. Importante saber que las variables
de clase no se inicializan con sus valores iniciales correctos hasta la
fase de Initialization.
int 0
long 0L
short (short) 0
char “\u0000”
byte (byte) 0
boolean false
reference null
float 0.0f
double 0.0d
El último paso en el proceso del ClassLoader es Initialization, que se
encarga de que las variables de clase se inicialicen correctamente con los
● Method Area: es parte de Heap Area. Contiene el esqueleto de la
clase (métodos, constantes, variables, atributos, constructor, etc.).
● Heap Area: fragmento de memoria donde se almacenan los objetos
creados (todo lo que se inicialice con el operador new). Si el objeto
se borra, el Garbage Collector se encarga de liberar su espacio. Solo
hay un Heap Area por JVM, por lo que es un recurso compartido
(igual que Method Area).
● Stack Area: fragmento de memoria donde se almacenan las variables
locales, parámetros, resultados intermedios y otros datos. Cada hilo
tiene una private JVM stack, creada al mismo tiempo que el hilo.
● PC Register: contiene la dirección actual de la instrucción que se
está ejecutando (una por hilo).
● Native Method Stack: igual que Stack, pero para métodos nativos,
normalmente escritos en C o C++.
Execution Engine
El bytecode que es asignado a las áreas de datos en la JVM es ejecutado
por el Execution Engine, ya que este puede comunicarse con distintas áreas
de memoria de la JVM. El Execution Engine tiene los siguientes
componentes.
● Interpreter: es el encargado de ir leyendo el bytecode y ejecutar el
código nativo correspondiente. Esto afecta considerablemente al
rendimiento de la aplicación.
● JIT Compiler: interactúa en tiempo de ejecución con la JVM para
compilar el bytecode a código nativo y optimizarlo. Esto permite
mejorar el rendimiento del programa. Esto se hace a través del
HotSpot compiler.
● Garbage Collector: libera zonas de memoria que han dejado de ser
referenciadas por un objeto.
Para poder ejecutar código Java, necesitamos una VM como la que
acabamos de ver. Si nos vamos al mundo de JavaScript, necesitamos el
motor V8 que usa Google Chrome. Estaría bien poder tener una sola VM
para distintos lenguajes y aquí es donde entra GraalVM. Es una extensión
de la JVM tradicional que permite ejecutar cualquier lenguaje en una única
VM (JavaScript, R, Ruby, Python, WebAssembly, C, C++, etc.). El objetivo
principal es mejorar el rendimiento de la JVM tradicional para llegar a tener
el de los lenguajes nativos, un desarrollo políglota, así como reducir la
velocidad de inicio a través de la compilación Ahead of Time (AOT). Esto
permite compilar algunas clases antes de arrancar la aplicación (en tiempo
de compilación).
Control de flujo
Son las sentencias que permiten controlar el flujo y orden de la ejecución
de un programa.
if/else
Los bloques if/else nos permiten ejecutar solo ciertas partes del código en
función de las condiciones que pasemos.
switch
Switch evalúa la expresión entre paréntesis y ejecuta las sentencias que
siguen al caso que coincide con el nuestro. Switch seguirá ejecutando todas
las sentencias que siguen, aunque sean parte de otro caso, hasta que se
encuentre un break. Funciona con los primitivos byte, short, char, int y sus
wrappers. También funciona con enums y la clase String.
switch (expresión) {
case "ABC":
// Código a ejecutar si la expresión es "ABC"
case "DEF":
// Código a ejecutar si la expresión es "ABC" o "DEF"
break;
case "GHI":
// Código a ejecutar si la expresión es "GHI"
break;
default:
// Código a ejecutar si la expresión no es ninguna de las
anteriores
break;
}
for
El bucle for repite una serie de sentencias mientras se cumpla una
condición. En la primera expresión antes del punto y coma podemos definir
y asignar una variable, en la segunda establecemos la condición que tiene
que cumplir el bucle para continuar y al final, tenemos el incremento.
for-each
while
while (expresión) {
// Sentencias
}
do/while
El código se ejecutará al menos una vez y se seguirá ejecutando mientras
se cumpla la condición.
do {
// Sentencias
} while(expresión);
Operadores
Operadores aritméticos
5 + 7; // 12
5 - 7; // -2
6 * 7; // 42
9 / 2; // 4
9.0 / 2; // 4.5
9 % 2; // 1
Operadores de asignación
Nos permiten asignar valores a variables. El operador más usado es =, que
asigna un valor concreto a una variable. También son bastante usados los
operadores += y -= que nos permiten incrementar o decrementar,
respectivamente, una variable el valor que especifiquemos.
String hola = " Hola!!"; // La variable hola ahora vale "Hola!!"
int num = 7; / /La variable num ahora vale 7
Operadores de comparación
“Hola” == “
Adios”;
// false
1 != 2 ; / true
/
1 < 2; / / true
1 <= 5 ; / / true
1 > 1;
/ false
/
1 >= 1 ;
/ / true
Podemos usar los operadores de desigualdades (<, <=, >, >=) con variables
no numéricas, pero no se recomienda este uso ya que puede dar lugar a
muchas confusiones. Para comparar objetos no debemos usar el operador
==, sino .equals(), ya que == comprueba si ambos son el mismo objeto y no
su valor.
ew String("
String str1 = n Hola mundo") ;
String str2 = n ew String(" Hola mundo") ;
Operadores lógicos
int x = 5;
; /
int x = 5 / 5 = 0101
int y = 3 ; / / 0011
Otros operadores
El operador +
también se puede usar para concatenar cadenas de texto.
String str = x > 5 ? "x es mayor que 5" : "x es menor o igual que
5";
// Es equivalente a:
String str = "";
Si en una expresión tenemos más de un operador se evaluarán siempre
siguiendo el siguiente orden:
● ++ y
--
● !
● *, / y
%
● +, -
● <, <=, > y
>=
● == y
!=
● &&
● ||
● ?:
● =, +=, -=, *= y /=
● Si hacemos ++var, primero se incrementa el valor de var y luego el
resto de la expresión.
● Si hacemos var++, primero se evalúa la expresión y luego se
incrementa el valor de v ar.
;
int num1 = 5
int num2 = 5 ;
Clases
Una clase define el comportamiento y el estado que pueden tener los
objetos que son instanciados a partir de ella. El estado se define mediante
atributos y el comportamiento mediante métodos. Ambos elementos son
tipados. Los primeros marcan el tipo de dato que pueden almacenar y los
segundos el que devuelven.
Además, estos elementos se acompañan de un modificador de visibilidad.
Éste indica qué objetos pueden o no pueden acceder a estos atributos o
métodos. La visibilidad puede ser:
Estos modificadores también se aplican a las propias clases. Cada clase
pública debe estar en un fichero .java con el mismo nombre. Los atributos
se marcan como private, siguiendo el principio de ocultación. Para acceder
a ellos, se utilizan los métodos conocidos como getter/setter, o incluso
otros, en función del acceso que queramos darles.
Cabe destacar que podemos utilizar la palabra reservada this para
referirnos a un atributo o método del objeto. Esto puede ser útil para
distinguirlo de parámetros o variables locales.
// El constructor
public SpeedCalc(double time, double distance) {
this.time = time;
this.distance = distance;
}
public void s
etTime(double time) {
this.time = time;
}
// ...
}
Para utilizar esta clase, haríamos lo siguiente:
Un uso típico es crear una constante global que pueda ser accedida desde
cualquier parte de la aplicación. En estos casos, los atributos se marcan
como públicos y estáticos, y se les suele dar un nombre en mayúsculas por
Podemos establecer una relación de herencia entre dos clases mediante la
palabra reservada extends. Esto hará que la clase hija herede todos los
métodos y atributos de la clase padre. Hay que tener en cuenta que sólo
podrá acceder a ellos si no están declarados como private.
@Override
public void read() {
super.read();
System.out.println("Es el número " + number + " y tiene " +
getPages() + " páginas.");
}
}
Existe también la posibilidad de dejar un método de una clase sin
implementar. Para ello, se define la firma del método y se le añade el
modificador abstract. Las clases con este tipo de métodos se denominan
abstractas y también deben llevar este modificador. Una clase abstracta no
puede instanciarse directamente. Sólo se pueden instanciar clases hijas
que sí implementen el comportamiento.
En este ejemplo, hemos visto algo interesante. Una variable de tipo Animal
a la que le asignamos un valor de tipo Cat. Esto es posible gracias al
concepto de abstracción. Al ser una clase hija, podemos considerar que Cat
es un Animal.
Además, también incluimos el concepto de polimorfismo. Como todos los
animales tienen el método getSound(), podemos llamarlo sin preocuparnos
de qué animal concreto es. El resultado dependerá de la clase hija concreta
que hayamos instanciado. Podríamos tener una clase Dog que devolviera
“guau” e intercambiarlas dinámicamente.
Interfaces
Las interfaces son un paso más en el proceso de abstracción. A diferencia
de las clases, no implementan métodos ni atributos. Sólo declaran la firma
de los métodos. Existe una excepción, los métodos marcados con la
palabra reservada default. Estos proveen una implementación por defecto.
Las interfaces son implementadas por clases y cada clase puede
implementar un número indefinido de ellas.
La forma más simple de verlo es que con las interfaces definimos qué hay
que hacer, pero no cómo hacerlo. Esto permite que clases muy diferentes
entre sí puedan compartir comportamientos. Por ejemplo, un ave puede
volar, pero un avión también. La diferencia es cómo lo hacen.
Como se puede apreciar, mediante el uso de interfaces se puede alcanzar
un grado mayor de abstracción y polimorfismo, ya que podemos definir
comportamientos iguales para objetos que no tienen nada que ver, con
implementaciones muy distintas de los mismos.
Es recomendable definir atributos, parámetros y tipos de retorno de los
métodos como interfaces siempre que podamos. Esto hará que nuestra
aplicación sea más tolerante al cambio, pues no nos atamos a una
implementación concreta.
Anotaciones
Las anotaciones son una forma de añadir metadatos a los elementos de
nuestras aplicaciones. Estos metadatos pueden ser luego utilizados por el
compilador, librerías o frameworks para tratar de una forma determinada
esas piezas de nuestro código. No afectan de ninguna manera al código en
sí.
Su introducción tiene que ver con la extensibilidad y mantenibilidad del
código. El funcionamiento de las librerías y frameworks hasta entonces se
basaba en la implementación o extensión de ciertas interfaces y clases, la
firma de clases y métodos, y archivos XML poco legibles. Después de su
aparición con Java 5, todo esto se simplificó con la posibilidad de anotar
cada elemento en el propio código.
Las anotaciones van precedidas del símbolo @. Ya hemos utilizado la
Control de excepciones
Hay ocasiones en las que una determinada operación puede salir mal.
Parámetros inválidos, recursos no disponibles, estados inconsistentes, etc.
Nuestras aplicaciones deben ser robustas y tolerar estos fallos. No solo
deben seguir funcionando, sino que debe asegurarse que el estado global
queda consistente.
Las excepciones, como casi todo en Java, son objetos. Todas ellas
descienden de la clase Exception. Pueden almacenar, además de la traza
de llamadas que la provocó, un mensaje y alguna otra excepción asociada
que provocó la actual.
Veamos un ejemplo:
También podemos añadir un bloque finally después de los bloques catch.
Este bloque se ejecutará siempre, vaya bien la ejecución del try o no. Se
try-with-resources
Desde Java 7 existe la fórmula try-with-resources que permite vincular el
cerrado de recursos a la conclusión del try, de modo que no se nos olvide
hacerlo manualmente.
// Con finally
String line = null;
BufferedReader br = new BufferedReader(new FileReader("myfile"));
try {
line = br.readLine();
} catch (Exception e) {
e.printStackTrace();
} finally {
if (br != null) br.close();
}
// Con try-with-resources
String line = null;
try (BufferedReader br = new BufferedReader(new
FileReader("myfile"))) {
line = br.readLine();
} catch (Exception e) {
e.printStackTrace();
}
Como se puede observar, definimos los recursos que deben ser cerrados
automáticamente después del try y entre paréntesis. Podemos incluir varios
recursos separándolos por punto y coma. Al escribirse de esta forma se
llamará al método close del BufferedReader al acabar la ejecución del
bloque, se produzcan errores o no.
Todos los recursos que se utilicen dentro de un try-with-resources deben
implementar la interfaz AutoCloseable, la cual tiene un único método close
Antes de Java 9, los recursos necesitaban inicializarse en el bloque try,
pero a partir de Java 9, pueden ser inicializados antes e incluirlos en el
bloque después, siempre que las variables sean final o efectivamente final.
La sintaxis es la siguiente:
RuntimeException
Si llevas programando un tiempo en Java, te habrás dado cuenta de que, en
ocasiones, tu código ha generado excepciones que el compilador no te ha
obligado a envolver en un bloque try/catch o en un método con throws.
Esto puede ocurrir cuando invocamos un método sobre una referencia a
objeto null, cuando accedemos a un índice de un array que excede sus
dimensiones, etc.
En este apartado vamos a ver algunas APIs básicas que Java nos ofrece
para tratar ciertas situaciones recurrentes.
Object
Object es la clase de la que heredan todas las clases en Java en última
instancia. Declara algunos métodos útiles que pueden ser invocados desde
cualquier clase:
Además, contamos con la clase Objects, que incorpora otros métodos
útiles. Algunos de estos métodos se superponen con los de la clase Object,
pero permiten lidiar de una forma más cómoda con valores null.
Arrays
Los arrays son la forma más básica de agrupar valores y objetos. Tienen
una longitud fija y pueden ser de una o varias dimensiones. Cuando
Los arrays en Java son tipados. Esto quiere decir que sólo pueden
almacenar un tipo de valor u objeto. Para declararlos, se añade [] al tipo o
nombre de la variable. Para crearlo, podemos utilizar la palabra reservada
new con el tamaño del array o utilizar un array de literales por defecto.
System.out.println(primos[0]); // 2
array1 [0] = "Hola mundo";
Además, disponemos de la clase Arrays, que contiene métodos estáticos
útiles para manipular arrays. Podemos ordenarlos, hacer búsquedas, etc..
Clases envoltorio
Integer a = 5;
Integer b = 4;
Integer r = a + b;
String str = r.toString();
Long l = Long.parseLong(str);
Además, podemos utilizar los objetos de estas clases envoltorio como si
fueran tipos primitivos y viceversa. Esto se conoce como boxing/unboxing.
Es necesario tener en cuenta este comportamiento en términos de
rendimiento, ya que el compilador crea una nueva variable del tipo
envoltorio cuando realiza el boxing por nosotros. Es especialmente
importante cuando se utiliza en bucle.
Integer a = 5;
// Integer a = Integer.valueOf(5);
Integer b = 4;
// Integer b = Integer.valueOf(4);
Integer r = a + b;
// Integer r = Integer.valueOf(a.intValue() + b.intValue());
String
En Java, String es una clase, no un tipo primitivo. Aun así, podemos crear
nuevas instancias de forma sencilla con literales entre comillas dobles. Los
objetos de esta clase son inmutables. Todas las operaciones que modifican
la cadena original devuelven un nuevo objeto sin alterar el primero.
Para concatenar dos cadenas, podemos usar el método concat() o el
operador +. También podemos concatenar un tipo primitivo o un objeto, en
cuyo caso se utilizará de forma transparente el método toString() que
Java utiliza una codificación Unicode de 16 bits para representar los
caracteres. Esto no es suficiente para representar todos los posibles
caracteres que se pueden encontrar en el estándar con un solo code point.
Para solventar el problema sin romper la compatibilidad con aplicaciones
ya en uso, Java introdujo el concepto de caracteres suplementarios, que se
codifican mediante dos code points, en lugar de uno.
Fechas
La forma clásica de trabajar con fechas en Java es con las clases Date y
Calendar. La primera representa un punto cronológico en el tiempo,
expresado en milisegundos. La segunda nos ofrece una interfaz más rica
con la que poder trabajar con fechas.
calendar.set(Calendar.YEAR, 1990);
calendar.set(Calendar.MONTH, 3);
calendar.set(Calendar.DATE, 10);
calendar.set(Calendar.HOUR_OF_DAY, 15);
calendar.set(Calendar.MINUTE, 32);
Sin embargo, esta API tiene algunos problemas. No tiene un diseño
demasiado bueno, puesto que no pueden utilizarse fechas y horas por
separado, y no es thread safe, lo que puede ocasionar problemas de
concurrencia. Por eso, Java 8 introdujo una nueva API que ofrecía fechas
inmutables, adaptadas a diferentes calendarios y con un diseño mejorado
que nos ofrece métodos factoría. Podemos encontrar esta API en el
paquete java.time.
Formateado de texto
Si tratamos de sacar por pantalla el valor de números decimales o fechas,
podemos encontrarnos con que el resultado no es demasiado legible ni
atractivo. Java nos ofrece algunas clases para trabajar con el formato del
texto en el paquete java.text. Veamos un ejemplo a continuación:
Concurrencia
La concurrencia es la capacidad de ejecutar varias partes de un programa
en paralelo, aunque no necesariamente tienen que estar ejecutándose en el
mismo instante. Una aplicación Java se ejecuta por defecto en un proceso,
que puede trabajar con varios hilos para lograr un comportamiento
asincrónico. Pero, ¿qué es un proceso? Un proceso corresponde con un
programa a nivel de sistema operativo. Normalmente, suele tener un
espacio aislado de ejecución con un conjunto de memoria reservada
exclusivamente para él. Además, comparte recursos con otros procesos
como el disco, la pantalla, la red, etc., y todo esto lo gestiona el propio S.O.
Dentro de los procesos podemos encontrar hilos (threads). Un hilo
corresponde con una unidad de ejecución más pequeña, compartiendo el
proceso de ejecución y los datos del mismo.
Un concepto importante a conocer es el de Condición de Carrera y surge
cuando dos procesos ‘compiten’ por llegar antes a un recurso específico.
Cuando un proceso es dependiente de una serie de eventos que no siguen
un patrón al ejecutarse y trabajan sobre un recurso compartido, se podría
producir un error si los eventos no llegan en el orden esperado. Pero si se
realiza una buena sincronización de los eventos que estén en condición de
carrera, no se producirán problemas de inconsistencia en el futuro.
La JVM permite que una aplicación tenga múltiples hilos ejecutándose
simultáneamente. Existen dos formas de crear Hilos en java:
...
Thread thread = new MyThread();
thread.start();
...
...
Thread thread = new Thread(new MyRunnable());
thread.start();
...
Estados de un Hilo
● New: cuando se crea una nueva instancia de la clase de Thread pero
sin llegar a invocar el método start().
● Runnable: tras invocar el método start(), un thread es considerado
Runnable aunque podría pasar o no a estado Running si es el
seleccionado por el thread scheduler.
● Running: indica el hilo actual que se está ejecutando.
● Non runnable (blocked): tras invocar el método sleep() o wait().
También se puede pasar a este estado en caso de que haya algún
bloqueo por una operación de entrada/salida o cuando se está
esperando a que otro hilo termine a través del método join().
● Terminated/dead: cuando el método r un() finaliza.
A parte de los métodos vistos, podemos encontrar otros para manipular el
estado de un hilo como yield() que permite pausar el hilo actual en
ejecución, dando la posibilidad de que otros hilos puedan ejecutarse o
getState() que permite conocer el estado actual de un hilo, además de
muchos otros que podemos encontrar en la documentación.
Cada hilo tiene una prioridad que se indica con un rango de números entre
el 1 y el 10. Al crear un hilo nuevo, podemos usar el método setPriority(int)
que recibe por parámetro un entero con la prioridad deseada. También
podemos usar valores por defecto MIN_PRIORITY, NORM_PRIORITY,
MAX_PRIORITY (los valores asignados son 1, 5, 10).
...
Thread thread = new MyThread();
thread.setPriority(Thread.MIN_PRIORITY);
thread.start();
...
Sincronización de hilos
Cuando dos hilos acceden a un mismo recurso, debemos gestionar su
acceso para evitar colisiones entre ellos (a esto se le conoce como Thread
safety). Una sección de código en la que se actualizan datos comunes a
varios hilos se le conoce como sección crítica. Cuando se identifica una
sección crítica, se ha de proveer un mecanismo para conseguir que el
acceso sea exclusivo de un solo hilo. A esto se le conoce como exclusión
mutua. En java, las secciones críticas se marcan con synchronized.
A un objeto de una clase con métodos synchronized se le conoce como
monitor. Cuando un hilo accede al interior de un método synchronized se
dice que el hilo ha adquirido el monitor. En ese momento, ningún hilo podrá
acceder a ninguno de los métodos synchronized hasta que el hilo libere el
monitor. A través del método wait(), podemos indicar a un hilo que espere
para ocupar el monitor y con notify()/notifyAll(), indicamos al hilo que ya
puede acceder al recurso.
En el siguiente ejemplo se observa cómo se pone en pausa el hilo en caso
de que el índice sea menor a 0. Cuando el valor del índice avance y se
asigne un valor a esa posición del array, lo notificaremos mediante el
método n
otifyAll() y
desbloquearemos el hilo correspondiente.
}
● Semaphore: ofrece un número limitado de permisos que los hilos
deben adquirir al entrar en la región crítica y liberarlos al salir.
Pools de hilos
Un pool de hilos no es más que una reserva de hilos que están
instanciados, a la espera de ejecutar alguna rutina. Esto nos permite, por
una parte, agilizar el lanzamiento de tareas concurrentes y, por otra, limitar
el número máximo de hilos activos, de forma que no se dispare el consumo
de recursos de la aplicación.
CallableSum(int sumTo) {
this.sumTo = sumTo;
}
}
}
ThreadPoolExecutor puede rechazar la ejecución de una tarea nueva. Esto
puede ocurrir porque el pool está apagado (mediante el método shutdown)
o porque el tamaño de la cola y de hilos máximos se ha excedido. En este
caso, el resultado dependerá de la política que utilice el pool. Podemos
seleccionarla mediante el método setRejectedExecutionHandler. Las
políticas que tenemos a disposición son:
ThreadLocal
Se puede inicializar ThreadLocal con el método withInitial, que acepta una
interfaz funcional. Un ejemplo de uso sería el siguiente:
AtomicInteger(0);
public static final ThreadLocal<Integer> threadId =
ThreadLocal.withInitial(() -> nextId.getAndIncrement());
}
// ...
Sin embargo, puede ser interesante limpiar el valor de una variable después
de concluir la tarea para la que es necesaria. Esto es debido a que los hilos
pueden ser reutilizados, como en los pools. Para ello, podemos invocar el
método remove() de ThreadLocal.
Si vas a utilizar concurrencia en tu aplicación, es mejor que te tomes tu
tiempo para entender cómo funciona cada pieza exactamente. Un mal uso
de la concurrencia puede desembocar en datos corruptos y uso excesivo de
recursos. Aquí van unos consejos:
Por último, animar a quien esté interesado en ahondar en la materia. La
concurrencia es un campo vasto de conocimiento, con multitud de
enfoques y detalles. Aquí sólo hemos expuesto lo básico, pero Java trae
consigo muchas más herramientas que pueden ayudarte a conseguir
exactamente lo que necesitas.
Generics
El término “Generic” viene a ser como un tipo parametrizado, es un tipo de
dato especial del lenguaje que permite centrarnos en el algoritmo sin
importar el tipo de dato específico que finalmente se utilice en él. Muchos
algoritmos son los mismos, independientemente del tipo de dato que
maneje. Por ejemplo, un algoritmo de ordenación, como puede ser “la
burbuja”, es el mismo, independientemente de si estamos ordenando tipos
como: String, Integer, Object, etc. La mayoría de los lenguajes de
programación los integran y muchas de las implementaciones que nos
ofrecen los usan. Mapas, listas, conjuntos o colas son algunas de las
implementaciones que usan genéricos.
Se llaman parametrizados porque el tipo de dato con el que opera la
funcionalidad se pasa como parámetro. Pueden usarse en clases, interfaces
y métodos, denominándose clases, interfaces o métodos genéricos
respectivamente. En Java, la declaración de un tipo genérico se hace entre
símbolos <>, pudiendo definir uno o más parámetros, por ejemplo: <T>, <K,
V>. Existe una convención a la hora de nombrarlos:
A continuación, se muestran dos ejemplos de declaración, el primero define
public T getGenericOne() {
return g1;
}
public K getGenericTwo() {
return g2;
}
}
// Main.java file
public class Main{
public static void main(String[] args) throws Exception {
GenericClass<Integer, String> clazz =
new GenericClass<>(1, "generic");
// WhiteBoard.java file
public class WhiteBoard {
// Main.java file
public static void main(String[] args) throws Exception {
WhiteBoard board = new WhiteBoard();
board.draw(circle);
}
Al trabajar con genéricos hay que tener en cuenta ciertas consideraciones:
el parámetro tipo no puede ser un tipo primitivo, ya que los genéricos sólo
trabajan con tipos de referencia. Tampoco se pueden usar en la
implementación del genérico los métodos de la clase/interfaz del tipo que
se defina en la instanciación, a menos que se indique explícitamente. Los
tipos parametrizados pueden definir límites o especializaciones que
permiten trabajar con determinados tipos en la definición del genérico:
En el ejemplo anterior, estamos indicando que el tipo T debe ser un subtipo
de Comparable, esto permite que dentro del método se puedan usar los
métodos definidos en la interfaz Comparable.
Los tipos parametrizados también permiten el uso de comodín “?” para
definir que un tipo parametrizado es desconocido. Los comodines se
pueden usar como tipo de un parámetro, atributo o variable local y algunas
veces como tipo de salida. En las declaraciones donde se utilizan tipos
parametrizados con comodín, se pueden usar las palabras clave “super” o
“extends”. El uso de uno u otro difiere en función de si la implementación
que usa el genérico lo consume o produce. Debemos seguir la regla
mnemotécnica “PECS” (Producers Extends, Consumers Super), es decir,
“super” se utiliza para cuando se consume el tipo parametrizado y
“extends” cuando se produce.
Cuando se usa “? extends”, el compilador de Java sabe que esta lista
podría contener cualquier subtipo de clase Vehicle pero no sabe qué tipo,
podemos tener Bike, Car, Bus, etc. El compilador no permitirá al
desarrollador insertar cualquier tipo de elemento en la lista, preservando la
seguridad de tipos. En cambio, cuando se recupera un elemento de la lista,
se garantiza que cualquier elemento recuperado es una subclase de
Vehicle. De ahí que se diga que el uso de “extends” como comodín es
usado para productores.
superiores de la clase Car, pero no sabe qué supertipo está almacenando
en la lista ya que podría ser Vehicle u Object, de ahí que, cuando
intentamos recuperar un valor de la lista, este retorne un Object. Sin
embargo, cualquier clase hija de Car podrá ser insertada en la lista pero no
sus clases padre, como puede ser Vehicle. Por tanto, el uso de “super” con
comodín se circunscribe a consumidores.
El uso de comodines con genéricos es recomendable cuando estemos
desarrollando frameworks o librerías que son usadas por terceros y donde
queremos indicar explícitamente el uso del genérico dentro de la
implementación.
Colecciones
Una colección es un objeto que agrupa múltiples elementos bajo una sola
entidad. A diferencia de los arrays, las colecciones no tienen un tamaño fijo
y se crean y manipulan exactamente igual que cualquier otro objeto. En
Java, se conoce como Collection Framework Hierarchy a la arquitectura
que representa y manipula las colecciones. Se observa en la imagen inferior
como se emplea la interfaz genérica Collection y la interfaz Map para este
propósito. Podemos almacenar cualquier tipo de objeto y usar una serie de
métodos comunes como: añadir, eliminar, obtener el tamaño de la
colección, etc. Partiendo de la interfaz genérica, extienden otra serie de
subinterfaces que aportan funcionalidades más concretas sobre la interfaz
anterior y se adaptan a distintas necesidades.
garantiza ningún orden a la hora de realizar iteraciones. Es la
implementación más usada debido a su rendimiento y a que,
generalmente, no nos importa el orden que ocupen los
elementos.
○ TreeSet: almacena los elementos ordenándolos en función del
criterio establecido por lo que es más lento que HashSet. Los
elementos almacenados deben implementar la interfaz
Comparable. Esto produce un rendimiento de log(N) en las
operaciones básicas, debido a la estructura de árbol empleada
para almacenar los elementos.
○ LinkedHashSet: igual que Hashset, pero esta vez almacena los
elementos en función del orden de inserción. Es un poco más
costosa que HashSet.
● Map: conjunto de pares clave/valor, sin repetición de claves.
○ HashMap: almacena las claves en una tabla hash. Es la
implementación con mejor rendimiento de todas pero no
garantiza ningún orden a la hora de realizar iteraciones.
○ TreeMap: almacena las claves ordenándolas en función del
criterio establecido, por lo que es más lento que HashMap. Las
claves almacenadas deben implementar la interfaz
Comparable. Esto produce un rendimiento de log(N) en las
operaciones básicas, debido a la estructura de árbol empleada
para almacenar los elementos.
○ LinkedHashMap: Igual que Hashmap pero almacena las claves
en función del orden de inserción. Es un poco más costosa que
HashMap.
La interfaz Iterable ofrece un método con el que podemos obtener un
objeto Iterator para una colección. Este objeto permite iterar por la
colección, accediendo sucesivamente a cada uno de sus elementos. En el
caso de las listas, existe la interfaz ListIterator que nos permite iterar
también hacia atrás. Por ejemplo:
Concurrencia y colecciones
Las colecciones de Java son mutables. Esto hace que trabajar con ellas
cuando varios hilos tienen acceso pueda producir problemas. Una manera
de lidiar con esto es envolverlas de forma que todos sus métodos sean
synchronized. La clase Collections nos ofrece métodos para llevar esto a
cabo según el tipo de colección.
Además, si se quiere utilizar un iterador sobre la colección, se debe
sincronizar su uso sobre la colección devuelta. De lo contrario, se
obtendrían resultados impredecibles. Por ejemplo:
synchronized(list) {
Iterator it = list.iterator();
while (it.hasNext()) {
// Hacer algo con it.next()
}
}
Sin embargo, esta aproximación supone un problema de rendimiento. Sólo
un hilo podrá acceder a la vez a la colección. Para subsanar este
inconveniente, Java ofrece interfaces y clases específicas para colecciones
concurrentes que puedes encontrar en el paquete java.util.concurrent.
Estas estructuras de datos son mucho más eficientes ya que han sido
pensadas para esta casuística y procuran crear los menores bloqueos
posibles.
Lambdas
Las lambdas fueron introducidas a partir de Java 8. No son más que
funciones anónimas que nos permiten programar en Java con un estilo más
funcional y, en ocasiones, declarativo.
Sintaxis
El operador flecha -> es característico de las lambda y separa los
parámetros del cuerpo de la función.
No es necesario incluir el tipo ya que este puede ser inferido. El paréntesis
de los parámetros puede omitirse cuando sólo existe un parámetro y no
incluimos el tipo. Si no hay parámetros los paréntesis son necesarios.
() -> { cuerpo }
En el caso del cuerpo, si solo tenemos una sentencia, podremos omitir las
llaves y el return, por ejemplo:
numero -> {
String cadena = String.valueOf(numero);
return cadena;
}
Interfaces funcionales
En Java, se considera interfaz funcional a toda interfaz que contenga un
único método abstracto. Es decir, interfaces que tienen métodos estáticos
o por defecto (default) seguirán siendo funcionales si solo tienen un único
método abstracto.
Ejemplo:
@FunctionalInterface
public interface SalaryToPrettyStringMapper {
En cualquier caso, es recomendado añadirla si queremos que la interfaz
sea funcional, ya que en caso de que alguien añada más métodos a la
interfaz, el compilador lanzará un error si tiene la anotación.
Las lambdas pueden usarse en cualquier parte que acepte una interfaz
funcional. La lambda tendrá que corresponder con la firma del método
abstracto de la interfaz funcional.
Y, finalmente y lo más habitual, en las llamadas a métodos que acepten
interfaces funcionales:
IntStream.range(0, 2)
.mapToObj(entero -> String.format("entero = %s", entero))
.forEach(cadena -> System.out.println(cadena));
// Salida:
// entero = 0
// entero = 1
Referencias a métodos
Cuando un método cualquiera coincida con la firma de una interfaz
funcional, podremos usar una referencia al método en vez de la sintaxis
habitual de las lambdas.
Utilizando el ejemplo del apartado anterior, podemos modificar la lambda
del forEach, ya que System.out.println coincide exactamente con la firma
del método que espera.
IntStream.range(0, 2)
.mapToObj(entero -> String.format("entero = %s", entero))
.forEach(System.out::println); // <- Referencia a método
Para usar referencias a métodos, ponemos :: justo antes del método, en
vez de un punto, e ignoramos los paréntesis. Así pues, estas podrían ser
referencias válidas a métodos:
System.out::println
this::miMetodo
super::metodoDeSuper
unObjeto::suMetodo
Interfaces funcionales estándar más importantes
Con la llegada de Java 8 y las lambdas, también se incluyeron varias
interfaces funcionales en el API estándar de Java. Del mismo modo,
interfaces que existían previamente y que contenían un único método
abstracto, fueron marcadas oficialmente como interfaces funcionales.
● Function
● Supplier
● Consumer
● Predicate
Mientras que algunas de las interfaces antiguas que a partir de Java 8 son
funcionales son:
● Runnable
● Callable
● Comparator
Para utilizar los streams sobre una colección, basta con invocar al método
stream() o parallelStream(), en función de si queremos paralelizar las
operaciones o no.
Un stream no almacena los valores, sino que se limita a computarlos.
Obtiene los datos de una colección y genera un resultado tras el procesado
de las operaciones intermedias del pipeline mediante una operación
terminal. Es importante tener en cuenta que las operaciones intermedias
devuelven un stream, mientras que las operaciones terminales no. Las
operaciones intermedias no se ejecutan hasta que se realiza una operación
terminal.
List<Other> l2 = l1.stream()
.filter(elem -> elem.getAge() < 65)
.sorted() // Ordena según la implementación de Comparable
.map(elem -> new Other(elem.getName,() elem.getAge()))
.collect(toList());
● Filtrado.
● Búsqueda.
● Mapeado.
● Matching.
● Reducción.
● Iteración.
Puedes encontrar un listado completo de las operaciones soportadas por
los streams en la interfaz java.util.stream.Stream.
Además de crear un stream para una colección, se pueden construir
streams para valores, un array, un fichero o incluso una función. Para
valores, se utiliza el método estático Stream.of, mientras que para arrays
se utiliza el método Arrays.stream.
Para convertir un archivo en un stream de líneas, podemos utilizar
Files.lines() como en el siguiente ejemplo:
El hecho de que los streams computen elementos, hace que podamos
crear streams infinitos a partir de funciones mediante Stream.generate y
Stream.iterate. Por ejemplo, puede ser interesante para obtener un valor
constante o número aleatorio.
Por último, aclarar que el método collect() es una operación terminal que
acepta un parámetro de tipo Collector. Podemos importar métodos factoría
para estos desde la clase Collectors. En función del tipo que utilicemos, la
colección resultante será diferente.
I/O
Java realiza la entrada-salida, en inglés Input-Output (I/O), de datos a
través de canales, mejor conocidos como Streams. Normalmente, el flujo
para trabajar con streams siempre es el mismo:
1. Se abre el canal.
2. Se realiza una operación.
3. Se cierra el canal.
Lo primero es cargar el fichero. Para poder leerlo, necesitamos usar el
método read(). Cuando ya no haya más datos por leer, el método devuelve
un -1 indicandolo. El último paso es cerrar el canal.
int i = 0;
while ((i = fis.read()) != -1) {
System.out.print((char) i);
}
} catch (Exception e) {
e.printStackTrace();
}
}
Asociamos el fichero a un stream y obtenemos sus bytes. Para poder
escribir, necesitamos el método w
rite(). El último paso es cerrar el canal.
Si queremos mejorar el rendimiento, ya sea de lectura o escritura, podemos
utilizar las clases BufferedInputStream y BufferedOutputStream,
respectivamente.
En el siguiente ejemplo, se observa como el BufferedOutputStream recibe
por parámetro FileOutputStream y una vez se ha escrito en el buffer (a
través de write()), se hace un flush para asegurarnos y forzar a que el
buffer escriba todos los datos. No debemos olvidarnos de cerrar ambos
canales.
● Character Stream: gestionan el I/O de datos en formato texto
(ficheros en texto plano, entradas por teclado, etc.). Podemos
encontrar dos superclases abstractas que son Reader y Writer.
Algunos ejemplos más comunes que heredan de las clases
nombradas son:
○ FileReader: permite leer un fichero. Es igual que el
FileInputStream visto anteriormente.
Al igual que vimos antes, también tenemos las clases BufferedReader y
BufferedWriter. Otras clases interesantes pueden ser InputStreamReader y
OutputStreamReader que actúan de puente entre un stream de bytes y un
stream de caracteres.
Serializable
Si la clase define algún atributo, como un objeto en lugar de un tipo
primitivo, la clase de ese objeto también deberá ser Serializable.
Todas las clases serializables deberían definir un campo versión. Éste es
útil cuando se modifica la clase y se producen conflictos con otras
aplicaciones que utilizan versiones antiguas de la misma librería de clases.
Su aplicación sería la siguiente:
Optional
Entre las muchas características que Java 8 incorporó al lenguaje, está
Optional: una clase genérica que permite aplicar el patrón Option, nacido
en los lenguajes funcionales e incorporado en esta versión de Java debido a
la inclusión de las lambdas. Este patrón permite indicar explícitamente que
un método puede o no devolver el valor deseado, obligando al desarrollador
a controlar la posible ausencia de valor de forma explícita.
La clase Optional no dispone de un constructor público, delegando
cualquier construcción a sus métodos de factoría estáticos.
tatic <
public s T> Optional<T> e
mpty()
public s tatic <T> Optional<T> o fNullable(T value)
public s tatic <T> Optional<T> o f(T value)
El primero nos permite retornar un objeto Optional vacío, es decir, sin valor.
El segundo retorna un objeto con valor, pero si el parámetro es nulo,
retorna uno vacío, y el último nos retorna un objeto con valor, y si se pasa
un valor nulo, lanzará una excepción NullPointerException.
El objeto nos proporciona un conjunto de métodos básicos para trabajar
con él:
oolean isPresent()
public b
public T get()
El método “isPresent” nos indica si el objeto tiene o no valor, es como si
estuviéramos realizando la comprobación "variable == null", y el método
“get” retorna el valor almacenado, devolviendo una excepción en caso de
no existir.
El siguiente grupo es bastante útil para trabajar con el valor sin la
necesidad de comprobar continuamente su presencia. Podremos ejecutar
las operaciones “filter”, “map” y “flatMap” que habitualmente se usan
cuando trabajamos con Streams.
Y el último bloque permite resolver la posible nulidad ejecutando una
determinada acción. Por ejemplo, el método “orElse” nos retorna el valor o,
si es nulo, el valor que pasamos por parámetro; “orElseGet” exactamente lo
mismo, pero esta vez retornará el valor devuelto por la ejecución de la
función y “orElseThrow” retorna el valor o, si no existe, lanzará una
excepción que retorne la ejecución de la función pasada.
Destacar que no debemos utilizar este patrón como solución al problema
de errores motivados por NullPointerException. A simple vista, uno puede
interpretarlo así y, de hecho, en muchos desarrollos se ha usado este
planteamiento incurriendo en un antipatrón. Por ejemplo, imaginad un
desarrollo de una aplicación donde queremos obtener el número de
alumnos que pueden ser escolarizados en un municipio e intentamos
resolver el problema de la nulidad usando Optional. El código usando
programación imperativa queda como se muestra abajo. Es muy difícil de
seguir y puede incurrir en errores si alguien tuviera que modificarlo.
● Retornar un valor por defecto o que represente la nulidad: muchos de
los casos donde puede ser retornado un Optional, puede ser resuelto
usando un valor por defecto o usar el patrón NullObject. Por ejemplo,
la clase HighSchool en vez de tener:
● No usar en atributos de un objeto: la clase Optional no implementa
serializable. Esto puede provocar errores si se usa en un atributo de
una clase.
}
● No usar en colecciones: este uso es un mal olor. Suele ser mejor
retornar una lista vacía.
● No usar como parámetro de un método: Opcional es una clase basada
en valores, por lo tanto, no tiene ningún constructor público, es
creada utilizando sus métodos estáticos de factoría. Su uso en
parámetros supone código adicional que dificulta su legibilidad,
siendo mejor no usarlo como tal.
return students;
}
Parte 2
Herramientas y técnicas
Introducción a Git
GIT es un Sistema de control de versiones distribuido (DVCS) y Open Source que
permite a los usuarios trabajar en un proyecto común y de forma independiente.
Esto se hace a través de una copia del repositorio en la máquina local, de manera
que no se necesita conexión a internet para realizar cambios. Cuando se necesite
compartir los nuevos cambios con el equipo, se publican en el repositorio remoto.
Algunas de las plataformas más conocidas para el control de versiones son
Github, G
itlab o B
itbucket
Instalación inicial
Git proporciona diferentes instaladores para varias plataformas: Linux, Mac,
Windows. En su web puedes descargarlos. Para comprobar que Git se ha instalado
correctamente, podemos usar el comando git --version.
Vamos a crear nuestro primer repositorio local. Para ello, tenemos que crear una
carpeta con el comando “mkdir myRepository” y acceder a ella con “cd
myRepository”. Una vez dentro, ejecutamos el comando “git init”. Veremos cómo
se crea una carpeta .git que contiene toda la información necesaria para el control
de versiones del proyecto (commits, dirección de repositorio remoto, logs, etc.).
También debemos crear un fichero llamado .gitignore que indicará todo aquello
que no queramos subir al repositorio remoto. En él podemos definir archivos con
cierta extensión o incluso, un directorio entero.
● Commit: contiene un conjunto de ficheros que refleja el estado del
proyecto en ese punto, referencias a sus padres y un hashing que lo
identifica unívocamente.
● Head: son referencias a un commit específico. Un repositorio puede tener
varios heads, pero sólo un HEAD (en mayúscula) que identifica el head
actual. Por defecto, hay un head en cada repositorio llamado ‘master’.
Además, los repositorios locales cuentan con un directorio de trabajo. Este
directorio está fuera del repositorio (la carpeta ‘.git’). En él, se encuentran los
ficheros sobre los que se está trabajando. Pueden tener cambios sobre HEAD o no.
Este directorio de trabajo cuenta con dos áreas especiales que podemos utilizar
para gestionar cómo se van a manejar los cambios en los ficheros:
● Área de staging: es el área donde se sitúan los ficheros con cambios que
van a ser incluidos en el próximo commit. La inserción de los ficheros
modificados en este área es manual.
● Área de stashing: en este área se sitúan ficheros con cambios que todavía
no están listos para ser incluidos en un commit. Permite almacenarlos
temporalmente mientras se resuelven otras tareas.
● Untracked: son los archivos que no han sido añadidos al área de staged y
que pueden ser consolidados una vez han pasado al estado Staged
haciendo commit. Se puede cambiar el estado de estos archivos utilizando
el comando “git add”.
● Unmodified: una vez se ha realizado un commit, se podría decir que los
archivos se quedan en un estado de Unmodified que puede cambiar a
Modified en caso de que cambie algo.
● Modified: archivos ya existentes en Staged pero que han sido editados.
● Staged: son los archivos que se han añadido para hacer un commit. Para
llegar a este estado, se ha tenido que ejecutar “git add”. Si queremos pasar
algún archivo al área de Untracked, debemos ejecutar “git rm --cached
[nombre_archivo]”.
Comandos básicos
Aunque GIT cuenta con un gran número de comandos y opciones, aquí se explican
aquellos que se usan con más frecuencia:
● clone: crea un repositorio a partir de un repositorio remoto. El repositorio
remoto se añade como ‘origin’ al registro de repositorios remotos.
● log: muestra el histórico del repositorio. Podemos usar la opción --oneline
para que sea más fácil de leer.
● add: añade uno o varios ficheros con cambios al área de staging. Es un
previo paso para consolidar los cambios.
● checkout: permite modificar el área de trabajo entre versiones. Puede
aplicarse sobre commits o ramas.
● merge: combina los cambios que se han producido en dos ramas desde el
punto en que se bifurcaron. Se ejecuta con el área de trabajo en el head de
la rama en la que queremos hacer merge de los cambios y se especifica el
nombre de la rama a fusionar como parámetro. Si hay conflictos, es
necesario resolverlos antes de finalizar el merge.
● stash: almacena los cambios en el área de stash y los elimina del área de
trabajo. Es útil cuando tienes que cambiar de tarea pero los cambios no
están listos para ser consolidados. Puedes tener más de un stash a la vez.
Se puede utilizar de las siguientes maneras:
○ git stash save “mensaje”: crea un stash con mensaje para poder
distinguirlo mejor.
○ git stash apply: aplica el stash al área de trabajo pero no lo elimina
del área de stash.
○ git remote -v: devuelve el nombre y las URL de los repositorios
configurados.
● git fetch: actualiza los cambios que se han producido en un repositorio
remoto pero no actualiza el área de trabajo.
● git pull: actualiza los cambios que se han producido en un repositorio
remoto y actualiza el área de trabajo. Puede causar conflictos. Es
equivalente a hacer un git fetch + git merge.
Existen más comandos y opciones que se pueden utilizar, pero con esto podrás
desenvolverte en la mayoría de las ocasiones. Consulta la documentación de Git
para más detalles.
Herramientas comunes
Además de la línea de comandos, podemos utilizar Git a través de aplicaciones
que nos proporcionan interfaces gráficas para diferentes propósitos. Un ejemplo
clásico son los sistemas de control de versiones integrados en los diferentes IDEs,
como Eclipse, Visual Studio Code o IntelliJ. Estos nos permiten utilizar los
comandos más habituales de una forma cómoda.
Además de éstos, existen otras herramientas utilizadas con frecuencia, tales
como:
● git-gui: facilita la preparación y elaboración de commits. Se instala junto a
git.
● GitKraken: es una interfaz gráfica para Linux, Mac y Windows con
funcionalidades muy útiles como poder deshacer y rehacer acciones con un
solo click. Su interfaz gráfica es muy intuitiva y nos permite hacer merge
simplemente arrastrando con el ratón de una rama a otra.
● SourceTree: se trata de una interfaz gráfica para Mac y Windows que
permite controlar nuestros repositorios locales y remotos, y realizar las
operaciones de gestión de versiones de los proyectos.
Existen muchas más herramientas. Puedes encontrar un listado más completo en
la web de Git.
Ramas
Una rama Git es simplemente un puntero a uno de los commits. Cada vez que se
confirmen los cambios del stage, se crea un nuevo commit y la rama apuntará a
éste. El nuevo commit guarda un puntero al commit precedente. La rama por
defecto de Git es la rama m
aster.
Al crear una nueva rama, a través del comando “git branch”, se creará un nuevo
puntero con el nombre. Por ejemplo, podemos crear una nueva rama “testing” con
el comando “git branch testing”:
Para que el HEAD apunte a este nuevo puntero habrá que ejecutar “git checkout
testing”. Existe una forma de hacer estos dos pasos en uno solo usando la opción
-b. El comando completo sería “git checkout -b testing”
Puede ocurrir que a la hora de mergear una rama con otra, los cambios sean
simplemente lineales: por ejemplo, tenemos una rama “hotfix” que queremos
mergear en nuestra rama “master”. Esta rama hotfix difiere de master en el
commit C3:
Al mergear hotfix en master, Git realiza un fast forward merge, actualizando el
puntero de la rama master a C3:
En este caso, los últimos cambios se incorporan sin necesidad de resolver
conflictos y pueden subirse directamente al repositorio remoto.
dd fichero_modificado
git a
git commit --amend
// En un solo paso
git commit -a --amend
los últimos “n” commits del histórico pero no descartar los cambios ejecutamos:
Las herramientas para gestionar los proyectos software han evolucionado con el
paso del tiempo. En un primer momento, la gestión se hacía a través de GNU Make
y Apache Ant, la primera herramienta que centralizaba la configuración de
proyectos Java en un fichero XML y que era totalmente independiente de la
estructura. Sin embargo, debido a las limitaciones de Ant para ciertas tareas como
la gestión de dependencias, nació Apache Maven y un poco más tarde Gradle.
Maven
Antiguamente, si queríamos compilar y generar ejecutables de un proyecto, se
debía realizar un previo análisis sobre la estructura, las dependencias del
proyecto, librerías, qué ficheros se querían compilar, etc. y luego, el propio equipo
ejecutar las acciones deseadas. El principal cambio con Ant/Make es que con
Maven de manera declarativa se define cómo es el proyecto de una manera
estándar y es la propia herramienta la que ejecuta las acciones definidas. De esta
manera, se permite también la rápida inclusión de personal ajeno al proyecto.
Es a través del POM (Project Object Model) definido mediante el fichero
pom.xml, donde se declaran las particularidades de nuestro proyecto. Contiene
toda la información del mismo: de qué librerías depende, qué versión de JVM va a
utilizar para compilar, qué informes hay que generar, etc. De este modo, nosotros
indicamos las dependencias que queremos usar y él, automáticamente, realiza las
tareas necesarias para obtenerlas del repositorio.
<properties>
<maven.compiler.target>1.8</maven.compiler.target>
<maven.compiler.source>1.8</maven.compiler.source>
</properties>
O definiendo el plugin:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.0</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
</configuration>
</plugin>
Maven se basa en una serie de patrones (fases del ciclo de vida de construcción
de un proyecto) y estándares (estructura de directorios) que veremos a
continuación.
Estructura de directorios
Maven establece una estructura común de directorios para todos los proyectos.
Ciclos de vida
Las fases más comunes son (el resto de fases se pueden encontrar en la
documentación oficial):
Existe una fase especial, clean, que solo se ejecuta si se indica explícitamente y
por lo tanto, está fuera del ciclo de vida de Maven. Esta fase limpia todas las
clases compiladas del proyecto.
Goals
Cada fase vista anteriormente tiene una serie de goals por defecto que se
encargan de realizar una tarea. Cada vez que ejecutamos una fase, se ejecutan sus
goals por defecto, aunque también podemos ejecutar únicamente un goal sin
tener que ejecutar su fase.
clean clean:clean
compile compiler:compile
test surefire:test
install install:install
deploy deploy:deploy
Si tenemos curiosidad por saber los goals ejecutados en un fase, podemos usar el
comando mvn help:describe -Dcmd=NOMBRE_FASE
Dependencias y Repositorios
La gestión de dependencias con Maven es muy sencilla. A través de maven
repository podemos buscar aquellas que sean necesarias para nuestro proyecto y
añadirlas dentro del apartado <dependencies> de nuestro fichero pom.xml. En el
siguiente ejemplo tenemos la dependencia de JUnit 5:
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.7.0</version>
<scope>test</scope>
</dependency>
</dependencies>
Un repositorio es un lugar donde se almacenan todas estas dependencias de uso
cotidiano que pueden ser accedidas por el resto de proyectos. Maven se encarga
de buscar las dependencias primero en el repositorio local y, si no las encuentra,
las buscará en los repositorios remotos que le hayamos indicado en el pom.xml
(por defecto https://repo.maven.apache.org/maven2).
En muchos proyectos el repositorio de Maven se queda corto. Por ejemplo, las
dependencias de Oracle como: j2ee, JTA o Activation no se encuentran. Esto
incrementa la necesidad de crear un repositorio compartido en la organización que
almacene estas librerías de terceros y las propias de la organización. Además,
presenta la ventaja de que todos los miembros de la organización tienen
actualizado su repositorio con las últimas versiones y reduce el ancho de banda ya
Arquetipos
Los arquetipos son las plantillas que podemos utilizar para generar nuestros
proyectos con Maven. Estas plantillas evitan el llamado “miedo al folio en blanco”
ya que nos generan una estructura de directorios y código de ejemplo, acorde con
la naturaleza del proyecto que queramos realizar.
Con el comando mvn archetype:generate podemos ver una lista con todos los
arquetipos que tiene por defecto cada tipo de proyecto y proporcionar la
información del groupId, el artifactId y la versión (por defecto 1.0-SNAPSHOT). Si
queremos filtrar los archetype por el paquete usamos la opción -Dfilter con el
formato [groupId:]artifactId
Si estamos utilizando algún IDE tipo IntelliJ, al crear un nuevo proyecto podemos
seleccionar si queremos crearlo a partir de un arquetipo.
Gradle
Gradle es una herramienta similar a Maven con la que podemos gestionar las
dependencias y las distintas fases de nuestro proyecto con las siguientes
diferencias:
apply plugin:'java'
apply plugin:'checkstyle'
apply plugin:'findbugs'
apply plugin:'pmd'
version ='1.0'
repositories {
mavenCentral()
}
dependencies {
testCompile group:'junit', name:'junit', version:'4.11'
}
● Las distintas fases que se pueden definir en la configuración son
etiquetadas como “task”.
● Gestión de dependencias.
● Construcción rápida, ya que evita la ejecución de aquellas tareas que
tengan como resultado la misma salida.
Introducción al testing
Las pruebas, en ingeniería de software, son los procesos que permiten verificar y
revelar la calidad del producto. Con la prueba, se lleva a cabo la ejecución de un
programa que, mediante técnicas experimentales, trata de evitar errores que se
producirían en tiempo de ejecución y comprueba la funcionalidad.
Hay muchos tipos distintos de pruebas: unitarias, de integración, funcionales, de
aceptación, de regresión, etc.
El hecho de tener pruebas sobre nuestro código nos asegura que lo que funciona
hoy, seguirá funcionando mañana; sobre todo, si la ejecución de las mismas está
automatizada e integrada dentro del ecosistema de desarrollo, con el soporte de
un servidor de integración continua.
Sin una buena batería de tests, cualquier modificación en el código puede ser el
origen de un nuevo bug; con los tests se pierde el miedo al cambio y cuanta mayor
cobertura, menos miedo tendremos. Eso sí, debemos tener cuidado y probar los
casos estrictamente necesarios. Muchas veces, por tener un porcentaje de
cobertura del 100%, se testean casos que son innecesarios. Lo normal es tener un
test por cada regla de negocio.
RED: primero comenzamos escribiendo el código del test, que no compilará
puesto que aún no hemos escrito nuestras clases, y no pasará porque no tiene
lógica de negocio.
GREEN: después escribimos el código de nuestras clases de negocio para que el
test compile y pase con el mínimo código posible.
Siguiendo la técnica del RED - GREEN - REFACTOR nos aseguramos que no
escribimos una línea de código que no esté probada mediante un test y, con ello,
no escribimos una línea de código innecesaria. TDD nos sitúa en el punto de vista
de quién tiene que usar la funcionalidad que estamos implementando. Esto
resulta en no construir más código del necesario para cubrir la funcionalidad, sin
complicar innecesariamente la aplicación.
Siguiendo con la misma filosofía, sólo deberíamos generar tests que cubren la
funcionalidad de historias de usuario o casos de uso. Los tests nos ayudan a
documentar el código que se va escribiendo. ¿Cómo? Cada test generado es una
regla de negocio que estamos probando y nos obliga a pensar en un buen naming
que describa exactamente lo que estamos validando, lo que incide directamente
en un mejor diseño.
JUnit
JUnit es la librería opensource más usada para el desarrollo de test unitarios en
aplicaciones Java.
@Test
public void test_name() {
...
}
Un aspecto fundamental de las pruebas es verificar que el código fuente probado
realmente hace lo que debe. Para hacer este seguimiento, JUnit proporciona la
clase Assert con la que a través de una serie de métodos podemos delegar ciertas
comprobaciones: que un objeto no sea nulo, que sea nulo, que dos objetos deban
ser iguales… Cuando alguna de estas comprobaciones no pasa, lanzará un
AssertionError y ese test fallará.
● Assert.failNotEquals: este método está pensado para forzar el fallo si los
objetos que le pasamos no son iguales.
● Assert.fail(): provoca explícitamente un fallo dentro del test. Sirve para forzar
errores que en condiciones normales no deberían existir. Aunque si lo que se
quiere probar es que se lanza una excepción, en JUnit5 se puede usar
Assertions.assertThrows.
@Test(expected = NullPointerException.class)
public void this_test_will_throw_a_null_pointer_exception() {
//..
}
@Rule
public ExpectedException expectedEx = ExpectedException.none();
@Test
public void this_test_will_get_the_null_pointer_exception_message() {
expectedEx.expect(NullPointerException.class);
expectedEx.expectMessage("my exception message");
}
@Test
public void this_test_will_throw_a_null_pointer_exception() {
Exception exception = assertThrows(NullPointerException.class, () ->{
//...
});
Hamcrest
Es una librería que nos permite añadir expresividad en los asserts de los test y así
hacerlos más legibles.
Uso tradicional:
...
assertEquals(expected, actual);
assertNotEquals(expected, actual)
...
Con Hamcrest:
...
// Los 3 primeros ejemplos son equivalentes
assertThat(a, equalTo(b));
assertThat(a, is(equalTo(b)));
assertThat(a, is(b));
assertThat(actual, is(not(equalTo(expected))));
assertThat(a, nullValue());
...
Estos son ejemplos muy sencillos pero ya se puede ver la potencia de los
matchers de Hamcrest. Estos matchers son los que nos permiten expresar lo que
queremos comprobar en el assert y sus principales ventajas son:
AssertJ
Es una alternativa a Hamcrest que permite también escribir tests con un lenguaje
assertThat(user.getName()).isEqualTo("Autentia");
assertThat(user).isNotEqualTo("any");
assertThat(actual).isNull();
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>X.X.X</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>prepare-package</phase>
<goals>
<goal>report</goal>
</goals>
/execution>
<
</executions>
</plugin>
Para ver el reporte generado, podemos ejecutar el goal de Maven mvn
jacoco:report que nos creará un html con el resultado. ¿Dónde? Dentro de la
carpeta /target/site/jacoco/index.html.
Estos reportes pueden ser utilizados para ser cargados y visualizados en otras
herramientas de integración continua como Sonar o Travis.
Dobles de Test
A veces, estamos testeando un componente que tiene dependencias con apis de
terceros o incluso se conecta a una base de datos para recuperar cierta
información. Pero, lo que realmente queremos testear es el comportamiento del
componente. Imaginemos que siempre que existen dependencias, realizamos una
conexión con la base de datos, en un proyecto pequeño el rendimiento de los
tests podría ser inapreciable, pero en un proyecto grande con cientos, incluso
miles de tests, la duración de todos ellos podría ser inmensa. Aquí es cuando
entran en juego los dobles de tests para simular dicho comportamiento que nos
permita centrarnos y testar solo lo que realmente necesitamos. Permiten
“engañar” al código para que se crea que colabora correctamente con otras clases,
es como si fueran los dobles de las películas para las escenas peligrosas.
dummy: se usa cuando no nos importa cómo se colabora con este objeto. Por
ejemplo, cuando sabemos que no se va a usar en absoluto. Lo necesitamos porque
nos interesa su interfaz pero no su implementación. La implementación de los
métodos de estos dobles no hacen nada y devuelven null. Normalmente, se usa
para rellenar una lista de parámetros.
class ServiceTest{
@Test
public void example_dummy_test() {
DummyRepositoryClass dummy = new DummyRepositoryClass();
ServiceClass myService = new ServiceClass(dummy);
}
Se debe tener en cuenta que el uso de un framework de mocks también es una
alternativa al ejemplo anterior y suele ser más común. Si usamos, por ejemplo,
mockito, se haría de la siguiente forma:
spy: es como un stub pero que espía a quien lo llama. Esto permite luego,
comprobar el número de veces que se ha llamado al método, el número de
argumentos que se le pasan, etc. Estos dobles son peligrosos porque acoplan el
test con la implementación concreta, lo que provocará que si se cambia la
implementación, aunque no cambie el comportamiento, el test fallará. Son tests
frágiles, por lo que debemos evitarlos.
Mockito nos ofrece el método verify(), que comprueba que se llama al método e
incluso el número de veces que ha debido ser invocado. Por ejemplo:
En el código anterior se comprueba que efectivamente se ha llamado al método
isAuthenticated() una sola vez (el valor por defecto). Si quisiéramos comprobar
que se ha llamado tres veces:
verify(spyAs, times(3)).isAuthenticated();
Vamos a ver con Mockito dos ejemplos sobre cómo especificar el resultado que
queremos que nos devuelva nuestro mock. Imaginemos que tenemos un servicio
que devuelve una lista de productos de una tienda. Esa lista es del tipo
List<Product>. La primera forma que veremos a continuación es type safe, esto
quiere decir que tiene en cuenta el tipo devuelto y por tanto, nos saldría un error
en tiempo de compilación indicandonos que se espera una lista de productos y se
está devolviendo una string:
considera un fake.
Recomendaciones
El principal objetivo de los tests es comprobar que todas las partes implicadas de
una aplicación queden libres de errores de forma unitaria e integrada para
prevenir problemas en sucesivas fases del ciclo de vida del proyecto.
FIRST
Si bien los propios tests deben perseguir también un buen diseño, para evitar que
la propia infraestructura de tests se convierta en un problema, debería cumplir
con el principio FIRST:
Fast: los tests deben ser de rápida ejecución, por eso debemos poner especial
énfasis en implementar tests unitarios y, solo test de integración en aquellos
casos en los que realmente necesitemos el contexto de un sistema externo para
ser ejecutados. Si nombramos correctamente los tests de integración, podemos
definir una fase concreta para la ejecución de los mismos dentro del ciclo de vida
de Maven, pudiendo ahorrar la ejecución de tal fase en una build normal y
recopilar estadísticas de cobertura independientes distinguiendo entre tests
unitarios y de integración.
ejecución.
Self-validating: deben ser autoevaluables, es decir, que el propio test identifique
si el test ha funcionado correctamente o no. Esta autoevaluación se realiza
mediante aserciones (asserts).
Timely: deben escribirse en el momento oportuno, es decir antes del código de
producción, y el motivo es muy simple: es más fácil hacer tests para un código
que todavía no está escrito que para uno que ya ha sido creado, del mismo modo
que es más fácil hacer crecer recto un árbol que todavía no ha brotado con una
guía, que enderezar uno que tiene varios metros de altura.
@Test
public void should_check_product_is_added_to_cart() {
//Given
Cart cart = new Cart();
cart.addProduct(new Product("Autentia book"));
//When
String result = cart.getProductByName("Autentia book");
//Then
assertThat(result, is("Autentia book"));
}
Entorno de ejecución
Depuración
En muchas ocasiones nuestro software no se comporta como esperamos o
produce un error no controlado. Depurar nuestro código nos ayuda a detectar
dónde está el fallo y así poder corregirlo.
Breakpoints
Los puntos de ruptura, también llamados breakpoints, ayudan al desarrollador a
parar la ejecución en un punto de código de manera que podamos inspeccionar el
estado de la aplicación, continuar con la ejecución en la siguiente línea, en un
nivel más (dentro del método que se va a invocar) o cancelar la ejecución actual.
Los IDE ofrecen la posibilidad de añadir puntos de ruptura de manera sencilla e
incluso condicionales, de manera que la ejecución se pare si se cumple una
expresión.
Para que los breakpoints se disparen, la aplicación debe compilarse y levantarse
en modo debug.
Observar variables
Una vez que un punto de ruptura se ha disparado, podemos observar el valor de
las variables e incluso cambiar su valor en caliente. Esto es posible hacerlo porque
Java es compatible con la JPDA (Java Platform Debugger Architecture), que es la
que permite cambiar código en ejecución.
Gestión de logs
La gestión de logs es una parte fundamental en el desarrollo de nuestro software
ya que proporciona información sobre posibles errores u otros datos que podrían
ser de interés para resolver algún problema, ofreciendo una depuración rápida y
un mantenimiento sencillo. Es una práctica común intercalar instrucciones de
código que van informando del estado de la ejecución de las aplicaciones,
generando así un log. Entonces, ¿en qué consiste hacer logging o sacar trazas de
una aplicación? En obtener un listado de mensajes que genera un sistema durante
su ejecución. Ya sean operaciones que realizan los usuarios o lo que hacen los
diferentes componentes de la aplicación.
En Java existe una librería llamada Log4Java (log4j) que nos permite gestionar
estas tareas de una forma simple. Para mostrar mensajes de log en una clase, se
debe crear un objeto de tipo Logger. También se debe tener en cuenta que no
todos los mensajes de log de una traza tienen la misma importancia y estos se
clasifican en niveles de criticidad:
OFF > FATAL > ERROR > WARN > INFO > DEBUG > TRACE > ALL
Cuantos más datos de traza, más fácil será encontrar los problemas. ¿Por qué no
mostrar el nivel máximo siempre? Lo primero, un exceso de información puede
llegar a ser contraproducente. Segundo, el rendimiento de la aplicación se puede
ver afectado. Por último, decir que el tamaño para almacenar los logs generados
puede llegar a ser muy grande, algo a tener en cuenta a la hora de mantener
nuestros entornos de producción.
El siguiente código de ejemplo consulta un array en memoria en una posición
equivocada para forzar una excepción en tiempo de ejecución:
import org.apache.log4j.*;
Bibliografía
Estas son las fuentes que hemos consultado y en las que nos hemos
basado para la redacción de este material:
● Documentación de Oracle:
https://docs.oracle.com/en/java/index.html
● https://git-scm.com/docs
● https://maven.apache.org/
● https://docs.gradle.org/
Página 70
Lecciones
aprendidas
con esta guía
En esta guía hemos puesto los • Descubrir las APIs más utilizadas,
cimientos sobre los que construir desde los tipos básicos,
nuestro castillo, utilizando Java genéricos y opcionales, hasta
como forjado. Algunos de los puntos las colecciones, los streams o la
más importantes son: concurrencia.
¡Conoce más!