Quantcast
Channel: Adictos al trabajo
Viewing all 989 articles
Browse latest View live

Swift: Codables a fondo

$
0
0

Índice de contenidos

  1. Introducción
  2. Entorno
  3. Funcionamiento básico
  4. Estructuras de datos anidadas
  5. Nullabilidad
  6. Decodificación/Codificación personalizada
  7. Class vs Struct
  8. Configuración del JSONDecoder/encoder
  9. Codificando/Decodificando fechas
  10. Estructuras de datos heterogéneas
  11. Arrays con elementos no decodificables
  12. Conclusiones

1. Introducción

Con la release 5.0 de Swift se introdujo en el lenguaje una utilidad a la que nombraron «Codables» que hace más fácil mapear a objetos distintos formatos, inicialmente JSON y PropertyLists de una forma sencilla. Esto hace que, por ejemplo ya no sea tan necesario recurrir a librerías de terceros (Mantle, ObjectMapper, etc) para poder trabajar con JSON de una forma mas cómoda.

2. Entorno

El tutorial está escrito usando el siguiente entorno:

  • Hardware: Portátil MacBook Pro 15’ (2.5 GHz Intel Core i7, 16 GB 1600 MHz DDR3)
  • Sistema Operativo: Mac OS X Catalina 10.15.3
  • Xcode 11.3.1
  • iOS 13.4
  • Swift 5.1

3. Funcionamiento básico

Supongamos que tenemos la siguiente respuesta JSON:

{
  "name": "Tokyo",
  "lat": 35.6894989,
  "lon": 139.6917114
}

Para mapearla lo haríamos de la siguiente manera

struct City: Codable {
    var name: String
    var lat: Double
    var lon: Double
}

Es importante remarcar que todos los atributos de la estructura de datos deben conformar el protocolo Codable, de nos ser así ya se encargará el compilador de recordárnoslo. Realmente, esto hará que el compilador nos genere esto por nosotros:

struct City: Codable {
    var name: String
    var lat: Double
    var lon: Double

    enum CodingKeys: String, CodingKey {
        case name
        case lat
        case lon
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        name = try container.decode(String.self, forKey: .name)
        lat = try container.decode(Double.self, forKey: .lat)
        lon = try container.decode(Double.self, forKey: .lon)
    }
    
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(name, forKey: .name)
        try container.encode(lat, forKey: .lat)
        try container.encode(lon, forKey: .lon)
    }
}

Más adelante veremos porque esto es importante.

Para probarlo en un Playground por ejemplo, con este pequeño fragmento de código podemos tanto decodificar cómo codificar nuestro objeto:

import Foundation

let json = """
{
  "name": "Tokyo",
  "lat": 35.6894989,
  "lon": 139.6917114
}
"""
let data = json.data(using: .utf8)!

struct City: Codable {
    var name: String
    var lat: Double
    var lon: Double
}

let decoder = JSONDecoder()
let city = try decoder.decode(City.self, from: data)
print(city.name) // Will print "Tokyo"
print(city.lat)  // Will print "35.6894989"
print(city.lon)  // Will print "139.6917114"

let encoder = JSONEncoder()
data = try encoder.encode(city)
print(String(data: data, encoding: .utf8)!) // Regenerates original JSON

Aquí cabe mencionar que podemos optar porque nuestro objeto sólo se pueda codificar o decodificar, haciendo que en vez de adoptar el protocolo Codable adopte Decodable o Encodable (de hecho literalmente Codable es un alias «public typealias Codable = Decodable & Encodable»). Personalmente y tras la experiencia en varios proyectos mi recomendación personal es que siempre que se pueda adoptar Codable, por ejemplo, nos puede interesar persistir esas respuestas, y si solo se adopta Decodable no podríamos hacerlo.

También podemos utilizar enums para mejorar nuestras estructuras de datos, por ejemplo, si tenemos este JSON que representa una película:

{
  "id": "1",
  "name": "Interstellar",
  "duration": 169,
  "genre": "SCI_FI"
}

Podemos mapearlo como:

struct Movie: Codable {
    enum Genre: String, Codable {
        case sciFi = "SCI_FI"
        case comedy = "COMEDI"
        case action = "ACTION"
        case drama = "DRAMA"
        case other = "OTHER"
    }
    
    var id: String
    var name: String
    var duration: Int
    var genre: Genre
}

En el caso de que el género viniese como un código numérico (hay gente muy rara ahí fuera…) bastaría con cambiar a enum Genre: Int, Codable e indicar los códigos de cada género de forma análoga.

4. Estructuras de datos anidadas

Imaginemos una respuesta más compleja, como cualquiera que vamos a encontrar en el Mundo Real ©. Por ejemplo cambiando un poco nuestra respuesta anterior:

{
  "name": "Japan",
  "cities": [
    {
      "name": "Tokyo",
      "coordinates": {
        "lat": 35.6894989,
        "lon": 139.6917114
      }
    },
    {
      "name": "Kyoto",
      "coordinates": {
        "lat": 35.0210686,
        "lon": 135.7538452
      }
    },
    {
      "name": "Osaka",
      "coordinates": {
        "lat": 34.6937408,
        "lon": 135.502182
      }
    }
  ]
}

Para mapear esta estructura podríamos crear los siguientes struct:

struct Country: Codable {
    var name: String
    var cities: [City]
}

struct City: Codable {
    var name: String
    var coordinates: Coordinates
}

struct Coordinates: Codable {
    var lat: Double
    var lon: Double
}

Y para decodificar la respuesta solo necesitaríamos hacerlo al tipo raíz

let country = try decoder.decode(Country.self, from: data)

Aquí es importante tener en cuenta que todos los objetos de la jerarquía deben adoptar los mismos protocolos (por ejemplo no podríamos hacer que Coordinates sea solo Decodable o Encodable, el compilador nos daría un error).

El decoder/encoder irá llamando recursivamente a los métodos init/encode de cada uno de los objetos para parsear los datos y construir la jerarquía de objetos.

5. Nullabilidad

Una ventaja de los Codables es que tienen en cuenta la nullabilidad. Si en la jerarquía de clases de nuestro Codable en algún punto se incumple el contrato de nullabilidad fallará la decodificación de la respuesta completa. Por ejemplo, en nuestra respuesta del apartado anterior, en caso de que en la respuesta JSON, basta con que una de las ciudades tenga el campo «name» a nil para que cuando intentemos decodificar la respuesta lance una excepción.

Por una parte la ventaja es clara, puesto que la nullabilidad va marcada por el contrato nuestro código no puede crashear en runtime al manejar una respuesta. Por otra parte puede llegar a dar problemas si no está correctamente especificada y documentada la nullabilidad del API rest (imaginad el caso anterior, con miles de ciudades y que falle la decodificación porque una tiene el campo «name» a null).

6. Decodificación/Codificación personalizada

En ocasiones es posible que tengamos que recurrir a implementar exactamente como decodificar y/o codificar los datos. Por defecto, el compilador sintetizará la implementación de las CodingKeys y los metodos init(from decoder: Decoder) y func encode(to encoder: Encoder) (según corresponda dependiendo si es Decodable, Encodable o ambos). Es más, si implementamos ambos métodos el compilador tampoco sintetizará las CodingKeys y tendremos que declararlas nosotros.

En cuanto a las CodingKeys, siempre y cuando estemos implementando nosotros mismos tanto la codificación como la decodificación, no tienen porqué llamarse así siempre, podríamos declarar perfectamente el ejemplo anterior de City de la siguiente manera:

struct City: Codable {
    var name: String
    var lat: Double
    var lon: Double

    enum CityKeys: String, CodingKey {
        case name
        case lat
        case lon
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CityKeys.self)
        name = try container.decode(String.self, forKey: .name)
        lat = try container.decode(Double.self, forKey: .lat)
        lon = try container.decode(Double.self, forKey: .lon)
    }
    
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CityKeys.self)
        try container.encode(name, forKey: .name)
        try container.encode(lat, forKey: .lat)
        try container.encode(lon, forKey: .lon)
    }
}

En cuanto a los CodingKeys, sirven para declarar que keys existen el las estructuras de datos. ¿Por qué podríamos necesitar implementarlos nosotros mismo? Bueno, imaginemos que estamos trabajando con una API un tanto peculiar y nos devuelve los datos de la siguiente manera (niños, no hagáis esto en casa 🙏)

{
  "n": "Tokyo",
  "l": 35.6894989,
  "lo": 139.6917114
}

En este caso por no queremos tener en nuestro código algo así, por lo que recurrimos a las CodingKeys para hacer la «traducción»

struct City: Codable {
    var name: String
    var lat: Double
    var lon: Double

    enum CodingKeys: String, CodingKey {
        case name = "n"
        case lat = "l"
        case lon = "lo"
    }
}

Con esto el decoder/encoder hará la traducción por nosotros.

Por otra parta, implementando los métodos para decodificar/codificar los datos, podemos controlar exactamente cómo decodificar/codificar los datos. Por ejemplo, con un JSON así:

{
  "id": "1",
  "description": "Machine1",
  "enabled": 0,
}

Podemos cambiar ese enabled a un valor Bool:

struct Machine: Codable {
    var id: String
    var description: String
    var enabled: Bool

    enum CodingKeys: String, CodingKey {
        case id
        case description
        case enabled
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        id = try container.decode(String.self, forKey: .id)
        description = try container.decode(String.self, forKey: .description)
        enabled = try container.decode(Int.self, forKey: .enabled) == 0
    }
    
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(id, forKey: .id)
        try container.encode(description, forKey: .description)
        try container.encode(enabled ? 1 : 0, forKey: .enabled)
    }
}

En el caso de decodificar casos opcionales, también tenemos el siguiente helper:

struct Foo: Codable {
    var bar: String?

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        bar = try container.decodeIfPresent(String.self, forKey: .bar)
    }
}

En este caso si «bar» fuese null o directamente no estuviese contenido en la respuesta lo seteará a nil, y sería similar a implementar lo siguiente:

struct Foo: Codable {
    var bar: String?

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        if container.contains(.bar) {
            bar = try container.decode(String?.self, forKey: .bar)
        }
    }
}

Cabe mencionar que en ambos casos, que si «bar» no fuese null pero tampoco fuese un String, fallaría la decodificación de la respuesta completa ya que se estaría violando el contrato. Si nuestra intención es que algún atributo que no pueda decodificarse por ser del tipo incorrecto se establezca a nil en vez de lanzar una excepción habría que modificar ligeramente la decodificación.

struct Foo: Codable {
    var bar: String?

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        bar = try? container.decodeIfPresent(String.self, forKey: .bar)
    }
}

7. Class vs Struct

Hasta ahora hemos estado trabajando con structs, aunque también tenemos la posibilidad de usar clases.

class Foo: Codable {
    var bar: String?

    required init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        bar = try? container.decodeIfPresent(String.self, forKey: .bar)
    }
}

La ventaja de usar structs es que estos son inmutables, y esa inmutabilidad es muy deseada de cara a utilizar respuestas, sobre todo con el fin de evitar errores y efectos colaterales si se modifica por descuido. Como desventaja está que no podemos usar herencia. Aun así, usar la herencia en el caso de los Codables es un tanto complejo, ya que nos fuerza a implementar absolutamente todo en la clase derivada, ya que no será capaz de sintetizar correctamentamente el código.

class MyClass: Codable {
    var bar: String
}

class MyChildClass: MyClass {
    var baz: String
    
    enum CodingKeys: String, CodingKey {
        case baz
    }
    
    required init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        baz = try container.decode(String.self, forKey: .baz)
        try super.init(from: decoder)
    }
}

Otra aproximación es usar structs y usar composición como alternativa a la herencia, aqui ya dependerá del caso concreto y de las preferencias de cada uno (aunque en ambos casos nos va a tocar implementar la decodificación a mano):

struct MyStruct: Codable {
    var bar: String
}

struct MyComplexStruct: Codable {
    var baz: String
    var myStruct: MyStruct
    
    enum CodingKeys: String, CodingKey {
        case baz
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        baz = try container.decode(String.self, forKey: .baz)
        myStruct = try MyStruct(from: decoder)
    }
}

8. Configuración del JSONDecoder/encoder

En función de la fuente de datos que estemos usando, puede que el decoder/encoder nos ofrezca la posibilidad de configurar su comportamiento (en este tutorial nos estamos centrando en JSON, aunque en principio se puede implementar un encoder/decoder a cualquier tipo de formato, podría ser XML).

En el caso de JSON, hay algunas cosas muy útiles que nos da la posibilidad de configurar.

  • Imaginemos que la respuesta JSON de un API rest nos vienen los keys en snake_keys (muy habitual con backends en Python por ejemplo). Mediante el atributo keyDecoding/EncodingStrategy podemos decirle que nos lo pase a came case (respuestas) o que nos lo pase a snake_case (peticiones). Esto nos evita tener que declarar manualmente las CodingKeys y su «traducción»
  • Fechas: Podemos configurar el atributo dateDecoding/EncodingStrategy. Por defecto intentará decodificar la fecha como el número de segundos desde el 1 de Enero de 2001 (WTF!). Este puede ser uno de los quebraderos de cabeza más grande, tanto que tiene su propio apartado.

Se puden consultar la documentacion de Apple en para ver otros comportamientos se pueden modificar
https://developer.apple.com/documentation/foundation/jsondecoder
https://developer.apple.com/documentation/foundation/jsonencoder

9. Codificando/Decodificando fechas

Como he avanzado en el apartado anterior, decodificar o codificar fechas (tipo Date) puede ser un dolor. Si tenemos mucha suerte y nuestro API rest utiliza siempre el mismo formato de fecha, tenemos la opción de establecer el atributo dateDecoding/EncodingStrategy. Aquí tenemos dos opciones interesantes:

  • iso8601: Formato de fecha estándar, tiene este formato «2020-03-24T12:00:00Z»
  • Otro formato, por ejemplo, el que usa javaScript al usar JSON.stringify, haciendo uso de un DateFormatter

let decoder = JSONDecoder()
let dateFormatter = DateFormatter()
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"
decoder.dateDecodingStrategy = .formatted(dateFormatter)

Si no tenemos suerte, y el API rest es una idiosincracia de formatos de fecha en el cual en la misma respuesta usa hasta dos o tres formatos de fecha distintos que despertarán los instintos asesinos más primitivos del desarrollador de front (si, he visto cosas que jamás creeríais…), podemos optar por alguna de estas soluciones:

  • Manejar los casos concretos en la decodificación/codificación de la propia respuesta:

struct MyStruct: Codable {
    var date: Date

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        let dateString = try container.decode(String.self, forKey: .date)
        (...)
        self.date = <converted dateString to date>
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(<converted date to appropiate format>, forKey: .date)
    }
}

  • Si sabemos cuales son los N formatos que usan las respuestas de nuestros servicios, podemos usar el siguiente truco

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom { decoder -> Date in
    let container = try decoder.singleValueContainer()
    let dateStr = try container.decode(String.self)
    
    let dateFormatter = DateFormatter()
    dateFormatter.locale = Locale(identifier: "en_US_POSIX")
    dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
    dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"
    
    let dateFormatter2 = DateFormatter()
    dateFormatter2.locale = Locale(identifier: "en_US_POSIX")
    dateFormatter2.timeZone = TimeZone(secondsFromGMT: 0)
    dateFormatter2.dateFormat = "yyyy-MM-dd"

    (...)
    
    for formatter in [dateFormatter,dateFormatter2,(...)] {
        if let date = formatter.date(from: dateStr) {
            return date
        }
    }
    
    // Set a fallback date or throw an exception to abort decoding, whatever best suits your project
    return Date.distantPast
}

Esto nos vale para la decodificación, para la codificación tendremos que valorar si la estructura de datos se va a utilizar para realizar la request (en cuyo caso habría que codificarlo en dicho formato de fecha) o bien si solo necesitamos que sea encodable para poder persistir los datos, de forma que lo suyo es usar el formato de fecha que menos informaciíon pierda (sería un tiro en el pie utilizar el formato de año, mes día si en la respuesta original nos ha venido con la hora).

10. Estructuras de datos heterogéneas

Puede darse el caso de que tengamos que lidiar con una respuesta JSON de este tipo:

[
  {
    "title": "Jurassic Park",
    "type": "MOVIE",
    "published": 1993,
    "duration": 127,
    "director": "Steven Spielberg",
    "music": "John Williams"
  },
  {
    "title": "Jurassic Park",
    "type": "BOOK",
    "published": 1990,
    "pages": 400,
    "writter": "Michael Crichton"
  },
  {
    "title": "Back In Black",
    "type": "SONG",
    "album": "Back In Black",
    "artist": "AC/DC",
    "duration": 256
  }
]

Aquí tenemos varias opciones, podemos implementar una estructura de datos donde los campos comunes (title, type y published) sean no opcionales y el resto sean todos de tipo opcional:

struct Media: Codable {
    enum ItemType: String, Codable {
        case movie = "MOVIE"
        case book = "BOOK"
        case song = "SONG"
    }
    
    var title: String
    var type: ItemType
    var published: Int
    
    var duration: Int?
    var director: String?
    var music: String?
    var pages: Int?
    var writter: String?
    var album: String?
    var artist: String?
}

Sería perfectamente válida, aunque habría que lidiar con el manejo de los opcionales, y también tiene la desventaja de que si la estructura JSON no cumple el contrato no se invalidaría la respuesta lanzando una excepción al decodificar (por ejemplo, un elemento de tipo «SONG» que no tuviese «artist»). Esto se puede paliar implementando nuestra propia decodificación/codificación que tenga en cuenta el «type» y actue en consecuencia.

Otra opción es usar herencia, con los pros y contras explicados en el punto 7.

Existe una última opción, es algo más compleja aunque permite seguir utilizando structs y a la vez evitar el problema de la nullabilidad obligatoria. Antes hemos visto que se pueden utilizar enums en las estructuras de datos, esto admite una vuelta de tuerca más. Podemos utilizar un enum con un tipo agregado para reemplazar el type por los distintos tipos de «Media»:

struct Media: Codable {
    enum ItemType: Codable {
        enum TypeKey: CodingKey {
            case type
        }
        
        case movie(MovieMedia)
        case book(BookMedia)
        case song(SongMedia)
        case unknown(String)
        
        init(from decoder: Decoder) throws {
            let container = try decoder.container(keyedBy: TypeKey.self)
            let type = try container.decode(String.self, forKey: .type)
            switch type {
            case "MOVIE":
                self = .movie(try MovieMedia(from: decoder))
            case "BOOK":
                self = .book(try BookMedia(from: decoder))
            case "SONG":
                self = .song(try SongMedia(from: decoder))
            default:
                self = .unknown(type)
            }
        }
        
        func encode(to encoder: Encoder) throws {
            var container = encoder.container(keyedBy: TypeKey.self)
            var type = ""
            switch self {
            case .movie(let item):
                type = "MOVIE"
                try item.encode(to: encoder)
            case .book(let item):
                type = "BOOK"
                try item.encode(to: encoder)
            case .song(let item):
                type = "SONG"
                try item.encode(to: encoder)
            case .unknown(let unknownType):
                type = unknownType
            }
            try container.encode(type, forKey: .type)
        }
    }
    
    var title: String
    var type: ItemType
    var published: Int
    
    enum CodingKeys: String, CodingKey {
        case title
        case published
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        title = try container.decode(String.self, forKey: .title)
        published = try container.decode(Int.self, forKey: .published)
        type = try ItemType(from: decoder)
    }
    
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(title, forKey: .title)
        try container.encode(published, forKey: .published)
        try type.encode(to: encoder)
    }
}

struct MovieMedia: Codable {
    var duration: Int
    var director: String
    var music: String
}

struct BookMedia: Codable {
    var pages: Int
    var writter: String
}

struct SongMedia: Codable {
    var duration: Int
    var album: String
    var artist: String
}

Podemos probar nuestra implementación en un Playground y ver como se manejaría la estructura de datos con el siguiente ejemplo de código:

let json = #"[{"title":"Jurassic Park","type":"MOVIE","published":1993,"duration":127,"director":"Steven Spielberg","music":"John Williams"},{"title":"Jurassic Park","type":"BOOK","published":1990,"pages":400,"writter":"Michael Crichton"},{"title":"Back In Black","type":"SONG","published":1980,"album":"Back In Black","artist":"AC/DC","duration":256}]"#
let data = json.data(using: .utf8)!
let media = try JSONDecoder().decode([Media].self, from: data)
media.forEach { media in
    switch media.type {
    case .movie(let media):
        print(media)
    case .book(let media):
        print(media)
    case .song(let media):
        print(media)
    default:
        break
    }
}
let encodedMedia = String(data: try JSONEncoder().encode(media), encoding: .utf8)

Resultado

11. Arrays con elementos no decodificables

Hay una característica de los codables que puede ser particularmente molesta si bien tiene todo el sentido del mundo. Si en nuestra estructura de datos tenemos un array de N elementos, basta con que uno solo de esos N elementos lance una excepción para que invalide la respuesta completa.

Por ejemplo, imaginemos por un momento que el atributo «name» de las ciudades del apartado 4 tuviese valor null:

{
  "country": "Japan",
  "cities": [
    {
      "name": "Tokyo",
      "coordinates": {
        "lat": 35.6894989,
        "lon": 139.6917114
      }
    },
    {
      "name": "Kyoto",
      "coordinates": {
        "lat": 35.0210686,
        "lon": 135.7538452
      }
    },
    {
      "name": null,
      "coordinates": {
        "lat": 34.6937408,
        "lon": 135.502182
      }
    }
  ]
}

Esto provocaría directamente que al intentar decodificar la respuesta lanzase una excepción, probablemente mostrando el típico mensajito de marras al usuario de «Se ha producido un error» o el también clásico «Respuesta inesperada del servidor». Si además en vez de 3 ciudades fuesen 3000 apaga y vámonos…

Lo normal sería que la nullabilidad de la API estuviese especificada o al menos que se comportase de forma consistente y eso no pasase, pero ya sabemos como se las gasta el Mundo Real ©… Para esos casos hay varias formas de atacar el problema.

Una opción puede ser marcar como opcional ese atributo que ha provocado el fallo de decodificación. Lo malo de esto es que podemos acabar con el código lleno de «?» por todas partes, con lo cual deberíamos evitar esto a no ser que realmente sea un atributo que tenga sentido que no venga según que casos.

Otra opción posible es usar un truco posible con Swift 5.1, los Property Wrappers (similar a las anotaciones de Java o a los decoradores de Python). Esto daría para un tutorial, pero digamos que encapsulan un tipo de dato concreto de forma que desde fuera se sigue «viendo» como dicho tipo de dato pero se controla el acceso a dicho elemento. Se implementaría de la siguiente manera.

@propertyWrapper
struct FailableCodableArray<T:Codable>: Codable  {
    private struct EmptyCodable: Codable {}
    var wrappedValue: [T]
    
    init(from decoder: Decoder) throws {
        wrappedValue = []
        var container = try decoder.unkeyedContainer()
        while !container.isAtEnd {
            if let item = try? container.decode(T.self) {
                wrappedValue.append(item)
            } else {
                // Needed to make nestedContainer iterator increase (doesn't increase on failed decoding), preventing infinite loop
                let _ = try container.decode(EmptyCodable.self)
            }
        }
    }
}

Con este property wrapper, para el caso concreto del apartado 4, bastaría con modificar la estructura de datos «Country»:

struct Country: Codable {
    var name: String
    @FailableCodableArray var cities: [City]
}

Si probasemos a decodificar la respuesta «mala» de las ciudades con esta modificación, podemos comprobar que funciona y que tiene solo 2 ciudades, ignorando aquella que no cumple el contrato.

12. Conclusiones

Los Codables fueron una de las mayores novedades de la versión 5 de Swift. En el caso de nuestros proyectos, nos ha permitido quitarnos todas dependencias de terceros para mapear los objetos y trabajar con ellos. Si bien es cierto que la curva de aprendizaje inicial es algo elevada al principio, y que todo funciona bien hasta que te encuentras con algún caso complicado, en general todos se pueden resolver de una manera u otra.

Además, el tema de la nullabilidad, si bien puede dar un poco de guerra, ayudan a que salgan a la superficie problemas que ni siquiera sabes que tenías. En nuestro caso el proyecto aún tenía gran parte de la base de código en Obj-C, y eso nos enmascaraba una gran cantidad de problemas con el patrón null object, que se lo «tragaba todo» y nos encontrábamos con pantallas completamente vacías porque el mapeo no funcionaba o nos venían a null cosas que no deberían venir nunca a null (seguro que a alguno le suena el «no, si esto nunca debería venir a null», hasta que un día vino…).

Por otra parte, conviene tener un mecanismo que nos permita tracear cualquier excepción que lance la codificación/decodificación, las cuales contienen información detallada de que ha sido lo que ha provocado la violación del contrato, para poder detectar posibles problemas de la integración de un API rest o comportamientos anómalos por parte de este.

La entrada Swift: Codables a fondo se publicó primero en Adictos al trabajo.


Cómo mapear objetos en Java con MapStruct

$
0
0
  1. ¿Qué es MapStruct?
  2. Añadir MapStruct a nuestro proyecto
  3. Ejemplo con Java
  4. Conclusión

¿Qué es MapStruct?

MapStruct es una herramienta que nos permite, mediante anotaciones crear mapeos entre objetos en tiempo de compilación. Evitando tener que escribir todo el código de mapeo a mano, está pensado para funcionar con Java 1.6 o posterior.

Añadir MapStruct a nuestro proyecto

Ahora veremos cómo incluir MapStruct en un proyecto con Maven, simulando conversiones entre objetos y objetos DTO, tal y como sucedería en una arquitectura REST.

Para añadir MapStruct añadimos la siguiente dependencia a nuestro proyecto:dependencia maven

Actualizamos los índices de Maven y con esto ya tenemos MapStruct listo para usarlo en nuestro proyecto, ahora veremos un ejemplo práctico.

Ejemplo con Java

