Hoe deze website werkt: een les in 'under-engineering'

'Overengineering' is een stukje jargon uit onder andere de wereld van het programmeren. Het houdt in dat iets eigenlijk veel meer kan dan nodig, bijvoorbeeld: een heel modulair plugin systeem in een todo-app. Het kan, zeker, en het is tot op zekere zin ook best mooi om te maken. Maar is het echt nodig? Nee niet echt. Vaak is de kunst in het programmeren om niet al te robuste systemen te maken, vooral voor kleine projecten. Mooi uit het boekje programmeren kan namelijk soms echt de moeite niet waard zijn.

Deze website is wat dat betreft zo'n klein project. Ik wilde graag een kleine blog, waar ik de wereld door een technische blik wilde laten zien. Maar die maken in WordPress, of andere al bestaande CMS-systemen vond ik maar niks. Maar, een complete text editor maken, waarin ik mijn posts kan schrijven? Dat vond ik ook te veel werk. Bovendien ben ik inmiddels groot fan van documentjes typen in NeoVim. Dus dan is er een leuke middenweg, deze website host namelijk markdown documentjes als 'posts'.

Probleem 1. Hoe doe je dat eigenlijk?

Markdown naar HTML is vrij simpel, met bijvoorbeeld CommonMark, kun je zo je markdown omzetten naar HTML. Maar goed, dat is niet hetzelfde als een volledig functionele blog: alleen het 'verhaal' wordt omgezet. En dat ziet er niet perse speciaal uit, dus heb ik eerst een kleine template gemaakt. Naja, ik niet, ik heb voor de ruige indeling onze grote vriend chatGPT even om hulp gevraagd. Ik kan echt wel een HTML-etje bouwen, maar vind dat vaak maar wat gedoe, en het is één van de weinige dingen binnen programmeren die zó consistent en eenvoudig zijn dat onze grote vriend er amper fouten in maakt. Maar goed, natuurlijk heb ik vanaf daar de touwen weer goed beetgepakt, want om nou te zeggen dat het er gelijk goed uitzag is een overstatement.

