El sistema de fitxers de l'ordinador ens permet persistir les dades en memòria secundària.

Introducció

Aquesta activitat té aquest projecte de suport https://gitlab.com/xtec/kotlin/file

Entorn de treball

Crea la carpeta file:

> md file
> cd file

Crea el projecte file amb Gradle:

gradle init --package file --project-name file --java-version 21 --type kotlin-application --dsl kotlin --test-framework kotlintest --no-split-project --no-incubating --overwrite

Construeix el projecte i obre'l amb Idea:

> .\gradlew build
> idea .

Gradle

Per poder serialitzar dades necessites modificar el fitxer app/build.gradle.kts.

Tens un exemple a https://gitlab.com/xtec/kotlin/file/-/blob/main/app/build.gradle.kts?ref_type=heads

Has d'afegir el plugin de serialització perquè gradle generi de manera automàtica el codi corresponent:

plugins {
    ...
    kotlin("plugin.serialization") version "2.0.0"
}

També has d'afegir una dependència de la biblioteca kotlinx-serialization-json per poder serialitzar les dades a format JSON (veure JSON - Objecte.

dependencies {
    ...
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.0")
}

Write

A continuació treballarem amb el fitxer App.kt

Kotlin té la classe kotlin.io.path.Path per representar un fitxer, que equival a la classe java.nio.file.Path.

Aquesta classe proporciona funcionalitat addicional sobre la implementació de Java mitjançant les funcions d'extensió.

El primer que has de fet per escriure un fitxer, és crear l'objecte que representa el "path" del fitxer.

package file

import kotlin.io.path.*

fun main() {
    val path = Path("mary.txt")
}

A continuació pots utlitzar el "path" per escriure el contingut del fitxer:

 fun main() {

    val path = Path("mary.txt")
    path.writeText("Mary Higgins is married")
}

Missió complerta: tenim el fitxer mary.txt amb aquest contingut!

Però aquest mètode sempre funciona?

Si el fitxer té restriccions d'accés o ja s'ha obert en un altre procés no s'escriurà res perqué es produirà una AccessDeniedException per posar un exemple.

On està el fitxer? En el directori des d'on has executat el codi, si és una IDE en el directori arrel del projecte.

I per ser més exactes?.

La variable d'entorn user.dir té aquesta informació:

val pwd = System.getProperty ("user.dir")
println(pwd)

Per guardar les dades és millor crear una carpeta data.

Una "path" pot representar un fitxer o un directori:

fun main() {

    val data = Path("data")
    data.createDirectory()
}

Si executo per segon cop aquest codi es produeix una Exception de tipus java.nio.file.FileAlreadyExistsException (el fitxer ja existeix).

Una opció és capturar l'excepció amb unn block "try-catch" i ignorar l'excepció:

fun main() {

    try {
        val data = Path("data")
        data.createDirectory()
    } catch (e: java.nio.file.FileAlreadyExistsException) {
        // Ignore
    }
}

L'altre manera més habitual és comprobar si el fitxer existeix (una carpeta és un fitxer):

fun main() {

    val data = Path("data")
    if (!data.exists()) {
        data.createDirectory()
    }
}

Ja pots crear el fitxer mary.txt dins la carpeta data:

fun main() {

    val data = Path("data")
    if (!data.exists()) {
        data.createDirectory()
    }

    val maryPath = data / "mary.txt"
    maryPath.writeText("Mary Higgins is married")
}

Amb Koltin tenim l'extensió / a la classe Path que ens petmet construir una ruta de manera segura:

val path = Path("docs") / "hello.txt"

JSON

En la nostra aplicació la informació està en Data.

Per guardar una dada primer l'has de convertir en un String.

La manera més habitual és convertir una dada a JSON - Objecte

Has d'anotar la data class com @Serializable perqué el "plugin" de serialitzador del compilador generi el mètode serializer().

El mètode serializer descomposa l'objecte en un conjunt de valor primitius:

package file

import kotlinx.serialization.*

@Serializable
data class Person(val name: String, val married: Boolean)

fun main() {

    println(Person.serializer().descriptor)
    //file.main.Person(name: kotlin.String, married: kotlin.Boolean)
}

Executa l'aplicació:

> gradle run
file.Person(name: kotlin.String, married: kotlin.Boolean)

La propietat descriptor del serialitzador mostra que la classe file.Person està formada pels elements primitius name de tipus kotlin.String i married de tipus kotlin.Boolean.

Si mires el fitxer Person.class que s'ha generat dins de la carpeta build pots veure que el "plugin" serialitzation ha generat el codi de Person.serializer() de manera automàtica:

A continuació, codifiquem el conjunt de valors primitius en un format concret mitjançant un codificador.

En el nostre cas, utilitzem un codificador JSON:

package file

