Frontend na dijeti pomoću budžeta

Jedna od bitnijih stvari danas je brzina učitavanja stranice. Google je pokazao da ako se stranica učitava duže od 3s čak 53% ljudi neće imati strpljenja i odustaće i pre nego što se stranica učita. Iz tog razloga je važno paziti na performanse tokom izrade sajta. Osim Google-a, istraživanje su radile i druge velike kompanije kao što su Amazon, Yahoo, Walmart i Ebay. Sve su došle do istog zaključka, a to je da su performanse (brzina) mnogo bitne. Evo i statistike do koje su oni došli.

Glavne stavke koje utiču na učitavanje stranice su resursi i javaskripta koja se izvršava. Ove probleme možemo vrlo lako identifikovati na razlicite načine, koristeći interne ili eketerne alate (možda će biti neki post o ovome kasnije). Ali ovaj posao postaje dosta teži kada se radi CD (Continuous Development), zato što je potrebno performanse testirati posle svake izmene. Dodatni problem predstavlja rad u timu, ko testira i koje su granice da se je nesto dobro?

Ovaj problem je poznat i više puta obrađen, a to je da postavimo budžet. Isto kao i kod izrade projekta, gde postoji finansijski budžet ili vremenski budžet (rok), tako treba da postoji budžet za performanse.

Šta je budžet performansi?

Definicija je vrlo slobodna i otvorena, ali možemo da kažemo da je to ograničenje za stranice koje tim ne sme da prekorači. Namerno nisam definisao na šta se odnosi ograničenje, zato što to zavisi od tima. Ograničenje može da se postavi na broj resursa, veličinu stranice, ukupnu veličina svih slika,...

Šta meriti?

Kada sam iznad definisao šta je budžet performansi, pomenuo sam da možemo da ograničimo različite stvari. Ograničenja možemo da podelimo u tri kategorije:

Vremenski događaji

To su događaji koji se okidaju posle određenog vremenskog perioda prilikom učitavanja stranice. Takvi događaji su Time-To-Interactive, First Contentful Paint, domContentLoaded,...

Kada pratimo vremenske događaje bitno je da pratimo više od jednog događaja. Zato što se često dešava da se jedna stranica učitava za 5 sekundi ali se veći deo sadržaja učita za manje od jedne sekunde dok se druga stranica učitava za dve sekunde a sadržaj za jednu i po sekundu. Iako se prva stanica učitava tri sekunde duže, sam sadržaj se brže učitao.

Zato je bitno da pratimo više vremenskih događaja i da ih uparimo sa drugim metrikama.

Kvantitativno ograničenje

Kao i što samo ime kaže, ovo su sirove brojke koje određuju, na primer, koliko zahteva ima ili kolika je veličina stranice. Ove brojke ne preslikavaju realno performanse, zato sto dve stranice mogu da budu iste veličine a da se jedna dosta brže učitava od druge.

Svakako, ova ograničenja igraju svoju ulogu i mogu biti korisna ako se upare sa drugim metrikama. Za razliku od drugih ograničenja, ovo je dosta lakše razumeti i odrediti. Znamo da ako naša stranica zauzima x i mi dodamo skriptu koja zauzima y da će naša stranica zauzimati x+y.

Ja lično koristim ovo ograničenje kao neku vrstu fizičkog ograničenja, u smislu da cela stranica treba da se učita za x sekundi na 3G mreži. Na primer:

Željeno vreme učitavanja stranice: 2 sekunde
Konekcija klijenta: 3G (1.6 Mbps)
Maksimalna veličina stranice:1 400 Kb

Kako stalno ne bismo računali koliko stranica može da teži, postoji kalkulator koji će da uradi svu matematiku za nas. Kao bonus, kalkulator nam pruža mogućnost da rasporedimo resurse po tipu.

Raspored budžeta po tipu resursa

Eksterne mere

U ovom slučaju kao mera ograničenja se koriste eksterni alati, kao što su Lighthouse ili Webpagetest. Obično se koristi jedna ili više brojki iz razultata kao ograničenje.

Iako je ovo ograničenje dosta bolje od kvantitativnog ograničenja, zato što pruža celokupnu sliku stranice, ipak nije idealno rešenje. Postoji odličan članak koji pokazuje da je moguće da se napravi sajt koji ima ocenu 100 u eksternim alatima, a da zapravo sajt nije moguće koristiti.

Šta je bitno?

