Przesyłanie i przechowywanie plików w Node.js REST API z Google Cloud Storage

Przesyłanie oraz przechowywanie statycznych plików przesyłanych przez użytkownika do systemu to bardzo popularny problem, który może znacząco zwiększyć złożoność i czas implementacji, zwłaszcza przy przesyłaniu plików o dużych rozmiarach lub przesyłaniu ich w dużej liczbie. W dzisiejszym artykule pokażę wam rozwiązanie, które ostatnio zastosowałem w projekcie, który obecnie tworzymy.

Przechowywanie plików na serwerze

Przechowywanie plików na serwerze

Najprostszym i najszybszym sposobem, który znajdziecie w wielu poradnikach dla początkujących będzie trzymanie plików bezpośrednio na serwerze. W konkretnych sytuacjach takie rozwiązanie może być dobre, np. w momencie, gdy stawiamy malutki serwis do przetwarzania plików i będą one przechowywane na serwerze tymczasowo, to nie ma sensu wysyłać ich do chmury.

Wady i zalety lokalnego przechowywania plików

Jakie są zalety i wady tego rozwiązania?

Dużą zaletą na pewno będzie całkowita kontrola nad plikiem, taki plik można przeskanować, zwalidować, zrobić z nim w zasadzie wszystko. Dodatkowo w razie konieczności wykonywania na nim różnych działań w późniejszym czasie nie będzie konieczności pobierania go na serwer i aktualizowania po ewentualnym przerobieniu.

Niestety rozwiązanie to nie jest zbyt dobrze skalowalne — jeżeli nasza aplikacja Node.js jest wdrożona na jakimś VPSie to będzie on dysponował ograniczonym miejscem na dysku. Trzymanie plików na serwerze może też być potencjalnie niebezpieczne, nigdy nie wiadomo kiedy jakiś haker złamie zabezpieczenia naszego mega zabezpieczonego pod każdym kątem API i aplikacja zapisze na dysku jakiś złośliwy plik, który wyczyści nasze zasoby.

Krok 1

Wybór dostawcy chmury

Głównym założeniem aplikacji, którą obecnie tworzymy jest wysoka skalowalność, więc naturalnie zwróciłem się o pomoc do chmury.

Do rozpatrzenia miałem kilka dostawców, między innymi takich jak:

Dlaczego wybrałem Google Cloud Storage?

Korzystałem już z S3 oraz Azure Blob Storage i coś (darmowe 400$ na start) mnie podkusiło, żeby sprawdzić Google Cloud Storage.

Zastosowanie tej usługi pozwoliło mi na błyskawiczne wręcz zaimplementowanie modułu przesyłania zdjęć do aplikacji (chociaż nie obyło się również bez paru chwilowych blokad), ale najważniejsza dla mnie w tym momencie była wygoda, bezpieczeństwo oraz skalowalność tego rozwiązania.

W dalszej części artykułu pokażę wam jak to wyglądało od strony kodu.

Krok 2

Rejestracja w Google Cloud Platform

Zacznijmy od początku, czyli rejestracji w usłudze Google Cloud.

Proces rejestracji i pierwsze kroki

Podczas rejestracji będziemy zmuszeni do podpięcia karty bankowej, bo bez tego nie wystartujemy.

Poza tym to jest to raczej standardowy formularz rejestracyjny, więc nie będziemy się dokładnie zagłębiać w jego specyfikę.

Po pomyślnej rejestracji zostanie utworzony nasz pierwszy projekt w GCP, wokół którego będziemy operować.

Tworzenie projektu i pierwsze spojrzenie na GCP

Po zalogowaniu na konto powinien wyświetlić się Wam ekran mniej więcej taki jak na poniższym zrzucie ekranu (zrzut ekranu nr 1).

W lewym dolnym rogu znajdziecie odnośnik do Cloud Storage, który przeniesie was na widok z dostępnymi zasobnikami (zrzut ekranu nr 2).

Google Cloud Storage – nowo utworzony projekt
Zrzut ekranu nr 1 – Nowo utworzony projekt
Zrzut ekranu nr 2 – Widok zasobników
Krok 3

Tworzenie i konfiguracja zasobnika w Google Cloud Storage

Przejdźmy do utworzenia nowego zasobnika, do którego będziemy przesyłać nasze dane:

  1. Zaczynamy od wyboru jego nazwy i regionu, który będzie nam najbardziej odpowiadał (w moim przypadku jest to europe-central2).
Widok tworzenia zasobnika

2. Wszystkie kolejne opcje zostawiłem zaznaczone domyślnie. Jako prosty i prawilny Node.js developer niemający za dużo styczności z chmurą w codziennej pracy potrzebowałem szybkiego setupu do startu developmentu z nadzieją, że przed wdrożeniem produkcyjnym ktoś jeszcze na to zerknie…

3. Dodatkowo wyłączyłem blokadę publicznego dostępu do tego zasobnika. Użytkownicy w naszym systemie będą wrzucać na niego np. swoje avatary, które potem muszą być widoczne w dostępnych publicznie miejscach aplikacji.

