Jak sprawić, by wydajność była widoczna dzięki GitLab CI i hoodoo artefaktów GitLab
Opublikowany: 2022-03-10Pogorszenie wydajności to problem, z którym borykamy się na co dzień. Moglibyśmy dołożyć starań, aby aplikacja działała szybko, ale wkrótce skończyliśmy tam, gdzie zaczęliśmy. Dzieje się tak z powodu dodawania nowych funkcji i faktu, że czasami nie zastanawiamy się nad pakietami, które stale dodajemy i aktualizujemy, lub myślimy o złożoności naszego kodu. To generalnie drobiazg, ale nadal chodzi o małe rzeczy.
Nie możemy sobie pozwolić na powolną aplikację. Wydajność to przewaga konkurencyjna, która może przyciągnąć i utrzymać klientów. Nie możemy sobie pozwolić na regularne spędzanie czasu na ponownej optymalizacji aplikacji. Jest to kosztowne i złożone. A to oznacza, że pomimo wszystkich korzyści płynących z wydajności z perspektywy biznesowej, jest to mało opłacalne. Pierwszym krokiem do znalezienia rozwiązania każdego problemu jest uwidocznienie go. Ten artykuł pomoże ci w tym dokładnie.
Uwaga : Jeśli masz podstawową wiedzę na temat Node.js, niejasne pojęcie o tym, jak działa CI/CD i zależy Ci na wydajności aplikacji lub korzyściach biznesowych, jakie może przynieść, to dobrze jest iść.
Jak stworzyć budżet wydajności dla projektu?
Pierwsze pytania, które powinniśmy sobie zadać, to:
„Jaki jest projekt wykonawczy?”
„Których wskaźników powinienem użyć?”
„Które wartości tych wskaźników są akceptowalne?”
Wybór metryk wykracza poza zakres tego artykułu i zależy w dużej mierze od kontekstu projektu, ale radzę zacząć od lektury User-centric Performance Metrics autorstwa Philipa Waltona.
Z mojej perspektywy dobrym pomysłem jest użycie rozmiaru biblioteki w kilobajtach jako metryki pakietu npm. Czemu? Cóż, dzieje się tak dlatego, że jeśli inni ludzie dołączają Twój kod do swoich projektów, być może będą chcieli zminimalizować wpływ Twojego kodu na ostateczny rozmiar ich aplikacji.
W przypadku witryny jako metrykę uznałbym Time To First Byte (TTFB). Ta metryka pokazuje, ile czasu zajmuje serwerowi odpowiedź. Ta metryka jest ważna, ale dość niejasna, ponieważ może obejmować wszystko — począwszy od czasu renderowania serwera, a skończywszy na problemach z opóźnieniami. Dlatego fajnie jest używać go w połączeniu z Server Timing lub OpenTracing, aby dowiedzieć się, z czego dokładnie się składa.
Należy również wziąć pod uwagę takie metryki jak Time to Interactive (TTI) i First Meaningful Paint (ten ostatni zostanie wkrótce zastąpiony przez Largest Contentful Paint (LCP)). Myślę, że oba są najważniejsze — z perspektywy postrzeganej wydajności.
Pamiętaj jednak: dane są zawsze powiązane z kontekstem , więc nie bierz tego za pewnik. Zastanów się, co jest ważne w Twoim konkretnym przypadku.
Najłatwiejszym sposobem zdefiniowania pożądanych wartości dla metryk jest skorzystanie z usług konkurencji — a nawet siebie. Ponadto od czasu do czasu przydatne mogą być narzędzia, takie jak Kalkulator Budżetu Wydajności — po prostu pobaw się nim trochę.
Pogorszenie wydajności to problem, z którym codziennie borykamy się. Moglibyśmy postarać się, aby aplikacja działała szybko, ale wkrótce skończyliśmy tam, gdzie zaczęliśmy.
“
Użyj konkurentów dla swojej korzyści
Jeśli zdarzyło Ci się kiedyś uciec przed ekstatycznie podekscytowanym niedźwiedziem, to już wiesz, że nie trzeba być mistrzem olimpijskim w bieganiu, żeby wyjść z tych kłopotów. Musisz być tylko trochę szybszy od drugiego faceta.
Zrób więc listę konkurentów. Jeżeli są to projekty tego samego typu, to zazwyczaj składają się z podobnych do siebie typów stron. Na przykład w przypadku sklepu internetowego może to być strona z listą produktów, stroną ze szczegółami produktu, koszykiem, kasą i tak dalej.
- Mierz wartości wybranych metryk na każdym typie strony dla projektów konkurencji;
- Mierz te same metryki w swoim projekcie;
- Znajdź najbliższy wynik lepszy niż Twoja wartość dla każdej metryki w projektach konkurenta. Dodaj do nich 20% i ustaw jako kolejne cele.
Dlaczego 20%? To magiczna liczba, która podobno oznacza, że różnica będzie widoczna gołym okiem. Więcej na temat tej liczby można przeczytać w artykule Denysa Mishunova „Why Perceived Performance Matters, Part 1: The Perception Of Time”.
Walka z cieniem
Masz wyjątkowy projekt? Nie masz konkurentów? A może jesteś już lepszy od każdego z nich we wszystkich możliwych zmysłach? To nie problem. Zawsze możesz rywalizować z jedynym godnym przeciwnikiem, czyli z samym sobą. Mierz każdy wskaźnik wydajności swojego projektu na każdym typie strony, a następnie popraw je o te same 20%.
Testy syntetyczne
Istnieją dwa sposoby mierzenia wydajności:
- Syntetyczny (w kontrolowanym środowisku)
- RUM (pomiary użytkownika rzeczywistego)
Dane są zbierane od rzeczywistych użytkowników w produkcji.
W tym artykule użyjemy testów syntetycznych i założymy, że nasz projekt używa GitLab z wbudowanym CI do wdrażania projektu.
Biblioteka i jej rozmiar jako metryka
Załóżmy, że zdecydowałeś się rozwinąć bibliotekę i opublikować ją w NPM. Chcesz, aby był lekki — znacznie lżejszy niż konkurenci — aby miał mniejszy wpływ na końcowy rozmiar projektu. Oszczędza to ruch klientów — czasami ruch, za który płaci klient. Pozwala również na szybsze ładowanie projektu, co jest bardzo ważne w kontekście rosnącego udziału telefonów komórkowych i nowych rynków z wolnymi prędkościami połączenia i rozdrobnionym zasięgiem Internetu.
Pakiet do pomiaru rozmiaru biblioteki
Aby rozmiar biblioteki był jak najmniejszy, musimy uważnie obserwować, jak zmienia się w czasie rozwoju. Ale jak możesz to zrobić? Cóż, przydałby się pakiet Size Limit stworzony przez Andreya Sitnika z Evil Marsians.
Zainstalujmy to.
npm i -D size-limit @size-limit/preset-small-lib
Następnie dodaj go do package.json
.
"scripts": { + "size": "size-limit", "test": "jest && eslint ." }, + "size-limit": [ + { + "path": "index.js" + } + ],
Blok "size-limit":[{},{},…]
zawiera listę rozmiarów plików, które chcemy sprawdzić. W naszym przypadku jest to tylko jeden plik: index.js
.
size
skryptu NPM po prostu uruchamia pakiet size-limit
, który odczytuje wspomniany wcześniej size-limit
bloku konfiguracyjnego i sprawdza rozmiar wymienionych tam plików. Uruchommy to i zobaczmy, co się stanie:
npm run size