Ahora vamos a crear una clase llamada Address y otra AddressDTO, que nos servirán para realizar los diferentes mapeos, vamos a crear varios ejemplos de mapeo por lo que iremos cambiando los atributos de las clases para cada ejemplo.

Ejemplo 1: Clases a mapear con los mismos atributos, tipos y nombres iguales.

En este primer ejemplo, veremos el caso más sencillo, en el que nuestra clase y nuestro DTO, tienen lo mismos campos, con los mismos tipos y los mismos nombres. Ambas clases tienen sus métodos getters, setters y constructor.

 

Una vez tenemos nuestras clases creadas, vamos a crear nuestro Mapper, que puede ser una interfaz o una clase abstracta, en este ejemplo utilizaremos la interfaz. 

Una vez tenemos esta interfaz creada, necesitamos que al compilar nuestro proyecto Maven nos genere la implementación de la clase, nos vamos al pom.xml y en pluginManagement ponemos lo siguiente:

plugin management

Ahora creamos una prueba unitaria para comprobar que funciona nuestro converter:


test

En el test creamos el objeto Address a convertir y el objeto AddressDTO que nos tendría que devolver para comprobar qué funciona correctamente, a su vez en el test hemos ‘instanciado’ el converter haciendo uso de MapStruct, con el método getMapper(). Ahora ejecutamos en nuestra terminal mvn clean verify y el test debería pasarnos, si vamos a la carpeta target, podemos ver la implementación de nuestro converter y lo que hace MapStruct por debajo.

Bien, hasta ahora hemos visto como mapear objetos idénticos, pero, ¿qué ocurre si tenemos los mismos tipos de atributos pero algunos con nombres distintos?

Ejemplo 2: Clases a mapear con los mismos atributos, tipos y algunos nombres diferentes.

Con la misma configuración de antes, dejamos la clase Address tal cual está, vamos a cambiar solo nuestra clase DTO, y vamos a dejarla así:

Como vemos, solo hemos cambiado de nombre los atributos phone -> phoneNumber y name -> personName, ahora si ejecutamos de nuevo nuestro test, fallará. Esto se debe a que MapStruct no está pudiendo realizar un mapeo directo, vamos a la interfaz y lo solucionamos de la siguiente manera:

De esta forma estamos diciéndole a MapStruct, que el atributo phone de Address, tiene que ser mapeado al atributo phoneNumber de AddressDTO, y lo mismo con name, como vemos es muy sencillo, pero que pasa si dentro del objeto a convertir tengo otro objeto que también necesita conversión, vamos a verlo en el tercer ejemplo:

Ejemplo 3: Clases a mapear con los mismos atributos, diferentes tipos y nombres iguales.

Vamos a poner los atributos de name, lastname y phone, dentro de un objeto llamado Person, y crearemos también un PersonDTO, vemos como quedarían nuestras clases:

 

Bien, ahora cambiamos nuestro test, para poder probar también los datos dentro del objeto Person:

Si ejecutamos esto, nuestros tests pasarán sin cambiar nada en MapStruct, porque es lo suficientemente ‘listo’ como para crear un mapeo directo entre Person y PersonDTO, pero si esos objetos fueran más complejos o tuviera dificultades MapStruct para mapearlos, podemos crear un método en la interfaz de esta forma:

O si la conversión fuera demasiado compleja, a partir de Java 8, podemos hacer uso de los métodos default en las interfaces y crear nosotros mismos a mano la conversión, pero esto solo en casos necesarios.

Conclusión

Como vemos, crear mapeos entre objetos con MapStruct es muy sencillo, también nos permite crear mapeos con objetos con herencia, con listas anidadas, etc, etc. Pero para todo ello ya existe la documentación de MapStruct en el siguiente enlace: MapStruct reference guide

Espero que te haya gustado este tutorial, nos vemos en el siguiente 🙂

La entrada Cómo mapear objetos en Java con MapStruct se publicó primero en Adictos al trabajo.

Top 6 nuevas características de Java 14

$
0
0

Índice de contenidos

 

Introducción

Java 14 (Java SE 14) y su código abierto Java Development Kit 14 (JDK 14) se lanzó el 17 de marzo de 2020. Esta versión nos trae algunas nuevas características de lenguaje interesantes (algunas de ellas en versión preliminar) junto con múltiples actualizaciones de la API de Java y mejoras generales de JVM. En este tutorial, veremos mi top 6 de características y mejoras junto con algunos ejemplos de código. ¡Vamos al grano!
 

NPE fatigue

¿Estás cansado de rastrear excepciones de null pointer y depurar para ver qué está sucediendo exactamente? ¡Pues ya no más! Java 14 ha introducido «Helpful null pointer exceptions». Dado que los NPE pueden ocurrir casi en cualquier parte de un programa, no es práctico intentar controlar este tipo de errores con un try catch y posteriormente seguir con el flujo del programa ya que se tratan de excepciones de tipo no verificadas (unchecked en inglés). Como resultado, los desarrolladores confían en la JVM para identificar la fuente de un NPE cuando realmente ocurre. Por ejemplo, supongamos que ocurre un NPE en este código:

public void produceNullPointer(){
      String a = null;
      String b = a.toLowerCase();
}

En versiones previas a Java 14, el mensaje de error que aparecía en consola era el nombre de la clase y la línea donde se produjo el null pointer:
Exception in thread "main" java.lang.NullPointerException
at NullPointersExample.produceNullPointer(NullPointersExample.java:7)
at Main.main(Main.java:5)

El uso de Java 14 nos permitirá recibir el siguiente resultado que nos ayudará a saber por segundo exactamente de donde viene el null pointer;
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.toLowerCase()" because "a" is null
at NullPointersExample.produceNullPointer(NullPointersExample.java:7)
at Main.main(Main.java:5)

Para habilitar los mensajes informativos de excepción, debe agregar la opción VM:
-XX:+ShowCodeDetailsInExceptionMessages

Aviso: Sin embargo, debemos tener en cuenta que existen algunos riesgos. El mensaje de detalles nulos puede contener nombres de variables del código fuente. Exponer este tipo de información podría considerarse un riesgo de seguridad.

 

Records (preview)

Los registros son similares a las clases de datos en Kotlin. Básicamente son versiones restringidas y mejoradas de las clases POJO normales en Java. Si desea escribir una clase POJO en Java, necesita implementar constructores, equalsHashCode, toString, getters, setters, etc. Normalmente, como muchos de nosotros, usamos Lombok para ayudarnos con la tarea y evitar el código duplicado.
Pero ahora hay una forma nativa de hacerlo sin necesidad de añadir dependencias externas -> Implementación de un Java Record. Todo lo que necesitamos hacer es declarar el tipo como registro y declarar los campos como argumentos.

public record EmployeeRecord(String firstName, String lastName, Integer age, BigDecimal salary){}

La diferencia ahora es cómo se accede al campo. Ahora, en vez de usar getters simplemente lo hacemos usando el nombre del campo. Ejemplo:

EmployeeRecord employeeRecord = new EmployeeRecord("John","Doe",26,BigDecimal.valueOf(80000));

employeeRecord.firstName(); // acceder el nombre del empleado
employeeRecord.salary(); // acceder el salario del empleado

 

Enchanced Switch Expressions (standard)

Java 14 tiene una declaración de cambio extendida, que puede usarse como una expresión con la ayuda de la función lambda (->), y ahora puede generar / devolver el valor. Esta fue una función de idioma de vista previa en JDK 13.

Antes de Java 14 necesitábamos implementar expresiones de cambio como esta:

public void determineBestRole() {
     String movie = "Lord Of The Rings";
     String bestRole = "";
     switch (movie) {
         case "Lord Of The Rings":
             bestRole = "Frodo";
             break;
         case "Lord Of The Rings 2":
             bestRole = "Aragorn";
             break;
         case "Lord Of The Rings 3":
             bestRole = "Sam";
             break;
      }
        System.out.println(bestRole);
}

Ahora java 14 nos permite la siguiente solución:

public void determineBestRole(){
  String movie = "Lord Of The Rings";
  String bestRole = switch (movie){
      case "Lord Of The Rings" -> "Frodo";
      case "Lord Of The Rings 2" -> "Aragorn";
      case "Lord Of The Rings 3" -> {
            // mas acciones
            yield "Sam";
          }
      default -> throw new IllegalStateException("Unexpected value: " + movie);
    };
      System.out.println(bestRole);
  }
}

Tal como vemos, el uso de expresiones de conmutación se simplifica e incluso podemos ejecutar más líneas de código en diferentes casos. Si queremos realizar más acciones en una expresión, es obligatorio utilizar la palabra clave yield para devolver el valor del caso.
 

Pattern Matching for instanceof (preview)

Normalmente, cuando tratamos con el tipo Object del paquete java.Lang, muchas veces necesitamos convertirlo al tipo requerido al verificar primero su tipo. Antes de Java 14, se hacía así

public void foo() {
      Object obj = "String object";
      if (obj instanceof String) {
          String str = (String) obj;
          System.out.println(str.toLowerCase());
      }
}

Usando java 14 podemos hacerlo sin emitir explícitamente el objeto, solo tenemos que proporcionar el nombre de la variable correspondiente y listo.

public void foo(){
   Object obj = "String object";
   if(obj instanceof String str){
       System.out.println(str.toLowerCase());
   }
}

 

Packaging Tool (Incubator)

Es una herramienta de empaquetado, diseñada para facilitar el proceso de instalación, que depende de varias dependencias. A veces no es suficiente proporcionar un solo archivo JAR. Se debe proporcionar un kit instalable apropiado para el local / nativo. Una herramienta de empaque también puede ayudar a llenar los vacíos que dejan otras tecnologías.

La herramienta jpackage agrupa una aplicación Java en un paquete específico de plataforma que contiene todas las dependencias requeridas. Como un conjunto de archivos JAR ordinarios o como una colección de módulos. Los formatos de paquete específicos de plataforma compatibles son:

    • Linux: deb y rpm
    • macOS: pkg y dmg
    • Windows: msi y exe

 

Second Preview Of Text Blocks

Todos sabemos que introducir cadenas HTML, JS o SQL en Java, a veces puede ser difícil de lograr, difícil de leer y mantener. Es por eso que podemos hacer un buen uso de los bloques de texto. Con Text Blocks, podemos declarar cadenas de líneas múltiples en Java sin la necesidad de concatenar cada línea de la cadena.
La característica se introdujo originalmente en Java 13 como primera vista previa. Teniendo en cuenta que dado que Java 13 no es un LTS release (no hay muchas personas migradas a él) mostraré la diferencia de Java sin Text Blocks (versiónes < 13) de texto y Java 14 (segunda vista previa de text blocks).

Antes de Java 14:

String beforeJava13And14 = "<html>\n" +
              "<body>\n" +
              "   <p>Hello World</p>\n" +
              "</body>\n" +
              "</html>";

Después Java 14:

Con los bloques de texto, podemos simplemente poner nuestro texto entre comillas triples y la interpretación de la cadena será preservada por Java. Tenga en cuenta que en la segunda vista previa ahora podemos agregar una barra diagonal al final de cualquier línea dentro de la cadena de varias líneas, para ignorar el próximo salto de línea

String java14 = """
                   <html>
                     <body>\
                       <p>Hello '\s' Java 14</p>
                     </body>
                   </html>
                """;

Imprimir el valor de la String java14 producirá el siguiente resultado

text-blocks-java-14
 

Conclusión

Así que esas fueron mis 6 características principales en el nuevo Java 14.
Teniendo en cuenta que estas características no afectarán al trabajo diario de cada desarrollador, podemos decir que no se van a usar tanto. Quizás los más utilizados serían los Helpful Null Pointer Exceptions durante el estado de desarrollo, la Pattern matching for instanceof, y la Records.
Sin embargo, la Pattern matching for instanceof y los Records solo son unas features de vista previa y aún no deben utilizarse en un entorno de producción. Se espera el lanzamiento completo de esta función en java 15.

La entrada Top 6 nuevas características de Java 14 se publicó primero en Adictos al trabajo.

Analista de Negocios, parte del Equipo Scrum

$
0
0

Analista de Negocios, parte del Equipo Scrum

Hoy en día el desarrollo de software es guiado principalmente por metodologías ágiles que permiten adaptar la forma de trabajo a las condiciones del proyecto, consiguiendo gestionar sus proyectos de forma flexible, autónoma y eficaz.

Uno de los marcos de trabajos ágiles más usados por las empresas de desarrollo de software es Scrum. Este marco de trabajo permite desarrollar, entregar y mantener productos complejos, basándose en pilares fundamentales como la transparencia entre todos los involucrados, la inspección continua de los artefactos de Scrum y la adaptabilidad ante situaciones presentables.

Scrum define un Equipo Scrum para el desarrollo de software: Product Owner, Development Team y Scrum Master, los cuales son autoorganizados y multifuncionales.

Scrum no define un rol de Analista de Negocio, sin embargo, hay empresas que implementan Scrum y tienen definido este rol como parte de su developer team, es entonces cuando nos planteamos las siguientes preguntas: ¿por qué incorporar a un Analista de Negocio? ¿Qué diferencia existe entre un Analista de Negocio y el Dueño del Producto (Product Owner) en una empresa que implementa Scrum?

Product Owner

Scrum define este rol como la persona responsable de maximizar el valor del
producto y el trabajo del equipo de desarrollo. El Product Owner gestiona todo el flujo de valor del producto, a través de la lista del producto (Product Backlog), así como todo lo relacionado con informes, presupuestos y relación con las partes interesadas en el producto (Stakeholders).

 

Muchas empresas que implementan Scrum tienen un Product Owner encargado de varios productos a la vez, lo que les dificulta tener un constante intercambio con el equipo Scrum en determinados momentos en que este lo precise. Y es en este punto donde interviene el Analista de Negocio.

Analista de Negocio

Scrum no reconoce ningún rol específico como Analista, Diseñador, Tester, etc., Sin embargo esto no quiere decir que estos roles no existan en Scrum, sino que Scrum los reconoce como equipo de desarrollo, trabajan juntos en la implementación del producto requerido.

Habitualmente conocemos que el Analista de Negocio es el encargado de capturar y documentar los requisitos y luego asegurarse de que esos requisitos sean entregados por el equipo de desarrollo. Si bien el Analista de Negocio se encarga de estas tareas mencionadas anteriormente, su función va un poco más allá.

El Analista de Negocio, formando parte del equipo de desarrollo en empresas que implementan Scrum, tiene tareas fundamentales para ayudar al equipo Scrum a avanzar en el desarrollo del producto.

 

La siguiente imagen proporciona, en mi opinión, qué papel desempeña cada uno de estos roles:

Conclusiones

Al igual que el Product Owner, el Analista de Negocio juega un papel fundamental en el trabajo con el equipo Scrum para ejecutar la visión del producto definiendo necesidades y recomendando soluciones que brinden valor. Su participación comienza desde el inicio del proyecto (levantamiento de historias de usuario) hasta la entrega de un producto terminado.

En mi opinión, cuando el Analista de Negocio forma parte del equipo Scrum, e interactúa directamente con el equipo en el desarrollo del producto, permite una mejor comprensión acerca de las necesidades de los interesados, pues este ayudará a resolver los obstáculos que se presenten durante el sprint, asegurando de esta manera que se obtenga un incremento del producto al finalizar el sprint.

La entrada Analista de Negocios, parte del Equipo Scrum se publicó primero en Adictos al trabajo.

Tipos de runner en GitLab CI: ¿Shell o docker?

$
0
0

GitLab CI es una herramienta muy potente y cada vez más utilizada. Si no la conoces, te recomiendo pasarte por este artículo que yo mismo escribí en mi blog personal hablando un poco de las bondades de esta herramienta. Hoy vamos a profundizar un poco más sobre los tipos de executors para tus runner en GitLab CI.

Como muchos sabréis, no es lo mismo utilizar una instalación de gitlab en un servidor propio que utilizar la web pública de gitlab.com, especialmente si usas la versión gratuita, mucho más limitada. Hace bastante tiempo, Rubén Aguilera ya nos contó cómo configurar GitLab CI en una instalación propia. Así que hoy vengo a contarte cómo es el entorno en la web pública de GitLab y qué limitaciones tiene. Vamos a enfrentar, principalmente, los executor de tipo SHELL (disponibles en instalaciones propias) frente a los executor DOCKER, (los que puedes usar en la versión gratuita de gitlab.com, y también en tu propio servidor).

Existen numerosos ejecutores, es decir, numerosas formas en las que tu runner puede comunicarse con la máquina servidora. En esta página de la documentación oficial puedes verlo detalladamente. No obstante, como este artículo es más un conjunto de recomendaciones en base a mi experiencia que un tutorial totalmente técnico, voy a centrarme principalmente en los dos ya mencionados.

Cómo funciona en Gitlab.com

Vamos a usar la capa gratuita para que tú mismo puedas probar. Para habilitar esta funcionalidad en un proyecto, accede primero a dicho proyecto y, después, en el menú de ajustes, busca la opción CI/CD:

Panel de settings y opción de CI/CD marcadaAquí podrás habilitar los shared runners. Es decir, que te vas a pelear con el resto de miles de usuarios de GitLab.com para conseguir ejecutar un job.

Además puedes configurar también el despliegue en runners de Kubernetes (o sea, más docker), pero para esto ya hay que pagar, porque serían tus propios contenedores. Así de simple. Ahora comprenderás más qué diferencias hay con un entorno SHELL en un servidor privado.

Ejecución en SHELL

Los shell executors son los más sencillos de todos. Repito que este tipo sólo es posible utilizarlo si tienes GitLab instalado en tu propio servidor.

El runner de GitLab hará simplemente de puente entre tu repositorio y una sesión de terminal del servidor. Supongamos un proyecto que utiliza java y maven. Para los tests de integración, con un plugin de maven o con test containers de Spring Boot, podemos levantar un contenedor. Para poder funcionar, por tanto, necesitamos que nuestro servidor tenga instalada la versión de java que necesitamos, también maven (aunque sería válido maven wrapper) y, por supuesto, docker.

De esta forma, los scripts de nuestros jobs se van a ejecutar directamente como si nosotros mismos estuviéramos escribiendo en la terminal del servidor. Esta imagen puede ayudarte:

Imagen explicativa por capas: shell excutor se ejecuta sobre sesión de terminal del servidorEsto provoca que el archivo .yml de tu proyecto sea más simple. Ya que con una sola línea puedes hacer mucho:

script:
    - mvn clean install -U -P *profileName* sonar:sonar -Dsonar.login=$SONAR_TOKEN -Dsonar.sourceEncoding=UTF-8

La diferencia radica en que, a la hora de escribirlo, piensas igual que si estuvieras escribiendo en la propia terminal de la máquina.

Ventajas y desventajas

++ Menor complejidad en tu yml. Cuando escribas tu .gitlab-ci.yml, tendrás que pensar como si lo estuvieras ejecutando tú mismo en la máquina. Desde luego, es una perspectiva mucho más simple que otras.

++ Más libertad. Al tratarse de comandos ejecutados sobre una máquina, tienes toda la libertad que tu máquina te ofrece. Ten cuidado, eso sí, de que mayor libertad no signifique mayor inseguridad.

++ Control total. No dependes de contenedores o servicios externos y todo lo que gestionas está realmente instalado en la máquina.

++ Entornos reales 100%. No vas a ejecutar nada sobre un servicio virtualizado. Va a ser una máquina totalmente real, por lo que ganas en cuanto a parecidos a producción.

— Mayor curva de configuración inicial de tu servidor. La ventaja de tener archivos yml más simples se ve descompensada porque tienes que instalar todo lo necesario. Por tanto, solo compensa si el servidor va a alojar proyectos con tecnologías similares.

— Casi imposible garantizar que el entorno está limpio. A diferencia de los contenedores, que se crean y se destruyen.

— Implicará posiblemente mayor dificultad de gestión y mantenimiento a largo plazo. Si tienes proyectos en java, otros en typescript, otros en python, otros en C, etc… Vas a tener que tener todas esas herramientas, frameworks y tecnologías instaladas. Y claro, eso requiere sus actualizaciones, reparaciones, mantenimientos… Puedes volverte realmente loco.

Casos de uso

Vistas sus ventajas y desventajas, yo lo veo muy claro. Usa este tipo de runner si, y solo si, tu servidor va a estar ocupado por un número bajo de proyectos, y mejor si son proyectos que utilicen tecnologías similares. Por ejemplo, si todos tus proyectos back son Java y tu front React, el número de herramientas a instalar es muy bajo y puedes suplir muchos proyectos, por lo que sacas partido a la hora de crear tus scripts.

Ejecución en DOCKER

Aquí el paradigma cambia. Ahora, cuando escribas tus scripts, no puedes pensar que estas en una máquina real. Volvamos al ejemplo de antes con java, maven y tests de integración automatizados con test containers. Si lanzas tu ejecución en un contenedor java, no van a funcionar los tests container porque no puedes ejecutar un «docker run» dentro de otro contenedor. ¿Vas pillando? Por tanto, deberás configurar tus diferentes perfiles para que se adapten según el entorno y, como te explicaré más adelante, esto te hará pensar más a la hora de escribir tu yml.

Con esta imagen seguro que te queda mucho más claro:

Shell executor que se ejecuta dentro de una terminal, que está dentro de un contenedor, que está en el servidorVale, seguimos con nuestro proyecto java, maven y todo eso. En este caso necesitas un perfil específico para el entorno de integración que deshabilite la creación de contenedores y lo que vas a hacer va a ser especificar algo así al principio de tu yml:

stages:
  - test
  - deploy

services:
  - mysql:8

variables:
  MYSQL_DATABASE: mon3x
  MYSQL_ROOT_PASSWORD: root

image: openjdk:13

Indicamos que necesitamos un servicio de mysql versión 8. Además, le especificamos dos variables: el nombre de la base de datos y la clave del usuario root. Da igual que esta clave se vea en el código del repositorio, solo va a estar ejecutada durante los tests.

Y, por supuesto, indicamos que la imagen que queremos en nuestro contenedor es java 13. De esta forma, sin que nuestros test container ni nadie levanten una base de datos, nos podremos conectar a una base de datos dockerizada cuyo nombre y contraseña root son los especificados.

Como ves, no es difícil, pero sí que resulta algo más engorroso. Sobre todo porque te enfrentas a ese cambio en la manera de pensar. Ya no vale pensar que estás en una máquina real, y puede resultar difícil de ver al principio.

Ventajas y desventajas

++ Los entornos siempre se iniciarán vacíos.

++ Menos problemas de configuración. Tu servidor solo necesitará GitLab y Docker.

++ Mayor polivalencia entre diferentes tecnologías. Si tienes cien proyectos y cada uno es de su padre y de su madre, te da igual. Van a tirar de Docker, punto pelota.

— Menor grado de libertad. Ahora estás en un sistema virtualizado, tus posibilidades se reducen a las extrictamente necesarias. Puede darte problemas según qué formas hayas diseñado para resolver ciertos problemas.

— Cuidado con ejecuciones masivas. He pasado por el problema de los dead containers y créeme, si todo tu sistema depende de docker, te vuelves loco. Cuidado también con cuánto espacio te ocupa docker en tu sistema. Vas a tener volúmenes e imágenes muertas a porrón. Pero bueno, nada que no se arregle con un buen prune programado, ¿no?.

— Más engorroso a la hora de pensar y escribir tus yml, que se ve compensado con la mayor facilidad a la hora de instalar y configurar todo el sistema en tu servidor.

Casos de uso

Seguramente pienses como yo y veas que los runners sobre docker son más usables. En general, siempre te va a funcionar con casi cualquier sistema. Si no sabes cuál utilizar, mi recomendación es que uses este. La ligera mayor curva en los yml, en cuanto te acostumbras, se te hace normal. Y docker tiene muchísimas imágenes, lo que te hace la vida muy fácil.

La entrada Tipos de runner en GitLab CI: ¿Shell o docker? se publicó primero en Adictos al trabajo.

El Juego de la Vida de Conway

$
0
0

Si ya conoces el Juego de la vida, puedes empezar a jugar directamente.

Índice

  1. Introducción
  2. Origen
  3. Reglas
  4. Impacto
  5. En la computación
  6. Empieza a jugar: Implementando Vida

1. Introducción

El «Juego de la Vida» de Conway, o simplemente «Vida», tiene poco de juego. No tiene jugadores, no se puede «ganar» y no se puede «perder». No parece muy interesante, sin embargo ha cautivado a matemáticos y programadores desde sus orígenes hace 50 años.

Se «juega» sobre un tablero parecido al del ajedrez, sobre una cuadrícula. Pero a diferencia del ajedrez o la mayoría de juegos, el único control que tenemos como «jugador» es la posición de las piezas iniciales.

Una vez el juego empieza, no tenemos ningún control. Las reglas determinan cómo acaba el juego.

2. Origen

Vida es lo que se llama un autómata celular. Un modelo matemático discreto de simular un sistema dinámico.

Vida fue inventado por el matemático John Conway en 1970. Su objetivo era crear un sistema que simulase la vida y su naturaleza impredecible.

La forma en la que representa la vida es muy sencilla. Una cuadrícula de células, algunas están vivas, otras están muertas. Cada célula tiene ocho células vecinas, en vertical, horizontal y en las diagonales. Este sistema aparentemente tan simple puede generar patrones complejos e impredecibles gracias a unas sencillas reglas.

Conway probó a aplicar muchas y distintas reglas, algunas hacían que las células murieran muy pronto y otras hacían que nunca murieran.

Las reglas de la versión final del juego mantienen un equilibrio. Esto hace que, a simple vista, sea muy difícil saber si un grupo de células vivirán o morirán después de cierto tiempo.

3. Reglas

Las reglas del juego son las siguientes:

  • Si una célula está viva y tiene dos o tres vecinas vivas, sobrevive.
  • Si una célula está muerta y tiene tres vecinas vivas, nace.
  • Si una célula está viva y tiene más de tres vecinas vivas, muere.

La disposición o patrón inicial de células se llama «semilla». La siguiente generación nace de aplicar las reglas del juego a todas las células de manera simultánea.