Aldus, met het templateje af, is het vrij makkelijk; de posts inladen vanuit lokale opslag, en klaar! Athans, dat zou ik zeggen als ik niet zou willen dat, na het opstarten de link naar het artikel niet verandert. Dit betekent dat alle identificatienummers (ID's) gelijk moeten blijven.

Probleem 2. Opslaan van ID's

Nu kan ik een gehele database gaan implementeren, maar aangezien we de posts alleen controlleren bij het opstartten, en aangezien mijn website onder de 3 seconden opstart, is het prima om dit bij het opstarten gewoon in een bestandje te schrijven. Nu heb ik al deze informatie in een Map<Path, Integer> staan, wat is er makkelijker dan dit gewoon direct in een bestandje te schrijven? Zoals dit:

/pad/naar/bestand.md;ID;/pad/naar/bestand2.md;ID2; (etc.)

Dit heeft een paar gevolgen:

  1. Bestandsnamen met ';' in de naam zullen dit systeem breken.
  2. Getallen in tekst schrijven is niet heel efficiënt, dus die moeten we met een bitmask in bytes omzetten. (een byte is een getal tussen de -128 en 127)
  3. De bytes van de ID's mogen niet gelijk zijn met die van ';' in UTF-8, wat het getal 59 is.

Mijn uiteindelijke code voor het schrijven van dit bestand ziet er als volgt uit. Waarschuwing: Java-code

private void writePostsFile(OutputStream stream) throws IOException {
    for (Map.Entry<Path, Integer> entry : fileIDs.entrySet()) {
        stream.write(entry.getKey().toString().getBytes(StandardCharsets.UTF_8));
        stream.write(BREAK);
        for (int i = 0; i < Integer.BYTES; i++) {
            stream.write((entry.getValue() >> (i * 8)) & BYTE_MASK);
        }
        stream.write(BREAK);
    }
}

Dit is opzich een leuk stukje code, en voor de non-techneuten zal ik het even in Jip- en Janneketaal uitleggen:

Voor elk post-bestand en bijbehorend ID die we hebben doet de code het volgende:

  1. Schrijf de locatie van het post-bestand naar het bestand.
  2. Schrijf het karakter ';' naar het bestand.
  3. Schrijf het ID, in 4 stukjes, naar het bestand.
  4. Schrijf het karakter ';' nogmaals naar het bestand.

Probleem 3. Lezen van ID's

Voor het lezen van dit bestand, wordt de code wat complexer;

    private HashMap<Path, Integer> parsePostsFile(InputStream reader) throws IOException {
        HashMap<Path, Integer> map = new HashMap<>();
        char[] buf = new char[128];
        int id = 0;
        int i = 0;
        boolean path = true;
        Path build = null;
        int b = reader.read();
        while (b != -1) {
            if (b == BREAK) {
                if (i == 0) {
                    throw new IllegalArgumentException("Empty field in file!");
                }
                if (path) {
                    build = Path.of(String.copyValueOf(buf, 0, i));
                    path = false;
                } else {
                    map.put(build, id);
                    path = true;
                    build = null;
                    id = 0
                }
                i = 0;
            } else {
                if (path) {
                    buf[i++] = (char) b;
                } else {
                    id |= b << (8 * i++);
                }
            }
            b = reader.read();
        }
        return map;
    

Dit stukje code gaat byte-voor-byte door het hele bestand heen. Voor de geïnteresseerde zal ik ook deze code even uitleggen;

  1. Het programma leest de volgende byte uit het bestand
  2. Als deze -1 is is het bestand klaar, en geven we de gelezen lijst terug.
  3. Als het programma niet klaar is, kijken we of de gelezen byte ';' is. Zo ja;
    1. Dan kijkt het programma hoeveel karakters we al gelezen hebben, is dit nul, betekent dat er een leeg veld in het bestand zit, dit kan niet. Dus gooien wij een error.
    2. Anders, als we een locatie aan het lezen waren, slaan we de locatie op, en gaan we over naar een ID lezen.
    3. Als wij een ID aan het lezen waren, slaan we deze op met de eerder opgeslagen locatie en gaan we weer over op het lezen van een locatie.
  4. Is dit niet ';', dan kijken we of we een locatie aan het lezen zijn. zo ja, dan schrijven we de byte in een buffer.
  5. Zijn we een getal aan het schrijven wordt de byte bij het getal opgeteld
  6. We gaan weer terug naar stap 1, tot alle bytes gelezen zijn

Deze manier van bestanden opslaan brengt zo haar problemen met zich mee.

Probleem 4. Titel en Voorbeeld

Goed, nu kan ik posts laten zien, maar voor het overzicht op de hoofdpagina wil ik de Titel een voorbeeld van de introductie graag ook uit de bestanden halen. Hier is weer een mooi voorbeel in maatwerk:

Ik pak de eerste Header van grootte 1 (<h1> in HTML), en noem deze de Titel. Ik pak de eerste paragraaf (<p> in HTML), kort deze in waar nodig, en noem deze het 'voorbeeld'

Probleem 5. Een Paragraaf inkorten

Een paragraaf kan verschillende subelementen bevatten die ik graag in mijn voorbeeld wil laten zien, dit betekent dat, als ik inkort in het midden van, bijvoorbeeld, een dikgedrukt stuk tekst, ik deze ook weer normaal moet maken, meet en zogeheten 'closing tag' in HTML. Daarna wil ik ook niet woorden in het midden al afbreken, dit betekent dat we het verkorten zo maken dat hij woorden heel laat. De manier waarop ik dit doe, is als volgt;

  1. Ik krijg de HTML van de paragraaf via CommonMark
  2. Ik haal de omringende <p></p> 'tags' weg, aangezien deze al in mijn template zitten
  3. Ik snij de HTML-code af met het volgende stukje code
    private void trimPreview(StringBuilder preview, int charLimit) {
        String[] split = preview.toString().split(" ");
        preview.delete(0, preview.capacity() - 1);
        Deque<String> elements = new ArrayDeque<>();
        for (String s : split) {
            if (s.isEmpty())
                continue;
            if (preview.length() + s.length() < charLimit) {
                preview.append(s);
                preview.append(" ");
                if (s.matches("(<[^/][^br](.*)>)")) {
                    int begin = s.indexOf('<');
                    while (s.charAt(begin + 1) == '/') {
                        begin = s.indexOf('<', begin + 1);
                    }
                    int end = s.indexOf(">", begin) - 1;
                    elements.push(s.substring(begin, end));
                }

                if (s.matches("(</([^br][A-Za-z0-9]+)>)")) {
                    int begin = s.indexOf('<');
                    while (s.charAt(begin + 1) != '/') {
                        begin = s.indexOf('<', begin + 1);
                    }
                    int end = s.indexOf(">", begin) - 1;
                    elements.remove(s.substring(begin, end));
                }
            } else {
                while (!elements.isEmpty()) {
                    preview.append("</").append(elements.pop()).append('>');
                }
                preview.append(" (...)");
                return;
            }
        }
    }

Deze code werkt als volgt:

  1. De code splitst de HTML-code op basis van spaties, dit neemt elk woord, en additonele tags, apart.
    • Let op: Tags met een spatie (<name attribute="value"> of <name \>) werken dus niet! Dit is niet zo erg, zolang als de eerste paragraaf maar geen syntax-highlight codeblokken bevat.
  2. De code kijkt of het volgende woord plus het huidige voorbeeld niet te lang is, zo ja;
    1. Het volgende woord wordt aan het voorbeeld toegevoegd
    2. Als het volgende woord een opening tag bevat, slaan we deze tijdelijk op
    3. Als het volgende woord een closing tag bevat, verwijderen we de opgeslagen opening tag.
  3. Als het woord wel te lang is, dan wordt deze niet toegevoegd.
  4. De resterende opgeslagen opening tags worden één voor één afgesloten door een closing tag te append-en
  5. Als laatste wordt (...) toegevoegd aan te tekst

Dit is niet een perfecte manier om HTML in te korten, maar het is perfect voor mijn gebruiksscenario. Aangezien mijn eerste paragraaf altijd een introductie zonder code is, en de HTML generator geen spaties in de tags zal invoegen.

Een laatste woord

Toen ik ooit begon met programmeren, en langzaam maar zeker ontwikkelingspatronen onder de knie begon te krijgen, was al mijn code zo robust mogelijk, en voor veel meer scenarios voorbereid dan waar het eigenlijk voor bedoeld was. Door de jaren heen heb ik mijzelf aangeleerd dit soort dingen alleen te doen in projecten die dat ook écht nodig hebben. Deze website is een mooi voorbeeld van een project die niet overdreven complex hoeft te zijn. Nu is hij nog niet af, als ik nog dingen wil toevoegen ga ik dat nog doen, maar dat is voor een andere keer.

Tot dan, dankjewel voor je interesse, en misschien zie ik je wel weer een keertje op mijn website!