Buforowanie żądań RESTful API za pomocą Heroku Data for Redis

Większość twórców oprogramowania napotyka dwa główne problemy: nazywanie rzeczy, buforowanie i błędy typu “off-by-one”.

W tym samouczku zajmiemy się buforowaniem. Pokażemy Państwu, jak zaimplementować buforowanie żądań RESTful za pomocą Redis. Z łatwością skonfigurujemy i wdrożymy ten system za pomocą Heroku.

W tym demo zbudujemy aplikację Node.js z rozszerzeniem Fastify frameworki zintegrujemy buforowanie z Redis, aby zmniejszyć niektóre rodzaje opóźnień.

Gotowy, by zacząć? Proszę zaczynać!

Node.js + Fastify + Długotrwałe zadania

Jak zapewne nasi czytelnicy wiedzą, Node.js jest bardzo popularną platformą do tworzenia aplikacji internetowych. Dzięki obsłudze JavaScript (lub TypeScript, lub obu jednocześnie!), Node.js pozwala na użycie tego samego języka zarówno dla front-endu, jak i back-endu aplikacji. Posiada również bogatą pętlę zdarzeń, która sprawia, że asynchroniczna obsługa żądań jest bardziej intuicyjna.

Model współbieżności w Node.js jest bardzo wydajny, zdolny do obsłużyć ponad 15 000 żądań na sekundę. Ale nawet wtedy mogą Państwo napotkać sytuacje, w których opóźnienie żądania jest niedopuszczalnie wysokie. Pokażemy to na przykładzie naszej aplikacji.

Podczas śledzenia, zawsze mogą Państwo przeglądać bazę kodu dla tego mini demo na mojej stronie repozytorium GitHub.

Proszę zainicjować aplikację podstawową

Używając Fastifymożna szybko uruchomić aplikację Node.js do obsługi żądań. Zakładając, że mają Państwo zainstalowany Node.js, rozpoczną Państwo od zainicjowania nowego projektu. Użyjemy npm jako nasz menedżer pakietów.

Po zainicjowaniu nowego projektu zainstalujemy nasze zależności związane z Fastify.

~/project$ npm i fastify fastify-cli fastify-plugin

Następnie aktualizujemy nasz plik package.json, aby dodać dwa skrypty i włączyć składnię modułu ES. Upewniamy się, że mamy następujące linie:

"type": "module",
"main": "app.js",
"scripts": {
  "start": "fastify start -a 0.0.0.0 -l info app.js",
  "dev": "fastify start -p 8000 -w -l info -P app.js"
},

Następnie tworzymy nasz pierwszy plik (routes.js) z początkową trasą:

// routes.js

export default async function (fastify, _opts) {
  fastify.get("/api/health", async (_, reply) => {
    return reply.send({ status: "ok" });
  });
}

Następnie tworzymy nasz app.js który przygotowuje instancję Fastify i rejestruje trasy:

// app.js
import routes from "./routes.js";

export default async (fastify, opts) => {
  fastify.register(routes);
};

Te dwa proste pliki – nasza aplikacja i nasze definicje tras – to wszystko, czego potrzebujemy, aby rozpocząć pracę z małą usługą Fastify, która udostępnia jeden punkt końcowy: /api/health. Nasz skrypt deweloperski w package.json jest ustawiony na uruchamianie aplikacji fastify-cli aby uruchomić nasz serwer na porcie 8000 localhost, co na razie wystarczy. Uruchamiamy nasz serwer:

Następnie w innym oknie terminala używamy curl aby trafić na punkt końcowy:

~$ curl http://localhost:8000/api/health
{"status":"ok"}

Proszę dodać symulowany długotrwały proces

To dobry początek. Następnie dodajmy kolejną trasę, aby zasymulować długotrwały proces. Pomoże nam to zebrać dane dotyczące opóźnień. W pliku routes.js dodajemy kolejną obsługę trasy w ramach naszej wyeksportowanej domyślnej funkcji asynchronicznej:

fastify.get("/api/user-data", async (_, reply) => {
  await sleep(5000);
  const userData = readData();
  return reply.send({ data: userData });
});

Udostępnia ona kolejny punkt końcowy: /api/user-data. Tutaj mamy metodę symulacji odczytu dużej ilości danych z bazy danych (readData) i długotrwały proces (sleep). Metody te definiujemy również w pliku routes.js. Wyglądają one następująco:

import fs from "fs";

function readData() {
  try {
      const data = fs.readFileSync("data.txt", "utf8");
      return data;
  } catch (err) {
    console.error(err);
  }
}

function sleep(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}

Z naszą nową trasą na miejscu, restartujemy nasz serwer (npm run dev).