Este proceso se puede ejecutar de manera indefinida.


4. Impacto

Vida es un ejemplo de lo que a veces se llama «complejidad emergente». Un estudio de cómo se pueden conseguir patrones complejos de unas reglas simples. Esto es visible en la propia naturaleza, por ejemplo, en los patrones de rayas de una cebra, en la forma de una nube o en las celdas de un panal de abejas.

Al interés científico y matemático se le agregó el interés general gracias a la nueva generación de ordenadores personales que estaban siendo introducidos en el mercado.

Y aunque para mucha gente, Vida solo era un juego con el que pasar el rato siendo hipnotizados por la aparente infinidad de patrones, el juego desarrolló un seguimiento de culto. Incluso creando, dentro de Vida, procesadores como los que encontramos en nuestro móviles u ordenadores.Pantalla de ordenador donde se ve código fuente y el reflejo del desarrollador

5. En la computación

Teóricamente, Vida se puede considerar como una máquina universal de Turing: cualquier algoritmo puede computarse dentro de los límites del juego.

Esto se debe a que en Vida es posible crear patrones que simulan puertas lógicas y contadores. Esto fue demostrado por Paul Rendell en su estudio A Turing Machine in Conway’s Game Life .

Otros ejemplos de Vida utilizado para crear un sistema determinista son la iniciativa de recrear Tetris o de recrear el propio Vida dentro de Vida.

6. Empieza a jugar: Implementando Vida

Varios programadores de Autentia, nos hemos propuesto crear Vida con diferentes lenguajes y condiciones. Esperamos que te apuntes a jugar con alguno de ellos.

Índice de tutoriales (iremos añadiendo cada semana):

 

La entrada El Juego de la Vida de Conway se publicó primero en Adictos al trabajo.

Juego de la vida con vue, typescript y neomorfismo

$
0
0

Puedes ver todos los tutoriales del Juego de la vida aquí: Tutoriales Juego de la vida.

En este tutorial realizaremos un pequeño homenaje a John Horton Conway desarrollando su famoso juego de la vida con Vue, typescript e incluyendo algunos toques de neomorfismo.

Índice de contenidos

1. Introducción

El Juego de la vida es un sencillo juego con unas reglas muy simples en el cual nadie gana ni pierde.

La primera aparición del juego fue en 1970 en la revista Scientific American.

Para saber más acerca de la historia del juego puedes consultar la wikipedia.

2. Entorno

El tutorial está escrito usando el siguiente entorno:

  • Hardware: Portátil MacBook Pro 15′ (2,7 Ghz Intel Core i7, 16GB DDR3).
  • Sistema Operativo: Mac OS Catalina 10.15.3
  • Entorno de desarrollo: Visual Studio Code
  • Framework: Vue
  • Lenguaje de programación: Typescript

3. Reglas del juego

Regla número 1: Para que una célula siga viva, esta debe tener 2 ó 3 vecinos vivos. Si tiene más muere debido a la superpoblación, si tiene menos entonces muere debido a la soledad.

Regla número 2: Si una célula muerta tiene exactamente 3 vecinas vivas revive.

4. Empezamos el juego

El primer paso a realizar es crear nuestra interfaz, utilizaremos un panel canvas, tres botones y un slider para poder elegir el tamaño de los pixeles.

<template>
  <div class="container">
    <v-container>
      <div class="buttons-container">
        <button @click="newGame()">New game</button>
        <div class="slider-container">
          <span>Pixel size</span>
          <input
            type="range"
            min="4"
            max="10"
            value="4"
            class="slider"
            id="myRange"
            v-model="pixelSize"
            @click="newGame()"
          />
          <input class="size" type="text" name="size" id="sizing" v-model="pixelSize" />
        </div>
        <button @click="play()" v-if="interval === null">Play</button>
        <button @click="pause()" v-else>Pause</button>
      </div>
      <div>
        <canvas :height="600" :width="600"></canvas>
      </div>
    </v-container>
  </div>
</template>

 

5. Estilos

Ahora le damos unos toques de neomorfismo a los botones, slider y contenedor donde estará el canvas.

<style lang="scss">
.container {
  border: 0;
  outline: 0;
  display: flex;
  justify-content: center;
  margin: 0px 350px;
  padding: 10px;
  border-radius: 50px;
  background: var(--primary-color);
  box-shadow: -5px -5px 20px var(--white-color),
    5px 5px 20px var(--secondary-color);
  box-sizing: border-box;
}

.buttons-container {
  display: flex;
  justify-content: center;
  margin-bottom: 10px;
}

button {
  border: 0;
  outline: 0;
  border-radius: 200px;
  padding: 10px;
  margin: 10px;
  width: 100px;
  font-size: 15px;
  background-color: var(--primary-color);
  font-weight: bold;
  box-shadow: -5px -5px 20px var(--white-color),
    5px 5px 20px var(--secondary-color);
  transition: all 0.2s ease-in-out;
  cursor: pointer;
  font-weight: 500;

  &:hover {
    box-shadow: inset -2px -2px 5px var(--white-color),
      inset 2px 2px 5px var(--secondary-color);
  }

  &:active {
    box-shadow: inset 1px 1px 2px var(--secondary-color),
      inset -1px -1px 2px var(--white-color);
  }
}

.slider-container {
  width: min-content;
}

.slider {
  -webkit-appearance: none;
  margin: auto;
  height: 20px;
  background: var(--primary-color);
  outline: 0;
  border-radius: 200px;
  box-shadow: -5px -5px 20px var(--white-color),
    5px 5px 20px var(--secondary-color);

  &:hover {
    box-shadow: inset -2px -2px 5px var(--white-color),
      inset 2px 2px 5px var(--secondary-color);
  }
  &::-webkit-slider-thumb {
    -webkit-appearance: none;
    appearance: none;
    border-radius: 50%;
    width: 25px;
    height: 25px;
    background: var(--tertiary-color);
    cursor: pointer;
  }
  &::-moz-range-thumb {
    width: 25px;
    height: 25px;
    border-radius: 50%;
    background: var(--tertiary-color);
    cursor: pointer;
  }
}

.size {
  border: 0;
  outline: 0;
  width: 12px;
  padding: 5px;
  text-align: center;
  background-color: var(--primary-color);
  border-radius: 40px;
  box-shadow: -5px -5px 20px var(--white-color),
    5px 5px 20px var(--secondary-color);
}
</style>

 

6. Código

Primero definimos las variables que usaremos.

data() {
    const pixelSize = 4;
    const side = Math.min(window.innerWidth, window.innerHeight);
    const height = Math.min(Math.floor((side - 100) / pixelSize), 175);
    const width = Math.min(Math.floor((side - 100) / pixelSize), 175);
    const state = "0".repeat(height * width);
    return {
      counter: 0,
      color: "#10069F",
      height: height,
      velocity: 100,
      interval: null,
      pixelSize: pixelSize,
      state: state,
      width: width
    };
  }

Después creamos los métodos que nos llenarán aleatoriamente el panel de células vivas y muertas. Con esto partimos con un panel diferente en cada nuevo juego.

newGame() {
      if (this.interval !== null) {
        this.pause();
      }
      let state = "";
      while (state.length !== this.height * this.width) {
        state += Math.floor(Math.random() * 2).toString();
      }
      this.state = state;
      this.draw();
    },
    draw() {
      const context = this.$el.querySelector("canvas").getContext("2d");
      let counter = 0;
      context.clearRect(
        0,
        0,
        this.width * this.pixelSize,
        this.height * this.pixelSize
      );
      context.fillStyle = this.color;
      while (this.state !== 0 && counter < this.width * this.height) {
        const x = counter % this.width;
        const y = Math.floor(counter / this.width);
        if (this.state[counter] === "1") {
          context.fillRect(
            x * this.pixelSize,
            y * this.pixelSize,
            this.pixelSize,
            this.pixelSize
          );
        }
        counter++;
      }
    }

Ahora necesitaremos saber si los vecinos de cada célula están vivos o muertos y así poder determinar el estado de la célula.

neighbors(position) {
      let north = position - this.width;
      if (north < 0) {
        north = north + this.height * this.width;
      }
      let northeast = north + 1;
      if (northeast % this.width === 0) {
        northeast = northeast - this.width;
      }
      let northwest = north - 1;
      if (northwest < 0 || northwest % this.width === this.width - 1) {
        northwest = northwest + this.width;
      }
      let east = position + 1;
      if (east % this.width === 0) {
        east = east - this.width;
      }
      let west = position - 1;
      if (west < 0 || west % this.width === this.width - 1) {
        west = west + this.width;
      }
      let south = position + this.width;
      if (south > this.width * this.height) {
        south = south - this.width * this.height;
      }
      let southeast = south + 1;
      if (southeast % this.width === 0) {
        southeast = southeast - this.width;
      }
      let southwest = south - 1;
      if (southwest < 0 || southwest % this.width === this.width - 1) {
        southwest = southwest + this.width;
      }
      return [
        north,
        northeast,
        east,
        southeast,
        south,
        southwest,
        west,
        northwest
      ];
    }

Ahora necesitaremos un método que nos ayude en cada iteración para repintar el panel.

nextIteration(state) {
      let counter = 0;
      let newState = "";
      while (counter < this.width * this.height) {
        const livingNeighbors = this.neighbors(counter).reduce(
          (alive, neighborsPosition) =>
            alive + parseInt(state[neighborsPosition]),
          0
        );
        if (
          state[counter] === "1" &&
          (livingNeighbors === 2 || livingNeighbors === 3)
        ) {
          newState += "1";
        } else if (state[counter] === "0" && livingNeighbors === 3) {
          newState += "1";
        } else {
          newState += "0";
        }
        counter++;
      }
      return newState;
    }

Y ahora haremos los métodos encargados de pausar el juego y de empezarlo.

pause() {
      clearInterval(this.interval);
      this.interval = null;
    },
    play() {
      this.interval = setInterval(() => {
        this.draw();
        this.state = this.nextIteration(this.state);
        this.counter++;
      }, this.velocity);
    }

7. Resultado final

Como resultado final tendremos un juego de la vida con esta apariencia.

 

8. Referencias

Podéis ver todos los tutoriales sobre el juego de la vida en https://www.adictosaltrabajo.com/2020/04/30/el-juego-de-la-vida-de-conway/

La entrada Juego de la vida con vue, typescript y neomorfismo se publicó primero en Adictos al trabajo.

El Juego de la Vida con Angular, React y TypeScript

$
0
0

Puedes ver todos los tutoriales del Juego de la vida aquí: Tutoriales Juego de la vida.

¿Cuáles son las reglas del juego?

El juego consiste en una cuadrícula de células.
Cada célula tiene 8 vecinos, en el lateral, vertical y diagonales.

Las células siguen ciertas normas:

Si la célula está viva:

  • Y tiene menos de dos vecinos vivos -> Muere
  • Y tiene dos o tres vecinos vivos -> Vive
  • Y tiene más de tres vecinos vivos -> Muere

Si la célula está muerta:

  • Y tiene tres vecinos vivos -> Nace

Estas sencillas reglas, en combinación, pueden crear mecánicas muy complejas.

Glider gun de Gosper

Análisis

He decidido separar la lógica del juego de la lógica de presentación.
Esto permite reutilizar el código del juego en Angular y React.

Implementación en TypeScript

En esta implementación se almacenan las células en un array de una dimensión.
Con la ventaja de que es una forma más sencilla de guardar los datos.
Y con la desventaja es que habrá que “simular” la bidimensionalidad.

// Una célula tiene dos posibles estados, viva o muerta.
export enum Cell {
  Alive,
  Dead,
}

// El estado del mundo es un array plano de células.
export type State = Cell[];

type Dimensions = { width: number; height: number };

export class GameOfLife {
  public dimensions: Dimensions;
  public state: State;

  private constructor(state: State, dimensions: Dimensions) {
    this.state = state;
    this.dimensions = dimensions;
  }

  // Esta función se encarga de generar un juego con la anchura, altura y estado inicial.
  public static new(width: number, height: number, initial: State): GameOfLife {
    // Si el estado inicial no coincide con la anchura y altura, se lanza un error.
    if (initial.length < width * height) {
      throw new Error("Invalid initial State");
    }

    return new GameOfLife(initial, { width, height });
  }

  // Esta función ejecuta las normas del juego y actualiza el estado.
  public tick(): void {
    // El nuevo estado.
    let newState = this.state.map((cell, index) => {
      const aliveNeighbors = this.aliveNeighborCount(index);

      // Si la célula está viva.
      if (cell === Cell.Alive) {
        // Si tiene menos de dos vecinos vivos, muere.
        if (aliveNeighbors < 2) return Cell.Dead;
        // Si tiene dos o tres vecinos vivos, vive.
        if (aliveNeighbors === 2 || aliveNeighbors === 3) return Cell.Alive;
        // Si tiene más de tres vecinos, muere.
        if (aliveNeighbors > 3) return Cell.Dead;
        // Si la célula está muerta.
      } else if (cell === Cell.Dead) {
        // Si tiene tres vecinos vivos, vive.
        if (aliveNeighbors === 3) return Cell.Alive;
      }

      return cell;
    });

    this.state = newState;
  }

  // Devuelve la cantidad de vecinos vivos de una célula.
  private aliveNeighborCount(index: number): number {
    const row = Math.floor(index / this.dimensions.width);
    const column = index - row * this.dimensions.width;
    let count = 0;

    // Se comprueba una matriz 3x3 alrededor de la célula.
    // (-1,-1) (0,-1) (1,-1)
    // (-1, 0) (0, 0) (1, 0)
    // (-1, 1) (0, 1) (1, 1)
    for (let deltaRow of [this.dimensions.height - 1, 0, 1]) {
      for (let deltaCol of [this.dimensions.width - 1, 0, 1]) {
        // Se descarta la célula en cuestión (0, 0).
        if (deltaRow === 0 && deltaCol === 0) {
          continue;
        }

        // Al usar el operador módulo, evitamos errores en los casos de los extremos.
        const neighborRow = (row + deltaRow) % this.dimensions.height;
        const neighborColumn = (column + deltaCol) % this.dimensions.width;
        const neighborIndex =
          neighborRow * this.dimensions.width + neighborColumn;

        count += this.state[neighborIndex] === Cell.Alive ? 1 : 0;
      }
    }

    return count;
  }
}

 

Probando la implementación

Para probar que el juego funciona he creado un par de funciones de parseo que harán que el código sea más fácil de escribir y de leer.

export function stringToGameState(...string: String[]): State {
  return string
    .join("")
    .split("")
    .map((value) => (value === "*" ? Cell.Alive : Cell.Dead));
}

// Esta función transforma un juego en un array de string.
export function gameToString(game: GameOfLife): String[] {
  return chunkArray([...game.state], game.dimensions.width).map((row) =>
    row.map((cell) => (cell === Cell.Alive ? "*" : "·")).join("")
  );
}

// Divide un array en varios según el parámetro.
function chunkArray<T>(array: T[], size: number): T[][] {
  const result = [];

  while (array.length) {
    result.push(array.splice(0, size));
  }

  return result;
}

Este código es compatible con Jest en React y Jasmine en Angular.

import { GameOfLife } from "./GameOfLife";
import { stringToGameState, gameToString } from "./GameOfLifeParser";

describe("GameOfLife", () => {
  // Estos objetos mantienen su forma.
  describe("Still Lifes", () => {
    test("Block", () => {
      const block = ["····", "·**·", "·**·", "····"];

      const game = GameOfLife.new(4, 4, stringToGameState(...block));
      game.tick();

      expect(gameToString(game)).toEqual(block);
    });

    test("Bee-hive", () => {
      const beeHive = ["······", "··**··", "·*··*·", "··**··", "······"];

      const game = GameOfLife.new(6, 5, stringToGameState(...beeHive));
      game.tick();

      expect(gameToString(game)).toEqual(beeHive);
    });

    test("Loaf", () => {
      const loaf = ["······", "··**··", "·*··*·", "··**··", "······"];

      const game = GameOfLife.new(6, 5, stringToGameState(...loaf));
      game.tick();

      expect(gameToString(game)).toEqual(loaf);
    });

    test("Boat", () => {
      const boat = ["·····", "·**··", "·*·*·", "··*··", "·····"];

      const game = GameOfLife.new(5, 5, stringToGameState(...boat));
      game.tick();

      expect(gameToString(game)).toEqual(boat);
    });

    test("Tub", () => {
      const tub = ["·····", "··*··", "·*·*·", "··*··", "·····"];

      const game = GameOfLife.new(5, 5, stringToGameState(...tub));
      game.tick();

      expect(gameToString(game)).toEqual(tub);
    });
  });

  // Estos objetos oscilan su forma.
  describe("Oscilators", () => {
    test("Blinker", () => {
      const blinker = ["·····", "·····", "·***·", "·····", "·····"];

      const game = GameOfLife.new(5, 5, stringToGameState(...blinker));
      game.tick();

      expect(gameToString(game)).toEqual([
        "·····",
        "··*··",
        "··*··",
        "··*··",
        "·····",
      ]);
    });

    test("Toad", () => {
      const toad = ["······", "······", "··***·", "·***··", "······", "······"];

      const game = GameOfLife.new(6, 6, stringToGameState(...toad));
      game.tick();

      expect(gameToString(game)).toEqual([
        "······",
        "···*··",
        "·*··*·",
        "·*··*·",
        "··*···",
        "······",
      ]);
    });

    test("Beacon", () => {
      const toad = ["······", "·**···", "·*····", "····*·", "···**·", "······"];

      const game = GameOfLife.new(6, 6, stringToGameState(...toad));
      game.tick();

      expect(gameToString(game)).toEqual([
        "······",
        "·**···",
        "·**···",
        "···**·",
        "···**·",
        "······",
      ]);
    });
  });

  // Estos objetos se mueven en el mundo.
  describe("Spaceships", () => {
    test("Glider", () => {
      const toad = [
        "··········",
        "·····*····",
        "···*·*····",
        "····**····",
        "··········",
      ];

      const game = GameOfLife.new(10, 5, stringToGameState(...toad));

      game.tick();
      expect(gameToString(game)).toEqual([
        "··········",
        "····*·····",
        "·····**···",
        "····**····",
        "··········",
      ]);

      game.tick();
      expect(gameToString(game)).toEqual([
        "··········",
        "·····*····",
        "······*···",
        "····***···",
        "··········",
      ]);

      game.tick();
      expect(gameToString(game)).toEqual([
        "··········",
        "··········",
        "····*·*···",
        "·····**···",
        "·····*····",
      ]);
    });
  });
});

Conexión con React

La implementación en React utiliza un componente funcional y hooks de estado.

import React, { useState, CSSProperties, useEffect } from "react";
import { Cell } from "../../game-of-life/GameOfLife";
import { GameExamples } from "../../game-of-life/GameExamples";
import "./SimpleGameOfLife.css";

export const SimpleGameOfLife: React.FC = () => {
  // Los ejemplos disponibles en la demo.
  const examples = {
    Block: GameExamples.block(),
    "Bee Hive": GameExamples.beeHive(),
    Loaf: GameExamples.loaf(),
    Blinker: GameExamples.blinker(),
    Toad: GameExamples.toad(),
    Beacon: GameExamples.beacon(),
    Glider: GameExamples.glider(),
  };

  // El ejemplo seleccionado, por defecto block.
  const [example, setExample] = useState(examples.Block);
  // El juego actual, con el ejemplo seleccionado como estado inicial.
  const [game, setGame] = useState(example);
  // Las células actuales.
  const [cells, setCells] = useState(game.state);

  // Cuando se seleccione un ejemplo, se actualizará el juego con ese ejemplo.
  const onExampleSelected = (event: React.ChangeEvent<HTMLSelectElement>) => {
    const key = event.target.value as keyof typeof examples;
    setExample(examples[key]);
  };

  // Este efecto se encarga de actualizar el estado el juego cada 200 milisegundos.
  useEffect(() => {
    const interval = setInterval(() => {
      game.tick();
      setCells(game.state);
    }, 200);

    return () => clearInterval(interval);
  }, [setCells, game]);

  // Este ejemplo se encarga de actualizar el juego con el ejemplo seleccionado.
  useEffect(() => {
    setGame(example);
  }, [example, setGame]);

  // Se utiliza el ancho de las dimensiones del juego como columnas para el grid de CSS.
  const style: CSSProperties = {
    gridTemplateColumns: `repeat(${game.dimensions.width}, auto)`,
  };

  return (
    <section>
      <label className="example">
        Example
        <select onChange={onExampleSelected}>
          {Object.keys(examples).map((key) => (
            <option value={key} key={key}>
              {key}
            </option>
          ))}
        </select>
      </label>
      <div className="world" style={style}>
        {cells.map((cell, index) => {
          return (
            <div
              className={`cell {cell === Cell.Alive ? "alive" : "dead"}`}
              key={index}
            ></div>
          );
        })}
      </div>
    </section>
  );
};

 

Conexión con Angular

La implementación en Angular utiliza un

Subscriber
  para ejecutar la función
tick
cada 200 milisegundos.
<section>
  <label className="example">
    Example
    <select [(ngModel)]="selected" (change)="onExampleChange()">
      <option *ngFor="let example of examples | keyvalue" [value]="example.key"
        >{{ example.key }}</option
      >
    </select>
  </label>
  <div class="world" [ngStyle]="getWorldStyle()">
    <div *ngFor="let cell of game.state" [ngClass]="getCellClass(cell)"></div>
  </div>
</section>

import { Component } from "@angular/core";
import { interval, Subscription } from "rxjs";
import { GameExamples } from "src/app/game-of-life/GameExamples";
import { Cell } from "src/app/game-of-life/GameOfLife";

@Component({
  selector: "app-simple-game-of-life",
  templateUrl: "./simple-game-of-life.component.html",
  styleUrls: ["./simple-game-of-life.component.css"],
})
export class SimpleGameOfLifeComponent {
  subscription: Subscription;
  // Los ejemplos disponibles en la demo.
  examples = {
    Block: GameExamples.block(),
    "Bee Hive": GameExamples.beeHive(),
    Loaf: GameExamples.loaf(),
    Blinker: GameExamples.blinker(),
    Toad: GameExamples.toad(),
    Beacon: GameExamples.beacon(),
    Glider: GameExamples.glider(),
  };
  // El ejemplo seleccionado.
  selected = "Block";
  // El juego actual.
  game = this.examples[this.selected];

  ngOnInit() {
    // Se va a actualizar el juego cada 200 milisegundos.
    this.subscription = interval(200).subscribe((_) => this.game.tick());
  }

  ngOnDestroy() {
    this.subscription.unsubscribe();
  }

  onExampleChange() {
    // Cuando se seleccione un ejemplo, se actualizará el juego con ese ejemplo.
    this.game = this.examples[this.selected];
  }

  getWorldStyle() {
    return {
      gridTemplateColumns: `repeat(${this.game.dimensions.width}, auto)`,
    };
  }

  getCellClass(cell: Cell) {
    return {
      cell: true,
      dead: cell === Cell.Dead,
      alive: cell === Cell.Alive,
    };
  }
}

Conclusiones

La tecnología ha cambiado mucho desde que Conway inventó el Juego de la Vida en 1970. Con frameworks y librerías modernas de web, podemos implementarlo con mucha facilidad.

Podéis ver todos los tutoriales sobre el juego de la vida en https://www.adictosaltrabajo.com/2020/04/30/el-juego-de-la-vida-de-conway/

La entrada El Juego de la Vida con Angular, React y TypeScript se publicó primero en Adictos al trabajo.


El Juego de la Vida con Flutter: Desarrollo y Testing

$
0
0

Puedes ver todos los tutoriales del Juego de la vida aquí: Tutoriales Juego de la vida.

EQUIPO

MacBook Pro (15-inch, Mid 2015), con macOS 10.14.6

Procesador: 2,5 GHz Intel Core i7

Memoria: 16 GB 1600 MHz DDR3

TERMINAL (Se puede utilizar también un emulador de Android)

Oneplus 7 Pro, con Android 10 (Oxygen OS 10.0.4)

Procesador: Snapdragon 855

Memoria: 6 GB


1. Introducción

Un buen reto para expandir o asentar conocimientos de un lenguaje, aprender a manejar arrays y las bases de testing es el Juego de la Vida de Conway: https://es.wikipedia.org/wiki/Juego_de_la_vida

Se trata de un tablero de dimensiones fijas con casillas que representan células, y pueden estar o bien vivas o muertas. La transición de las celdas de un estado a otro se basa en 4 reglas:

  • Si una célula está viva y le rodean 2 o 3 células vivas, sobrevive.
  • Si una célula viva está rodeada por menos de 2 células vivas, muere por infrapoblación.
  • Si una célula viva está rodeada por más de 3 células vivas, muere por sobrepoblación.
  • Si una célula está muerta y le rodean exactamente 3 células vivas, pasa a estar viva.

Esto se puede resumir en las siguientes 3 reglas, lo que nos facilitará la programación:

  • Si una célula viva está rodeada por menos de 2 o más de 3 células vivas, muere.
  • Si una célula está muerta y le rodean exactamente 3 células vivas, pasa a estar viva.
  • En cualquier otro caso, la célula mantiene su estado anterior.

2. Comienzo del desarrollo

Antes de seguir este tutorial hay que tener instalado el SDK de Flutter. Aquí tenéis la página oficial que deberéis seguir para instalarlo:

https://flutter.dev/docs/get-started/install

Una vez instalado, comenzamos por crear un proyecto de Flutter. Para ello, en VS Code o IntelliJ seleccionamos Crear Nuevo Proyecto, y entre las distintas opciones aparecerá Flutter. Esto nos generará la siguiente estructura de directorios:

En el archivo main.dart, borramos todo menos la clase MyApp y la función main. En Flutter, todo es un Widget, desde algo tan «grande» como una pantalla, hasta algo como un simple Container() o un Column(). Hay dos tipos de Widgets: SatelessWidget y StatefulWidget. Si un Widget es Stateful, significa que tiene un estado, variables internas que variarán, y esto influirá en su interfaz (necesitando actualizarla).