Prošli smo kroz različite vrste ograničenja ali kada imamo više opcija uvek se postalvja pitanje koje ograničenje je pravo ograničenje? Odgovor na ovo pitanje je dosta subjektivno, ali mislim da svi objektivno možemo da se složimo da je idealno imati kombinaciju ograničenja. Zato što nijedno ograničenje neće da nam da potpunu sliku, ali ako iskoristimo kombinaciju ograničenja možemo sigurno pokriti mnogo više slučajeva.

Često akteri, dizajneri i ljudi bez inžinjerske pozadine nisu svesni da njihove odluke utiču loše na performanse. Ovo je u neku ruku razumljivo, naša uloga je da objasnimo projekt menadžerima, dizajnerima i akterima kako izmene utiču na korisničko iskustvo.

Iz tog razloga je bitno da performans budžet postane deo projekta i nađe se na početku a ne na kraju kao opcija.

Uključite budžete u proces izgradnje

Ja lično imam jedno bitnije pitanje, ustvari inspiraciju za ovaj članak, a to je kako da integrišemo ovo unutar tima? U uvodu sam pomenuo isto ovo, a to je da je lako izmeriti i naći probleme ali kako integrisati ceo ovaj proces u tim gde radi više od jednog čoveka sa konstantnim izmenama (CI/CD).

Kroz ovaj članak ću pokušati da pokrijem kako da integrišemo budžet u sklopu git procesa, tako da pri kreiranju svakog PR-a dobijemo rezultate za performanse. Naravno, da bismo ovo uspeli potreban nam je CI, konkretno za ovaj primer ću korististi CircleCI ali se isto odnosi i na druge CI-ove.

Aplikacija koju testiramo

Aplikacija koju testiramo je SPA aplikacija koja je napravljena kao deo prezentacije. Homemau je aplikacija koja predstavlja dom za mačke i gde drugi ljudi mogu potražiti mačku koja će biti njihov novi kućni ljubimac. Aplikacija nema nikakvu konkretnu funkcionalnost već služi kao primer.

Ciljevi testiranja

  • Prilikom pravljenja PR-a novi kôd treba testirati svaki put.
  • Aplikacija mora da ima Lighthouse perfomance ocenu manju od 90.
  • Aplikacija mora da ima manji bundle od 200kb.
  • Pokrenuti sve testove više puta i prikazati prosek.
  • Ispisati rezultate izveštaja unutar PR-a. Takođe, korisno je ako možemo da ostavimo link do HTML izveštaja.
  • Ako ne ispoštujemo budžet PR treba biti odbijen.

Postavljanje budžeta

Kako bismo ispoštovali sve ono o čemu smo pričali, moramo da odredimo budžet. U idealnom svetu ovo se određuje na početku projekta kad i finansijski budžet. Recimo da smo se složili koji je naš budžet, šta dalje?

Ovo je na nama. Možemo da napravimo novi fajl, i moja praksa je da se on nalazi unutar package.json.  Smatram da sve što se tiče projekta treba da se nalazi tu i dosta je lakše da prolazimo kroz jedan konfiguaracioni fajl nego kroz više manjih fajlova.

"budget": {
    "lighthouse": {
        "performance": 90,
        "accessibility": 80,
        "best-practices": 80,
      	"seo": 80
    },
}

Proces testiranja

Kako bismo se malo bolje upoznali sa CircleCI-em i terminologijom, proćiću korak po korak kroz proces testiranja.

graph LR
	A[Instaliraj biblioteke]-->B[Build and deploy]
	B-->C[Pokreni Lighthouse]
	C-->D[Analiziraj i ažuriraj PR]

U CircleCI-u svaki od ovih koraka se zove "job" a sekvenca poslova (kao što mi imamo) je "workflow". Konfiguraciju za CircleCI čuvamo u config.yaml koji treba da stavimo u .circleci folder.

Drugi korak u celom ovom procesu je vrlo bitan i mnogo zavisi od našeg okruženja. Vrlo je bitno da postoji mogućnost da iz našeg CI-a pokrenemo deploy proces, zato što nam je potreban link do poslednje verzije aplikacije.

Korisno je da imamo više okruženja prilikom izrade. Obično je praksa da postoje development, staging i live okruženje. Ovo je možda članak sam za sebe ali je bitno za ovaj proces.

Pokretanje Lighthouse-a

