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 zastosowałem w projekcie, który obecnie tworzymy.
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
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 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.
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.
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).
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).
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.
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.
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ć.
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).
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ąc 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.
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.
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. Dowiedz się więcej o niektórych z najciekawszych projektów, jakimi mieli przyjemność się zajmować.