En el caso de este juego en concreto, lo enfocaremos de la siguiente manera: tendremos el Widget GameOfLife que será Stateless, y este contendrá al Widget que representa el tablero (Board), que será Stateful, ya que este tendrá que actualizar sus celdas. Como veis, un Widget padre puede ser Stateless y contener a un hijo que sea Stateful sin problema, ya que será el hijo el responsable de actualizarse a sí mismo.

3. GameOfLife

En el archivo main.dart metemos el Widget GameOfLife debajo de la clase MyApp, y sustituimos en esta misma clase MyHomePage por el Widget que acabamos de crear. Quedaría tal que así.

import 'package:flutter/material.dart';
import 'package:game_of_life/src/domain/board.dart';
import 'package:game_of_life/src/utils/size-config.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: GameOfLife(),
    );
  }
}

class GameOfLife extends StatelessWidget {
  static final _boardStateKey = GlobalKey();
  final Board _board = Board(key: _boardStateKey, rows: 50, columns: 50);

  _reset() {
    _boardStateKey.currentState.resetCells();
  }

  @override
  Widget build(BuildContext context) {
    SizeConfig.init(context);
    return Scaffold(
      appBar: AppBar(
        title: Text("Game of Life"),
      ),
      body: Column(
        children: [
          this._board,
          FlatButton(
            child: Text(
              "Reset",
            ),
            color: Colors.blue,
            textColor: Colors.white,
            onPressed: this._reset,
          )
        ],
      ),
    );
  }
}

Como podemos ver, este Widget es muy simple, y lo único que hace es contener al tablero. Aún así, hemos añadido la funcionalidad de poder resetear el juego apretando un botón para que podamos hipnotizarnos con el patrón cuantas veces queramos. Varias cosas a tener en cuenta sobre Dart y Flutter para entender este código y los siguientes:

  • Si el nombre de una variable comienza por una barra baja, significa que es privada.
  • Los Widgets tienen una propiedad key que se utiliza sólo en ciertos casos (). En este caso, la usamos para poder acceder al estado de nuestro Widget Board, ya que como ahora veremos, los StatefulWidget se dividen entre el Widget como tal y su estado.
  • Todos los Widgets necesitan incluir el método build, que será al que llame Flutter para renderizarlo.
  • Los constructores de Dart son muy ricos en cuanto a las posibilidades que ofrecen, se pueden hacer muchas virguerías según las necesidades (recomiendo leer este artículo: https://medium.com/flutter-community/deconstructing-dart-constructors-e3b553f583ef). Como pronto y necesario para entender este tutorial, si un constructor tiene llaves que rodean a sus parámetros, significa que son parámetros nombrados. A este constructor MyWidget({this.someProperty}); habrá que llamarlo tal que así: MyWidget(someProperty: foo); También se pueden mezclar los dos tipos de parámetros en un mismo constructor.

4. Board

Creamos un archivo al que le vamos a llamar cell.dart, donde simplemente definiremos la clase que representa una celda del tablero:

import 'dart:math';

class Cell {
  bool _isAlive;

  Cell.fromRandomLifeStatus() : this._isAlive = new Random().nextInt(5) == 0;

  Cell(this._isAlive);

  bool get isAlive => _isAlive;
  die() => this._isAlive = false;
  revive() => this._isAlive = true;

  bool operator ==(otherCell) =>
      otherCell is Cell && this.isAlive == otherCell.isAlive;

  int get hashCode => this._isAlive.hashCode;
}

Hemos implementado dos constructores, uno por defecto al que le podemos pasar directamente el estado inicial de la celda, y otro que decidirá el estado inicial de la casilla de forma aleatoria. También implementamos los métodos die() y revive() para cambiar la propiedad _isAlive de una forma más imperativa que si directamente le diéramos un nuevo valor. Implementamos también el operador == y el getter hashCode. Realmente sólo utilizaremos el operador ==, pero es recomendado implementar los dos si vamos a usar alguno de ellos.

Hecho esto, vamos con la chicha. Creamos un archivo board.dart donde estará realmente la lógica del programa. El Widget (no el estado) quedaría de la siguiente forma:

import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:game_of_life/src/domain/cell.dart';
import 'package:game_of_life/src/utils/map-to-list-extension.dart';
import 'package:game_of_life/src/utils/size-config.dart';

class Board extends StatefulWidget {
  final int _columns;
  final int _rows;
  final List _initialCells;

  Board({key, @required int rows, @required int columns})
      : _columns = columns,
        _rows = rows,
        _initialCells = null,
        super(key: key);

  Board.fromPredefinedCells({key, List cells})
      : assert(cells.length > 0 && cells.every((row) => row.length > 0)),
        _columns = cells.length,
        _rows = cells[0].length,
        _initialCells = cells,
        super(key: key);

  @override
  BoardState createState() => BoardState();
}

Como se puede ver, también hemos implementado dos constructores, uno por si queremos hacer un tablero predefinido por nosotros (que también nos facilitará el trabajo cuando testeemos) y otro al que sólo le tendremos que pasar el número de columnas y filas que deseamos, y generará un tablero con una semilla aleatoria.

Justo debajo del Widget implementamos el BoardState. Por una parte, necesitamos que el tablero se actualice cada cierto tiempo, por lo que declaramos un timer que ejecutará el método updateCells() repetidamente:

this._timer = Timer.periodic(Duration(milliseconds: 50), (timer) {
      this.updateCells();
    });

En el método updateCells() lo que haremos será:

  • Clonar la tabla actual para leer de la actual y actualizar valores sobre la copia. De esta forma, en un mismo instante de tiempo, los estados futuros de unas casillas no influirán en el estado actual de otras.
  • Recorrer todas las celdas, calculando en cada una el número de celdas vecinas vivas con _getAliveNeighbours.
  • Dependiendo de las celdas vecinas vivas que tenga una celda, actualizar su estado correspondientemente.

El código completo de BoardState quedaría así:

class BoardState extends State {
  List _cells;
  Timer _timer;

  List get cells => this._cells;

  @override
  void initState() {
    this.resetCells();
    this._timer = Timer.periodic(Duration(milliseconds: 50), (timer) {
      this.updateCells();
    });
    super.initState();
  }

  @override
  void dispose() {
    this._timer?.cancel();
    super.dispose();
  }

  resetCells() {
    this._cells = widget._initialCells ??
        List.generate(
            this.widget._columns,
            (col) => List.generate(
                this.widget._rows, (row) => Cell.fromRandomLifeStatus()));
  }

  updateCells() {
    // Clonamos las celdas (por valor, en vez de por referencia)
    List updatedCells = List.of(
        this._cells.map((e) => e.mapToList((e) => Cell(e.isAlive))));

    for (int col = 0; col  this._buildCell(context, this._cells[col][row])),
                )));
  }

  Widget _buildCell(BuildContext context, Cell cell) {
    double squareWidth = SizeConfig.blockSizeHorizontal * 1.5;
    return Container(
      width: squareWidth,
      height: squareWidth,
      decoration: BoxDecoration(
        border: Border.all(color: Colors.grey, width: 0.5),
        color: cell.isAlive ? Colors.blue : Colors.white,
      ),
    );
  }
}

Hay un par de cosas a tener en cuenta para este código:

  • Cuando en Flutter se modifica alguna variable que conlleve un cambio en el estado habrá que avisar al framework de que tiene que redibujar la interfaz. Por esto, cuando sustituimos las celdas antiguas por las actualizadas, ejecutamos esta acción dentro de la función setState. En la realidad, este método prácticamente no se usa, siendo sustituido por dinámicas de manejo de estado más complejas. Aún así, en casos tan simples y directos como este, sí es recomendable usarlo.
  • Cuando realizamos un map sobre una lista, Dart devolverá un Iterable, por lo que dependiendo de la situación, habrá que hacer un toList: miList.map((x) => function).toList();. Gracias a las extensiones de Dart (similares a las de Kotlin), he implementado una en un archivo aparte para poder directamente utilizar este nuevo método mapToList(). dart extension MapToListExtension on List { List mapToList(T f(e)) => this.map(f).toList(); }
  • En el método _buildCell en el que construimos la interfaz de una celda, calculamos primero los píxeles que ocupará el lado del cuadrado: double squareWidth = SizeConfig.blockSizeHorizontal · 1.5; SizeConfig es una clase que he implementado para mantener la proporcionalidad de la interfaz a través de pantallas de distintos tamaños. Básicamente lo que hace es coger el ancho y largo de la pantalla y dividir cada uno de ellos entre 100:

import 'package:flutter/widgets.dart';

  class SizeConfig {
    static double screenWidth;
    static double screenHeight;
    static double blockSizeHorizontal;
    static double blockSizeVertical;

    static void init(BuildContext context) {
      MediaQueryData mediaQueryData = MediaQuery.of(context);
      screenWidth = mediaQueryData.size.width;
      screenHeight = mediaQueryData.size.height;
      blockSizeHorizontal = screenWidth / 100;
      blockSizeVertical = screenHeight / 100;
    }
  }

Para inicializar esta clase necesitamos pasarle un BuildContext, que sólo se recibe en los métodos build. Por eso si echamos un vistazo al Widget GameOfLife, que es el padre del resto, podemos ver que hemos incluido la línea SizeConfig.init(context); en el método build. Es recomendable usar esta clase para cualquier distancia que utilicemos en Flutter, en vez de usar distancias absolutas.

¡A probar!

Una vez hecha la magia, sólo tendremos que ejecutar flutter run para hipnotizarnos con los patrones creados por nuestro programa:

5. Testing

Vamos a testear que el tablero actualice correctamente las casillas del tablero. Para ello, testeamos el método updateCells() del BoardState. Vamos a crear un archivo board_test.dart, implementando primero la función que se encargará de crear el Widget para su testeo:

El framework de testing de Flutter es bastante curioso y, aunque haya poca documentación, está bastante preparado para cumplir su función. Para preparar el test creamos la función setup(), donde crearemos el widget. Para ello tenemos que usar el método testWidgets en vez de test, que nos inyecta la herramienta WidgetTesters, con la que podremos acceder a funcionalidades bastante importantes.

Future setup(WidgetTester tester, List cells) async {
  await tester.pumpWidget(
      makeTestableWidget(child: Board.fromPredefinedCells(cells: cells)));
  BoardState state = tester.state(find.byType(Board));
  Board board = tester.widget(find.byType(Board));
  return SetupObj(board, state);
}

Para empezar usamos pumpWidget, lo que hará que se renderice el Widget. También tendremos que hacer el Widget testeable para poder renderizarlo, lo que quiere decir meterlo en un Container o similar. En nuestro caso, necesitamos meterlo en una MaterialApp, para poder iniciar la clase SizeConfig (aunque el BuildContext se inyecte en cualquier método build, nuestra clase usa la propiedad MediaQuery, que es generada por Widgets como MaterialApp). Para esto, nos creamos la siguiente función para poder ser reutilizada:

Widget makeTestableWidget({child}) {
  return MaterialApp(home: Builder(builder: (BuildContext context) {
    SizeConfig.init(context);
    return Container(child: child);
  }));
}

Después, podemos usar el WidgetTester para encontrar la instancia tanto de nuestro Widget como la de su estado. Esto es muy importante, porque prácticamente toda la lógica de nuestro tablero está en su estado. Después de coger los dos, los «comprimimos» en un objeto y lo retornamos.

Hecho esto, creamos el test:

void main() {
  group('Board', () {
    testWidgets('should return updated cells', (WidgetTester tester) async {
      final setupObj = await setup(tester, [
        [Cell(true), Cell(false), Cell(true)],
        [Cell(false), Cell(false), Cell(true)],
        [Cell(true), Cell(true), Cell(true)],
      ]);
      BoardState boardState = setupObj.state;

      boardState.updateCells();

      expect(boardState.cells, [
        [Cell(false), Cell(true), Cell(false)],
        [Cell(true), Cell(false), Cell(true)],
        [Cell(false), Cell(true), Cell(true)],
      ]);
    });
  });
}

 

Corremos el test ejecutando el comando por consola flutter test, y vemos que funciona correctamente.

Probamos a forzar el error para comprobar que no es un falso positivo.

Después aprovechamos para añadir otro par de distribuciones del tablero para comprobar que actualiza correctamente en distintas situaciones. El código completo board_test.dart quedaría así:

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:game_of_life/src/domain/board.dart';
import 'package:game_of_life/src/domain/cell.dart';
import 'package:game_of_life/src/utils/make-testable-widget.dart';

void main() {
  group('Board', () {
    testWidgets('should return updated cells', (WidgetTester tester) async {
      final setupObj = await setup(tester, [
        [Cell(true), Cell(false), Cell(true)],
        [Cell(false), Cell(false), Cell(true)],
        [Cell(true), Cell(true), Cell(true)],
      ]);
      BoardState boardState = setupObj.state;

      boardState.updateCells();

      expect(boardState.cells, [
        [Cell(false), Cell(true), Cell(false)],
        [Cell(true), Cell(false), Cell(true)],
        [Cell(false), Cell(false), Cell(true)],
      ]);
    });

    testWidgets('should return updated cells 2', (WidgetTester tester) async {
      final setupObj = await setup(tester, [
        [Cell(true), Cell(false), Cell(true)],
        [Cell(false), Cell(true), Cell(false)],
        [Cell(true), Cell(true), Cell(true)],
      ]);
      BoardState boardState = setupObj.state;

      boardState.updateCells();

      expect(boardState.cells, [
        [Cell(false), Cell(true), Cell(false)],
        [Cell(false), Cell(false), Cell(false)],
        [Cell(true), Cell(true), Cell(true)],
      ]);
    });

    testWidgets('should return updated cells 3', (WidgetTester tester) async {
      final setupObj = await setup(tester, [
        [Cell(true), Cell(false), Cell(false)],
        [Cell(true), Cell(false), Cell(false)],
        [Cell(true), Cell(false), Cell(true)],
      ]);
      BoardState boardState = setupObj.state;

      boardState.updateCells();

      expect(boardState.cells, [
        [Cell(false), Cell(false), Cell(false)],
        [Cell(true), Cell(false), Cell(false)],
        [Cell(false), Cell(true), Cell(false)],
      ]);
    });
  });
}

class SetupObj {
  T widget;
  State state;

  SetupObj(this.widget, this.state);
}

Future setup(WidgetTester tester, List cells) async {
  await tester.pumpWidget(
      makeTestableWidget(child: Board.fromPredefinedCells(cells: cells)));
  BoardState state = tester.state(find.byType(Board));
  Board board = tester.widget(find.byType(Board));
  return SetupObj(board, state);
}

Final

Bueno, y hasta aquí el tutorial. Si queréis ver el código completo, lo tenéis en el siguiente repositorio: https://github.com/javiermrz/game-of-life

Podéis ver todos los tutoriales sobre el juego de la vida en https://www.adictosaltrabajo.com/2020/04/30/el-juego-de-la-vida-de-conway/

Espero que os haya gustado, y cualquier duda o consejo ¡no dudéis en dejar un comentario!

La entrada El Juego de la Vida con Flutter: Desarrollo y Testing se publicó primero en Adictos al trabajo.

Probando tu api Rest con el cliente de Intellij IDEA

$
0
0

Índice de contenidos

  1. Introducción
  2. Entorno
  3. El primer test
  4. Entornos
  5. Almacenar datos entre tests
  6. Conclusiones
  7. Referencias

 

1. Introducción

Cuando desarrollamos nuestro api rest o tu aplicación necesita integrarse con otro de terceros, muchas veces nos preguntamos, ¿qué devuelve esto realmente? ¿Estoy haciendo las llamadas correctas? Quién no ha hecho un curl o ha utilizado otras herramientas fuera de tu IDE. En este tutorial vamos a conocer cómo realizar estos tests con Intellij.

 

2. Entorno

El tutorial está escrito usando el siguiente entorno:

  • Intellij Idea Ultimate 2019.3

 

3. El primer test

En nuestro proyecto, en cualquier directorio, creamos un fichero con extensión .http ,

Y el contenido, por ejemplo:

GET https://adictosaltrabajo.com
Accept: */*
Cache-Control: no-cache

Para ejecutar, sólo tendremos que darle al play como cualquier test en Intellij

Y mostrará la respuesta en la consola:

 

4. Entornos

Vale, está claro que sólo es un test de ejemplo. Pero cuando estamos desarrollando, a veces queremos probar en diferentes entornos. Por ejemplo en tu local y en el servidor de integración. Para ello, podemos crear un fichero definiendo los entornos y las variables:

Debe tener el nombre rest-client.env.json para que detecte los entornos.

{
  "local": {
    "host": "localhost:8080"
  },
  "pro": {
    "host": "adictosaltrabajo.com"
  }
}

Y se referencia la variable en el test:

GET https://{{host}}
Accept: */*
Cache-Control: no-cache
###

Al ejecutar, nos permitirá elegir el entorno.

Eligiendo pro, muestra en la consola la llamada con el valor de pro.

 

5. Almacenar datos entre tests

En muchas ocasiones, para consultar nuestro api necesitamos un token válido que adjuntar o cualquier otro dato que enviar a otras llamadas.

En el fichero de entorno, incluimos una variable, por ejemplo token.

{
  "local": {
    "host": "localhost:8080",
    "token": ""
  },
  "pro": {
    "host": "adictosaltrabajo.com",
    "token": ""
  }
}

Y se puede crear otro fichero http-client.private.env.json que almacene los datos sensibles, como por ejemplo el password del login. Este fichero se añadiría al .ignore del proyecto.

{
  "local": {
    "password": "localPass"
  },
  "pro": {
    "password": "proPass"
  }
}

El body de la petición se incluye debajo de la llamada, y el siguiente bloque permite definir una función que en este caso recupera el token de la respuesta y lo asigna a la variable token.

POST http://{{host}}/api/auth/login
Accept: */*
Cache-Control: no-cache
Content-Type: Application/Json

{"username": "stoledano@adictosaltrabajo.com", "password": {{passwrod}} }

> {%
    client.global.set("token", response.body.token);
%}

###
GET http://{{host}}/v1/documents?page=30
Accept: */*
Cache-Control: no-cache
Content-Type: Application/Json
Authorization: Bearer {{token}}

> {%
    client.test("Request executed successfully", function() {
        client.assert(response.status === 200, "Response status is not 200");
    });
%}

De tal manera que si ejecutamos el segundo test «v1/documents» la variable token tiene el valor recuperado en la anterior petición. Al incluir la función de comprobación status se ve que no recibimos un 401 y que el token se está incluyendo correctamente.

Se puede reutilizar en otros test de este mismo fichero o en otros, pues lo almacena de manera global. Cuando el token caduque, basta con realizar sólo la llamada del token.

 

6. Conclusiones

Creo que la puesta en marcha de estos test es muy rápida y permite centralizar nuestro desarrollo en una sola herramienta e incluso la facilidad de almacenarlo con el proyecto, con las ventajas que ello supone.

 

7. Referencias

https://www.jetbrains.com/help/idea/http-client-in-product-code-editor.html

La entrada Probando tu api Rest con el cliente de Intellij IDEA se publicó primero en Adictos al trabajo.

Primeros pasos con SwiftUI

$
0
0
  1. Introducción
  2. Entorno
  3. Primeros pasos con SwiftUI
  4. Los elementos básicos de la interfaz
  5. Usando @State y @Binding
  6. Conclusiones
  7. Referencias

Introducción

Este tutorial nos ayudará a dar los primeros pasos con SwiftUI. Es una nueva forma declarativa y simple de crear interfaces de usuario en todas las plataformas de Apple usando solamente Swift. Puedes crear las interfaces de usuario para cualquier dispositivo Apple utilizando solo un API de SwiftUI. Con una sintaxis declarativa de Swift, que es fácil de leer y natural de escribir, SwiftUI te permite mantener su código y diseño perfectamente sincronizados con las nuevas herramientas de diseño de Xcode.

También SwiftUI te permite ignorar el Interface Builder (IB) sin tener que escribir instrucciones detalladas paso a paso para diseñar su interfaz de usuario. IB y Xcode eran aplicaciones separadas antes de Xcode 4, y las costuras aún se muestran cada vez que editas el nombre de un IBAction o IBOutlet o lo eliminas de su código, y tu aplicación se bloquea porque IB no ve cambios en el código. O te disgustan los identificadores de tipo String para «segues» o celdas de vista de tabla que tienes que usar en tu código, pero Xcode no puede comprobarlo porque son de tipo «string».

Y sí, es más rápido y fácil diseñar una nueva interfaz de usuario en un editor WYSIWYG, es mucho más eficiente copiar o editar la interfaz de usuario cuando está escrita en código declarativo. Puedes obtener una vista previa de una vista SwiftUI lado a lado con su código; un cambio en un lado actualizará el otro lado, por lo que siempre están sincronizados. No hay los identificadores tipo «string» para equivocarse. Y es código, pero mucho menos de lo que escribirías para UIKit, por lo que es más fácil de entender, editar y depurar.

SwiftUI no reemplaza a UIKit: como Swift y Objective-C, puedes usar ambos en la misma aplicación. No podrás ejecutar aplicaciones SwiftUI iOS en macOS. Para eso se necesita usar Catalyst (un framework de Apple). Pero, las API de SwiftUI son consistentes en todas las plataformas, por lo que será más fácil desarrollar la misma aplicación en múltiples plataformas que usan el mismo código fuente en cada una. Lo malo es que SwiftUI es solo compatible con iOS 13, pero con el tiempo esta desventaja se va diminuyendo.

Entorno

Este tutorial está escrito usando el siguiente entorno:

    • Hardware: MacBook Pro 15’ (2,5 GHz Intel Core i7, 16GB DDR3, mediados de 2015)
    • Sistema operativo: macOS Catalina 10.15
    • Versiones del software:
      • Xcode: 11
      • iOS SDK: 13.4

Es importante saber que para probar SwiftUI se necesita instalar Mac OS Catalina 10.15 y Xcode 11.

Primeros pasos con SwiftUI

Aunque los storyboards y XIB nos han servido bien, no son perfectos: pueden ser complicados de usar con el control de código fuente, hacen que sea difícil, si no imposible, moverse entre el código y los diseños visuales, y confían en un sistema inestable de conexiones mediante acciones y outlets (IBAction y IBOutlet).

SwiftUI barre todo eso de varias maneras importantes:

Hay una nueva estructura de interfaz de usuario declarativa que define cómo se ven y funcionan nuestros diseños.
La actualización de la vista previa de la IU genera automáticamente un nuevo código de Swift, y al cambiar el código de Swift se actualiza la vista previa de la IU.
Cualquier enlace en Swift, por ejemplo, los outlets y las acciones, efectivamente, ahora se verifican en tiempo de compilación, por lo que no hay más riesgo de que la IU falle por sorpresa en tiempo de ejecución.
Aunque utiliza controles de UIKit y AppKit por debajo, se ubica encima de ellos, lo que hace que el framework de la interfaz de usuario subyacente sea un detalle de implementación en lugar de algo que nos interesa específicamente.

Para conocer mejor el nuevo mundo de SwiftUI vamos a crear un proyecto muy simple. En Xcode 11 beta, crea un nuevo proyecto Xcode (Shift-Command-N), selecciona iOS App Aplicación de vista única, nombra el proyecto como quieras, luego marca la casilla «Use SwiftUI»:

En el navegador de proyecto, abre el grupo SwiftUI para ver lo que tiene: el antiguo AppDelegate.swift ahora se divide en AppDelegate.swift y SceneDelegate.swift. SceneDelegate no es específico de SwiftUI, pero esta línea sí lo es:

window.rootViewController = UIHostingController(rootView: ContentView())

UIHostingController permite integrar vistas SwiftUI en una aplicación existente. Cuando se inicia la aplicación, la ventana muestra una instancia de ContentView, que se define en ContentView.swift. Es una estructura que se ajusta al protocolo View.

struct ContentView: View {
  var body: some View {
    Text("Hello World")
  }
}

Este código declara que el cuerpo de ContentView contiene una vista de texto que muestra Hello World. Abajo en el bloque DEBUG, ContentView_Previews produce una vista que contiene una instancia de ContentView.

#if DEBUG
struct ContentView_Previews: PreviewProvider {
  static var previews: some View {
    ContentView()
  }
}
#endif

Aquí es donde puede especificar datos de muestra para la vista previa. Pero, ¿dónde está esta vista previa?
Haz clic en «Resume» y espera un momento para ver la vista previa:

El único archivo que no aparece en la lista es Main.storyboard: creará su interfaz de usuario en el código SwiftUI, vigilando la vista previa para ver cómo se ve. Pero no te preocupes, ¡no escribirá cientos de líneas de código para crear vistas!
SwiftUI es declarativo: tienes solo que declarar las vistas que quieras y SwiftUI convierte sus declaraciones en un código eficiente que hace el trabajo. Apple lo alienta a crear tantas vistas como necesite, para mantener su código fácil de leer y mantener. Las vistas reutilizables y parametrizadas se recomiendan especialmente: es cómo extraer código en una función, y es fácil de hacer con dos clicks.

Los elementos básicos de SwiftUI

Vamos a añadir más elementos visuales a nuestra vista. Para añadir más elementos a la vista tenemos que usar los contenedores como VStack o HStack. Son contenedores que distribuyen las vistas verticalmente o horizontalmente. La manera mas fácil de añadirlos es hacer Command-Click a la única vista que tenemos que es TextEntonces aparecerá el menú que nos permite fácilmente integrar nuestra única vista a HStack o a VStack.

Para añadir mas vistas tenemos que pulsar el botón arriba a la derecha. Podemos elegir entre Text, TextField, DatePicker, Slider, Toggle y, por supuesto, Button. También podemos editar las propiedades de cada vista a través del mismo menu, pulsando «Show SwiftUI Inspector». Cada vista tiene su conjunto de propiedades y para modificarlas hay que añadir modificadores. Por ejemplo, la fuente, el color, la alineación etc. También se puede hacer editando el código.

struct ContentView : View {
    