import kotlin.io.path.*
import kotlinx.serialization.*
import kotlinx.serialization.json.*

@Serializable
data class Person(val name: String, val married: Boolean)

fun main() {

    val mary = Person("Mary",true)
    val json = Json.encodeToString(mary)
    
    println(mary)
    println(json)

    // Person(name=Mary, married=true)
    // {"name":"Mary","married":true}
}

Per convertir un objecte a format JSON has d'utilitzar la funció d'extensió Json.encodeToString, que serialitza l'objecte que es passa com a paràmetre i el codifica en un string JSON.

Fixa't que en la representació JSON no es guarda informació de la classe, en aquest cast dev.xtec.main.Person.

Ja pots guardar les dades de "Mary" en un fitxer:

@Serializable
data class Person(val name: String, val married: Boolean)

fun main() {

    val mary = Person("Mary", true)

    val data = Path("data")
    if (!data.exists()) {
        data.createDirectory()
    }

    (data / "mary.json").writeText(
        Json.encodeToString(mary)
    )
}

Activitat

1.- Guarda les dades de "Esther", "Marc", "Eva" i "Raquel" en la carpeta data:

@Serializable
data class Person(val name: String)

fun main() {

    val persons = arrayListOf(
        Person("Esther"), Person("Marc"), Person("Eva"), Person("Raquel")
    )


    // I/O

    val data = Path("data")
    if (!data.exists()) {
        data.createDirectory()
    }

    persons.forEach { person ->
        (data / "${person.name}.json").writeText(
            Json.encodeToString(person)
        )
    }
}

appendText()

Molts cops el que volem es afegir dades al final del fitxer.

En aquests casos tenim la funció appendText(text: String).

Escriu el poema, vers a vers:

val path = Path("la-vaca-cega.txt")
path.appendText("Topant de cap en una i altra soca,\n")
path.appendText("avançant d'esma pel camí de l'aigua,\n")
path.appendText("se'n ve la vaca tota sola. És cega.\n")
path.appendText("D'un cop de roc llançat amb massa traça,\n")
path.appendText("el vailet va desfer-li un ull, i en l'altre\n")
path.appendText("se li ha posat un tel: la vaca és cega.\n")

Obre el fitxer la-vaca-cega.txt i verifica que estan les dos primeres estrofes.

Activitat

Tenim aquest diagrama de dades:

classDiagram
    direction LR

    class Person {
        id: Long
        givenName: String
        familyName: String
        /name: String
    }

    class Address {
        street: String
        city: String
    }

    Person --> Address

1.- Crea les data class coresponents.

Recorda que Address també s'ha de marcar com @Serializable.

2.- Crea algunes dades demo i guarda-les totes (cada una en una línia nova) en el fitxer person.data

3.- Modifica algunes dades i les afegeixes al final del fitxer person.data.

Referències

Quan serialitzem dades no estem guardant dades en una base de dades.

Referències repetides

No hi ha referències, hi ha redundància

Que vol dir que pot haver dades repetides perquè un objecte pot fer referència al mateix objecte de manera directa o indirecta.

Per exemple, en un projecte l'owner i el mantainer són del tipus User:

@Serializable
class Project(val name: String, val owner: User, val maintainer: User)

@Serializable
class User(val name: String)

I poden ser la mateixa persona:

@Serializable
class Project(val name: String, val owner: User, val maintainer: User)

@Serializable
class User(val name: String)

fun main() {
    val david = User("David")
    val data = Project("Quantum System", david, david)
    println(Json.encodeToString(data))
}
flowchart LR
    project["Quantum System"]
    david["👽 David"]
    project -- owner --> david
    project -- mantainer --> david

El procés de serialització no té en compte que són referències al mateix objecte:

{ 
  "name": "Quantum System",
  "owner": { "name":"david" },
  "maintainer":{"name":"david"}
}

És per aquest motiu que les bases de dades documentals com MongoDB tenen dades redundants.

Referències circulars

Com ja hem explicat abans, la serialització no té en compte si un objecte ja s'havia serialitzat.

Això vol dir que a més de la redundància, la serialització només funciona amb arbres: un objecte arrel, ramificacions i tota acaba en fulles.

flowchart TB
    boeing["✈ Boeing 747"]
    pilot["🤠 Peter"]
    boeing -- pilot --> pilot
    passengers["[🤗 Julia, 🫣 Mary, 🙄 Tom, 😮‍💨 Jane, 😮 Albert, 🤢 Josephine, 🥴 Clara, 😵‍💫 Mike"]
    boeing -- passengers --> passengers

Es comença amb un objecte i es van processant de manera recursiva totes les propietats serialitzables que són referències fins que no queda cap objecte referenciat per processar.