Pomiar opóźnienia za pomocą Curl

Jak zmierzyć opóźnienie? Najprostszym sposobem jest użycie curl. Curl przechwytuje różne metryki profilowania czasu podczas wykonywania żądań. Musimy tylko sformatować dane wyjściowe curl, abyśmy mogli łatwo zobaczyć różne dostępne wartości opóźnień. Aby to zrobić, definiujemy dane wyjściowe, które chcemy zobaczyć za pomocą pliku tekstowego (curl-format.txt):

   time_namelookup: %{time_namelookup}s\n
      time_connect: %{time_connect}s\n
   time_appconnect: %{time_appconnect}s\n
  time_pretransfer: %{time_pretransfer}s\n
     time_redirect: %{time_redirect}s\n
time_starttransfer: %{time_starttransfer}s\n
  -------------------  ----------\n
        time_total:  %{time_total}s\n

Mając zdefiniowany format wyjściowy, możemy go użyć w naszym następnym poleceniu curl call:

curl -w "@curl-format.txt" \
     -o /dev/null -s \
     "http://localhost:8000/api/user-data"

Otrzymana odpowiedź wygląda następująco:

   time_namelookup: 0.000028s
      time_connect: 0.000692s
   time_appconnect: 0.000000s
  time_pretransfer: 0.000772s
     time_redirect: 0.000000s
time_starttransfer: 5.055683s
                    ----------
        time_total: 5.058479s

Cóż, nie jest dobrze. Ponad pięć sekund to zdecydowanie za długo jak na czas transferu (czas potrzebny serwerowi na faktyczną obsługę żądania). Proszę sobie wyobrazić, że ten punkt końcowy jest obsługiwany setki lub tysiące razy na sekundę! Państwa użytkownicy byliby sfrustrowani, a serwer mógłby ulec awarii pod ciężarem ciągłego ponownego wykonywania tej pracy.

Redis jako rozwiązanie

Buforowanie odpowiedzi jest pierwszą linią obrony w celu skrócenia czasu transferu (zakładając, że zajęli się Państwo wszelkimi złymi praktykami programistycznymi, które mogą powodować opóźnienia!) Załóżmy więc, że zrobiliśmy wszystko, co w naszej mocy, aby zmniejszyć opóźnienia, ale nasza aplikacja nadal potrzebuje pięciu sekund, aby połączyć te złożone dane i zwrócić je użytkownikowi.

W naszym scenariuszu, ponieważ dane są takie same za każdym razem dla każdego żądania do /api/user-data, mamy idealnego kandydata do buforowania. Dzięki buforowaniu wykonamy niezbędne obliczenia raz, zbuforujemy wynik i zwrócimy zbuforowaną wartość dla wszystkich kolejnych żądań.

Redis jest wydajnym, przechowywanym w pamięci magazynem kluczy/wartości i jest powszechnie używanym narzędziem do buforowania. Aby go wykorzystać, najpierw instalujemy Redis na naszej lokalnej maszynie. Następnie musimy dodać Fastify’s Redis plugin do naszego projektu:

~/project$ npm i @fastify/redis

Proszę zarejestrować wtyczkę Redis w Fastify

Tworzymy plik, redis.js, który konfiguruje naszą wtyczkę Redis i rejestruje ją w Fastify. Nasz plik wygląda następująco:

// redis.js

const REDIS_URL = process.env.REDIS_URL || "redis://127.0.0.1:6379";

import fp from "fastify-plugin";
import redis from "@fastify/redis";

const parseRedisUrl = (redisUrl) => {
  const url = new URL(redisUrl);
  const password = url.password;
  return {
    host: url.hostname,
    port: url.port,
    password,
  };
};

export default fp(async (fastify) => {
  fastify.register(redis, parseRedisUrl(REDIS_URL));
});

Większość linii w tym pliku jest poświęcona analizowaniu pliku REDIS_URL na wartość hosta, portu i hasła. Jeśli mamy REDIS_URL jako zmienną środowiskową, to rejestracja Redis w Fastify jest prosta. Po skonfigurowaniu naszej wtyczki, musimy tylko zmodyfikować app.js, aby z niej korzystać:

// app.js

import redis from "./redis.js";
import routes from "./routes.js";

export default async (fastify, opts) => {
  fastify.register(redis);
  fastify.register(routes);
};

Teraz mamy dostęp do naszej instancji Redis, odwołując się do fastify.redis w dowolnym miejscu naszej aplikacji.

Modyfikacja naszego punktu końcowego w celu użycia buforowania

Z Redis w miksie, zmieńmy nasz /api/user-data aby korzystał z buforowania:

fastify.get("/api/user-data", async (_, reply) => {
  const { redis } = fastify;

  // check if data is in cache
  const data = await redis.get("user-data", (err, val) => {
    if (val) {
      return { data: val };
    }
    return null;
  });

  if (data) {
    return reply.send(data);
  }

  // simulate a long-running task
  await sleep(5000);
  const userData = readData();

   // add data to the cache
  redis.set("user-data", userData);

  return reply.send({ data: userData });
});

Tutaj widać, że zakodowaliśmy w Redis pojedynczy klucz, user-datai przechowujemy nasze dane pod tym kluczem. Oczywiście naszym kluczem może być identyfikator użytkownika lub inna wartość identyfikująca określony typ żądania lub stanu. Moglibyśmy również ustawić wartość limitu czasu, aby wygasić nasz kluczw przypadku, gdy spodziewamy się zmiany danych po upływie określonego czasu.

Jeśli w pamięci podręcznej znajdują się dane, zwrócimy je i pominiemy całą czasochłonną pracę. W przeciwnym razie należy wykonać długotrwałe obliczenia, dodać wynik do pamięci podręcznej, a następnie zwrócić go użytkownikowi.

Jak wyglądają nasze czasy transferu po dwukrotnym uderzeniu w ten punkt końcowy (pierwszy, aby dodać dane do pamięci podręcznej, a drugi, aby je pobrać)?

   time_namelookup: 0.000023s
      time_connect: 0.000560s
   time_appconnect: 0.000000s
  time_pretransfer: 0.000729s
     time_redirect: 0.000000s
time_starttransfer: 0.044512s
                  ----------
        time_total: 0.047479s

Znacznie lepiej! Skróciliśmy czas żądania z kilku sekund do milisekund. To ogromna poprawa wydajności!

Redis ma o wiele więcej funkcji, które mogą być przydatne w tym przypadku, w tym limit czasu par klucz / wartość po określonym czasie; jest to bardziej powszechny scenariusz w środowiskach produkcyjnych.

Korzystanie z Redis we wdrożeniu Heroku

Do tego momentu pokazaliśmy tylko, jak to działa w środowisku lokalnym. Teraz pójdziemy o krok dalej i wdrożymy wszystko w chmurze. Na szczęście Heroku oferuje wiele opcji wdrażania aplikacji internetowych i pracy z Redis. Przyjrzyjmy się, jak się tam skonfigurować.

Po zarejestrowaniu konta Heroku i zainstalowaniu aplikacji CLI tool, jesteśmy gotowi do utworzenia nowej aplikacji. W naszym przypadku nazwiemy naszą aplikację fastify-with-caching. Oto nasze kroki:

Krok 1: Proszę zalogować się do Heroku

~/projects$ heroku login
...
Logging in... done

Krok 2: Tworzenie aplikacji Heroku

Kiedy utworzymy naszą aplikację Heroku, otrzymamy z powrotem nasz adres URL aplikacji Heroku. Zwracamy na to uwagę, ponieważ będziemy go używać w kolejnych żądaniach curl.

~/project$ heroku create -a fastify-with-caching
Creating ⬢ fastify-with-caching... done
https://fastify-with-caching-3e247d11f4ad.herokuapp.com/ | https://git.heroku.com/fastify-with-caching.git

Krok 3: Dodanie danych Heroku dla dodatku Redis

Musimy skonfigurować plik Redis add-on który spełnia potrzeby naszej aplikacji. Dla naszego projektu demonstracyjnego wystarczy utworzyć instancję Redis mini-tier:

~/project$ heroku addons:create heroku-redis:mini -a fastify-with-caching
Creating heroku-redis:mini on ⬢ fastify-with-caching…
…
redis-transparent-98258 is being created in the background.
…

Uruchomienie instancji Redis może zająć dwie lub trzy minuty. Możemy okresowo sprawdzać stan naszej instancji:

~/project$ heroku addons:info redis-transparent-98258
...
State: creating

Niedługo potem widzimy to:

State:        created

Jesteśmy prawie gotowi do pracy!

Kiedy Heroku uruchamia nasz dodatek Redis, dodaje również nasze dane uwierzytelniające Redis jako zmienne konfiguracyjne dołączone do naszej aplikacji Heroku. Możemy uruchomić następujące polecenie, aby zobaczyć te zmienne konfiguracyjne:

~/project$ heroku config -a fastify-with-caching
=== fastify-with-caching Config Vars
REDIS_TLS_URL: rediss://:p171d98f7696ab7eb2319f7b78083af749a0d0bb37622fc420e6c1205d8c4579c@ec2-18-213-142-76.compute-1.amazonaws.com:15940
REDIS_URL: redis://:p171d98f7696ab7eb2319f7b78083af749a0d0bb37622fc420e6c1205d8c4579c@ec2-18-213-142-76.compute-1.amazonaws.com:15939