Ono što treba da uradimo jeste da pokrenemo Lighthouse u odnosu na naš development ili staging server. Lighthouse pruza CLI koji možemo da iskoristimo ali potrebno nam je celokupno okruženje. Na svu sreću postoji Docker container kporras07/lighthouse-ci koji mi možemo da iskoristimo kao neku vrstu interfejsa.

 runPerf:
    docker:
      - image: kporras07/lighthouse-ci

    steps:
      - checkout

      - run:
          name: Run lighthouse against staging deployment

          environment:
            DEV_URL: https://marko.ilic.ninja/homemau

          command: |
            lighthouse $TEST_URL \
              --chrome-flags=\"--headless\" \
              --output-path=/home/chrome/reports/anonymous-"$(echo -n $CIRCLE_SHELL_ENV | md5sum | awk '{print $1}')" \
              --output=json \
              --output=html

      - persist_to_workspace:
          root: /home/chrome
          paths:
            - reports

Ovo što smo ovde opisali je samo treći korak našeg procesa.

CircleCI će prvo da preuzme i pokrene Docker image kporras07/lighhouse-ci.

Zatim se izvršava samo jedan korak, kom smo dodelili ime (name) koje kasnije vidimo unutar CircleCI-a. Postavimo promenjivu DEV_URL koju ćemo kasnije da koristimo. Na kraju pokrenemo lighthouse komandu kojoj prosledimo par parametara, kao što su output parametri za tip izveštaja koji želimo.

JSON izveštaj koristimo radi lakšeg upoređivanja sa našim budžetom. Dok je HTML izveštaj radi prikaza i linkovanja u PR.

Opcija persist_to_workspace daje mogućnost da sve iz tog kontejnera ostane dostupno pod /reports direktorijumom unutar workspace-a.

Ako sada pokrenemo CircleCI on će pokrenuti test, generisati izveštaje i sačuvaće ih u /reports direkotorijumu.

Poređenje i postavljanje izveštaja

Nakon generisanja izveštaja neophodno ih je uporediti sa postavljenim budžetom na početku. Zato hajde da napišemo skriptu koja će da radi poređenje i zatim postavi komentar na PR.

Za ovo ne postoji neki "gotov" ili specijalan način, pošto radimo od nule, ovu skriptu moramo sami napisati. Pošto imamo JSON fajlove mislim da je najlakše da skriptu napišemo u NodeJS-u.

Pravimo novi "job" u CI-u:

updatePr:
    docker:
      - image: circleci/node:11.4.0

    steps:
      - checkout
      - restore_cache:
          keys:
            - node-v1-{{ checksum "package.json" }}-{{ checksum "yarn.lock" }}
      - attach_workspace:
          at: "."
      - store_artifacts:
          path: reports
          destination: reports
      - run:
          name: "Analyze and comment to the PR"
          command: ./.circleci/build-score.js budget.json reports

Pošto nam je potreban Node ja ću iskoristiti najnoviji "container" node:11.4.0. Takođe ono što je vrlo bitno je da imamo pristup izveštajima. Kako smo u prethodnom "job"-u koristili persist_to_workspace sada možemo isti taj folder iskoristiti tako što ćemo ga dodati pomoću attach_workspace komande. Još jedna stvar koja nam je bitna da imamo pristup HTML izveštaju unutar PR-a. Time postižemo da uvek imamo pristup izveštaju i detaljniji pregled. CircleCI pruža artifakte, koji služe za čuvanje podataka nakon što se poslovi završe. Artifakte možemo da koristimo koristeći store_artifacts  komandu.

Kad smo sve to lepo podeseli, vreme je da pokrenemo našu skriptu. Ja ću je čuvati u .cricleci folderu i zvaće se build-score.js.

Ono što sam dodao je da skripta prima dva parametra, putanju do budžeta i putanju do direktorijuma gde se čuvaju izveštaji. Ovo je zato što u slučaju da odlučim da promenim imena ovih datoteka ne moram da menjam skriptu već je skripta ista za svaki projekat.

Pisanje skripte

Ja ću ovde pokriti samo neke osnove kao kako postaviti komentar i uzeti izveštaj. Cela skripta je dostupna na Github-u.

Kako bih lakše postavljao komentar i uzeo link do artifakta koristiću circle-github-bot ovo je konkretno bot za Github koji u pozadini koristi ništa više nego Github API. Tako da je vrlo lako uraditi ovo i za Bitbucket.

#!/usr/local/bin/node

const fs = require('fs');
const path = require('path');
const bot = require('circle-github-bot').create();

const budget = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
const { lighthouse } = budget;

// Ucitaj sve izvestaje