Per exemple, si David té una referència directa o indirecta amb Esther, i Esther té una referència directa o indirecta amb David, la recursió no acabarà mai 💫💫💫💫💫 ... a no ser que 🙀🙀 ??

flowchart LR
    david["😺 David"]
    esther["🦝 Esther"]
    david --> esther
    esther --> david

Vam explicar a Data que quan representem dades totes les propietats són val.

D'aquesta manera és impossible crear referències circulars !!

Pots veure que en aquest exemple, la sentència esther.partner = david no compila:

@Serializable
class Person(val name: String, val partner: Person? = null)

fun main() {

    val esther = Person("Esther")
    val david = Person("David", esther)
    esther.partner = david
}

El disseny correcte és aquest:

@Serializable
class Person(val name: String)

@Serializable
class Partners(val a: Person, val b: Person)

fun main() {
    
    val esther = Person("Esther")
    val david = Person("David")
    println(Json.encodeToString(Partners(esther, david)))
    {"a":{"name":"Esther"},"b":{"name":"David"}}
}
classDiagram
    direction LR

    class Partners {}

    class Person {
        name: String
    }

    Partners --> Person: a
    Partners --> Person: b

Això mai!

El que no has de fer mai quan representes dades, és utilitzar un var com es mostra a continuació:

@Serializable
class Person(val name: String, var partner: Person? = null)

fun main() {

    val esther = Person("Esther")
    val david = Person("David",esther)
    esther.partner = david
    println(Json.encodeToString(david))
}

Quan executes aquest codi ...

> gradle run
Exception in thread "main" java.lang.StackOverflowError
	at kotlinx.serialization.json.internal.WriteModeKt.switchMode(WriteMode.kt)
    ...

La serialització no acaba mai ...

El que acaba és el programa amb un "Stack Overflow Error".

Read

Ja saps que una seqüència de bytes pot representar qualsevol cosa, entre altres coses un text.

El mètode readText() llegeix tot el fitxer i el converteix en un objecte String.

Com que el mètode readText() per defecte "llegeix" el contingut en format UTF-8, l'únic que fa és copiar el bytes a un atribut intern de l'objecte String.

A continuació tens un exemple:

import kotlin.io.path.*

fun main() {

    val file = Path("data") / "message.txt"

    val message = "No diguis blat fins que no el tinguis al sac i ben lliga"
    file.writeText(message)

    require(file.readText() == message)
}

Si la ruta relativa no és correcta, o el fitxer no existeix, es produeix un FileNotFoundException:

import kotlin.io.path.*

fun main() {

    val file = Path("data") / "message-77879879.txt"

    val message = "Març marçot mata la vella vora el foc i la jove si pot"
    require(file.readText() == message)
}

Al executar le codi ...

> gradle run
Exception in thread "main" java.nio.file.NoSuchFileException: data\message-77879879.txt
   ...

De totes maneres, el més habitual és verificar primer que el fitxer existeix.

import kotlin.io.path.*

fun main() {

    val file = Path("data") / "message-77879879.txt"

    val message = "Març marçot mata la vella vora el foc i la jove si pot"
    if (file.exists()) {
        require(file.readText() == message)
    } else {
        println("No hi a missatge \uD83E\uDD14")
    }
}

Aquest cop quan executem el codi s'executa correctament:

> gradle run
No hi a missatge 🤔

JSON

Per convertir un string JSON en un objecte has d'utlitzar la funció d'extensió Json.decodeFromString.

Aquesta funció, a més de l'String, espera un paràmere de tipus per saber a quina classe ha de descodificar perquè l'string JSON no té aquesta informació,

Tens dos opcions:

// Passar el parametre de tipus a la funció
val project1 =  Json.decodeFromString<Project>(data)

// Que la variable tingui un tipus explícit
val project2: Project = Json.decodeFromString(data)

A continuació tens un exemple:

import kotlinx.serialization.*
import kotlinx.serialization.json.*

@Serializable
data class Project(val name: String, val language: String)

fun main() {
    val data = """{"name":"Quantum System","language":"Borg"}"""
    
    val project = Json.decodeFromString<Project>(data)
    require(project == Project("Quantum System", "Borg"))
}

Si executes aquest codi pots verificar que a partir d'un string pots crear un objecte Project amb el contingut de l'string.

I molt important, la funció només espera una classe "serialitzable" que tingui una estructura a la qual pugui descodificar perqué té una estructura identica a Project.

A continuació tens un exemple d'una classe que no és Project i "consumeix" les mateixes dades:

import kotlinx.serialization.*
import kotlinx.serialization.json.*

@Serializable
data class Person(val name: String, val language: String)

fun main() {
    val data = """{"name":"Raquel Durán","language":"Català"}"""

    val raquel = Json.decodeFromString<Person>(data)
    require(raquel == Person("Raquel Durán", "Català"))
}