Widzimy rozmiar pliku, ale tak naprawdę ten rozmiar nie jest pod kontrolą. Naprawmy to dodając limit
do package.json
:
"size-limit": [ { + "limit": "2 KB", "path": "index.js" } ],
Teraz, jeśli uruchomimy skrypt, zostanie on zweryfikowany względem ustawionego przez nas limitu.

W przypadku, gdy nowe oprogramowanie zmieni rozmiar pliku do punktu przekroczenia zdefiniowanego limitu, skrypt zakończy się niezerowym kodem. To, poza innymi rzeczami, oznacza, że zatrzyma potok w CI GitLab.

Teraz możemy użyć git hook, aby sprawdzić, czy rozmiar pliku jest zgodny z limitem przed każdym zatwierdzeniem. Możemy nawet wykorzystać opakowanie husky, aby zrobić to w przyjemny i prosty sposób.
Zainstalujmy to.
npm i -D husky
Następnie zmodyfikuj nasz package.json
.
"size-limit": [ { "limit": "2 KB", "path": "index.js" } ], + "husky": { + "hooks": { + "pre-commit": "npm run size" + } + },
A teraz przed każdym zatwierdzeniem zostanie wykonane automatycznie polecenie npm run size
i jeśli zakończy się niezerowym kodem, to zatwierdzenie nigdy by się nie wydarzyło.