const reportsFolder = process.argv[3];
const reports = {
    json: [],
    html: [],
};

fs.readdirSync(reportsFolder).forEach(file => {
    switch (path.extname(file)) {
        case '.json':
            reports.json.push(JSON.parse(fs.readFileSync(path.join(reportsFolder, file), 'utf8')));
            break;
        case '.html':
            reports.html.push(file);
            break;
    }
});

Sada kad imam pristup svim izveštajima i budžetu koji smo postavili, možemo vrlo lako da manipulišemo podacima, zatim da ih formatiramo i postavimo kao komentar.

Prilikom generisanja linka za artifakte koristi se funkcija artifactLink() ali putanja koju vrati nije tačna zato što vrati punu putanju koja ima unutar sebe /home/circleci/project. Ovo samo treba zameniti praznim stringom.

Evo primera kako izgleda moja funkcija za uzimanje svih HTML izveštaja:


const reportLinks = reports.html.map((filename, i) => {
  return bot.artifactLink(`reports/${filename}`, `Report ${i + 1}`).replace('/home/circleci/project', '');
});

Evo kako izgleda kranji izveštaj:

Komentar na PR-u

Testovi po meri

Na početku sam pričao o različitim metrikama koje možemo da koristimo i vidimo da Lighthouse pokriva veliki deo, ali ipak ima nekih nedostataka. Recimo da hoćemo da ograničimo veličinu našeh izlaznog JS fajla.

Ako zagrebemo malo dublje u Lighthouse dokumentaciju možemo da nađemo način na koji se pišu testovi po meri. Možemo da iskoristimo Lighthouse da nam vrati neke korisne podatke, zatim da uporedimo sa našim budžetom.

Arhitektura Lighthouse-a, Github.

Kako bismo napisali test potrebne su dve komponente:

  • Sakupljač (Gatherer) - skuplja potrebne podatke za test.
  • Test (Audit) - pokreće se i vraća true ili false.

Za početak je potrebno da definišemo podešavanja koje će Lighthouse da uzme. Nazvaćemo fajl weight-audit-config.js

module.exports = {
    // Pokreni nas test uz ostale regularne (default) testove
    extends: "lighthouse:default",
    
    // Testovi koji ce da se pokrenu uz Lighthouse
    // Na osnovu ovog se odredjuje ime fajla.
    audits: ["weight-audit"],
    
    // Napravi novu kategoriju `Velicina JS datoteke`
    categories: {
        "js-weight": {
            title: "Velicina JS datoteke",
            description: "Alooo bato, ajmo malo crossfit za sajt!",
            auditRefs: [{ id: "weight-audit", weight: 1 }]
        }
    }
};

Pošto smo gore nazvali test weight-audit tako isto moramo da nazovemo fajl koji će da pokreće test. Zato pravim fajl weight-audit.js.

Ustvari, pre nego što napravimo taj fajl bitno je da razmislimo gde da stavimo koliki je naš budžet i možda još bitnije ime fajla. Mislim da je najbolje da se ovi podaci nalaze tamo gde i sav budžet budget.json.

"bundleSize": 200,
"bundleName": "app.js"

Potencijalni problem ako bundleName bude ovako definisan je što ako korisitmo neki heš prilikom generisanje fajla to neće da radi. Iz tog razloga ovde možemo da napišemo RegEx i kasnije koristimo test() metodu.

Tako da u našem slučaju taj RegEx može da izgleda ovako js/app.*\\.js

Da se vratimo na weight-audit.js

// Zato sto je `npm link` pravio problem morao sam da navedem celu putanju
const Audit = require("/usr/local/lib/node_modules/lighthouse").Audit;

class WeightAudit extends Audit {
    static get meta() {
        return {
            id: "weight-audit",
            title: "Velicina JS datoteke",
            failureTitle: `JS bundle exceeds your threshold of ${
                process.env.MAX_BUNDLE_SIZE_KB
            }kb`,
            description: "Alooo bato, ajmo malo crossfit za sajt!",
            // Sakpuljac (Getherer)
            requiredArtifacts: ["devtoolsLogs"]
        };
    }