    var body: some View {
        HStack {
            Text("Hello World")
                .fontWeight(.light)
                .foregroundColor(Color.red)
                .multilineTextAlignment(.center)
        }
    }
}

Igualmente puedes hacer Command-Click en el código sobre un elemento y te sale el menú que te ayuda integrar tu vista dentro de los contenedores, conseguir ayuda rápida o incluso hacer algún refactoring. Este menú es muy útil y ayuda bastante sobre todo al principio.

 

Añadimos Button y centramos nuestras vistas. Además hemos añadido también el modificador spacing para meter el espacio entre los elementos de nuestro stack.

struct ContentView : View {
    
    var body: some View {
        VStack(alignment: .center, spacing: 10.0) {
            Text("Hello")
            Button(action: {}) {
                Text("Show Alert").foregroundColor(Color.red)
            }
        }
    }
}

¿Y si queremos vincular nuestra vista con otra? Si queremos crear un stack de vistas como lo hacíamos antes con el NavigationController, también tenemos que añadir otras declaraciones. Todo en SwiftUI se hace a través de las declaraciones. Al principio puede parecer muy inusual e incómodo, pero con el tiempo te vas acostumbrando y realmente es bastante fácil y rápido. Solo es otra manera de hacer las cosas.

Tenemos que envolver nuestro contenedor VStack con las vistas en NavigationView y añadir un elemento que se llama NavigationLink. El código es muy explicativo y fácil de entender. NavigationLink es un botón que necesita solo el destino (otra vista) y el texto. Y para demostrar el titulo de «la barra de navegación» adjuntamos un modificador «navigationBarTitle».

struct ContentView : View {
    var body: some View {
        NavigationView {
            VStack(alignment: .center, spacing: 10.0) {
                Text("Hello")
                Button(action:{}) {
                    Text("Show Alert")
                        .foregroundColor(Color.red)
                }
                NavigationLink(destination: DetailView()) {
                    Text("Go to Detail View")
                }
            }.navigationBarTitle("Main View")
        }
    }
}

Y solo con unas quince lineas de código tenemos la navegación entre dos vistas, la barra de navegación y un botón que no hace nada 😉

Usando property wrappers @State y @Binding

Cada vista en Swift UI es una estructura, así que puedes añadir y usar constantes y variables «normales» en SwiftUI. ¿Y si tenemos las vistas que dependen de los valores de  nuestras variables? ¿Cómo podemos sincronizar las vistas y las variables? Para eso tienes que usar una variable @State o @Binding si la UI debe actualizarse siempre que cambie su valor. Son «variables vivas» 😉 . Con la introducción de Swift 5.1 ahora tenemos el property wrapper: una cosa fundamental en SwiftUI. El property wrapper es una forma de añadir más funcionalidad a la propiedad calculable. SwiftUI tiene varios tipos de property wrappers ya implementados, pero nosotros siempre podemos añadir nuestros.

Los más utilizable son @State, @Binding. Son fundamentales para el desarrollo en Swift UI.

@State es probablemente el property wrapper más utilizado en SwiftUI. Es una variable local que se usa para vincular la vista y la variable. Siempre cuando se cambia el valor de variable @State, la vista vinculada se actualiza. Veamos un simple ejemplo.

struct ContentView : View {
    @State var myNumber = 1
    
    var body: some View {
            VStack(alignment: .center, spacing: 10.0) {
                Text("Number \(myNumber)")
                Button(action: { self.myNumber+=1}) {
                    Text("Add 1")
                        .foregroundColor(Color.red)
                }
        }
    }
}

Cada vez que pulsemos el botón el texto se actualiza, porque nuestra vista «Text» está vinculada con el valor de variable @State. Nuestra variable es «la fuente de la verdad» de nuestra vista.

@Binding

Como aprendimos anteriormente, los property wrappers siempre deben relacionarse con una vista específica. Pero a veces queremos tener acceso a un property wrapper desde el exterior, por ejemplo, desde las vistas secundarias.

Para crear dicha referencia, usamos el property wrapper @Binding. Si quieres crear un enlace desde una vista secundaria a la vista que contiene la propiedad State, simplemente declara una variable dentro de la vista secundaria y márcala con la palabra clave @Binding.

struct ChildView: View {
    
    @Binding var myBinding: String
    
    var body: some View {
        //...
    }
}

Luego puedes crear el enlace inicializando la vista secundaria con referencia a la variable @State utilizando la sintaxis «$».

struct ContentView: View {
    
    @State var myBinding = "Hello"
    
    var body: some View {
         ChildView(myBinding: $myBinding)
    }
    
}

La variable @Binding se usa igual que @State. Por ejemplo, cuando actualiza la variable @Binding, también hace que @State cambie su valor, lo que hace que la vista también vuelva a actualizarse.

struct ContentView: View {
    
    @State var myInteger = 1
    
    var body: some View {
        VStack {
            Text("\(myInteger)")
            OutsourcedButtonView(myInteger: $myInteger)
        }
    }
    
}

struct OutsourcedButtonView: View {
    
    @Binding var myInteger: Int
    
    var body: some View {
        Button(action: {self.myInteger += 1}) {
            Text("Tap me!")
        }
    }
}

En el ejemplo anterior, ContentView contiene una variable que contiene un número entero y un texto para mostrar esos datos. ChildView contiene un enlace a esta variable @State y un botón para aumentar el valor de la propiedad del enlace. Cuando tocamos el botón, los datos del estado se actualizan a través del enlace, lo que hace que ContentView se vuelva a mostrar y finalmente muestre el numero actualizado.

Hagamos un ejemplo que nos ayudará a entender cómo funciona el mecanismo de property wrappers.

struct ContentView : View {
    @State private var showingAlert = false
    @State private var name = "Anton"
    
    var body: some View {
        NavigationView {
                VStack(spacing: 20.0) {
                    ChildView(name: $name, showingAlert: $showingAlert)
                        }.alert(isPresented: $showingAlert) {
                            Alert(title: Text("Important message"), 
                            message: Text("Welcome \(name)"), 
                            dismissButton: .default(Text("Got it!")))
                        }
                        NavigationLink(destination: DetailView()) {
                            Text("Go to Detail")
                        }
                    }.padding(.all, 10.0)
                    .navigationBarTitle("Main Page")
        }
}

struct ChildView: View {
    @Binding var name : String
    @Binding var showingAlert: Bool

    var body: some View {
        VStack {
            TextField("Placeholder", text: $name)
                .padding(.horizontal, 15.0)
            Button(action: {
                self.showingAlert = true
            }) {
                Text("Show Alert")
                    .foregroundColor(Color.red)
            }
        }
    }
}

Aquí hemos dividido nuestra vista en dos, creando dos vistas: la parental y la filial. Creamos las variables «showingAlert» y «name» con el property wrapper @State y las pasamos a la vista filial, pero dentro de ella se necesita usar el property wrapper @Binding. Las propiedades @State son «locales» y las de @Binding se usan para vincular dos vistas diferentes. Para crear un vínculo (binding) entre el property wrapper y otra vista se utiliza el símbolo «$». Es como una referencia al valor de esta propiedad que crea un enlace (binding) para pasarlo a otras vistas.

Con la ayuda de los property wrappers podemos dividir nuestra lógica en diferentes partes. También nos ayuda a sincronizar las vistas con las variables de manera muy fácil y eficaz. No tenemos que escribir mucho código para vincular nuestro modelo con la interfaz. Los property wrappers son imprescindibles en el desarrollo Swift UI.

Conclusiones

Este artículo apenas roza la superficie de SwiftUI, pero ahora tienes una idea de cómo usar algunas nuevas herramientas de Xcode para diseñar y previsualizar vistas, y cómo usar las variables @State y @Binding para mantener actualizada su UI. También cómo presentar las alertas. Sin duda, con el paso del tiempo SwiftUI será el estandarte en el desarrollo para iOS y Mac. Solo puedes utilizar SwiftUI a partir de iOS 13 y MacOS X 10.15. Esto impide el uso de SwiftUI para crear las aplicaciones que ya existen, pero dentro de un par de años no será algo relevante. Así que tenemos el tiempo para aprender Swift UI hasta que se convierta en el estándar del mundo iOS.

 

Referencias:

  1. https://developer.apple.com/documentation/swiftui
  2. https://nshipster.com/propertywrapper/
  3. https://www.raywenderlich.com/3715234-swiftui-getting-started

La entrada Primeros pasos con SwiftUI se publicó primero en Adictos al trabajo.

Nueva tendencia de diseño: Neomorfismo

$
0
0

En este post hablaremos sobre la nueva tendencia de diseño llamada neomorfismo, su principal problema y dónde podemos aprender a usarlo.

Índice de contenidos

1. Introducción

El neomorfismo es un nuevo estilo de diseño para la interfaz de usuario de sistemas operativos u otros elementos visuales.

Esta tendencia de diseño utiliza efectos de luz, sombreados, profundidad y texturas que en conjunto permiten crear un estilo bastante futurista y elegante a las interfaces de usuario.

2. Entorno

El tutorial está escrito usando el siguiente entorno:

  • Hardware: Portátil MacBook Pro 15′ (2,7 Ghz Intel Core i7, 16GB DDR3).
  • Sistema Operativo: Mac OS Catalina 10.15.3

3. Tendencias de diseño anteriores

Esqueumorfismo: trata de plasmar las texturas y colores de objetos de la realidad para trasladarlos a las nuevas tecnologías.

Minimalismo: diseño conciso, claro y consistente. La finalidad que se persigue con este diseño es ofrecer una comunicación visual clara.

4. Neomorfismo

El neomorfismo es una tendencia de diseño emergente en este año 2020. La palabra es la combinación de neu (nuevo) y morfismo (de skeumorfismo).

A diferencia del esqueumorfismo, que imita el mundo físico, el neomorfismo añade un elemento físico a la interfaz de usuario plana de hoy.

Esta tendencia de diseño está llena de riqueza visual y es capaz de transmitir una gran cantidad de información ya que mantiene la limpieza característica del minimalismo, sin llegar a ser confusa.

Este diseño añade un aspecto futurista al diseño actual de las interfaces de usuario. Incorpora efectos de luz, sombras e incluye textura y profundidad a elementos planos. La elección de los colores es muy importante con tonalidades muy suaves pero vistosas, con el fin de ofrecer una navegación mucho más cómoda y agradable.

5. Punto débil del neomorfismo

Uno de los principales problemas con los que lidia esta tendencia de diseño es la accesibilidad, todos estos problemas están muy bien explicados en este post de medium. Recomiendo su lectura ya que explica de manera muy clara los problemas que puede ocasionar.

6.  ¿Cómo puedo aprender?

Hoy dos páginas que me han ayudado mucho cuando empecé a aprender este nuevo diseño.

La primera es este post de css-tricks en el cual nos explica explica perfectamente todos los estilos que se le aplican a los elementos para conseguir este diseño.

La segunda es una web que visualmente te permite crear los elementos y darle el estilo que deseemos. Puede visitarla desde aquí.

7. Conclusiones

Desde mi punto de vista es un diseño bonito y limpio. Y Ojalá en unos meses/años poder verlo en muchas más aplicaciones, webs y sistemas operativos.

8. Referencias

La entrada Nueva tendencia de diseño: Neomorfismo se publicó primero en Adictos al trabajo.

Ansible Testing Using Molecule with Ansible as Verifier

$
0
0

In this tutorial, we would be learning how to test infrastructure code written in Ansible using a testing framework known as Molecule. Inside Molecule we would be making use of Ansible as our verifier which is something I wasn’t able to find anywhere yet. Let’s do this!

Table of Contents

1. Introduction

Ansible is an Open-source IT automation tool used in Configuration Management, Application Deployment , Infrastructure Service Orchestration, Cloud Provisioning and many more. It is an easy-to-use tool yet makes very complex repetitive IT automation tasks look easy. It can be used for multi-tier IT application deployment.

Just like in any other IT sphere, testing is inevitable. An untested infrastructure can be easily written off as an already broken infrastructure. By testing infrastructure code, we ensure the development of production-grade infrastructure code that is void of errors and bugs which can be very costly if not discovered before production.

Molecule is a framework designed to aid the development and testing of roles in Ansible. As of September 26, Ansible announced its adoption of Molecule and Ansible-lint as an official Red Hat Ansible projects. This shows the confidence the Red Hat community has in this tool and the amount of work they are putting into making it even better and better.

Molecule makes it possible to test roles across different instances, operating systems and distributions, virtualisation providers, test frameworks and testing scenarios.
Molecule supports a TDD-like form of testing infrastructure code. In this tutorial, we would look at the lifecycle that molecule testing should follow, according to my opinion.

2. Installing Molecule

It is assumed that the reader already has some experience with package management on UNIX systems.

Molecule requires the following packages to function:

  • Python 2.7 or Python 3.5 or greater (For this tutorial, we would be using Python 3.7)
  • Ansible 2.5 or greater (For this tutorial, we would be using Ansible 2.9.6)
  • Docker (latest version)

Pip is the only officially supported package manager for the installation of Molecule. If you’re using Python 2.7.9 (or greater) or Python 3.4 (or greater), then PIP comes installed with Python by default.

To install Molecule using pip:

$ pip3 install molecule

See molecule installation for more installation tips. To check if molecule was properly installed, run

$ molecule --version
.

When testing Ansible Playbooks, it is important to understand that we do not test that Ansible works, i.e that Ansible has done its job of, for example, creating a file, a user or a group, rather what we test is that our intent, as expressed in plain English, corresponds to Ansible’s declarative language, i.e what has been created is exactly what we wished to create and there was no human errors (e.g, typographical errors or omissions).

3. Initialising Molecule in Ansible Roles

There are two ways of initialising Molecule for testing Ansible roles:

a. Initiating Molecule with a new Ansible Role
Molecules makes use of Ansible Galaxy to generate the standard Ansible role layout. To create a new role with Molecule:

$ molecule init role <the_role_name>

b. Initiating Molecule for an already existing Ansible role
Molecule can also be used to test already existing roles, simply enter the following command in the root directory where the role is located or inside the role directory making sure that the role names match:

$ molecule init scenario -r <the_already_existing_role_name>

Regardless of how you initialise Molecule, a new Molecule folder is added to the root folder of the project. The resulting folder layout is as follows:

.
├── README.md
├── files/                                                
├── handlers/                                              
├── meta/                                                            
├── tasks/                                                 
├── templates/                                              
├── tests/                                               
├── vars/                                              
└── molecule/
        └── default                        
                ├── molecule.yml                        
                ├── converge.yml                        
                ├── verify.yml                                               
                └── INSTALL.rst

Below we would discuss the contents of the Molecule folder and their usage:

molecule.yml

In the molecule.yml, we specify all the molecule configuration needed to test the roles.

---
dependency:
  name: galaxy
  enabled: true # to disable, set to false
driver:
  name: docker
platforms:
  - name: instance
    image: docker.io/pycontribs/centos:7
    pre_build_image: true
provisioner:
  name: ansible
verifier:
  name: ansible

dependency:
This is the dependency manager which is responsible for resolving all role dependencies. Ansible Galaxy is the default dependency used by Molecule. Other dependency managers are Shell and Gilt. By default,

dependency
  is set to
true
, but can be disabled by setting
enabled
to
false
.

driver:
Driver tells Molecule where we want our test instances to come from. Molecule’s default driver is Docker but also has other options such as: AWS, Azure, Google Cloud, Vagrant, Hetzner Cloud and many more. See molecule drivers for more on this.

platforms:
The platforms key indicate what type of instances we want to launch to test our roles. This should correspond to the driver, for example, in the above snippet, it says what type of docker image we want to launch.

provisioner:
The provisioner is the tool that runs the converge.yml file against all launched instances (specified in platforms). The only supported provisioner is Ansible.

verifier:
The verifier is the tool that validates our roles. This verifier runs the verify.yml file to assert that our instance’s actual state (converge state) matches the desired state (verify state). The default verifier is Ansible but there are also other verifiers, such as: testinfra, goss and inspec. Earlier, testinfra was the default verifier but because of the need for a unified testing UX and to avoid the need to learn another language, Python, in the case of testinfra, the community has decided that Ansible becomes the default verifier and I support this decision. See git issue here.

Additional keys that are not generated by default are lint and scenario. These keys can be added to the molecule.yml file at will.

lint:
Lint represents what tool Molecule must use to ensure that declarative errors, bugs, stylistic errors, and suspicious constructs are spotted and flagged. Popular lints are yamllint, ansible-lint, flake8, etc.

scenario:
Scenario describes the lifecycle of the Molecule test. The test scenario is customisable as the steps in the sequence can be interchanged or commented out to suit whatever scenario needed. Every role should have a default scenario which is called default.
Unless otherwise stated, the scenario name is usually the name of the directory where the Molecule files are located. Below is the default scenario run when we run the corresponding command sequence:

scenario:
  create_sequence:
    - dependency
    - create
    - prepare
  check_sequence:
    - dependency
    - cleanup
    - destroy
    - create
    - prepare
    - converge
    - check
    - destroy
  converge_sequence:
    - dependency
    - create
    - prepare
    - converge
  destroy_sequence:
    - dependency
    - cleanup
    - destroy
  test_sequence:
    - dependency
    - lint
    - cleanup
    - destroy
    - syntax
    - create
    - prepare
    - converge
    - idempotence
    - side_effect
    - verify
    - cleanup
    - destroy

From they above snippet, we can tell what happens when we run a Molecule command, for example,

$ molecule create
would run then create_sequence while
$ molecule check
would run the check_sequence and so on.

In general, we only add the scenario key when we want to customise our scenario else it is unnecessary as it is the default scenario and hence, implicit.

converge.yml

The converge.yml file, just as the name implies, is used to convert the state of the instances to the real state declared in the actual roles to be tested. It runs the single converge play on the launched instances. This file is run when we run the

$ molecule converge
  command.

verify.yml

The verify.yml file runs the play that calls the test roles. These roles are used to validate that the already converged instance state matches the desired state. This file is run when we run the

$ molecule verify
 command.

INSTALL.rst

This file contains instructions for additional dependencies needed for a successful interaction between Molecule and the driver.

4. Writing Ansible Tests with Ansible Verifier

In this section, we would practice what in my opinion should be the workflow for testing Ansible roles using the Ansible verifier in Molecule.

Running

$ molecule test
runs the entire test_sequence but always destroys the created instance(s) at the end and this can consume a lot of time considering we have to recreate the instances everytime we make changes to our actual or test roles.
Therefore, the workflow to follow which suits the Given-When-Then approach of BDD is:
# given phase
$ molecule create

# when phase
$ molecule converge

# then phase
$ molecule verify

In the above snippet, the given phase doesn’t change often, so we just create the instance(s) once. After that, we iterate between when and then phases until our tests are all verified and error free.

In this tutorial, our goal is to implement TDD while testing our infrastructure. We would be writing unit tests. So say we wanted to implement a role called  alpha-services, that accomplished the following tasks:

  • Task 1: Installs Java-1.8 on the host machine
  • Task 2: Creates a dir at path /var/log/tomcat belonging to owner ‘tomcat’, group ‘tomcat’ and of mode ‘0755’
  • Task 3: Installs, starts and enables httpd
  • Task 4: Copy a template file from template/tomcat/context.xml to /etc/tomcat/context.xml

First, we create the role using the molecule init command:

$ molecule init role alpha-services

This creates a similar folder layout as shown earlier. Next, we create

alpha-services/molecule/default/roles/test_alpha-services
  path:
$ cd alpha-services
$ mkdir -p molecule/default/roles/test_alpha-services

This is where our test roles would be contained. Inside test_alpha-services directory, we
create our test roles using the standard Ansible role layout (we create only the folders that we require for testing, in this case, defaults, tasks and vars). Each created folder should have its main.yml file. For the individual task, we would create separate yml files to differentiate them for each other, prefixing

test_
to the task name. For example, the task to install java would be called

test_java.yml
 .
$ cd molecule/default/roles/test_alpha-services
$ mkdir defaults && touch defaults/main.yml
$ mkdir tasks && touch tasks/main.yml tasks/test_java.yml tasks/test_tomcat.yml tasks/test_httpd.yml tasks/test_aws.yml
$ mkdir vars && touch vars/main.yml

We would then be left with the following folder layout:

alpha-services/
        ├── README.md
        ├── files/                                                
        ├── handlers/                                              
        ├── meta/                                                            
        ├── tasks/                                                 
        ├── templates/                                              
        ├── tests/                                               
        ├── vars/                                              
        └── molecule/
                └── default                        
                        ├── molecule.yml                        
                        ├── converge.yml                        
                        ├── verify.yml                                               
                        ├── INSTALL.rst                       
                        └── roles/    
                              └── test_alpha-services/                        
                                        ├── defaults/                        
                                              └── main.yml                                               
                                        ├── tasks/                        
                                              ├── main.yml 
                                              ├── test_java.yml                        
                                              ├── test_tomcat.yml                        
                                              ├── test_httpd.yml                                               
                                              └── test_aws.yml      
                                        └── vars/                        
                                              └── main.yml

We configure the molecule.yml file:

---
dependency:
  name: galaxy
  enabled: false
driver:
  name: docker
platforms:
  - name: instance
    image: docker.io/pycontribs/centos:7
    pre_build_image: true
provisioner:
  name: ansible
verifier:
  name: ansible

We leave the converge.yml file as is:

---
- name: Converge
  hosts: all
  tasks:
    - name: "Include alpha-services"
      include_role:
        name: "alpha-services"

We edit the verify.yml file to include our

test_provisioner
 role:
---
# This is an example playbook to execute Ansible tests.
- name: Verify
  hosts: all
  tasks:
    - name: "Include test_alpha-services"
      include_role:
        name: "test_alpha-services"

GIVEN PHASE: We run

$ molecule create
  to create the instances.
$ molecule create

--> Test matrix
    
└── default
    ├── dependency
    ├── create
    └── prepare
    
--> Scenario: 'default'
--> Action: 'dependency'
Skipping, dependency is disabled.
--> Scenario: 'default'
--> Action: 'create'
--> Sanity checks: 'docker'
    
    PLAY [Create] ******************************************************************
    
    TASK [Log into a Docker registry] **********************************************
    skipping: [localhost] => (item=None) 
    
    TASK [Check presence of custom Dockerfiles] ************************************
    ok: [localhost] => (item=None)
    ok: [localhost]
    
    TASK [Create Dockerfiles from image names] *************************************
    skipping: [localhost] => (item=None) 
    
    TASK [Discover local Docker images] ********************************************
    ok: [localhost] => (item=None)
    ok: [localhost]
    
    TASK [Build an Ansible compatible image (new)] *********************************
    skipping: [localhost] => (item=molecule_local/docker.io/pycontribs/centos:7) 
    
    TASK [Create docker network(s)] ************************************************
    
    TASK [Determine the CMD directives] ********************************************
    ok: [localhost] => (item=None)
    ok: [localhost]
    
    TASK [Create molecule instance(s)] *********************************************
    changed: [localhost] => (item=instance)
    
    TASK [Wait for instance(s) creation to complete] *******************************
    FAILED - RETRYING: Wait for instance(s) creation to complete (300 retries left).
    changed: [localhost] => (item=None)
    changed: [localhost]
    
    PLAY RECAP *********************************************************************
    localhost                  : ok=5    changed=2    unreachable=0    failed=0    skipped=4    rescued=0    ignored=0
    
--> Scenario: 'default'
--> Action: 'prepare'
Skipping, prepare playbook not configured.

WHEN PHASE: We run

$ molecule converge
to run the actual roles which are yet to be implemented. This doesn’t effect any change on the created instance.
$ molecule converge

--> Test matrix
    
└── default
    ├── dependency
    ├── create
    ├── prepare
    └── converge
    
--> Scenario: 'default'
--> Action: 'dependency'
Skipping, dependency is disabled.
--> Scenario: 'default'
--> Action: 'create'
Skipping, instances already created.
--> Scenario: 'default'
--> Action: 'prepare'
Skipping, prepare playbook not configured.
--> Scenario: 'default'
--> Action: 'converge'
--> Sanity checks: 'docker'
    
    PLAY [Converge] ****************************************************************
    
    TASK [Gathering Facts] *********************************************************
    ok: [instance]
    
    TASK [Include alpha-services] **************************************************
    
    TASK [alpha-services : include java installation tasks] ************************
    included: /Users/chukwudiuzoma/Documents/DevOps/ANSIBLE/MyTutorials/AnsibleTestingWithMolecule/alpha-services/tasks/java.yml for instance
    
    TASK [alpha-services : Install java] *******************************************
    changed: [instance]
    
    PLAY RECAP *********************************************************************
    instance                   : ok=3    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Now we go ahead to develop the roles.
Following TDD approach, we first create the tests and check that they fail before implementing the roles that they are testing.

TASK 1: Install Java-1.8.0 on the host machine

---
- name: "java - check Java package status"
  package:
    name: "java-1.8.0"
    state: "installed"
  check_mode: yes
  register: pkg_status

- name: "java - test java package is installed"
  assert:
    that:
      - not pkg_status.changed

Check Java package status task tries to install

java-1.8.0
in check mode and registers the result of that operation in
pkg_status
. In actual sense, if
java-1.8.0
  is already installed, the assertion
not pkg_status.changed
  would return
true
  because the state would not have changed. Thanks to Juan Antonio for this tip.

We include the

test_java.yml
tasks in
alpha-services/molecule/default/roles/test_alpha-services/tasks/main.yml
file like so:
---
- name: "include tasks for testing Java"
  include_tasks: "test_java.yml"

THEN PHASE: We run

$ molecule verify
. As expected, it should fail with the following error:
$ molecule verify

--> Test matrix
    
└── default
    └── verify
    
--> Scenario: 'default'
--> Action: 'verify'
--> Running Ansible Verifier
--> Sanity checks: 'docker'
    
    PLAY [Verify] ******************************************************************
    
    TASK [Gathering Facts] *********************************************************
    ok: [instance]
    
    TASK [Include test_alpha-services] *********************************************
    
    TASK [test_alpha-services : include tasks for testing Java] ********************
    included: /Users/chukwudiuzoma/Documents/DevOps/ANSIBLE/MyTutorials/AnsibleTestingWithMolecule/alpha-services/molecule/default/roles/test_alpha-services/tasks/test_java.yml for instance
    
    TASK [test_alpha-services : Check Java package status] *************************
    changed: [instance]
    
    TASK [test_alpha-services : Test java package is installed] ********************