El motiu és serialitzes dades per comunicar-te amb altres aplicacions i serveis, i cada aplicació fa servir el nom de la classe que més li convé:

package file

import kotlinx.serialization.*
import kotlinx.serialization.json.*

fun main() {
    @Serializable
    data class Kangaroo(val name: String)

    @Serializable
    data class Person(val name: String)

    val json = """{"name":"toby"}"""

    // Creem un cangur i una persona a partir del mateix JSON
    val kangaroo = Json.decodeFromString<Kangaroo>(json)
    val person = Json.decodeFromString<Person>(json)

    // Verifiquem que kangaroo i person son objectes diferents, però que tenen el mateix nom
    require(kangaroo != person)
    require(kangaroo.name == person.name)
}

En alguna aplicació "Toby" pot ser un 🧒 i en una altre pot ser un 🦘.

Activitat

En l'activitat "Sky" de Data vas dissenyar un sistema de gestió de vols:

classDiagram
    direction TB

    class Flight {
        << 🐦‍🔥 >>
        id: Long 🔑
        ufoSeen: Boolean 🛸
    }
    
    class Plane {
        << ✈ >>
        id: Long 🔑
        name: String
        seats: Int 💺
    }

    class Airport {
        << ⛩ >>
        id: Long 🔑
        name: String
        country: String 
    }
    
    class Passenger {
        << 🙂 >>
        id: Long 🔑
        name: String 
        }
    
    Flight --> Plane
    Flight --> "1..* 🤗 🫣 🙄 😮" Passenger 
    
    Flight --> Airport : departure 🛫
    Flight --> Airport: arrival 🛬

Escriu unes quantes dades al sistema de fitxers, i verifica que les pots recuperar.

Lines

Una manera habitual de guardar dades és una dada per línia com has vist abans.

package file

import kotlin.io.path.*

fun main() {

    val text = """Zero
        |One
        |Two
        |Three
        |Four
        |Five
    """.trimMargin()

    (Path("data") / "numbers.txt").writeText(text)
}

readLines()

La funció readLines() fa el mateix que la funció readText() excepte que en lloc de tornar un String torna una List<String> .

Com el nom indica, cada String de la llista és una línia de text:

import kotlin.io.path.*

fun main() {

    val numbers = (Path("data") / "numbers.txt").readLines()
    require(numbers[5] == "Five")
}

forEachLine()

Si vols llegir un fitxer gran no té sentit carregar-lo tot directament a memòria principal, és millor anar llegint per parts.

Amb una funció lambda podem processar el contingut linia per linia sense saturar la memòria principal:

import kotlin.io.path.*

fun main() {

    var three = false
    (Path("data") / "numbers.txt").forEachLine {
        if (it == "Three") {
            three = true
            return@forEachLine
        }
    }

    require(three)
    println("numbers.txt has 'Three'")
}

Activitat

1.- Gurada tots els vols en un únic fitxer flight.data.

2.- Recupera tots el vols linia a linia.

Bytes

TODO

readBytes()

Potser et pot sorprendre que la majoria d'accesos a fitxers no són a fitxers de text!

A més, el mètode readText() d'abans fa servir la funció readBytes() per obtenir els bytes.

El mètode readBytes() llegeix el fitxer i torna un ByteArray:

val lines = Path(fileName).readBytes()

Si vols pots modificar el contingut del ByteArray, però no la seva mida.

Si també vols modificar la seva mida, per exemple afegint més bytes, primer l'has de convertir a un MutableList amb el mètode toMutableList() ( i de nou a ByteArray amb toByteArray() ).

Important!. Estem modificant el bytes que estan a la memòria principal de l'ordinador, en cap cas el contingut del fitxer.

writeBytes

Com era d'esperar el mètode `writeText() no escriu text, sinò els bytes que corresponen al text codificat.

I fa servir, com pots fer tu quan et fa falta, el mètode writeBytes(array: ByteArray):

val bytes = byteArrayOf(1,2,3)
Path("bytes").writeBytes(bytes)

Si vols afegir bytes a un fitxer tens la funció `appendBytes(array: ByteArray):

Path("bytes").appendBytes(byteArrayOf(5,6,7)

Activitat

Crea una aplicació de missatgeria:

  • Cada usuari té un id, per exemple @david.
  • Es poden crear grups al que els usuaris es poden afegir o sortir.
  • Es pot enviar un missatge a més d'un usuari i/o grups.
  • L'usuari pot llegir els missatges
  • etc.

Instruccions:

  1. Has de crear el model de dades (diagrama) i l'aplicació (amb una interficie senzilla).
  2. La part de "presentation", "model" i accés de fitxers a "data" la pots fer en Java o Kotlin
  3. Les dades s'han de guardar en el sistema de fitxers.
  4. Crea una distribució amb gradle distZip