    static async audit(artifacts, context) {
        // Uzmi Devtools log
        const devtoolsLogs = artifacts.devtoolsLogs["defaultPass"];
        // Uzmi network tab
        const networkRecords = await artifacts.requestNetworkRecords(devtoolsLogs);

        // Nadji sve resurse koji su tipa `Script` i podudaraju se sa nasim imenom
        const bundleRecord = networkRecords.find(record =>
                record.resourceType === "Script"
            &&  new RegExp(process.env.BUNDLE_NAME).test(record.url)
        );

        // Proveri da li je test prosao
        const belowThreshold =  bundleRecord.transferSize <= process.env.BUNDLE_SIZE * 1024;

        return {
            rawValue: (bundleRecord.transferSize / 1024).toFixed(1),
            // Rezultat se vraca izmedju 0 i 1, posto kod nas to moze da bude samo true (1) ili false (0)
            // Prebacicu rezultat iz Boolean u Number.
            score: Number(belowThreshold),
            displayValue: `${bundleRecord.url} je ${(bundleRecord.transferSize / 1024).toFixed(1)}kb`
        };
    }
}

module.exports = WeightAudit;

Malo da objasnim o čemu se radi. Na početku definišem meta podatke kao što su ime, opis, poruku ako je pao test i, najbitnije, sakupljač podataka (gatherer).

Posotji opcija da se ručno piše sakpuljač podataka od nule, ali pošto su nama potrebni podaci koji su već dostupni u devtools, nema potrebe da pišemo naš "gatherer". Evo primera kako se piše.

Zatim pišemo naš test koji treba da vrati objekat sa nekim definisanim svojstvima. Bitno je da napomenem da se rezultat vraća kao vrednost od nula do jedan iako je u testu prikazana kao 0 - 100.

Sad kad smo definisali test ostalo je još da kažemo Lighthouse-u da pokrene test. To se radi tako što prilikom pokretanja lighthouse CLI prosledimo --config-path putanju do naše datoteke. Dobijamo sledeću komandu:

command: |
  BUNDLE_NAME="$(node -p 'require("./package.json").lighthouse.bundleName')" \
  BUNDLE_SIZE="$(node -p 'require("./package.json").lighthouse.bundleSize')" \
  lighthouse $TEST_URL \
      --port=9222 \
      --chrome-flags=\"--headless\" \
      --config-path=./.circleci/lighthouse/weight-audit-config.js \
      --output-path=/home/chrome/reports/anonymous-"$(echo -n $CIRCLE_SHELL_ENV | md5sum | awk '{print $1}')" \
      --output=json \
      --output=html

Ovaj naš test će se sada pojaviti i u HTML izveštaju.

Autentifikacija

Odužio se ovaj članak, ali evo još malo kraja. Za kraj izborićemo se sa najvećim problemom, autentifikacija. Verovano se velika većina tokom čitanja ovog teksta pitala "šta je sa stranicama koje imaju autentifikaciju?"

Ovo je odlično pitanje koje je bitno da rešimo. Zato što je bitno da možemo da testiramo i delove sajta koji su iza prijave za korisnika.

Ligthouse pruža --extra-headers opciju, ali to znači da moramo ručno da generišemo tokene. Tokeni ističu što znači test može da padne ako zaboravimo da ažuriramo token. Ovo rešenje nije prihvatljivo.

Pošto imamo pristup NodeJS-u možemo ručno da punimo localStorage ali to neće da funkcioniše za svaki projekat, takođe može da bude poprilično naporno za održavanje.

Rešenje do kojeg sam ja došao je malo "hacky", zato što se oslanja ne redosled poziva "gatherer-a" i njegovih "hookova" da simulira kao da smo otvorili tu stranicu.

Napisaću skupljać koji ništa ne skuplja ;) već koristim beforePass "hook" i da unutar njega podignem puppeteer, ulogujem se i onda se pokrene test.

class Auth extends Gatherer {
    async beforePass(options) {

        const ws = await options.driver.wsEndpoint();

        const browser = await puppeteer.connect({
            browserWSEndpoint: ws,
        });

        const page = await browser.newPage();
        await page.goto(process.env.TEST_URL);

        await page.click('input[name=username]');
        await page.keyboard.type(process.env.ADMIN_USER);

        await page.click('input[name=password]');
        await page.keyboard.type(process.env.ADMIN_PASSWORD);

        await page.click('button[type="submit"]');
        await page.waitForSelector('#logout');

        browser.disconnect();
        return {};
    }
}

Reference

1: Ako je brzina konekcije 1.6 Mbps (MegaBITA po sekundi) mora da se prebaci u MB/s (MegaBAJTA po sekundi) što znači da delimo sa 8. Krajnja jednačina je 1.6 / 8 * 2 = 0.4 Mb = 400 Kb