fatal: [instance]: FAILED! => {
    "assertion": "not pkg_status.changed",
    "changed": false,
    "evaluated_to": false,
    "msg": "Assertion failed"
}
    
    PLAY RECAP *********************************************************************
    instance                   : ok=3    changed=1    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0
    
ERROR:

Now we implement Task 1. We first create an

alpha-services/tasks/java.yml
file and populate it with the following:
---
- name: "Install '{{ java_required_software }}'"
  package:
    name: "{{ java_required_software }}"
    lock_timeout: 60
    state: "present"

We then include the

java.yml
tasks in
alpha-services/tasks/main.yml
file like so:
---
- name: "Include java installation tasks"
  include_tasks: "java.yml"

WHEN PHASE: Now we run

$ molecule converge
to effect changes on the instance.

THEN PHASE: Here we run

$ molecule verify
which should pass the test if the converge phase was successful.
$ molecule verify

--> Test matrix
    
└── default
    └── verify
    
--> Scenario: 'default'
--> Action: 'verify'
--> Running Ansible Verifier
--> Sanity checks: 'docker'
    
    PLAY [Verify] ******************************************************************
    
    TASK [Gathering Facts] *********************************************************
    ok: [instance]
    
    TASK [Include test_alpha-services] *********************************************
    
    TASK [test_alpha-services : include tasks for testing Java] ********************
    included: /Users/chukwudiuzoma/Documents/DevOps/ANSIBLE/MyTutorials/AnsibleTestingWithMolecule/alpha-services/molecule/default/roles/test_alpha-services/tasks/test_java.yml for instance
    
    TASK [test_alpha-services : Check Java package status] *************************
    ok: [instance]
    
    TASK [test_alpha-services : Test java package is installed] ********************
    ok: [instance] => {
        "changed": false,
        "msg": "All assertions passed"
    }
    
    PLAY RECAP *********************************************************************
    instance                   : ok=4    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
    
Verifier completed successfully.

TASK 2: Create a dir at path /var/log/tomcat belonging to owner ‘tomcat’, group ‘tomcat’ and of mode ‘0755’

---
- name: "tomcat - '{{ test_tomcat_home_dir }}' - retrieve information from path"
  stat:
    path: "{{ test_tomcat_home_dir }}"
  register: directory

- name: "tomcat - assert that directory '{{ test_tomcat_home_dir }}' is created correctly"
  assert:
    that:
      - "directory.stat.exists"
      - "directory.stat.isdir"
      - "directory.stat.mode == {{ test_tomcat_mode }}"
      - "directory.stat.pw_name == {{ test_tomcat_user }}"
      - "directory.stat.gr_name == {{ test_tomcat_group}}"

We define the variables in Molecule’s test defaults yml file:

---
#TOMCAT
test_tomcat_mode: "0755"
test_tomcat_user: "tomcat"
test_tomcat_group: "tomcat"
test_tomcat_home_dir: "/var/log/tomcat"

The first task uses Ansible’s

stat
module to get file system status while the second one checks that the statuses are match.

Next, we include the

test_java.yml
task in
alpha-services/molecule/default/roles/test_alpha-services/tasks/main.yml
file like so:
---
- name: "include tasks for testing Tomcat"
  include_tasks: "test_tomcat.yml"

After this, we go ahead to run

$ molecule verify
which rightfully fails just like in the previous test. We therefore implement the actual task:
---
- name: "tomcat - create required tomcat logging directory"
  file:
    path: "{{ tomcat_home_dir }}"
    state: "directory"
    mode: "0755"
    owner: "{{ tomcat_user }}"
    group: "{{ tomcat_group }}"
    recurse: yes

We define the variables in the actual role’s defaults yml file:

---
#TOMCAT
tomcat_mode: "0755"
tomcat_user: "tomcat"
tomcat_group: "tomcat"
tomcat_home_dir: "/var/log/tomcat"

We include the tomcat.yml tasks in

alpha-services/tasks/main.yml
file like so:
---
- name: "Include java installation tasks"
  include_tasks: "java.yml"

WHEN PHASE: Now we run

$ molecule converge
to effect changes on the instance.
THEN PHASE: We then run
$ molecule verify
which should pass the test if the converge phase was successful.

TASK 3: Install, start and enable httpd

We will not test this task because Ansible does that for us. As stated in the Ansible documentation, «Ansible resources are models of desired-state. As such, it should not be necessary to test that services are started, packages are installed, or other such things. Ansible is the system that will ensure these things are declaratively true».
As such, if the service doesn’t exit and we try to start it, the task will fail with the error shown below:

TASK [alpha-services : httpd - start and enable httpd service] *****************
fatal: [instance]: FAILED! => {"changed": false, "msg": "Could not find the requested service httpd: host"}

Therefore we will only implement the task:

---
- name: "Httpd - install httpd service"
  package:
    name: "httpd"
    state: "latest"

- name: "Httpd - start and enable httpd service"
  service:
    name: "httpd"
    state: "started"
    enabled: "yes"

It is worth noting that running

httpd
on a linux systems requires
systemd
which is not present by default in docker containers. To be able to start a service on the docker container, we add the following edited
platforms
key in the molecule.yml file:
---
platforms:
  - name: instance
    image: docker.io/pycontribs/centos:7
    pre_build_image: false # we don't need ansible installed on the instance
    command: /sbin/init
    tmpfs:
      - /run
      - /tmp
    volumes:
      - /sys/fs/cgroup:/sys/fs/cgroup:ro
    privileged: true

For more information on running systemd, see link.
We now run

$ molecule create
and
$ molecule converge
. If they both run successfully then the httpd service is up and running. To manually check for the httpd service, we run:
$ molecule login         # this logs you into the docker container shell

$ systemctl | grep httpd
httpd.service loaded active running The Apache HTTP Server

$ exit                  # this logs you out of the docker container to your local terminal

TASK 4: Copy a template file from template/tomcat/context.xml to /etc/tomcat/context.xml

Just like before, what we will be testing here is the existence of the exact file that we want to copy from the controller to the host system. We want to test that the file name is what we expect and probably some specific contents of the file are present as expected. We could have made an error while naming the file or creating the file contents and these are what we need to check. By default, if the file is not copied, Ansible would let us know.

We add the following to their respective files:

- name: "tomcat - test tomcat file"
  block:
    - name: "tomcat - retrieve information from path '{{ test_tomcat_context_xml_file }}'"
      stat:
        path: "{{ test_tomcat_context_xml_file }}"
      register: remote_file
    - name: "tomcat - assert that '{{ test_tomcat_context_xml_file }}' file is created correctly"
      assert:
        that:
          - "remote_file.stat.exists"
          - "remote_file.stat.isreg" # is a regular file
          - "remote_file.stat.path == '{{ test_tomcat_context_xml_file }}'"
          - "remote_file.stat.mode == '0755'"

test_tomcat_conf_dir: "/etc/tomcat"
test_tomcat_context_xml_file: "{{ test_tomcat_conf_dir }}/context.xml"

After this, we run

$ molecule verify
to see that it fails. After that, we implement the actual tasks:
- name: "tomcat - copy dynamic tomcat server config files"
  template:
    src: "{{ tomcat_context_xml_file }}"
    dest: "{{ tomcat_conf_dir }}"

tomcat_conf_dir: "/etc/tomcat"
tomcat_context_xml_file: "tomcat/context.xml"

We then run

$ molecule converge
and
$ molecule verify
subsequently. The tests should pass if everything was done right.

Finally, just to be sure, we run the

$ molecule test
to execute the entire Molecule test_sequence. Everything should run smoothly without any errors.

5. Conclusions

In conclusion, in my opinion, this is the right approach to developing Molecule tests for ansible roles. Infrastructure code should be tested before being deployed in production to avoid unpleasant surprises. This tutorial has been a simple demonstration of how Ansible testing can be done with Molecule using Ansible verifier. This way there is no need to learn another programming language such as Python, Ruby or Go.

La entrada Ansible Testing Using Molecule with Ansible as Verifier se publicó primero en Adictos al trabajo.

Desplegar aplicaciones en Kubernetes con Helm

$
0
0

Índice de contenidos

​1.​ Introducción

En este tutorial vamos a ver cómo utilizar Helm para desplegar aplicaciones en Kubernetes partiendo de cualquier rango de conocimiento de Kubernetes, ya seamos principiantes o maestros de Kubernetes. En este tutorial mencionaré algunos conceptos avanzados de Kubernetes, si no conoces esos conceptos no pasa nada, no es necesario para entender el tutorial.

Hacer un despliegue en Kubernetes de una aplicación, aunque la conozcamos, es laborioso al tener que partir de cero y definir en distintos ficheros todos los componentes de esa aplicación de cara a utilizarlo. Esta tarea es además muy compleja si no se tiene ni idea de cómo funciona internamente Kubernetes y qué clase de recursos hay que definir para conseguir el funcionamiento esperado de la aplicación.

Si tenemos conocimientos básicos de Kubernetes nos podemos hacer muchas preguntas sobre cómo abordar la tarea. 

¿Es mejor desplegar los pods directamente, o mejor hacer un replicaSet que se encargue de ellos? 

¿Es mejor hacer un deployment de Kubernetes que defina el replicaSet que se encargue de desplegar y mantener constante el número de pods deseados? 

¿Y si en vez de un deployment debo de hacer un daemonSet y que haya un pod único en cada nodo?

¿A lo mejor es más útil un Stateful Set para facilitar la persistencia de la información de los pods?

Lo mas seguro es que se acabe buscando en un tutorial para desplegar esa aplicación en Kubernetes como este https://www.adictosaltrabajo.com/2018/11/21/configurar-nginx-ingress-en-k8s/, que casualmente usa Helm para el despliegue. 

Si el tutorial usa kubectl, habrá que utilizar los ficheros que otra persona ya haya hecho previamente, teniendo que ejecutar cada fichero con la definición del recurso de Kubernetes uno a uno con kubectl apply y esperando que las variables definidas en el fichero consigan desplegar correctamente la aplicación. 

Cuantas más aplicaciones están desplegadas en Kubernetes más complicado es mantenerlas y gestionarlas si hemos tenido que desplegar los ficheros de Kubernetes uno a uno con kubectl, ya que para hacer cambios habría que volver a desplegar todos los ficheros afectados y para eliminar el despliegue hay que ejecutar otra vez múltiples comandos de kubectl. Helm simplifica la tarea haciendo más rápidos y mantenibles los despliegues.

​2.​ ¿Que es Helm?

Helm es el gestor de paquetes (package manager) de Kubernetes. Ayuda a manejar el despliegue de aplicaciones en Kubernetes. Helm es un proyecto de la CNCF y está mantenido por la comunidad de helm. 

Helm ofrece un marco de referencia de un despliegue de una aplicación en Kubernetes, evitando que tengamos que pensar desde 0 cómo montar el despliegue. Se apoya en el trabajo de la comunidad que se ha tenido que enfrentar a esas preguntas y combina las soluciones y procedimientos que conjuntamente hayan logrado. Evoluciona gracias a la compartición de conocimiento y por ese mismo motivo está abierto a cualquier sugerencia de mejora, ya sea en forma de issue que detectemos o en forma de pull request para añadir funcionalidad a la ya existente.

​2.1.​ Ventajas de Helm

Con solo ejecutar un comando de Helm podemos hacer las siguientes operaciones sobre un chart: instalar, actualizar, desinstalar, hacer rollback a otra versión, listar todos los charts desplegados. Helm se encarga de contactar con la API de Kubernetes y hacer lo necesario, lo cual nos abstrae de entrar en detalles técnicos de cada chart. 

Para desplegar aplicaciones en Kubernetes en Helm existe el concepto de chart. Todos los charts oficiales de Helm garantizan que su despliegue en Kubernetes funciona sin tener que configurar ningún valor. Esto no quiere decir que podemos desplegar un chart de Helm tal cual y ya está todo el trabajo hecho. En la mayoría de casos tendremos que leer la documentación del chart y hacer algunos cambios para que el despliegue de la aplicación sea el deseado para el uso que vayamos a darle.

​2.2.​ Versiones de Helm

Actualmente la última mayor versión de Helm es Helm 3. Esta versión tiene diferencias sustanciales con Helm 2 y no son totalmente compatibles, por ello los clusters de Kubernetes que usen Helm 2 van a tener que acabar emigrando a Helm 3 puesto que en noviembre de 2020 ya no se dará soporte a Helm 2.

En otro tutorial abordaré las principales diferencias entre Helm 2 y 3 y cómo hacer la migración entre las versiones. 

​3.​ ¿Qué son los charts de Helm y qué los componen?

Helm usa un formato de empaquetado llamado charts. Un chart es una colección de archivos que describen un conjunto relacionado de recursos de Kubernetes. Los charts de Helm ayudan a definir un marco común de uso básico de la aplicación y todas las configuraciones posibles. Se puede usar un solo chart para implementar algo simple, como un pod memcached, o algo complejo, como una pila completa de aplicaciones web con servidores HTTP, bases de datos, cachés, etc.

​3.1.​ Estructura de los charts

Los charts se agrupan como un directorio con los archivos en forma de árbol. Este directorio se empaquetará en un archivo versionado listo para ser desplegado. El nombre del directorio es el nombre del chart (sin información de versión). Por lo tanto, una chart que describe Nginx-ingress se almacenaría en el directorio nginx-ingress/.

Dentro de este directorio, Helm esperará una estructura que coincida con esto:

nginx-ingress/
  Chart.yaml          # Fichero YAML que contiene información sobre el chart
  LICENSE             # OPCIONAL: Fichero plano que contiene la licencia del chart
  README.md           # OPCIONAL: Fichero README del chart
  values.yaml         # Valores de configuración por defecto del chart
  values.schema.json  # OPCIONAL: Esquema JSON para forzar la estructura del fichero values.yaml
  charts/             # OPCIONAL:Directorio que contiene los charts de los que depende este chart.
  crds/               # OPCIONAL: Custom Resource Definitions (concepto de Kubernetes, más info)
  templates/          # Directorio de plantillas que combinado con el fichero values.yaml generan ficheros válidos de manifiesto de Kubernetes                    
  templates/NOTES.txt # OPCIONAL: Fichero plano de texto que contiene notas sobre el chart

Helm reserva el uso de los directorios charts/ , crds/ y templates/ además de los ficheros arriba nombrados. El resto de ficheros o directorios que pueda haber en el chart no serán utilizados por Helm. 

Para simplificar la tarea de ver cómo funciona Helm sin abrumar con todas las opciones configurables que existen, solo vamos a profundizar en los ficheros y el directorio obligatorios que cada chart tiene que tener.

​3.1.1.​ Fichero Chart.yaml

Este fichero contiene muchos valores opcionales como las dependencias de este chart con otros charts o las personas que se encargan de mantener el chart (útil para los charts en repositorios públicos mantenidos por la comunidad), etc. Vamos a obviar esos valores y centrarnos en los que son obligatorios que siempre vamos a ver en el fichero. Estos son:

apiVersion: Versión de la API del chart (obligatorio).
name: Nombre del chart (obligatorio)
version: Versión del chart. Sigue las reglas del versionado semántico 2.0.0 (obligatorio)
description: Descripción de una línea del proyecto (opcional)
appVersion: La versión de la app que contiene este chart. (opcional). En cualquier formato.

El campo apiVersion tendrá el valor v2 si el chart requiere Helm 3 y v1 si el chart se puede usar con versiones anteriores de Helm, incluido Helm 3. 

El campo appVersion no tiene nada que ver con el campo version. Por ejemplo, si el chart contiene elasticsearch, se puede especificar que usa la versión 7.6.1 de elasticsearch aunque la versión del chart sea la 1.2.3. 

​3.1.2.​ Directorio Templates

Las plantillas de los charts de Helm están escritas en el lenguaje de plantillas de Go, con la suma de 50 funciones de plantilla adicionales de la biblioteca Sprig y algunas otras funciones especiales.

Todos los archivos de plantilla se almacenan en este directorio. Cuando Helm procesa los charts, pasará todos los archivos de ese directorio a través del motor de plantillas.

Los valores para las plantillas se proporcionan de dos maneras:

  • Los desarrolladores de charts pueden proporcionar un archivo llamado values.yaml dentro del chart. Este archivo contiene los valores predeterminados.
  • Los usuarios de charts pueden proporcionar uno o varios archivos YAML que contenga valores. Estos ficheros se pueden proporcionar con un flag en la línea de comandos de Helm al instalar o actualizar un chart.

Cuando un usuario proporciona valores personalizados, estos valores anularán los valores por defecto en el archivo values.yaml del chart.

La cantidad de ficheros de la carpeta templates es variable, depende del objetivo del chart y lo que se necesite. Como ejemplos de lo que puede haber dentro de la carpeta templates y de los muchos o pocos ficheros que la pueden componer, están estos 2 charts oficiales, publicados en el repositorio público de charts de GitHub. Nginx-ingress contiene más de 30 ficheros: https://github.com/helm/charts/tree/master/stable/nginx-ingress/templates. En cambio Cerebro solo contiene 6 ficheros: https://github.com/helm/charts/tree/master/stable/cerebro/templates

​3.1.3.​ Fichero Values.yaml

Los valores de este fichero dependen exclusivamente de las variables de Helm que hayamos utilizado en los ficheros dentro del directorio Templates. Values.yaml tiene que contener todos los valores necesarios para esas variables de tal forma que se pueda usar el chart sin tener que modificar ninguna variable. 

​3.2.​ Versionado de los charts

Cada chart debe tener un número de versión. Todas las versiones deben seguir las reglas del estándar de versionado semántico 2.0.0 (SemVer 2). Helm usa los números de versión como indicador de los despliegues. Los paquetes en repositorios se identifican por nombre más versión.

Por ejemplo, un chart de nginx-ingress cuya versión sea la 1.2.3, una vez empaquetado tendrá el siguiente nombre: nginx-ingress-1.2.3.tgz

​4.​ Configurando Helm

Para usar Helm necesitamos tener un cluster de Kubernetes, kubectl instalado y configurado y descargar Helm. La versión que tenga el cluster de Kubernetes o de kubectl no es lo importante puesto que Helm funciona sin problemas con las versiones de Kubernetes superiores a la 1.6. A fecha del tutorial Kubernetes está en la versión 1.18, con lo que si se da el caso de que tienes el cluster en una versión inferior a la 1.6 (12 versiones por debajo de la última), habría que actualizarlo a una versión más reciente.

Asumo que ya tenemos el cluster de Kubernetes y kubectl listo para usar Helm. 

Importante: Helm siempre apuntará al cluster de Kubernetes al que apunte kubectl. En cualquier momento en el que se cambie el cluster al que apunta kubectl, Helm cambiará y apuntará a ese cluster.

Para saber qué cluster de Kubernetes es al que apunta Helm ejecuta

kubectl config current-context

​4.1.​ Instalando Helm

Así que vamos a instalar Helm. Las releases de Helm se pueden encontrar aquí: https://github.com/helm/helm/releases

Si contáis con un sistema gestor de paquetes para vuestro sistema operativo, instaladlo con él, si no podéis acceder al link y seguir los pasos para instalarlo.

En mac uso homebrew, con él instalar Helm es tan sencillo como escribir en la consola

brew install helm

Con este comando tal cual instalaremos la última versión de Helm, que es la 3.x.x. Si estás trabajando en un proyecto que maneja un cluster de Kubernetes con Helm 2, tendrás que instalar la versión 2 de Helm puesto que no son retrocompatibles. 

Para poder usar Helm 2 después de instalarlo, tendrás que ejecutar el comando 

helm init --upgrade

para que cree todas las carpetas en el ordenador necesarias para usar Helm. Esta parte la hacen en Helm automáticamente sin tener que ejecutar nada.

Recomiendo que si es tu primera vez, uses Helm 3 y te olvides de Helm 2.

Para comprobar que Helm se ha instalado correctamente solo hay que escribir

helm version

​4.2.​ Añadiendo repositorios a Helm

Ya instalado, necesitamos añadir a Helm los repositorios donde están los charts de Helm que vamos a usar.

Esto se hace fácilmente con

helm repo add "nombreRepositorio" "urlRepositorio"

Por ejemplo, si queremos añadir el repositorio stable (contiene la mayoría de charts oficiales), alojado en esta url https://Kubernetes-charts.storage.googleapis.com/, el comando quedaría

helm repo add stable https://Kubernetes-charts.storage.googleapis.com/ helm repo update

Siempre después de añadir un repositorio a Helm hay que ejecutar el comando repo update para que Helm recargue la configuración de los repositorios. Hasta que no se ejecute ese comando, Helm no tendrá en cuenta los repositorios añadidos. 

Ayuda: Siempre que Helm muestre un problema con los repositorios, ejecutad helm repo update para que se vuelva a cargar la configuración y se solucione.

Nota Importante: el 13 de Noviembre de 2020, coincidiendo con la muerte de Helm 2, marcaran los repositorios stable e incubator como obsoletos, esto quiere decir que todos los charts que ahora están alojados en stable e incubator se moverán a otros repositorios que habrá que añadirlos con el comando de arriba. Helm ya recomienda buscar los charts en Helm Hub. Dentro de Helm Hub están todos los charts con soporte y las instrucciones para añadir los nuevos repositorios e instalar cada chart.

Para asegurarnos que se ha añadido bien el repositorio y ver los distintos repositorios añadidos a Helm podemos ejecutar

helm repo list

5. Desplegar una aplicación en Kubernetes con Helm

Ya tenemos Helm listo. Vamos a desplegar una aplicación en Kubernetes. Si no sabemos el repositorio en el que está el chart de la aplicación que queremos desplegar, podemos pedir a Helm que nos indique en qué repositorios hay un chart de esa aplicación haciendo una búsqueda en Helm Hub (agregador de todos los repositorios de charts de Helm) por el nombre:

helm search hub "nombre o parte del nombre del chart"

Si en vez de buscar por el hub, queremos buscar por un repositorio que ya hayamos añadido a Helm, podemos modificar el comando para buscar en ese repo:

helm search repo “nombre del repo” "nombre o parte del nombre del chart"

5.1.​ Instalar aplicaciones con Helm

Si sabemos el nombre del chart y el repositorio donde está, solo tenemos que añadirlo a Helm y ejecutar el siguiente comando para instalarlo sin configurar nada. El namespace por defecto en el que Helm instalará el chart será “default”.​

helm install “nombre en Kubernetes” “repositorio/nombre chart”

Como ejemplo voy a usar nginx-ingress

helm install nginx-ingress stable/nginx-ingress

Hemos visto la forma básica y rápida de instalar, ahora vamos a ver las opciones que más frecuentemente vamos a tener que añadir al comando de Helm para personalizar la instalación y adaptarla a nuestras necesidades. Helm nos ofrece bastantes flags para usar en sus comandos de instalación. 

Los más útiles en mi opinión son -n y -f. Con -n podemos decirle a Helm el namespace de Kubernetes en el que se va a desplegar todos los componentes del chart. Un namespace es una división lógica de Kubernetes, no es obligatorio usar varios, sí recomendable, más info.

El namespace a usar en Helm tiene que haber sido creado previamente en Kubernetes. Para crear un namespace en Kubernetes con ejecutar el siguiente comando podemos hacerlo. 

kubectl create namespace "nombre namespace"

Con -f podemos especificarle ficheros de valores, en formato yaml, que sobreescribiran los valores por defecto del fichero values.yaml del que antes he hablado. Podemos usar el flag -f tantas veces queramos en el mismo comando. En el caso de que un valor aparezca varias veces, Helm prioriza los valores del último flag -f que aparezca sobre el resto de valores, sobreescribiéndolos.

Ahora vamos a ver un ejemplo de cómo usar estos flags con el comando install de antes:

helm install nginx-ingress -n ingress -f common.yaml -f environment.yaml stable/nginx-ingress

Aquí le estamos indicando a Helm que nos instale nginx-ingress en el namespace ingress, para tener este despliegue separado de otras aplicaciones que tengamos en otro namespace. Además estamos sobreescribiendo los valores por defecto primero con el contenido del fichero common.yaml y luego con el contenido del fichero environment.yaml.

Esta separación de ficheros es útil cuando tienes múltiples entornos donde desplegar la aplicación y quieres tener unos valores generales para todos los entornos, que irá en el fichero common.yaml y luego tener un fichero distinto por entorno, este caso llamado environment.yaml en el que cambiar el nombre de certificados o cualquier valor específico de un entorno. Al tener varios ficheros reutilizamos todo lo posible lo genérico y hacemos más fácil de ver las diferencias.

​5.2.​ Actualizar aplicaciones con Helm

Ahora que hemos visto el comando install para desplegar aplicaciones en Kubernetes por primera vez, tengo una noticia que daros: este comando ya no lo uso. Hasta ahora no había comentado que el comando install sólo funciona si no hay una aplicación desplegada con ese mismo nombre. Si ya hay una, el comando dará error.  

Hay otro comando mejor que hace las funciones de este comando de install y a la vez actualiza el chart si se han introducido cambios nuevos, es el comando update.

helm update -i -n ingress -f common.yaml -f environment.yaml nginx-ingress stable/nginx-ingress

Este comando añade el flag -i, que indica a Helm que si no encuentra el chart para actualizarlo, lo que queremos es que Helm lo instale. Si solo queremos la actualización del comando, quitad el flag y Helm dará error si el chart no está desplegado.

Lo más fácil es dejar siempre el flag -i y utilizar el comando update para ambos usos.