(Państwa dane uwierzytelniające będą oczywiście unikalne i inne niż te widoczne powyżej).

Proszę zauważyć, że mamy REDIS_URL zmienną ustawioną dla nas. To dobrze, że nasz plik redis.js jest zakodowany tak, aby poprawnie analizować zmienną środowiskową o nazwie REDIS_URL.

Krok 4: Utworzenie Heroku Remote

Na koniec musimy utworzyć pilota Heroku w naszym repozytorium Git, abyśmy mogli łatwo wdrożyć za pomocą Git.

~/project$ heroku git:remote -a fastify-with-caching
set git remote heroku to https://git.heroku.com/fastify-with-caching.git

Krok 5: Wdrożenie!

Teraz, gdy wypchniemy naszą gałąź do zdalnego Heroku, Heroku zbuduje i wdroży naszą aplikację.

~/project$ git push heroku main
...
remote: Building source:
remote:
remote: -----> Building on the Heroku-22 stack
remote: -----> Determining which buildpack to use for this app
remote: -----> Node.js app detected
remote:
remote: -----> Creating runtime environment
...
remote: -----> Compressing...
remote: Done: 50.8M
remote: -----> Launching...
remote: Released v4
remote: https://fastify-with-caching-3e247d11f4ad.herokuapp.com/ deployed to Heroku
remote:
remote: Verifying deploy... done.

Nasza aplikacja jest uruchomiona. Czas ją przetestować.

Proszę przetestować naszą wdrożoną aplikację

Zaczynamy od podstawowego curl żądania do naszego /api/health endpoint:

$ curl https://fastify-with-caching-3e247d11f4ad.herokuapp.com/api/health
{"status":"ok"}

Doskonale. Wygląda to obiecująco.

Następnie wyślijmy nasze pierwsze żądanie do długo działającego procesu i zbierzmy metryki opóźnień:

$ curl \
  -w "@curl-format.txt" \
  -o /dev/null -s \
  https://fastify-with-caching-3e247d11f4ad.herokuapp.com/api/user-data

   time_namelookup: 0.035958s
      time_connect: 0.101336s
   time_appconnect: 0.249308s
  time_pretransfer: 0.249389s
     time_redirect: 0.000000s
time_starttransfer: 5.384986s
------------------- ----------
        time_total: 6.554382s

Gdy wyślemy to samo żądanie po raz drugi, oto wynik:

$ curl \
  -w "@curl-format.txt" \
  -o /dev/null -s \
  https://fastify-with-caching-3e247d11f4ad.herokuapp.com/api/user-data

     time_namelookup:  0.025807s
        time_connect:  0.091763s
     time_appconnect:  0.236050s
    time_pretransfer:  0.236119s
       time_redirect:  0.000000s
  time_starttransfer:  0.334859s
  -------------------  ----------
          time_total:  1.276264s

Znacznie lepiej! Buforowanie pozwala nam ominąć długotrwałe procesy. Z tego miejsca możemy zbudować znacznie solidniejszy mechanizm buforowania dla naszej aplikacji we wszystkich naszych trasach i procesach. Możemy nadal opierać się na Heroku i dodatku Heroku Redis, gdy będziemy musieli wdrożyć naszą aplikację w chmurze.

Wskazówka bonusowa: Czyszczenie pamięci podręcznej dla przyszłych testów

Nawiasem mówiąc, jeśli chcą Państwo przetestować to więcej niż jeden raz, może być konieczne usunięcie pary klucz/dane użytkownika w Redis. Mogą Państwo użyć Heroku CLI, aby uzyskać dostęp do Redis CLI dla swojej instancji Redis:

~$ heroku redis:cli -a fastify-with-caching
Connecting to redis-transparent-98258 (REDIS_TLS_URL, REDIS_URL):
ec2-18-213-142-76.compute-1.amazonaws.com:15940> DEL user-data
1

Wnioski

W tym samouczku zbadaliśmy, w jaki sposób buforowanie może znacznie poprawić czas odpowiedzi usługi internetowej w przypadkach, w których identyczne żądania dałyby identyczne odpowiedzi. Przyjrzeliśmy się, jak to zaimplementować za pomocą Redis, standardowego w branży narzędzia do buforowania. Wszystko to zrobiliśmy z łatwością w aplikacji Node.js, która wykorzystuje framework Fastify. Na koniec wdrożyliśmy naszą aplikację demonstracyjną na Heroku, wykorzystując wbudowane Heroku Data do zarządzania instancjami Redis w celu buforowania w chmurze.