Istnieje jednak wiele sposobów na pominięcie haków (celowo lub nawet przypadkowo), więc nie powinniśmy zbytnio na nich polegać.
Należy również pamiętać, że nie powinniśmy blokować tego sprawdzenia. Czemu? Ponieważ to w porządku, że rozmiar biblioteki rośnie podczas dodawania nowych funkcji. Musimy sprawić, by zmiany były widoczne, to wszystko. Pomoże to uniknąć przypadkowego zwiększenia rozmiaru z powodu wprowadzenia biblioteki pomocniczej, której nie potrzebujemy. I być może daj programistom i właścicielom produktów powód do rozważenia, czy dodawana funkcja jest warta zwiększenia rozmiaru. A może, czy istnieją mniejsze pakiety alternatywne. Bundlephobia pozwala nam znaleźć alternatywę dla prawie każdego pakietu NPM.
Więc co powinniśmy zrobić? Pokażmy zmianę rozmiaru pliku bezpośrednio w żądaniu scalenia! Ale nie naciskasz bezpośrednio na opanowanie; zachowujesz się jak dorosły programista, prawda?
Uruchamianie naszego testu na GitLab CI
Dodajmy artefakt GitLab typu metryki. Artefakt to plik, który „ożyje” po zakończeniu operacji potoku. Ten specyficzny typ artefaktu pozwala nam wyświetlić dodatkowy widżet w żądaniu scalenia, pokazując każdą zmianę wartości metryki między artefaktem w gałęzi głównej i gałęzi funkcji. Format artefaktu metrics
to tekstowy format Prometheus. W przypadku wartości GitLab wewnątrz artefaktu jest to tylko tekst. GitLab nie rozumie, co dokładnie zmieniło się w wartości — po prostu wie, że wartość jest inna. Więc co dokładnie powinniśmy zrobić?
- Zdefiniuj artefakty w potoku.
- Zmień skrypt tak, aby tworzył artefakt w potoku.
Aby stworzyć artefakt musimy zmienić .gitlab-ci.yml
ten sposób:
image: node:latest stages: - performance sizecheck: stage: performance before_script: - npm ci script: - npm run size + artifacts: + expire_in: 7 days + paths: + - metric.txt + reports: + metrics: metric.txt
-
expire_in: 7 days
— artefakt będzie istniał przez 7 dni. paths: metric.txt
Zostanie zapisany w katalogu głównym. Jeśli pominiesz tę opcję, nie będzie można jej pobrać.reports: metrics: metric.txt
Artefakt będzie miał typreports:metrics
Teraz sprawmy, aby Limit rozmiaru wygenerował raport. Aby to zrobić musimy zmienić package.json
:
"scripts": { - "size": "size-limit", + "size": "size-limit --json > size-limit.json", "test": "jest && eslint ." },
size-limit
z kluczem --json
dane w formacie json:

size-limit --json
wyprowadza kod JSON do konsoli. JSON zawiera tablicę obiektów, które zawierają nazwę pliku i rozmiar, a także informuje nas, czy przekracza limit rozmiaru. (duży podgląd) A przekierowanie > size-limit.json
zapisze JSON w pliku size-limit.json
.
Teraz musimy stworzyć z tego artefakt. Format sprowadza się do [metrics name][space][metrics value]
. Stwórzmy skrypt generate-metric.js
:
const report = require('./size-limit.json'); process.stdout.write(`size ${(report[0].size/1024).toFixed(1)}Kb`); process.exit(0);
I dodaj go do package.json
:
"scripts": { "size": "size-limit --json > size-limit.json", + "postsize": "node generate-metric.js > metric.txt", "test": "jest && eslint ." },
Ponieważ użyliśmy prefiksu post
, polecenie npm run size
najpierw uruchomi skrypt size
, a następnie automatycznie wykona skrypt postsize
, co spowoduje utworzenie pliku metric.txt
, naszego artefaktu.
W rezultacie, gdy scalimy tę gałąź do masteru, zmienimy coś i utworzymy nowe żądanie scalenia, zobaczymy co następuje:

W widżecie, który pojawia się na stronie, najpierw widzimy nazwę metryki ( size
), a następnie wartość metryki w gałęzi funkcji oraz wartość w wzorcu w nawiasach okrągłych.
Teraz możemy zobaczyć, jak zmienić rozmiar pakietu i podjąć rozsądną decyzję, czy powinniśmy go scalić, czy nie.
- Możesz zobaczyć cały ten kod w tym repozytorium.
Wznawianie
OK! Więc wymyśliliśmy, jak poradzić sobie z trywialną sprawą. Jeśli masz wiele plików, po prostu oddziel metryki podziałami wierszy. Jako alternatywę dla limitu rozmiaru możesz rozważyć rozmiar pakietu. Jeśli używasz WebPack, możesz uzyskać wszystkie potrzebne rozmiary, budując z flagami --profile
i --json
:
webpack --profile --json > stats.json
Jeśli używasz next.js, możesz użyć wtyczki @next/bundle-analyzer. To zależy od Ciebie!
Korzystanie z latarni morskiej
Lighthouse to de facto standard w analityce projektów. Napiszmy skrypt, który pozwoli nam zmierzyć wydajność, a11y, najlepsze praktyki i dostarczyć nam wynik SEO.
Skrypt do pomiaru wszystkich rzeczy
Na początek musimy zainstalować pakiet latarni, który wykona pomiary. Musimy również zainstalować lalkarza, którego będziemy używać jako przeglądarki bez głowy.
npm i -D lighthouse puppeteer
Następnie utwórzmy skrypt lighthouse.js
i uruchommy naszą przeglądarkę:
const puppeteer = require('puppeteer'); (async () => { const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox', '--headless'], }); })();
Napiszmy teraz funkcję, która pomoże nam przeanalizować dany URL:
const lighthouse = require('lighthouse'); const DOMAIN = process.env.DOMAIN; const buildReport = browser => async url => { const data = await lighthouse( `${DOMAIN}${url}`, { port: new URL(browser.wsEndpoint()).port, output: 'json', }, { extends: 'lighthouse:full', } ); const { report: reportJSON } = data; const report = JSON.parse(reportJSON); // … }
Świetnie! Mamy teraz funkcję, która zaakceptuje obiekt przeglądarki jako argument i zwróci funkcję, która zaakceptuje URL
jako argument i wygeneruje raport po przekazaniu tego URL
do lighthouse
.
Do lighthouse
przekazujemy następujące argumenty :
- Adres, który chcemy przeanalizować;
- opcje
lighthouse
, w szczególnościport
przeglądarki ioutput
(format wyjściowy raportu); - konfiguracja
report
ilighthouse:full
(wszystko, co możemy zmierzyć). Aby uzyskać dokładniejszą konfigurację, zapoznaj się z dokumentacją.
Wspaniały! Mamy teraz nasz raport. Ale co możemy z tym zrobić? Cóż, możemy sprawdzić metryki pod kątem limitów i wyjść ze skryptu z niezerowym kodem, który zatrzyma potok:
if (report.categories.performance.score < 0.8) process.exit(1);
Ale chcemy tylko, aby wydajność była widoczna i nie blokowała? Następnie zaadoptujmy inny typ artefaktu: artefakt wydajności GitLab.
Artefakt wydajności GitLab
Aby zrozumieć ten format artefaktów, musimy przeczytać kod wtyczki sitespeed.io. (Dlaczego GitLab nie może opisać formatu swoich artefaktów we własnej dokumentacji? Tajemnica. )
[ { "subject":"/", "metrics":[ { "name":"Transfer Size (KB)", "value":"19.5", "desiredSize":"smaller" }, { "name":"Total Score", "value":92, "desiredSize":"larger" }, {…} ] }, {…} ]
Artefakt to plik JSON
zawierający tablicę obiektów. Każdy z nich reprezentuje raport o jednym URL
.
[{page 1}, {page 2}, …]
Każda strona jest reprezentowana przez obiekt z kolejnymi atrybutami:
-
subject
identyfikator strony (przydatne jest używanie takiej ścieżki); -
metrics
Tablica obiektów (każdy z nich reprezentuje jeden pomiar, który został wykonany na stronie).
{ "subject":"/login/", "metrics":[{measurement 1}, {measurement 2}, {measurement 3}, …] }
measurement
to obiekt, który zawiera następujące atrybuty:
-
name
Nazwa pomiaru, np. może to byćTime to first byte
lubTime to interactive
. -
value
Numeryczny wynik pomiaru. -
desiredSize
Jeśli wartość docelowa powinna być jak najmniejsza, np. w przypadku metrykiTime to interactive
, wartość powinna byćsmaller
. Jeśli powinien być jak największy, np. w przypadku ocenyPerformance score
latarni morskiej , użyjlarger
.
{ "name":"Time to first byte (ms)", "value":240, "desiredSize":"smaller" }
Zmodyfikujmy naszą funkcję buildReport
w taki sposób, aby zwracała raport dla jednej strony ze standardowymi metrykami latarni.

const buildReport = browser => async url => { // … const metrics = [ { name: report.categories.performance.title, value: report.categories.performance.score, desiredSize: 'larger', }, { name: report.categories.accessibility.title, value: report.categories.accessibility.score, desiredSize: 'larger', }, { name: report.categories['best-practices'].title, value: report.categories['best-practices'].score, desiredSize: 'larger', }, { name: report.categories.seo.title, value: report.categories.seo.score, desiredSize: 'larger', }, { name: report.categories.pwa.title, value: report.categories.pwa.score, desiredSize: 'larger', }, ]; return { subject: url, metrics: metrics, }; }
Teraz, gdy mamy funkcję, która generuje raport. Zastosujmy to do każdego typu stron projektu. Najpierw muszę stwierdzić, że process.env.DOMAIN
powinien zawierać domenę pomostową (do której należy wcześniej wdrożyć projekt z gałęzi funkcji).
+ const fs = require('fs'); const lighthouse = require('lighthouse'); const puppeteer = require('puppeteer'); const DOMAIN = process.env.DOMAIN; const buildReport = browser => async url => {/* … */}; + const urls = [ + '/inloggen', + '/wachtwoord-herstellen-otp', + '/lp/service', + '/send-request-to/ww-tammer', + '/post-service-request/binnenschilderwerk', + ]; (async () => { const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox', '--headless'], }); + const builder = buildReport(browser); + const report = []; + for (let url of urls) { + const metrics = await builder(url); + report.push(metrics); + } + fs.writeFileSync(`./performance.json`, JSON.stringify(report)); + await browser.close(); })();
- Pełne źródło można znaleźć w tym skrócie i działającym przykładzie w tym repozytorium.
Uwaga : W tym momencie możesz chcieć przerwać mi i krzyczeć na próżno: „Dlaczego zajmujesz mój czas — nie możesz nawet prawidłowo używać Promise.all!” Na swoją obronę śmiem twierdzić, że nie zaleca się uruchamiania więcej niż jednej instancji latarni jednocześnie, ponieważ wpływa to niekorzystnie na dokładność wyników pomiarów. Ponadto, jeśli nie wykażesz się należytą pomysłowością, doprowadzi to do wyjątku.

Korzystanie z wielu procesów
Czy nadal interesują Cię pomiary równoległe? W porządku, możesz chcieć użyć klastra węzłów (lub nawet wątków roboczych, jeśli lubisz grać odważnie), ale ma sens omawianie tego tylko w przypadku, gdy twój potok działa w środowisku z wieloma dostępnymi serwerami. I nawet wtedy należy pamiętać, że ze względu na naturę Node.js w każdym rozwidleniu procesu będzie odradzana pełnowymiarowa instancja Node.js (zamiast ponownego użycia tej samej, co doprowadzi do wzrostu zużycia pamięci RAM). Wszystko to oznacza, że będzie droższe ze względu na rosnące wymagania sprzętowe i trochę szybsze. Może się wydawać, że gra nie jest warta świeczki.
Jeśli chcesz podjąć to ryzyko, będziesz musiał:
- Podziel tablicę adresów URL na porcje według liczby rdzeni;
- Utwórz widelec procesu zgodnie z liczbą rdzeni;
- Przenieś części tablicy do widełek, a następnie pobierz wygenerowane raporty.
Aby podzielić tablicę, możesz użyć podejścia wielokrotnego. Poniższy kod — napisany w kilka minut — nie byłby gorszy od pozostałych:
/** * Returns urls array splited to chunks accordin to cors number * * @param urls {String[]} — URLs array * @param cors {Number} — count of available cors * @return {Array } — URLs array splited to chunks */ function chunkArray(urls, cors) { const chunks = [...Array(cors)].map(() => []); let index = 0; urls.forEach((url) => { if (index > (chunks.length - 1)) { index = 0; } chunks[index].push(url); index += 1; }); return chunks; }
/** * Returns urls array splited to chunks accordin to cors number * * @param urls {String[]} — URLs array * @param cors {Number} — count of available cors * @return {Array } — URLs array splited to chunks */ function chunkArray(urls, cors) { const chunks = [...Array(cors)].map(() => []); let index = 0; urls.forEach((url) => { if (index > (chunks.length - 1)) { index = 0; } chunks[index].push(url); index += 1; }); return chunks; }
Wykonaj widły według liczby rdzeni:
// Adding packages that allow us to use cluster const cluster = require('cluster'); // And find out how many cors are available. Both packages are build-in for node.js. const numCPUs = require('os').cpus().length; (async () => { if (cluster.isMaster) { // Parent process const chunks = chunkArray(urls, urls.length/numCPUs); chunks.map(chunk => { // Creating child processes const worker = cluster.fork(); }); } else { // Child process } })();
Przenieśmy tablicę porcji do procesów podrzędnych i odzyskajmy raporty:
(async () => { if (cluster.isMaster) { // Parent process const chunks = chunkArray(urls, urls.length/numCPUs); chunks.map(chunk => { const worker = cluster.fork(); + // Send message with URL's array to child process + worker.send(chunk); }); } else { // Child process + // Recieveing message from parent proccess + process.on('message', async (urls) => { + const browser = await puppeteer.launch({ + args: ['--no-sandbox', '--disable-setuid-sandbox', '--headless'], + }); + const builder = buildReport(browser); + const report = []; + for (let url of urls) { + // Generating report for each URL + const metrics = await builder(url); + report.push(metrics); + } + // Send array of reports back to the parent proccess + cluster.worker.send(report); + await browser.close(); + }); } })();
I na koniec ponownie zmontuj raporty w jednej macierzy i wygeneruj artefakt.
- Zapoznaj się z pełnym kodem i repozytorium z przykładem, który pokazuje, jak wykorzystać latarnię morską z wieloma procesami.
Dokładność pomiarów
Otóż zrównoleglaliśmy pomiary, co zwiększyło i tak już niefortunnie duży błąd pomiaru lighthouse
. Ale jak to zredukować? Cóż, zrób kilka pomiarów i oblicz średnią.
W tym celu napiszemy funkcję, która obliczy średnią pomiędzy aktualnymi wynikami pomiarów a poprzednimi.
// Count of measurements we want to make const MEASURES_COUNT = 3; /* * Reducer which will calculate an avarage value of all page measurements * @param pages {Object} — accumulator * @param page {Object} — page * @return {Object} — page with avarage metrics values */ const mergeMetrics = (pages, page) => { if (!pages) return page; return { subject: pages.subject, metrics: pages.metrics.map((measure, index) => { let value = (measure.value + page.metrics[index].value)/2; value = +value.toFixed(2); return { ...measure, value, } }), } }
Następnie zmień nasz kod, aby z nich korzystać:
process.on('message', async (urls) => { const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox', '--headless'], }); const builder = buildReport(browser); const report = []; for (let url of urls) { + // Let's measure MEASURES_COUNT times and calculate the avarage + let measures = []; + let index = MEASURES_COUNT; + while(index--){ const metric = await builder(url); + measures.push(metric); + } + const measure = measures.reduce(mergeMetrics); report.push(measure); } cluster.worker.send(report); await browser.close(); }); }
- Zapoznaj się z treścią z pełnym kodem i repozytorium z przykładem.
A teraz możemy dodać lighthouse
do rurociągu.
Dodanie go do rurociągu
Najpierw utwórz plik konfiguracyjny o nazwie .gitlab-ci.yml
.
image: node:latest stages: # You need to deploy a project to staging and put the staging domain name # into the environment variable DOMAIN. But this is beyond the scope of this article, # primarily because it is very dependent on your specific project. # - deploy # - performance lighthouse: stage: performance before_script: - apt-get update - apt-get -y install gconf-service libasound2 libatk1.0-0 libatk-bridge2.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget - npm ci script: - node lighthouse.js artifacts: expire_in: 7 days paths: - performance.json reports: performance: performance.json
Wiele zainstalowanych pakietów jest potrzebnych dla puppeteer
. Alternatywnie możesz rozważyć użycie docker
. Poza tym sensowne jest to, że określamy rodzaj artefaktu jako performance. A gdy tylko gałąź główna i gałąź funkcji będą to mieć, zobaczysz taki widżet w żądaniu scalenia:

Miły?
Wznawianie
W końcu skończyliśmy z bardziej złożoną sprawą. Oczywiście poza latarnią morską istnieje wiele podobnych narzędzi. Na przykład sitespeed.io. Dokumentacja GitLab zawiera nawet artykuł, który wyjaśnia, jak używać sitespeed
w potoku GitLab. Istnieje również wtyczka do GitLab, która pozwala nam wygenerować artefakt. Ale kto wolałby, aby produkty typu open source kierowane przez społeczność były własnością korporacyjnego potwora?
Nie ma odpoczynku dla niegodziwych
Może się wydawać, że wreszcie jesteśmy, ale nie, jeszcze nie. Jeśli korzystasz z płatnej wersji GitLab, artefakty z metrics
i performance
typów raportów są obecne w planach, począwszy od premium
i silver
, które kosztują 19 USD miesięcznie za każdego użytkownika. Ponadto nie możesz po prostu kupić określonej funkcji, której potrzebujesz — możesz zmienić tylko plan. Przepraszam. Więc co możemy zrobić? W przeciwieństwie do GitHub z jego API Checks i API Status, GitLab nie pozwoliłby na samodzielne utworzenie rzeczywistego widżetu w żądaniu scalenia. I nie ma nadziei, że zdobędziemy je w najbliższym czasie.

Jeden ze sposobów sprawdzenia, czy rzeczywiście obsługujesz te funkcje: Możesz wyszukać zmienną środowiskową GITLAB_FEATURES
w potoku. Jeśli na liście brakuje merge_request_performance_metrics
i metrics_reports
, ta funkcja nie jest obsługiwana.
GITLAB_FEATURES=audit_events,burndown_charts,code_owners,contribution_analytics, elastic_search, export_issues,group_bulk_edit,group_burndown_charts,group_webhooks, issuable_default_templates,issue_board_focus_mode,issue_weights,jenkins_integration, ldap_group_sync,member_lock,merge_request_approvers,multiple_issue_assignees, multiple_ldap_servers,multiple_merge_request_assignees,protected_refs_for_users, push_rules,related_issues,repository_mirrors,repository_size_limit,scoped_issue_board, usage_quotas,visual_review_app,wip_limits
Jeśli nie ma wsparcia, musimy coś wymyślić. Na przykład możemy dodać komentarz do żądania scalenia, komentarz z tabelą zawierającą wszystkie potrzebne nam dane. Możemy pozostawić nasz kod nietknięty — artefakty będą tworzone, ale widżety zawsze będą wyświetlały komunikat «metrics are unchanged»
.
Bardzo dziwne i nieoczywiste zachowanie; Musiałem się dobrze zastanowić, żeby zrozumieć, co się dzieje.
Więc jaki jest plan?
- Musimy odczytać artefakt z gałęzi
master
; - Utwórz komentarz w formacie
markdown
; - Pobierz identyfikator żądania scalenia z bieżącej gałęzi funkcji do wzorca;
- Dodaj komentarz.
Jak czytać artefakty z gałęzi głównej?
Jeśli chcemy pokazać, jak zmieniają się metryki wydajności między gałęzią master
i gałęzią funkcji, musimy odczytać artefakt z master
. Aby to zrobić, będziemy musieli użyć fetch
.
npm i -S isomorphic-fetch
// You can use predefined CI environment variables // @see https://gitlab.com/help/ci/variables/predefined_variables.md // We need fetch polyfill for node.js const fetch = require('isomorphic-fetch'); // GitLab domain const GITLAB_DOMAIN = process.env.CI_SERVER_HOST || process.env.GITLAB_DOMAIN || 'gitlab.com'; // User or organization name const NAME_SPACE = process.env.CI_PROJECT_NAMESPACE || process.env.PROJECT_NAMESPACE || 'silentimp'; // Repo name const PROJECT = process.env.CI_PROJECT_NAME || process.env.PROJECT_NAME || 'lighthouse-comments'; // Name of the job, which create an artifact const JOB_NAME = process.env.CI_JOB_NAME || process.env.JOB_NAME || 'lighthouse'; /* * Returns an artifact * * @param name {String} - artifact file name * @return {Object} - object with performance artifact * @throw {Error} - thhrow an error, if artifact contain string, that can't be parsed as a JSON. Or in case of fetch errors. */ const getArtifact = async name => { const response = await fetch(`https://${GITLAB_DOMAIN}/${NAME_SPACE}/${PROJECT}/-/jobs/artifacts/master/raw/${name}?job=${JOB_NAME}`); if (!response.ok) throw new Error('Artifact not found'); const data = await response.json(); return data; };
Tworzenie tekstu komentarza
Musimy zbudować tekst komentarza w formacie markdown
. Stwórzmy kilka funkcji serwisowych, które pomogą nam:
/** * Return part of report for specific page * * @param report {Object} — report * @param subject {String} — subject, that allow find specific page * @return {Object} — page report */ const getPage = (report, subject) => report.find(item => (item.subject === subject)); /** * Return specific metric for the page * * @param page {Object} — page * @param name {String} — metrics name * @return {Object} — metric */ const getMetric = (page, name) => page.metrics.find(item => item.name === name); /** * Return table cell for desired metric * * @param branch {Object} - report from feature branch * @param master {Object} - report from master branch * @param name {String} - metrics name */ const buildCell = (branch, master, name) => { const branchMetric = getMetric(branch, name); const masterMetric = getMetric(master, name); const branchValue = branchMetric.value; const masterValue = masterMetric.value; const desiredLarger = branchMetric.desiredSize === 'larger'; const isChanged = branchValue !== masterValue; const larger = branchValue > masterValue; if (!isChanged) return `${branchValue}`; if (larger) return `${branchValue} ${desiredLarger ? '' : '' } **+${Math.abs(branchValue - masterValue).toFixed(2)}**`; return `${branchValue} ${!desiredLarger ? '' : '' } **-${Math.abs(branchValue - masterValue).toFixed(2)}**`; }; /** * Returns text of the comment with table inside * This table contain changes in all metrics * * @param branch {Object} report from feature branch * @param master {Object} report from master branch * @return {String} comment markdown */ const buildCommentText = (branch, master) =>{ const md = branch.map( page => { const pageAtMaster = getPage(master, page.subject); if (!pageAtMaster) return ''; const md = `|${page.subject}|${buildCell(page, pageAtMaster, 'Performance')}|${buildCell(page, pageAtMaster, 'Accessibility')}|${buildCell(page, pageAtMaster, 'Best Practices')}|${buildCell(page, pageAtMaster, 'SEO')}| `; return md; }).join(''); return ` |Path|Performance|Accessibility|Best Practices|SEO| |--- |--- |--- |--- |--- | ${md} `; };
Skrypt, który zbuduje komentarz
Do pracy z GitLab API potrzebny będzie token. Aby go wygenerować, należy otworzyć GitLab, zalogować się, otworzyć w menu opcję „Ustawienia”, a następnie otworzyć „Tokeny dostępu” znajdujące się po lewej stronie menu nawigacyjnego. Powinieneś wtedy widzieć formularz, który umożliwia wygenerowanie tokena.

Potrzebny będzie również identyfikator projektu. Znajdziesz go w repozytorium „Ustawienia” (w podmenu „Ogólne”):

Aby dodać komentarz do prośby o scalenie, musimy znać jego identyfikator. Funkcja pozwalająca na uzyskanie ID żądania scalenia wygląda tak:
// You can set environment variables via CI/CD UI. // @see https://gitlab.com/help/ci/variables/README#variables // I have set GITLAB_TOKEN this way // ID of the project const GITLAB_PROJECT_ID = process.env.CI_PROJECT_ID || '18090019'; // Token const TOKEN = process.env.GITLAB_TOKEN; /** * Returns iid of the merge request from feature branch to master * @param from {String} — name of the feature branch * @param to {String} — name of the master branch * @return {Number} — iid of the merge request */ const getMRID = async (from, to) => { const response = await fetch(`https://${GITLAB_DOMAIN}/api/v4/projects/${GITLAB_PROJECT_ID}/merge_requests?target_branch=${to}&source_branch=${from}`, { method: 'GET', headers: { 'PRIVATE-TOKEN': TOKEN, } }); if (!response.ok) throw new Error('Merge request not found'); const [{iid}] = await response.json(); return iid; };
We need to get a feature branch name. You may use the environment variable CI_COMMIT_REF_SLUG
inside the pipeline. Outside of the pipeline, you can use the current-git-branch
package. Also, you will need to form a message body.
Let's install the packages we need for this matter:
npm i -S current-git-branch form-data
And now, finally, function to add a comment:
const FormData = require('form-data'); const branchName = require('current-git-branch'); // Branch from which we are making merge request // In the pipeline we have environment variable `CI_COMMIT_REF_NAME`, // which contains name of this banch. Function `branchName` // will return something like «HEAD detached» message in the pipeline. // And name of the branch outside of pipeline const CURRENT_BRANCH = process.env.CI_COMMIT_REF_NAME || branchName(); // Merge request target branch, usually it's master const DEFAULT_BRANCH = process.env.CI_DEFAULT_BRANCH || 'master'; /** * Adding comment to merege request * @param md {String} — markdown text of the comment */ const addComment = async md => { const iid = await getMRID(CURRENT_BRANCH, DEFAULT_BRANCH); const commentPath = `https://${GITLAB_DOMAIN}/api/v4/projects/${GITLAB_PROJECT_ID}/merge_requests/${iid}/notes`; const body = new FormData(); body.append('body', md); await fetch(commentPath, { method: 'POST', headers: { 'PRIVATE-TOKEN': TOKEN, }, body, }); };
And now we can generate and add a comment:
cluster.on('message', (worker, msg) => { report = [...report, ...msg]; worker.disconnect(); reportsCount++; if (reportsCount === chunks.length) { fs.writeFileSync(`./performance.json`, JSON.stringify(report)); + if (CURRENT_BRANCH === DEFAULT_BRANCH) process.exit(0); + try { + const masterReport = await getArtifact('performance.json'); + const md = buildCommentText(report, masterReport) + await addComment(md); + } catch (error) { + console.log(error); + } process.exit(0); } });
- Check the gist and demo repository.
Now create a merge request and you will get:

Resume
Comments are much less visible than widgets but it's still much better than nothing. This way we can visualize the performance even without artifacts.
Uwierzytelnianie
OK, but what about authentication? The performance of the pages that require authentication is also important. It's easy: we will simply log in. puppeteer
is essentially a fully-fledged browser and we can write scripts that mimic user actions:
const LOGIN_URL = '/login'; const USER_EMAIL = process.env.USER_EMAIL; const USER_PASSWORD = process.env.USER_PASSWORD; /** * Authentication sctipt * @param browser {Object} — browser instance */ const login = async browser => { const page = await browser.newPage(); page.setCacheEnabled(false); await page.goto(`${DOMAIN}${LOGIN_URL}`, { waitUntil: 'networkidle2' }); await page.click('input[name=email]'); await page.keyboard.type(USER_EMAIL); await page.click('input[name=password]'); await page.keyboard.type(USER_PASSWORD); await page.click('button[data-test]', { waitUntil: 'domcontentloaded' }); };
Before checking a page that requires authentication, we may just run this script. Gotowy.
Streszczenie
In this way, I built the performance monitoring system at Werkspot — a company I currently work for. It's great when you have the opportunity to experiment with the bleeding edge technology.
Now you also know how to visualize performance change, and it's sure to help you better track performance degradation. But what comes next? You can save the data and visualize it for a time period in order to better understand the big picture, and you can collect performance data directly from the users.
You may also check out a great talk on this subject: “Measuring Real User Performance In The Browser.” When you build the system that will collect performance data and visualize them, it will help to find your performance bottlenecks and resolve them. Good luck with that!