Otros flags que pueden ser útiles y que se pueden utilizar tanto con install como con update son: 

  • –wait: Obliga a Helm a retrasar la notificación de éxito del despliegue hasta que Kubernetes le responda que los recursos necesarios han sido desplegados correctamente. Si algún recurso de Kubernetes no está listo, se espera hasta que esté listo o hasta que se alcance el timeout, que por defecto es de 5 minutos. En ese caso fallaría el comando ejecutado. Si no ponemos el flag, en cuanto Helm haya transmitido a Kubernetes correctamente la información para hacer el despliegue, Helm nos notificara que el despliegue es correcto, sin tener que ser realmente cierto y teniendo que ir a Kubernetes a comprobarlo.
  • –timeout: Permite modificar el timeout usado en el comando –wait. Solo tiene sentido usarlo junto a –wait, si wait no esta no se tendrá en cuenta el valor de este comando. Por defecto es de 5 minutos. El formato es número seguido de la unidad. Ejemplo 5m30s.
  • –set: Este comando permite configurar los valores de una variable del fichero values.yaml sin tener que usar un fichero aparte y el flag -f. Para hacer más fácil la gestión del cambio y saber los valores configurados en cada momento recomiendo usar el fichero y no usar este flag. Ejemplo de uso –set image.tag=1.0.0.

​5.3.​ Desinstalar aplicaciones con Helm

Además de instalar con Helm 3, desinstalar un chart es muy sencillo. Con solo poner el nombre del despliegue y el namespace en el que está desplegado Helm, se encarga de borrar lo necesario (ciertos config maps, secretos, pvc y pv asociados al chart no se borrarán) para no dejar rastro de la aplicación.

helm uninstall nginx-ingress -n ingress

Si estáis usado Helm 2 (habría que cambiar a Helm 3) el comando análogo sería:

helm delete nginx-ingress --purge

Con estos comandos tenéis la funcionalidad básica de Helm para desplegar aplicaciones en Kubernetes. 

​6.​ Conclusión

Como hemos visto, Helm nos facilita la vida al trabajar con Kubernetes y nos da la flexibilidad de poder configurar cada despliegue a nuestro antojo. También es una herramienta genial para poder compartir al mundo implementaciones propias de despliegues y permitir que evolucionen hasta se conviertan en estándar. Desplegar aplicaciones en Kubernetes nunca fue tan fácil.

Una vez lo hayáis probado estoy seguro que nunca más desplegareis una aplicación con kubectl sin antes buscar si hay un chart de Helm de esa aplicación. 

La entrada Desplegar aplicaciones en Kubernetes con Helm se publicó primero en Adictos al trabajo.

Comentando el libro ‘Conceptos ágiles aplicados a distintas áreas de una empresa’, de Roberto Canales

$
0
0

Roberto Canales ha lanzado su cuarto libro: ‘Conceptos ágiles aplicados a distintas áreas de una empresa’. Alguien podría pensar que es otro libro más, que te resume las prácticas ágiles más utilizadas. Es cierto que lo hace, pero va mucho más allá. Utiliza un enfoque que no siempre es fácil de encontrar: exponiendo la base teórica paulatinamente según nos va contando sus diferentes experiencias de transformación empresarial y, sobre todo, invitando a que el lector use el pensamiento crítico

Según vamos avanzando en el libro, iremos descubriendo (o redescubriendo) los distintos principios, valores y prácticas de las metodologías ágiles. Pasaremos por Scrum, Kanban, XP… e iremos viendo cómo aplicarlas en diferentes contextos, siempre desde una visión pragmática (una característica muy presente en Roberto). Las metodologías ágiles están a nuestro servicio y no al revés.

Los 12 principios ágiles

Recomiendo que tengas libreta y bolígrafo a mano, pues según avances en el libro te surgirán muchas ideas que aplicar en tu empresa, tu equipo o tus próximos clientes. Lo que más me ha gustado es que en todo momento nos invita a tener ese espíritu experimental, de probar cosas y quedarnos con las que nos funcionen. 

Acabando el libro, plantea un buen ejercicio en el que une todas las piezas en el capítulo de «la isla». Además de divertido, es realmente instructivo. Habrá cosas con las que no estés de acuerdo, pero que te invitarán a la reflexión afrontando esta lectura con la mente abierta. Y eso es bueno, muy bueno. Además, el autor está disponible para debatir contigo cualquier tema por lo que no dudes en contactarle.

Si quieres tener ‘Conceptos ágiles aplicados a distintas áreas de una empresa’ sólo tienes que descargarlo gratuitamente en nuestra web.

Be agile, my friend!

La entrada Comentando el libro ‘Conceptos ágiles aplicados a distintas áreas de una empresa’, de Roberto Canales se publicó primero en Adictos al trabajo.


Primeros pasos con Vapor

$
0
0

1. Introducción

Vapor es una librería de código abierto para escribir nuestras aplicaciones en lado servidor, actualmente en su versión 3.1.13 y con la versión 4 en fase final de Beta.

Su potencia reside en que puedes crear tanto una API Restful como aplicaciones en tiempo real usando WebSocket.

De cara al despliegue de tus aplicaciones en Vapor, dispones de servicios compatibles con esta tecnología:

  • Microsoft Azure
  • Google clouds
  • AWS
  • IBM Cloud
  • Heroku

Algunas puntualizaciones interesantes:

  • Interfaz asíncrona vía SwiftNIO.
  • Autenticación mediante JWT y oAuth2.0.
  • Soporte Redis y cache en memoria.
  • Soporte para BBDD de tipo: MySQL, MariaDB, MongoDB, PostgreSQL y SQLite, todo vía Fluent(ORM).
  • Soporte para contenedores Docker.
  • Buildpacks de Heroku.
  • Soporte para HTTP_2 (Vapor 4)

 

2. Entorno

El tutorial está escrito usando el siguiente entorno:

  • Hardware: Portátil MacBook Pro 15′ (2,3 Ghz Intel Core i9 de 8 núcleos, 32GB DDR4).
  • Sistema Operativo: Mac OS Catalina 10.15.4
  • Entorno de desarrollo: XCode 11.4.1

 

3. Instalación

Para instalar Vapor vamos a usar HomeBrew. Si no disponemos de este gestor de paquetes, abrimos el Terminal y escribimos:

mkdir homebrew && curl -L https://github.com/Homebrew/brew/tarball/master | tar xz --strip 1 -C homebrew

Una vez instalado Brew, pasamos a instalar Vapor con el siguiente comando:

brew install vapor/tap/vapor

Después nos situamos en la ruta donde queramos instalar el proyecto de Xcode y escribimos el siguiente comando:

vapor new helloWorld

 

Ya nos ha creado el proyecto, pero no hemos terminado, para instalar las dependencias del proyecto deberemos movernos a la ruta del proyecto y escribir el siguiente comando:

vapor xcode

Es importante tener en cuenta que cada vez que cambiemos las dependencias del proyecto será necesario cerrar XCode y volver a ejecutar este comando en la ruta del proyecto.

 

4. Vistazo rápido

Una vez abierto el proyecto nos vamos al fichero Package.swift