Przyznawanie dostępu publicznego do wyświetlania plików w zasobniku
Przyznawanie dostępu publicznego do wyświetlania plików w zasobniku

4. W celu uzyskania tego efektu musimy wybrać opcję Uprawnienia do edytowania w konfiguracji naszego zasobnika, a następnie wybrać rolę Wyświetlający obiekty Cloud Storage dla podmiotu allUsers. Dzięki temu wszystkie pliki, które prześlemy, będą miały publiczne adresy URL, które będziemy mogli wykorzystywać jako źródła obrazów w naszej aplikacji.

Przykładowy przesłany plik z dostępem publicznym
Krok 4

Generowanie klucza dostępu

Kolejną rzeczą, którą będziemy musieli zrobić jest wygenerowanie klucza dostępu, który pozwoli nam komunikować się z Google Cloud Storage z poziomu Node.js.

Proces tworzenia konta usługi

W tym celu wybieramy zakładkę: Konta usługi w panelu: Administracja, po czym wybieramy: Utworzenie konta usługi.

Po utworzeniu konta usługi z interesującymi nas uprawnieniami (ja zaznaczyłem wszystko dla szybkości, pamiętajcie, żeby przy generowaniu produkcyjnych kont dostępu wybrać tylko potrzebne do działania aplikacji uprawnienia) możemy przejść do jego zakładki Klucze, gdzie możemy wygenerować plik .json, którego użyjemy w aplikacji. Na ten moment to tyle jeśli chodzi o klikanie, przechodzimy teraz do pisania kodu, ale będziemy musieli tu jeszcze wrócić.

Widok tworzenia konta usługi
Widok tworzenia konta usługi
Krok 5

Integracja z Node.js

Stack technologiczny, którego używamy do pisania naszej aplikacji to Nest.js oraz Vite + React. Zaczniemy więc od strony backendowej aplikacji i przygotujemy sobie endpoint do generowania linku, na który będziemy przesyłać pliki do Google Cloud Storage.

Jest to prostsze i szybsze rozwiązanie niż przesyłanie plików przez backend, mimo pewnych ograniczeń odnośnie walidacji przesyłanego pliku. Jest to jednak wystarczająco dobre rozwiązanie dla naszych obecnych potrzeb.

Instalacja biblioteki

Zacznijmy więc od instalacji potrzebnej biblioteki do komunikacji z GCS:

npm i @google-cloud/storage

Generowanie adresu URL do przesyłania plików

Następnie napiszmy sobie funkcję, która będzie nam generowała adres URL, na który będziemy wysyłać pliki bezpośrednio z frontu.

import { randomBytes } from 'node:crypto'
import { Storage } from '@google-cloud/storage'

async generateUploadUrl() {
    const storage = new Storage({ keyFilename: 'google-cloud-key.json' })

    const file = randomBytes(16).toString('hex')

    const [url] = await storage
      .bucket('dogtronic-media')
      .file(file)
      .getSignedUrl({
        version: 'v4',
        action: 'write',
        expires: Date.now() + 15 * 60 * 1000,
        contentType: 'image/webp',
      })

    return url
  }

Omówienie kluczowych fragmentów kodu

Dobra, to teraz wyjaśnijmy sobie najważniejsze elementy tego kodu:

  • Przy tworzeniu instancji Storage podajemy keyFilename, czyli nazwę pliku zawierającego wygenerowany wcześniej klucz dostępu (możemy również podać ścieżkę, przy podaniu samej nazwy klucz będzie szukany w katalogu głównym projektu).

  • Korzystając z natywnej funkcji randomBytes modułu crypto generujemy sobie losową nazwę pliku.

  • Następnie przy generowaniu URLa podajemy nazwę naszego zasobnika, czas ważności linku (tutaj jest to 15 minut) oraz typ pliku (w ramach unifikacji my przyjmujemy tylko pliki .webp).
Krok 6

Integracja z React

Teraz w dowolny sposób możemy dostarczyć wygenerowany URL na frontend. My wystawiamy pod to zabezpieczony endpoint.

Nie jest to ani trudne, ani temat tego artykułu więc nie będę się w to zagłębiał, ale jak coś to odsyłam do dokumentacji nest.js.

Skoro już więc mamy możliwość wygenerowania linku z frontu, to przejdźmy do naszej apki reactowej.

Ja zrobiłem tutaj komponent, do którego możemy wsadzić dowolne zdjęcie, które zostanie przerobione na .webp, a następnie wysłane do GCS.

Jak to dokładnie wygląda? Zobaczcie poniżej:

const fileToRawImage = (file: File) =>
  new Promise<HTMLImageElement>(res => {
    const img = new Image()
    img.addEventListener('load', () => res(img))
    img.src = URL.createObjectURL(file)
  })