Este fichero es el encargado de gestionar nuestras dependencias (paquetes -> https://github.com/apple/swift-package-manager), como vemos, por defecto nos ha instalado Vapor y Fluent para SQLite.

Si queremos instalar otro paquete, basta con añadir una llamada a .package con la URL del mismo, por ejemplo si quisiéramos añadir la librería de oAuth 2.0 añadiremos:

A continuación vamos a buscar el fichero configure.swift

Como podemos ver en este fichero es donde vamos a poder configurar los Providers, los routers, la BBDD y las migraciones de ésta.

Si ahora nos vamos al fichero routes.swift veremos que Vapor nos ha creado ya unas llamadas que podremos hacer si arrancamos el proyecto:

Podemos probar y arrancar el proyecto, seleccionamos el esquema Run y como target My mac:

Cuando pulsemos run tardará un poco la primera vez, pero una vez arrancado podemos ir al navegador y escribir http://localhost:8080 , entonces podremos comprobar que nuestro servidor está arrancado:

5. Arrancando

Pero vamos a hacer nuestra propia implementación, como primer paso vamos a borrar los siguientes ficheros:

  • Todo.swift
  • TodoController.swift

Ahora nos vamos al fichero configure.swift y borramos la adición de la tabla Todo, quedándonos de la siguiente forma:

Ahora nos vamos al fichero  routes.swift y borramos todo el contenido de la función routes:

Vamos a crear nuestra tabla, pulsamos en el menu File/New/file y seleccionamos Swift file a la que llamaremos User que nos va a quedar de la siguiente forma:

La magia de Vapor es que conformándonos con los protocolos que hemos puesto vamos a tener muchísima funcionalidad ya implementada.

Ahora nos vamos al fichero configure.swift y añadimos nuestra tabla:

Ahora vamos a crear nuestro controller en el que vamos a crear y gestionar nuestros end points, volvemos a crear un fichero que vamos a llamar UserController, en el fichero vamos a crear un struct (UserController) que va a conformar el protocolo RouteCollection, este protocolo no obliga a implementar la función boot(_:) en la que vamos a recibir como parámetro el router principal de la app:

Para personalizar nuestra ruta vamos a llamar a la función grouped del parámetro recibido, y a continuación vamos a crear nuestro método GET:

Para que esta llamada funcione vamos a necesitar registrar el controller, vamos a ir al fichero routes.swift y vamos a añadir las siguientes lineas:

Si arrancamos de y escribimos en el navegado la URL http://localhost:8080/api/test/hello/vapor vamos a ver que nos responde sin problemas.

Para la claridad de nuestro código vamos a extraer la función GET a una función aparte:

Ahora vamos a ponernos con nuestra tabla y ver como trabaja Vapor con las BBDD, para poder continuar vamos a necesitar descargarnos Postman o usar su herramienta web:

https://www.postman.com/

Una vez descargado vamos a añadir a nuestro UserController una petición de tipo post para la creación de un usuario, escribimos lo siguiente:

Veamos lo que hemos hecho, hemos añadido una llamada a la función post() de nuestro apiRouter en la que pasamos el tipo que queremos decodificar, la ruta y la función que resuelve el closure, este closure tiene un retorno de Future<HTTPStatus>.

Los futuros son la solución que nos brinda SwiftNIO para la programación asíncrona:

Estos pueden devolver el resultado o un error, pero nosotros no tenemos ni que preocuparnos por este aspecto ya que Vapor va a gestionar los errores de forma automática, aunque siempre podemos hacer nuestra implementación de gestión de errores.

En la función createUser lo que recibimos es la Request, que contiene un enlace a la BBDD y el objeto user, ya construido, gracias a la magia de los Codables, ten en cuenta que lo que vamos a enviar en esta petición es un JSON.

Únicamente nos queda salvar en la base de datos el usuario, así que llamamos la función save() de nuestro objeto usuario, que es una de las funciones que tenemos gracias a que lo hemos conformado con el protocolo SQLiteUUIDModel, esta función nos va a devolver un futuro de tipo Future<User>.

Que nos devolvería la tabla de usuarios pero como lo que nos interesa es obtener un 201 llamamos a la función transform() para transformarlo en un HTTPStatus, así de fácil.

Ahora vamos a Postman y vamos a crear una request de tipo POST:

El body que vamos a pasar es un JSON con la estructura de nuestra clase User, también acordaros de añadir el Content-Type de tipo application/json en los headers, si tenemos arrancado el servidor con nuestros cambios en el UserController vamos a obtener un 201 indicando que nuestro usuario ha sido creado:

Ya hemos creado nuestro primer usuario ahora vamos a crear una función que nos recupere todos los usuarios de la base de datos, añadimos a nuestro UserController las siguientes líneas:

Añadimos un nuevo GET con la ruta “allUsers” y que se resuelve en la función allUsers(:), en esta función recibimos de nuevo la Request que como hemos dicho antes tiene un enlace con la BBDD y devuelve un futuro con un array de usuarios. Lo que vamos a devolver en esta función es sencillo, la clase User tiene un función query(:) que también nos la ha brindado el protocolo SQLiteUUIDModel en la que podemos lanzar una consulta en la tabla y para obtener todos los registros de la tabla, continuamos la llamada con la función all().

Si creamos esta llamada en Postman vamos a obtener la lista de usuarios creados, recuerda que ahora mismo tenemos la función de persistencia en memoria, por lo que para obtener la lista de usuarios creados tendremos que crearlos de nuevo tras cada ejecución:

Como se puede ver, también nos ha creado ya el id de la clase User, con éste id podemos identificar al usuario de forma única en la BBDD.

Vamos a crear una última función en la que recuperemos el usuario a través de su mail, escribimos las siguientes líneas en nuestro UserController:

Paso a paso, hemos creado un nuevo GET con ruta “user” “mail” que se resuelve en queryUserByMail(🙂, en esta función lo primero que hemos hecho es recuperar el parámetro de la request mediante la función query(🙂, en caso de no encontrarlo devolvemos un error de tipo Bad Request.

A continuación vamos a retornar la cadena de llamadas que van a resultar en el futuro de User deseado. Lo primero, hacemos una consulta en la tabla User como hemos visto en la función anterior de recuperar todos los usuarios, después vamos a aplicar un .filter(). Existen varias funciones de tipo .filter(), en la que hemos usado se pasa como primer parámetro el Keypath que queremos comparar de nuestra clase User, como segundo parámetro el operador de comparación y como tercero el objeto a comparar. Como este filter nos devuelve un futuro de tipo opcional llamamos a continuación a la  función unwrap(), si tiene éxito el unwrap devolverá el futuro de User, de lo contrario ejecutará el Abort que le hemos pasado.

Si ahora creamos nuestra llamada en Postman veremos que nos devuelve el resultado con nuestro usuario consultado:

6. Conclusiones

Como hemos podido ver, Vapor nos ofrece una forma muy fácil de construir nuestras aplicaciones de lado servidor. Hemos visto muy por encima las virtudes de ésta librería, pero es altamente configurable para proyectos más complejos.

Lo que podemos estar seguros es que nos ofrece una forma segura de manejar el lado servidor por el simple hecho de usa Swift, un lenguaje de programación que fue creado para que las aplicaciones fueran más seguras.

 Una gran oportunidad de aprendizaje para los desarrolladores de Front en iOS de poder convertirse en FullStack.

 

La entrada Primeros pasos con Vapor se publicó primero en Adictos al trabajo.

Introducción a Rust

$
0
0

En esta introducción a Rust vamos a ver los rasgos principales del lenguaje.

Entorno

Esta introducción está escrita en la versión 1.41.1 de

rustc
  en MacOS Catalina 10.15.3.

¿Qué es Rust?

Rust es un lenguaje que promete excelente rendimiento gracias a su bajo nivel, sin sacrificar en ergonomía o en fiabilidad.

Sus objetivos de diseño son:

  • Concurrencia segura por defecto.
  • Acceso seguro de memoria (sin punteros nulos o colgantes).
  • Eficiencia sin recolector de basura.

Recursos para aprender Rust

Los mejores recursos para aprender Rust son los dos libros oficiales y los ejercicios prácticos:

Yo recomiendo empezar por el primer libro The Rust Programming Language. Explica de principio a fin todo lo que tiene que ver con el lenguaje.

Instalación

En Linux o MacOs:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

 

En Windows:
Instalar rustup-init.exe.

Existen otros métodos de instalación en la guía de instalación.

Para comprobar que la instalación ha funcionado, revisar la versión con:

rustc --version

Si tienes problemas en MacOS, prueba a ejecutar este comando:

xcode-select --install

Plugin de Rust para IDE

Para facilitar el trabajo en Rust, existen plugins para los IDE más usados.

 

He probado ambas y he tenido mejor resultado con InteliJ IDEA. Pero ninguna es infalible y no es capaz de autocompletar en todos los casos.

Hola Mundo

Para crear un proyecto de Rust, el comando

cargo new
  crea un nuevo proyecto de tipo aplicación:
cargo new hello_world

Esto creará el directorio

hello_world
, con los archivos:
  • cargo.toml
    : Archivo que define el proyecto. Piensa
    pom.xml
    o
    package.json
    . ¿Qué es .toml?
  • src/main.rs
    : Archivo de entrada de la aplicación.
  • .gitignore
    : Configura Git para ignorar la carpeta
    /target
    .

 

El contenido de

main.rs
 es el siguiente, parece que nuestro trabajo está hecho:
fn main() {
    println!("Hello, world!");
}

Para compilar el código, usamos

cargo build
:
cargo build

Para comprobar que el código compila, usamos

cargo check
. Es más rápido que
build
:
cargo check

Para ejecutar la aplicación, usamos

cargo run
:
cargo run

Stack y Heap

Para usar Rust correctamente, debemos conocer cómo es su gestión de memoria.
En Rust, la memoria se divide en stack y heap.

Stack

Guarda la memoria en el orden en el que entra.
Esto se llama último dentro, primero fuera. Imagina una pila de platos, solo puedes quitar el último que has añadido.

Toda lo almacenado en el stack debe tener un tamaño fijo, si no sabemos el tamaño de algo, debemos guardarlo en el heap.

Heap

Almacena la memoria de una forma menos organizada.
Cuando guardamos algo en el heap, el sistema operativo busca un hueco disponible, y guarda la dirección de esa memoria en el stack.

De esta forma, tenemos acceso a una memoria de tamaño desconocido desde el stack.

 

Stack y Heap

Variables y Mutabilidad

Para declarar una variable, usamos

let
:
fn main() {
    let x = 1;
    println!("{}", x);
}

Ahora, si intentamos modificar la variable:

fn main() {
    let x = 1;
    println!("{}", x);
    x = 2;
    println!("{}", x);
}

Vamos a obtener un error con este aspecto:

error[E0384]: cannot assign twice to immutable variable `x`
 --> src/main.rs:4:5
  |
2 |     let x = 1;
  |         - first assignment to `x`
3 |     println!("", x);
4 |     x = 2;
  |     ^^^^^ cannot assign twice to immutable variable

Esto se debe a que en Rust las variables son inmutables por defecto.

Si queremos que una variable sea mutable, debemos usar

mut
.
fn main() {
    let mut x = 1;
    println!("{}", x);
    x = 2;
    println!("{}", x);
}

Documentación sobre variables y mutabilidad.

Tipos primitivos

Escalares

En Rust existen muchos tipos primitivos escalares:

  • Enteros con signo:
    i8
    ,
    i16
    ,
    i32
    ,
    i64
    ,
    i128
    ,
    isize
    .
  • Enteros sin signo:
    u8
    ,
    u16
    ,
    u32
    ,
    u64
    ,
    u128
    ,
    usize
  • Punto flotante:
    f32
    ,
    f64
    .
  • char
    para valores como
    'a'
    ,
    'b'
    ,
    'c'
    .
  • bool
    puede ser
    true
    o
    false
    .
  • El tipo unidad
    ()
    , una tupla vacía. Parecido a un
    void
     en otros lenguajes.

Tuplas

Una

tupla
 es una colección de valores con distintos tipos. Se construyen con los paréntesis.
fn main() {
    let tuple = (1, "string", true);

    let one = tuple.0;
    let string = tuple.1;
    let boolean = tuple.2;
}

El compilador de Rust puede extraer los tipos en situaciones normales como esta, por eso no necesitamos especificar los tipos así:

fn main() {
    let tuple: (i32, &str, bool) = (1, "string", true);

    let one: i32 = tuple.0;
    let string: &str = tuple.1;
    let bool: bool = tuple.2;
}

También podemos extraer los valores de la tupla de esta manera:

fn main() {
    let tuple: (i32, &str, bool) = (1, "string", true);

    let (one, string) = tuple;
}

Arrays

Un

array
 es una colección de elementos del mismo tipo que se van a guardar en memoria de manera continua. Se crean usando los corchetes y es necesario especificar su tamaño:
fn main() {
    // [tipo, tamaño]
    let array: [i32, 3] = [1, 2, 3];
}

En muchos casos el compilador podrá extraer el tipo del array en tiempo de compilación. Basta con:

fn main() {
    let array = [1, 2, 3];
}

Documentación sobre Tipos primitivos.

Structs

Con

struct
 podemos crear tuplas con nombre:
struct Position(i32, i32);

let position = Position(1, 2);

Estructuras que almacenan datos en campos nombrados:

struct Position {
    x: i32,
    y: i32,
}

let position = Position {x: 1, y: 2};

Si queremos que los campos sean accesibles desde fuera, usamos

pub
.
struct Position {
    pub x: i32,
    pub y: i32,
}

let position = Position {x: 1, y: 2};
let x = position.x;

Más adelante veremos cómo adherir métodos a un

struct
con
impl
.

Documentación sobre structs.

Enums

Un

enum
 permite la creación de un tipo que puede ser una de las variantes disponibles.
El valor de un enum puede contener datos, como una tupla o un struct.
enum Action {
    // Sin guardar datos ().
    Jump,
    // Como un struct.
    Move { x: i32, y: i32 },
    // Como una tupla.
    Speak(String),
}

let jump = Action::Jump;
let movement = Action::Move { x: 1, y: 2 };
let speak = Action::Speak(String::from("Hello World"));

Documentación sobre enums.

El enum Option

En Rust no existe el concepto de

null
.

Pero sí existe un

enum
 que representa el concepto de un valor existente o absente.
Este es
Option
, y está dentro de la librería estándar del lenguaje. No hace falta importarlo.

Un

Option
 puede ser dos cosas:
  • None
    , indica que no tiene valor real.
  • Some(value)
    , una tupla que contiene el valor real de tipo
    T
    .

// Some es genérico, puede contener cualquier tipo.
let some_number = Some(1);
let some_string = Some("hello world");

// Si queremos inicializar un Option a None, debemos especificar el tipo.
let none_number: Option<i32> = None;

Ahora vamos a intentar hacer una operación con un

Option
:
let x: i32 = 10;
let y: Option<i32> = Some(2);

let total = x + y;

Si intentamos ejecutar este código, obtendremos el error:

error[E0277]: the trait bound `i32: std::ops::Add<std::option::Option<i32>>` is
not satisfied
 -->
  |
5 |     let total = x + y;
  |                 ^ no implementation for `i32 + std::option::Option<i32>`
  |

Esto se debe a que

Option
es un wrapper que contiene el valor real. Para sacar el valor podemos usar la función
unwrap()
. Esto nos permitirá «desenvolver» el valor real.
let x: i32 = 10;
let y: Option<i32> = Some(2);
let y: i32 = y.unwrap();

let total = x + y;

Pero que pasa si intentamos hacer

unwrap()
de un valor
None
:
let x: i32 = 10;
let y: Option<i32> = None;
let y: i32 = y.unwrap();

let total = x + y;

thread 'main' panicked at 'called `Option::unwrap()` on a `None` value',
/rustc/f3e1a954d2ead4e2fc197c7da7d71e6c61bad196/src/libcore/macros/mod.rs:15:40

Llamar directamente a

unwrap()
puede resultar en un error de ejecución. Una forma sencilla de solventar este error es usar
unwrap_or(default)
, que devolverá un valor por defecto si
Option
es
None
.
let x: i32 = 10;
let y: Option<i32> = None;
let y: i32 = y.unwrap_or(0);

let total = x + y;

Documentación sobre Option.

Pattern Matching

Rust tiene un mecanismo de control de búsqueda de patrones con el operador

match
.

Permite comparar un valor contra una serie de patrones y ejecutar código en el patrón que se cumpla. El compilador no nos dejará continuar si no cubrimos todas las ramas posibles.

enum Attack {
    Punch,
    Sword,
    Spear,
    Magic
}

let attack = Attack::Punch;

let damage = match attack {
    Attack::Punch => 1,
    Attack::Sword => 5,
    Attack::Spear => 7,
    Attack::Magic => 10,
    _ => 0 // Si es cualquier otro valor.
           //En este caso no es necesario porque estamos cubriendo todas las ramas.
};

Pero

match
no solo sirve para devolver un valor, podemos ejecutar código dentro de las ramas.
enum Attack {
    Punch,
    Sword,
    Spear,
    Magic
}

let attack = Attack::Punch;

let damage = match attack {
        Attacks::Punch => {
            println!("Ouch!");
            1
        },
        Attacks::Sword => {
            println!("Slash Slash!");
            5
        },
        Attacks::Spear => {
            println!("Poky Poky!");
            7
        },
        Attacks::Magic => {
            println!("✨✨✨");
            10
        },
        _ => 0
    };

Documentación sobre Pattern Matching.

Pattern Matching con Option

Otra forma de utilizar el valor real dentro de un

Option
es con
match
.
let x = 10;
let y = Some(2);

let total = match y {
    Some(y) => Some(x + y),
    None => None,
};

También podemos utilizar

match
como anteriormente hemos usado
unwrap_or()
let x = 10;
let y = Some(2);

let total = match y {
    Some(y) => x + y,
    None => x,
};

Otra sintaxis disponible es

if let
, que nos permite contrastar un único patrón de manera más concisa.
Pero el compilador no nos obligará a cumplir todas las ramas.
let x = 10;
let y = Some(2);
let mut total = 0;

if let Some(y) = y {
    total = x + y;
} else {
    total = x;
};

Funciones

En Rust las funciones se escriben con la palabra

fn
:
fn do_nothing() {}

Si la función tiene parámetros:

fn hello(who: String) {
    println!("Hello {}", who);
}

Si la función devuelve un valor, el valor debe estar en la última línea sin punto y coma.

fn hello(who: String) -> String {
    let hello_who = format!("Hello {}", who);

    hello_who
}

En muchas ocasiones no será necesario especificar el tipo del resultado, ya que el compilador puede interpretar la función.

Documentación sobre funciones.

Métodos

Los métodos son funciones asociadas a una estructura. En Rust se utiliza

impl
 para crear la implementación de un tipo.
struct Position {
    x: i64,
    y: i64,
}

// El bloque de implementación va separado del bloque de estructura.
impl Position {
    // Esta es una función estática o de tipo. Se las llama con tipo::nombre_funcion.
    // Se pueden usar como constructor.
    fn new(x: i64, y: i64) -> Position {
        Position { x, y }
    }
}

let position = Position::new(1, 2);

Si queremos crear un método que tenga acceso a la instancia del objeto actual, usamos

&self
:
impl Position {
    // &self es una referencia a la instancia actual.
    fn delta_to(&self, pos: Position) -> (i64, i64) {
        (pos.x - self.x, pos.y - self.y)
    }
}

let pos1 = Position::new(1, 2);
let pos2 = Position::new(5, 4);
let delta = pos1.delta_to(pos2);

Si queremos modificar un campo de la instancia usamos

&mut self
:
impl Position {
    // La firma &mut self, le dice al compilador que al llamar a esta función
    // se va a modificar la instancia de la estructura.
    // Por esto, nos obligará a marcarla como mutable al instanciarlo.
    fn move_to(&mut self, to: Position) {
        self.x = to.x;
        self.y = to.y;
    }
}

// Sin mut, este código no compila.
let mut pos1 = Position::new(1, 2);
let pos2 = Position::new(5, 4);

pos1.move_to(pos2);

Documentación sobre métodos.

Closures

Una

closure
, también conocida como expresiones lambda, es una función que puede capturar el entorno que la rodea. Esto significa que tiene acceso a las variables del contexto.

Tiene algunas diferencias con las funciones:

  • Utiliza
    ||
    en lugar de
    ()
     para las variables de entrada.
  • El cuerpo
    {}
     es opcional en expresiones únicas.
  • Puede capturar su entorno.

let x = 5;

let times_x = |value: i32| value * x;

let five_times_x = times_x(5);

Documentación sobre closures.

Ownership

Las reglas de ownership dominan Rust con estas normas:

  • Todo valor tiene una propiedad llamada su dueño.
  • Solo puede tener un dueño en todo momento.
  • Cuando el dueño sale del contexto, el valor también lo es.

Estas reglas se encargan del control de la memoria en Rust sin necesitar un colector de basura.

{                           // hello no es válido, aún no se ha declarado.
    let hello = "hello";    // hello es válido de aquí en adelante.
    // ...
}                           // hello ya no es válido. Se libera la memoria.

Vamos a ver las reglas en acción.

let x = 5;
let y = x;

Este código es bastante aparente. Se inicializa

x
a
5
y se inicializa
y
a
x
.

Es correcto presuponer que ambas variables van a tener valor

5
. Pero esto se debe a que son de tipo entero, y tienen un tamaño fijo. Por lo que se guardan en el stack.

Ahora vamos a usar el tipo

String
, que es de un tamaño indeterminado y se guarda en el heap.
let string1 = String::from("hello");
let string2 = string1;

Parece que el funcionamiento será el mismo, pero no es así. Vamos a ver cómo se reparte el tipo

String
 entre el stack y el heap.

 

String en Stack y Heap

Cuando se crea un

String
, se introducen en el stack la longitud, la capacidad y una referencia de memoria al heap, en el que se guardan el valor del
String
.

Cuando instanciamos

string2
a partir de
string1
, se añade al stack una nueva variable con longitud, capacidad y la referencia apunta a la misma memoria que
string1
. Mientras que
string1
 se marca como invalido.

String en Stack y Heap

 

Si queremos mantener las dos variables válidas, debemos usar

clone()
. Hay que tener en cuenta que esta operación es mucho más cara que la anterior.
let string1 = String::from("hello");
let string2 = string1.clone();

Ownership en las funciones

Si ejecutamos el siguiente ejemplo:

fn main() {
    let s = String::from("hello");

    takes_ownership(s);
    println!("{}", s);
}

fn takes_ownership(some_string: String) {
    println!("{}", some_string);
}

Recibiremos este error.

error[E0382]: borrow of moved value: `s`
 --> src/main.rs:6:20
  |
2 |     let s = String::from("hello");
  |         - move occurs because `s` has type `std::string::String`, which does not implement the `Copy` trait
3 |
4 |     takes_ownership(s);
  |                     - value moved here
5 |
6 |     println!("{}", s);
  |

Esto se debe a una de las reglas que vimos anteriormente.

  • Todo valor tiene un único dueño.

Cuando se pasa

s
por parámetro a
takes_ownership
, la función pasa a ser su dueño.
Y cuando la función termina y sale del contexto,
s
 pasa a ser invalido.

Por esto no podemos acceder a

s
después de
takes_ownership
.

En el caso de que necesitemos una operación parecida a la anterior, debemos usar referencias.

Referencias

Para marcar referencias usamos

&
.
fn main() {
    let s = String::from("hello");

    takes_ownership(&s);
    println!("{}", s);
}

fn takes_ownership(some_string: &String) {
    println!("{}", some_string);
}

Si queremos modificar una referencia, usamos

&mut
. Es importante saber que, si queremos modificar una referencia, debemos asegurarnos que el valor también es mutable.
fn main() {
    let mut s = String::from("hello");

    takes_ownership(&mut s);
    println!("{}", s);
}

fn takes_ownership(some_string: &mut String) {
    some_string.push_str(" world");
    println!("{}", some_string);
}

Con las referencias vienen varias reglas que debemos seguir:

  • Puedes tener o una referencia mutable o múltiples referencias inmutables.
  • Las referencias siempre deben ser válidas.

Documentación sobre Ownership.

Testing

Rust viene con herramientas de testing automático de serie.

fn add(val1: i32, val2: i32) -> i32 {
    val1 + val2
}

#[cfg(test)]
mod tests {
    // Importa todo lo que hay en el mismo fichero.
    use super::*;

    #[test]
    fn adds_correctly() {
        let given = (1, 2);
        let expected = 3;

        let result = add(given.0, given.1);

        assert_eq!(result, expected)
    }
}

Para ejecutar los test, comando es

cargo test
:
running 1 test
test tests::adds_correctly ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

Documentación sobre testing.

Conclusiones

Gracias por seguir está guía introductoria a Rust. Espero que haya sido educativa.

Si quieres seguir aprendiendo, te recomiendo el libro oficial 100% gratuito The Rust Programming Language.

Gracias!

 

La entrada Introducción a Rust se publicó primero en Adictos al trabajo.

Recopilación de Fichas Agile

$
0
0

En Autentia consideramos que la información es poder y somos conscientes de que cuando se organiza la información surgen las ideas. Es por ello que ofrecemos la Recopilación de Fichas Agile publicadas hasta la fecha en un único archivo para su descarga.

Entre los mazos encontrarás fichas orientadas al Coaching, a las Metodologías Ágiles haciendo referencia a Scrum, Kanban y eXtreme Programming entre otros. También encontrarás otras orientadas a la Visualización y a negocio como las fichas Evidence-Based Management (EBM) o las fichas de Sesgos Cognitivos.

Las fichas concentran gran cantidad de información valiosa que de forma visual, resumida y muy amena ofrecen una amplia base de conocimiento accesible de forma rápida para todos aquellos interesados.

Las puedes descargar gratis desde el siguiente enlace:

Descargar Fichas Agile

La entrada Recopilación de Fichas Agile se publicó primero en Adictos al trabajo.

Maneras de medir el rendimiento de tus queries

$
0
0

En este tutorial vamos a ver maneras de medir el rendimiento de tus queries en Postgres. También hablaremos sobre las fases por las que pasa una query antes de ser ejecutada.

Índice de contenidos

1. Introducción

Muchas veces pensamos que el mal rendimiento en base de datos viene dado porque se ha hecho un mal diseño, una mala configuración o faltan recursos como memoria o capacidad de procesamiento. En este tutorial explicaremos maneras de cómo medir el rendimiento de tus queries.

2. Entorno

El tutorial está escrito usando el siguiente entorno:

  • Hardware: MacBook Pro Retina 15′ (2,5 Ghz Intel Core i7, 16GB DDR3).
  • Base de datos Postgres 12
  • Python 3.
  • Sistema Operativo: Mac OS Catalina 10.15.4.
  • Docker versión 18.06.1-ce, build e68fc7a

3. Preparando la base datos

Este tutorial utilizaremos un contenedor de Docker con PostgreSQL 12. Utilizaremos una base de datos importada sobre películas lo suficientemente grande como para ver cambios en el rendimiento de las queries que se hagan.

Para ello utilizaremos el loader de este repo para cargar un dataset de Kaggle. Para la instalación de la base de datos y ejecución del loader hay que realizar los siguientes pasos:

  • Nos descarga el dataset de Kaggle (900 MB aprox) y extremos los CSVs. Hemos incluido los CSVs en una carpeta llamada dataset.
  • Ejecutamos la siguiente sentencia para instanciar la base de datos de Postgres en Docker.

docker run 
--name db-tuto 
-p 5432:5432 
-e POSTGRES_PASSWORD=postgres 
-e POSTGRES_DB=test_db 
-e POSTGRES_USER=postgres 
-d postgres

  • Nos clonamos el repo de github con el dataset y el loader.

git clone https://github.com/guenthermi/the-movie-database-import

  • En mi caso, he tenido que instalar unos módulos de python, ya que me dio error al ejecutar el loader. Instalamos los módulos pandas y psycopg2 usando pip.

pip install pandas
pip install psycopg2-binary

  • Ejecutamos el loader. Os podéis ir a tomar un café, ya que el proceso tarda bastante tiempo debido a la cantidad de datos a importar. Hemos elegido un dataset grande para que sean visibles los cambios en el rendimiento para este y futuros tutoriales sobre el tema.

python3 loader.py dataset

  • Una vez terminada la ejecución del loader vemos que la base de datos cargada queda de la siguiente manera.

A la vista de este esquema, si quisiéramos obtener las películas en español lanzadas después del 2000 con una puntuación mayor de 4 estrellas. La query resultante sería algo así:

<pre class="lang:pgsql decode:true " title="películas de los 20s con una calificación mayor a 4 estrellas">SELECT m.title,
       m.release_date,
       m.rating,
       String_agg(g.name, ', ') AS genre
FROM   movies_genres mg
       inner join genres g
               ON mg.genre_id = g.id
       inner join movies m
               ON mg.movie_id = m.id
       inner join spoken_languages sp
               ON mg.movie_id = sp.movie_id
       inner join languages l
               ON l.id = sp.language_id
                  AND l.lang_key = 'es'
WHERE  m.rating > 4
       AND release_date ~ '^d{4}-d{2}-d{2}$'
       AND Date_part('year', To_timestamp(release_date, 'YYYY-MM-DD')) >= 2000
GROUP  BY m.title,
          m.release_date,
          m.rating
ORDER  BY m.rating DESC,
          To_timestamp(release_date, 'YYYY-MM-DD') DESC;

Hemos tenido que validar con una expresión regular las fechas de lanzamiento de las películas, ya que existen fechas que no cumplen el formato al ser de tipo varchar.

Pero a la vista de la query, me surgen varias preguntas, ¿Qué coste tiene la ejecución de la query? ¿Cómo lo puede obtener?. Vamos a intentar responderlas.

4. ¿Qué pasa antes de ejecutar una query?

Postgres realiza 4 fases cuando solicitamos la ejecución de una query:

  1. Parser: En esta fase se divide la query en varios tokens (componentes léxicos que significan algo) quitando espacios o comentarios que pueda tener. Algunos de los token extraídos son identificadores, palabras reservadas (keywords), operadores, símbolos, constantes, etc). Devolviendo una lista enlazada de árboles con los tokens encontrados, tal y como aparece en la imagen:

Lista enlazada de árboles devuelta por el parser de Postgres

  1. Analyze and Rewrite: Convierte los árboles del paso anterior, en una lista de nodos de estructura Query. La estructura Query agrega información (el tipo de sentencia que es, si usa subselect, si tiene recursividad, etc) tal como podemos ver en la imagen.

Lista enlazada de árboles devuelta por el analyze de Postgres

A esta nueva lista, le aplica una serie de algoritmos y heurísticas para tratar de optimizar y simplificar la query haciendo que se ejecute más rápido.

  1. Plan: Crea un plan de ejecución generando un tercer árbol de nodos que forman una lista de instrucciones para que Postgres las siga. En la siguiente imagen se muestra el árbol generado para una query:

A la hora de construir la lista de instrucciones a ejecutar, el planificador toma una serie de decisiones sobre las estrategias a tomar antes de ejecutar las queries.

Salida EXPLAIN donde se muestras los diferentes pasos o estrategias que aplica el planificador

En el siguiente apartado veremos cómo obtener información sobre las decisiones tomadas por el planificador. Algunas de las estrategias que aplica el planificador son las siguientes:

  • De acceso: (Sequential Scan,Index scan,Bitmap Index Scan).
  • Cláusulas JOIN: (nested loop, merge join, hash join).
  • Cláusulas de agrupación: (Plain,sorted,hashed).

4. Execute: Simplemente ejecuta el plan obtenido en la fase anterior.

5. Maneras de medir el rendimiento de tus queries

5.1. Las estadísticas. Encontrar las consultas lentas

Si queremos medir el rendimiento de las queries ejecutadas en la base de datos utilizaremos el módulo de Postgres pg_stat_statements capaz de almacenar el coste, tiempo, filas devueltas, etc.

Para activar el módulo tenemos que añadir un par de líneas al fichero postgresql.conf. Para ello realizamos los siguientes pasos:

  • Entramos en el contenedor de Postgres (Recuerda que podemos obtener el identificador del contenedor ejecutando un docker ps).

docker exec -it 6cce8f6676a3 /bin/bash

  • El fichero de configuración se encuentra en /var/lib/postgresql/data/postgresql.conf. Para modificarlo necesitamos instalar un editor, en mi caso he escogido nano.

apt-get update && apt-get install nano
nano /var/lib/postgresql/data/postgresql.conf

  • Añadimos las siguientes líneas al final de fichero postgresql.conf:

# En esta propiedad podemos añadir una lista de librerías compartidas 
# (separadas por comas) que se cargarán al inicio de todas las sesiones.

shared_preload_libraries = 'pg_stat_statements'

# Incrementamos el tamaño máximo  que puede tener una query para ser monitorizada

track_activity_query_size = 2048

# El nivel de tracking de sentencias. Esta propiedad puede tomar los siguiente valores
# - all: Todo, incluido las sentencias anidadas
# - top: Las sentencias ejecutadas por el cliente
# - none: Deshabilita las estadísticas

pg_stat_statements.track = all

  • Una vez guardado el fichero de configuración, reiniciamos el servicio:
    • Reiniciando el servicio de Postgres.

service postgresql restart

    • O bien, saliendo del contenedor y reiniciar el contenedor.

docker restart 6cce8f6676a3

  • Conectamos con la base de datos y habilitamos la extensión pg_stat_statements :

-- Habilitamos la extension
CREATE EXTENSION pg_stat_statements;

-- Comprobamos que la extensión se ha instalado correctamente
select * from pg_extension where extname='pg_stat_statements';

-- Listado de extensiones disponibles
SELECT * FROM pg_available_extensions ORDER BY name;

  • Con esta query obtendremos el top 3 de las queries más lentas ordenadas por tiempo medio de ejecución. También recuperamos datos como la query, las filas que devuelve o el porcentaje de acierto al ir a buscar a cache.

-- Reseteo de las tablas de estadísticas
SELECT pg_stat_statements_reset();

-- Las 3 queries más lentas ordenadas por el tiempo medio de ejecución
SELECT query,
       calls,
       total_time,
       ( total_time / calls )
       AS average_time,
       rows,
       100.0 * shared_blks_hit / Nullif(shared_blks_hit + shared_blks_read, 0)
       AS
       hit_percent
FROM   pg_stat_statements
ORDER  BY total_time DESC
LIMIT  3

5.2. EXPLAIN. Plan de ejecución.

Esta sentencia nos permite extraer información sobre el plan de ejecución generado para una query por el planificador. Al ejecutar EXPLAIN podemos obtener lo siguiente:

sentencia expllain

  • Coste inicial: Es el primer valor que aparece en el coste (3373.36) y presenta el coste antes de realizar la fase de salida.
  • Coste estimado: Es el coste de realizar la ejecución de la planificación hasta el final. El coste se mide en unidades arbitrarias determinadas por los parámetros de coste del planificador como son el coste por página, coste de cpu por tupla, coste de cpu por filtro, etc. Teniendo en cuenta estos costes y conociendo el número de registros y bloques de la tabla, podemos calcular el coste de la siguiente manera (esta fórmula se aplica para una sentencia select de una sola tabla).

cost = blocks * seq_page_cost + number_of_records * (cpu_tuple_cost + cpu_filter_cost).

Como podemos ver en el cálculo del coste se relaciona el número de registros que recupera la query, su coste computacional y el espacio en disco.

  • Filas estimadas: Número de filas que se obtendrán como salida si se finaliza el plan de ejecución.

También podemos ver los distintos nodos o instrucciones que ejecuta el planificador (comentados en el apartado anterior), así como las estrategias escogidas para el acceso a los datos, para hacer lo joins, etc.

Para calcular el tiempo que tarda la query, lo podemos hacer usando el historial haciendo una select a pg_stat_statements como hemos visto antes, o añadir a la sentencia EXPLAIN la palabra reservada ANALYZE.

ADVERTENCIA: Si se hace un EXPLAIN ANALYZE se ejecutará la sentencia a analizar, por lo que si analizamos un DELETE, UPDATE, INSERT se aplicarán los cambios si no se hace un rollback. Aquí podemos ver un ejemplo.

BEGIN;
EXPLAIN ANALYZE
DELETE FROM users WHERE id = 1;
ROLLBACK;

Al ejecutar esta sentencia podemos ver el tiempo que tardó la sentencia, el número de registro que devolvió, así como las veces que se ejecutó el nodo en la planificación. Durante la planificación, hay nodos que se ejecutan más de una vez como es el caso en las agrupaciones o escaneos haciendo usos de índices.

Salida adicional que ofrece la sentencia ANALYZE en combinación con EXPLAIN

Cuando tengamos mucha diferencia entre las filas estimadas por el planificador y las filas reales al ejecutar la query, se recomienda ejecutar VACUUM ANALYZE para que se actualicen las estadísticas y se mejore la estimación.

Una forma gráfica de ver los datos devueltos por la sentencia EXPLAIN ANALYZE puede ser utilizando https://explain.depesz.com/.

Forma gráfica de ver el plan de ejecución

Y desglosar de una forma más amigable las estadísticas.

Desglose de estadísticas de un plan de ejecución

6. Las mediciones de EXPLAIN y estadísticas no coinciden

Al realizar mediciones para otro tutorial que estoy preparando sobre optimización de queries correladas, me di cuenta de que los tiempos obtenidos en las estadísticas y ejecutar la planificación con EXPLAIN no coinciden.

Si nos piden que listemos las 10 películas que superan la media en presupuesto, podíamos hacer algo así (soy consciente de que se podría calcular de forma más sencilla, pero es un ejemplo extraído para optimizar subqueries).

SELECT *
FROM   (SELECT m.title,
               m.budget,
               (SELECT Avg(budget)
                FROM   movies
                WHERE  budget IS NOT NULL
                       AND m.title = title
                GROUP  BY title) AS avg_budget
        FROM   movies m
        WHERE  m.budget IS NOT NULL) AS temp
WHERE  budget > avg_budget
ORDER  BY budget DESC
LIMIT  10;

Y si realizamos mediciones con los distintos métodos obtenemos lo siguiente:

  • Timing (ejecutando dentro del contenedor)

Medición activando el timing en psql

  • EXPLAIN ANALYZE

Salida de la sentencia EXPLAIN

  • pg_stat_statements

Salida obtenida consultando las estadísticas de pg_stat_statements

Podemos ver que las mediciones no son iguales, haciendo una medición por diferentes métodos. En el caso de pg_stat_statements bastante diferentes pero puede ser que se encuentre cacheada.

Las diferencias podrían ser mayores en queries más pesadas, así que aconsejamos que los valores obtenidos tanto del coste como del tiempo se tomen de forma orientativa para detectar fallos de implementación o cuellos de botella pero no como una medición absoluta.

En este hilo de github se explica la razón de porqué se producen grandes diferencias entre EXPLAIN y las estadísticas para un Postgres dockerizado en Mac (nuestro entorno), dando detalles de implementación.

7. Conclusiones

Como hemos visto en el tutorial, hay diferentes formas de medir el rendimiento de tus queries. Esto nos será útil para detectar posibles cuellos de botellas o defectos de implementación en las queries. Como conclusión final sobre la importancia de monitorizar y medir, me quedo con una frase de Peter Drucker

Lo que no se mide, no se controla, y lo que no se controla, no se puede mejorar.

En el siguiente tutorial utilizaremos la base de datos y los métodos de medición de este tutorial para explicar cómo afecta en el rendimiento la manera en la que implementamos queries correladas.

8. Referencias

La entrada Maneras de medir el rendimiento de tus queries se publicó primero en Adictos al trabajo.

Distribución de betas con Firebase App Distribution y Fastlane

$
0
0

Índice

  1. Introducción
  2. Entorno
  3. Proceso
  4. Conclusiones

1. Introducción

Firebase incorporó en septiembre de 2019 (aún en fase beta) a su plataforma para aplicaciones móviles una herramienta para la distribución de aplicaciones beta. En este tutorial se va a explicar cómo instalarla y utilizarla junto a Fastlane para simplificar la distribución entre grupos de tester o usuarios particulares a los cuales queramos proporcionar acceso a una versión preliminar de nuestra aplicación.

2. Entorno

El tutorial está escrito usando el siguiente entorno:

  • Hardware: Portátil MacBook Pro 15’ (2.3 GHz Intel Core i9, 32 GB 2400 MHz DDR4)
  • Sistema Operativo: Mac OS X Catalina 10.15.4
  • Ruby 2.6.3
  • Fastlane 2.145.0

3. Proceso

Configuración de Firebase.

En primer lugar es necesario habilitar la distribución de nuestra app en nuestro proyecto de Firebase yendo a Quality -> App Distribution

 

Posteriormente seleccionamos la aplicación sobre la que queremos permitir la distribución y pulsamos en «Get Started».

Lo siguiente que vamos a hacer es crear un grupo para las pruebas, vamos a la pestaña de la herramientas «Testers and Groups» y seleccionamos añadir grupo. En mi caso usaré el nombre «testers» y añadiré mi correo a dicho grupo. (También es posible añadir testers individuales que podríamos especificar individualmente en el momento de realizar la distribución).

Configuración de testers

Tras dejar configurados los grupos, ahora sí, vamos al meollo de la cuestión… ¡la automatización!

Automatización con Fastlane

Abrimos un terminal y lo primero que vamos a hacer es instalar el cliente de firebase que nos permitirá logarnos y que fastlane pueda posteriormente usar el plugin de distribución. Para instalarlo ejecuta en el terminal

curl -sL firebase.tools | bash

y hacemos login ejecutando

firebase login

(Si necesitas hacer logout puedes ejecutar firebase logout)

Se abrirá el navegador solicitándote los permisos de tu cuenta de google. Una vez aceptados estamos listos para proseguir.
Ahora será necesario ir al directorio del proyecto que queramos distribuir en firebase y ejecutar

fastlane add_plugin firebase_app_distribution

Una vez añadido el plugin podemos crear una lane en nuestro Fastfile como el siguiente con un bloque firebase_app_distribution() :

desc "Submit a new Beta Build to FirebaseAppDistribution"
lane :beta do |options|
  #Tarea que tengamos definida para realizar build. Para este tutorial simplificamos con
  gradle(task: "clean assembleDebug")
  firebase_app_distribution(
      app: "APP_ID",
      testers: "",
      groups: options[:group],
      release_notes: options[:notes],
      firebase_cli_path: "/usr/local/bin/firebase",
      apk_path: "./app/build/outputs/apk/debug/app-debug.apk"
     #ipa_path: "" Para aplicaciones para iOS
   )
end

En mi caso he pasado por parámetro ciertos valores de bloque pero esto puede ser adaptado a las necesidades de cada proyecto.
Los parámetros del bloque de firebase consisten en lo siguiente:

  • app: Aquí debes introducir el AppId de firebase que encontrarás en la sección de configuración del proyecto.

  • testers: Lista de emails para compartir la aplicación
  • groups: Lista de ids de grupos de testers. El id de un grupo lo puedes encontrar en el apartado de app distribution como se muestra en la imagen.
  • release_notes: Texto descriptivo con las notas de la release.
  • firebase_cliepath: Ruta del cliente de firebase.
  • apk_path/ipa_path: Ruta al archivo de la aplicación.

(Podéis ver más opciones aqui)

Una vez configurado podríamos realizar la distribución de la siguiente manera.

fastlane beta note:"Release notes" group:testers

[10:43:58]: ---------------------------------------
[10:43:58]: --- Step: firebase_app_distribution ---
[10:43:58]: ---------------------------------------
[10:43:58]: ▸ i  getting app details...
[10:44:01]: ▸ i  uploading distribution...
[10:44:07]: ▸ ✔  uploaded distribution successfully!
[10:44:07]: ▸ i  adding release notes...
[10:44:08]: ▸ ✔  added release notes successfully
[10:44:08]: ▸ i  adding testers/groups...
[10:44:09]: ▸ ✔  added testers/groups successfully

+------+---------------------------+-------------+
|                fastlane summary                |
+------+---------------------------+-------------+
| Step | Action                    | Time (in s) |
+------+---------------------------+-------------+
| 1    | default_platform          | 0           |
| 2    | clean assembleDebug       | 14          |
| 3    | firebase_app_distribution | 11          |
+------+---------------------------+-------------+

[10:44:10]: fastlane.tools finished successfully 🎉

4. Conclusiones

Esta integración entre firebase y fastlane agiliza la distribución y nos ahorra tiempo generando apks, subiéndolos a la plataforma, etc. Se puede complementar con otros scripts o mejorando el bloque con otros de los parámetros posibles. Podéis documentaros más en las correspondientes webs de firebase y fastlane.

El siguiente paso sería aprovechar esta funcionalidad en sistemas de integración continua para desarrollos mobile como la de este ejemplo y configurarlo para que ante ciertos eventos de actualización del repositorio se distribuya automáticamente la beta. De dicha integración hablaré en un futuro tutorial ya que tiene sus particularidades. ¡Hasta el siguiente!

 

La entrada Distribución de betas con Firebase App Distribution y Fastlane se publicó primero en Adictos al trabajo.

Viewing all 989 articles
Browse latest View live