const rawImageToWebpBlob = (rawImage: HTMLImageElement) =>
  new Promise<Blob>(res => {
    const canvas = document.createElement('canvas')
    const ctx = canvas.getContext('2d')
    canvas.width = rawImage.width
    canvas.height = rawImage.height
    ctx?.drawImage(rawImage, 0, 0)
    canvas.toBlob(blob => {
      if (blob) {
        res(blob)
      }
    }, 'image/webp')
  })

const onDrop = React.useCallback(async ([file]: File[]) => {
    const rawImage = await fileToRawImage(file)
    const webpBlob = await rawImageToWebpBlob(rawImage)

    const {
      data: { url },
    } = await getUploadUrlHandler({})

    await fetch(url, {
      method: 'PUT',
      body: webpBlob,
    })

    onChange(url.split('?')[0])
  }, [])

const {
    getRootProps,
    getInputProps,
    isDragActive,
    acceptedFiles,
    fileRejections,
  } = useDropzone({
    onDrop,
    maxFiles: 1,
    maxSize: 5000000,
    accept: {
      'image/jpeg': ['.jpeg', '.jpg'],
      'image/png': ['.png'],
      'image/webp': ['.webp'],
    },
  })

Tutaj też sprawdź sobie po kolei, co się dzieje w kodzie:

  • Jak możecie zauważyć, korzystam z biblioteki react-dropzone, w której definiuje walidacje pliku oraz co robimy z przesyłanym plikiem.

  • Najpierw przesyłany plik przekształcam do surowego obrazu w funkcji fileToRawImage.

  • Następnie surowy obraz przekształcam do formatu .webp korzystająć z natywnych możliwości canvasa w funkcji rawImageToWebpBlob.

  • Generuje URL do przesłania pliku funkcją getUploadUrlHandler, która opakowuje zapytanie HTTP do backendu.

  • Za pomocą fetch i metody PUT przesyłam plik do GCS.

  • W onChange ustawiam sobie publiczny link do przesłanego zdjęcia (jest to ten sam link co do przesłania), który potem jest częścią np. formularza tworzenia użytkownika, dzięki czemu na backend wysyłam tylko URL i zapisuje go do bazy.

Krok 7

Rozwiązanie problemu z CORS

No i co wszystko śmiga elegancko, co nie? Otóż nie, bo w momencie próby przesłania pliku na GCS wyskoczy nam CORS error.

Co to jest CORS i dlaczego jest ważny?

Błąd ten powoduje, że nasza przeglądarka zablokuje żądanie HTTP do domeny innej niż nasza, jeżeli serwer nie zwróci nam odpowiedniego nagłówka. Oznacza to tyle, że ostatnią rzeczą, która za pierwszym razem sprawiła mi najwięcej kłopotu, będzie konfiguracja CORSów dla naszego zasobnika.

Konfiguracja CORS dla zasobnika w GCP

W tym celu musimy otworzyć Cloud Shell, do którego przycisk znajdziecie w prawym górnym rogu ekranu Google Cloud.

Widok po otwarciu Cloud Shell

W dowolnym edytorze tekstu przygotujmy plik .json, w którym zdefiniujemy następującą konfigurację:

[
    {
      "origin": ["http://localhost:5173"],
      "method": ["GET", "PUT"],
      "responseHeader": ["Content-Type", "Content-Length", "Access-Control-Allow-Origin"],
      "maxAgeSeconds": 3600
    }
]

Dzięki niej będziemy mogli przesyłać pliki podczas lokalnego developmentu. Przy wdrożeniu gdziekolwiek waszej aplikacji pamiętajcie o dodaniu odpowiedniej domeny do tablicy origin. Jedyne co pozostało to aktywować tę konfigurację, co robimy za pomocą tego polecenia:

gsutil cors set cors_config.json gs://dogtronic-media

Pamiętajcie o podaniu odpowiedniej nazwy pliku i wskazaniu odpowiedniego zasobnika.

Podsumowanie

Mając ustawioną taką konfigurację powinniśmy mieć już działający, uniwersalny mechanizm, który będzie odpowiadał za przesyłanie i przechowywanie plików użytkowników w naszym systemie. Jak możecie zauważyć, jest to dosyć prosta rzecz, ale początkującym może sprawić różne kłopoty.

Jeżeli macie pomysły lub uwagi odnośnie tego rozwiązania to zapraszam do podzielenia się nimi w komentarzach, chętnie dowiem się nowych rzeczy, które mogłem pominąć.

Sprawdź jak nasi programiści wykorzystują swoją wiedzę w praktyce. W zakładce „Case studies” znajdziesz szczegółowe opisy najciekawszych projektów, jakimi mieli przyjemność się zajmować.

Udostępnij
Karol Ścibior

Full-Stack TypeScript Dev | Everything Node.js related | Casual DevOps with Docker & Github Actions

Zostaw komentarz:

Witryna jest chroniona przez reCAPTCHA i Google Politykę Prywatności oraz obowiązują Warunki Korzystania z Usługi.