IlmHamroh
JavaScript Full-stack/29-qism. Backend integratsiyalar: fayl, to'lov, bot, realtime4/69-dars25 daqiqa
Mundarija (32)

Object storage: S3 va MinIO bilan fayllarni saqlash Node'da

Qisqacha: Object storage (obyekt ombori) — fayllarni server diskida emas, alohida xizmatda saqlash: bucket (savat) ichida har fayl bitta kalit (satr) ostida yotadi, papkalar yo'q. Amazon S3 ning API'si sohada standartga aylangan: Cloudflare R2, MinIO va boshqalar o'sha API'ni gapiradi, Node'dan esa @aws-sdk/client-s3 (v3) bilan ishlanadi. Bucket sukutda private; ochiq faqat kerakli prefiks (masalan avatar/*).

Bu darsda

  • Object storage nima va nega fayllar server diskida saqlanmasligini tushuntirasiz.
  • Bucket, kalit, obyekt va metama'lumotni farqlaysiz, "papka" illyuziyasini tushunasiz.
  • @aws-sdk/client-s3 bilan yozish, o'qish, ro'yxat va o'chirish buyruqlarini berasiz.
  • ETag, xatolar (NoSuchKey, NotFound) va multipart cheklovlarini kanon o'lchovi bilan bilasiz.
  • Private va public bucket farqini, MinIO, AWS S3 va Cloudflare R2 tanlovini bilasiz.

Oldin bilishingiz kerak: Fayl yuklash: multipart va Multer, Katta fayllarni stream bilan yuklash, Environment o'zgaruvchilar va .env, Async iteratorlar va Web Streams.

Oldingi darsda fayl oqimini FaylOmbori.yoz ga uzatdik va "ombor keyingi darsda" dedik. Mana u.

1. Nega bu kerak?

Birinchi darsda Multer fayllarni server diskiga yozdi. Kichik loyihada bu ishlaydi. Lekin vazifalar-api o'sganda uchta muammo chiqadi:

  1. Bir nechta server. Foydalanuvchi soni oshdi, API ikki nusxada ishlaydi. Malika faylni 1-serverga yukladi, keyingi so'rovi 2-serverga tushdi — u yerda fayl yo'q.
  2. Disk tugaydi va o'ladi. Server diskini kengaytirish qiyin, zaxira nusxasini o'zingiz qilasiz. Server o'chsa — fayllar ham.
  3. Trafik. Har yuklab olish API serveri orqali o'tadi, uni band qiladi.

Object storage bularning hammasini alohida xizmatga beradi. Server faqat "qaysi faylni kim ko'ra oladi" ni hal qiladi. Baytlarni saqlash, nusxalash va tarqatishni ombor qiladi.

O'xshatish: kutubxonadagi garderob. Siz paltongizni berasiz, raqamli jeton olasiz. Palto qaysi javonda turgani sizga qiziq emas — jeton bo'yicha qaytarib berishadi. Kalit — jeton, bucket — garderob.

Fayl yuklash va progress darsida brauzer tomondan "fayllar S3, R2 yoki MinIO ga boradi" degan edik. Endi server tomonini quramiz.

2. Asosiy tushunchalar

2.1 Bucket, kalit, obyekt

  • Bucket (savat) — obyektlar idishi. Har bucket o'z nomi, sozlamalari va ruxsatlari bilan. Loyiha uchun odatda bitta yoki bir nechta bucket.
  • Kalit (key) — obyektning bucket ichidagi to'liq nomi, oddiy satr: vazifalar/7/1f3c…b2.png.
  • Obyekt — baytlar + metama'lumot (tur, hajm, o'zgargan vaqt, o'z yozuvlaringiz).
flowchart LR
  A[vazifalar-api] -->|PutObject kalit| B[(Bucket: vazifalar)]
  A -->|GetObject kalit| B
  B --- O1[vazifalar/7/uuid.png]
  B --- O2[vazifalar/8/uuid.pdf]
  B --- O3[avatar/7.webp]
  D[(Postgres)] -.->|kalit, egasi, nomi| A

Diagrammaga qarang: API omborga kalit bo'yicha yozadi va o'qiydi. Qaysi kalit kimga tegishli — Postgres'da (Fayl metama'lumoti darsida). Ombor faylning egasini bilmaydi.

Fayl tizimi (disk) Object storage
Tuzilma papkalar daraxti tekis: bucket → kalit
Tahrirlash faylning o'rtasini o'zgartirsa bo'ladi faqat butunlay qayta yozish
Kirish fs HTTP API (S3)
Hajm disk hajmicha amalda cheksiz

Ikkinchi qatorga e'tibor bering: obyektni qisman o'zgartirib bo'lmaydi. Rasmni kichraytirsangiz — yangi obyekt yozasiz.

2.2 "Papka" — illyuziya

Kalitda / bor, lekin papka yo'q. vazifalar/7/a.png — shunchaki 17 belgili satr. Konsol va SDK papkani ko'rsatadi, xolos: kalitlarni prefiks bo'yicha tanlab, / gacha guruhlaydi. Buni toza JavaScript bilan takrorlaymiz (kanon tajribasidagi kalitlar bilan):

js
const kalitlar = [
  "avatar/7.webp",
  "vazifalar/7/a.png",
  "vazifalar/7/b.png",
  "vazifalar/7/eslatma.txt",
  "vazifalar/8/c.png",
];

function royxat(prefiks = "", ajratgich) {
  const fayllar = [];
  const papkalar = new Set();
  for (const k of kalitlar.filter((k) => k.startsWith(prefiks))) {
    const qolgan = k.slice(prefiks.length);
    const i = ajratgich ? qolgan.indexOf(ajratgich) : -1;
    if (i === -1) fayllar.push(k);
    else papkalar.add(prefiks + qolgan.slice(0, i + 1));
  }
  return { papkalar: [...papkalar], fayllar };
}

console.log(royxat("vazifalar/7/").fayllar);
console.log(royxat("", "/"));
console.log(royxat("vazifalar/", "/").papkalar);

Konsolda:

text
[ 'vazifalar/7/a.png', 'vazifalar/7/b.png', 'vazifalar/7/eslatma.txt' ]
{ papkalar: [ 'avatar/', 'vazifalar/' ], fayllar: [] }
[ 'vazifalar/7/', 'vazifalar/8/' ]

S3 da xuddi shu ikki parametr bor: Prefix (shu bilan boshlanadiganlar) va Delimiter (shu belgigacha guruhlash → CommonPrefixes). Kanon MinIO'dan aynan shunday javob oldi — buni «Ro'yxat: prefiks va sahifalash» bo'limida ko'ramiz.

Amaliy natija: "papkani o'chirish" yoki "papkani qayta nomlash" degan amal yo'q. Prefiks ostidagi har obyekt alohida o'chiriladi yoki nusxalanadi.

2.3 Bucket nomi qoidalari

Bucket nomi DNS nomi sifatida ishlatilishi mumkin (vazifalar.s3.amazonaws.com). Shuning uchun qat'iy qoidalar bor (AWS hujjati). Nom — 3–63 belgi, faqat kichik lotin harflar, raqamlar, nuqta va chiziqcha, harf yoki raqam bilan boshlanib tugaydi, ikki nuqta yonma-yon emas, IP manzilga o'xshamaydi.

js
function bucketNomiTogrimi(nom) {
  if (nom.length < 3 || nom.length > 63) return false;
  if (!/^[a-z0-9][a-z0-9.-]*[a-z0-9]$/.test(nom)) return false;
  if (nom.includes("..")) return false;
  if (/^\d+\.\d+\.\d+\.\d+$/.test(nom)) return false;
  return true;
}

for (const nom of ["vazifalar", "t04-vazifalar", "Katta_Harf",
  "ab", "192.168.1.10", "vazifa..fayl"]) {
  console.log(nom, bucketNomiTogrimi(nom));
}

Konsolda:

text
vazifalar true
t04-vazifalar true
Katta_Harf false
ab false
192.168.1.10 false
vazifa..fayl false

Kanon MinIO'da Katta_Harf bucket'ini yaratmoqchi bo'ldi — server InvalidBucketName: The specified bucket is not valid. ("ko'rsatilgan bucket nomi yaroqsiz") bilan rad etdi. AWS'da oddiy bucket nomi barcha hisoblar orasida noyob bo'lishi kerak: vazifalar ni kimdir allaqachon olgan bo'lishi mumkin. Shuning uchun nomga loyiha va muhit qo'shiladi: ilova-vazifalar-prod.

Tekshirib ko'ring: vazifalar/7/ "papkasi"da 3 ta fayl bor. Ulardan hammasini o'chirsangiz, vazifalar/7/ papkasi ro'yxatda qoladimi?

Javob

Yo'q. Papka alohida narsa emas — u kalitlardagi prefiksdan "yasaladi". Shu prefiksli kalit qolmasa, CommonPrefixes da ham ko'rinmaydi. (Ba'zi vositalar bo'sh "papka" ko'rsatish uchun / bilan tugaydigan bo'sh obyekt yaratadi — lekin bu ham oddiy obyekt.)

3. MinIO: lokal S3

3.1 Docker'da ishga tushirish

Haqiqiy AWS hisobisiz S3 ni o'rganish uchun kanon MinIO ni ishlatdi — S3 API'sini gapiradigan ochiq kodli server. Kanon buyrug'i (kalitlar .env dan; parolni kodga yozmang):

bash
docker run -d --name minio \
  -p 127.0.0.1:9000:9000 -p 127.0.0.1:9001:9001 \
  -e MINIO_ROOT_USER=$S3_KALIT -e MINIO_ROOT_PASSWORD=$S3_SIR \
  -v minio-data:/data \
  minio/minio:latest server /data --console-address :9001
  • 9000 — S3 API porti, kod shunga ulanadi.
  • 9001 — veb-konsol: brauzerda bucket va obyektlarni ko'rish.
  • -v minio-data:/data — obyektlar konteyner o'chganda ham qoladi (Volume).

Kanon mashinasida bu buyruq RELEASE.2025-09-07T16-13-09Z image'ini ishlatdi — u mashinada oldindan yuklab olingan edi. Kanon 9100/9101 portlarini ochdi, shuning uchun uning natijalarida shu portlar uchraydi.

Diqqat: Bu buyruq yangi kompyuterda hozir ishlamasligi mumkin — sababi keyingi bo'limda.

3.2 MinIO holati: 2026-10

MinIO'ning tarqatish siyosati o'zgardi. Rasmiy GitHub repozitoriyasida (github.com/minio/minio, 2026-10-28 da o'qildi):

  • "THIS REPOSITORY IS NO LONGER MAINTAINED" — repozitoriya endi qo'llab-quvvatlanmaydi;
  • repozitoriya 2026-04-25 da arxivlangan (faqat o'qish uchun);
  • community nashri endi faqat manba kod sifatida tarqatiladi, tayyor binar fayllar chiqarilmaydi; eski binarlar yangilanmaydi;
  • kompaniya o'rniga AIStor (bepul va tijoriy nashrlar) ni taklif qiladi.

Amalda bu nimani anglatadi:

  • Tayyor Docker image yo'q. Biz 2026-10-28 da Docker Hub API'sini so'radik: minio/minio va minio/mc repozitoriylari javobi — 404 (topilmadi). IBM qo'llab-quvvatlash sahifasi (2026-09-25) ham Quay'dagi MinIO image'i o'chirilganini aytadi. Demak docker run minio/minio image'i kompyuterida yo'q odamda ishlamaydi.
  • Image oldindan yuklangan kompyuterda (kanondagidek) MinIO ishlaydi, lekin xavfsizlik yamoqlarisiz qoladi. Lokal o'rganish uchun bo'ladi; production'da yangi o'rnatish uchun tanlamang. Rasmiy README'ga ko'ra endi uni faqat manba koddan o'zingiz yig'asiz.
  • Kursdagi kod MinIO'ga emas, S3 API'ga yozilgan — omborni almashtirsangiz, faqat endpoint va kalitlar o'zgaradi. Shuning uchun darsdagi natijalarni kanon chiqishidan o'qiysiz, toza JS bloklarni esa o'zingiz yurgizasiz.

Muqobillar (kanonda sinalmagan, hujjat bo'yicha): Garage va SeaweedFS — o'z serveringizda S3 mos ombor; LocalStack — testlar uchun AWS xizmatlari taqlidi. Internetda MinIO'ning boshqa odamlar qayta joylagan image nusxalari ham bor — ular rasmiy emas, ichida nima borligini kafolatlab bo'lmaydi, ishlatmang.

Diqqat: MinIO kalitlari (MINIO_ROOT_USER, MINIO_ROOT_PASSWORD) — administrator huquqi. Portni 127.0.0.1 dan tashqariga ochmang va sukut minioadmin parolini qoldirmang.

4. Node'dan ulanish: AWS SDK v3

4.1 O'rnatish va klient

bash
npm i @aws-sdk/client-s3@3.1153.0

AWS SDK v3 — modulli: har xizmat alohida paket. Eski aws-sdk (v2) ni eski kodda uchratasiz: u bitta katta paket, 2025-yil 8-sentyabrda qo'llab-quvvatlanishi tugagan. Yangi kodda faqat v3.

js
import { S3Client } from "@aws-sdk/client-s3";

const s3 = new S3Client({
  endpoint: process.env.S3_ENDPOINT, // MinIO: http://127.0.0.1:9000
  region: process.env.S3_REGION ?? "us-east-1",
  forcePathStyle: true, // MinIO uchun
  credentials: {
    accessKeyId: process.env.S3_KALIT,
    secretAccessKey: process.env.S3_SIR,
  },
});
  • endpoint — AWS S3 da bermaysiz (SDK o'zi biladi). MinIO va R2 da — majburiy.
  • region — AWS'da bucket joylashgan hudud. MinIO uchun istalgan qiymat; kanon us-east-1.
  • forcePathStyle: true — manzil http://host:9000/bucket/kalit ko'rinishida. AWS sukutda bucket.s3.amazonaws.com/kalit (virtual-hosted) ishlatadi. Lokal MinIO'da bucket uchun DNS nomi yo'q — shuning uchun path-style.
  • Kalitlar faqat env'dan (Sirlarni boshqarish).

.env.example (namuna, haqiqiy kalit emas):

bash
S3_ENDPOINT=http://127.0.0.1:9000
S3_REGION=us-east-1
S3_BUCKET=vazifalar
S3_KALIT=almashtiring
S3_SIR=almashtiring-uzun-tasodifiy

4.2 Buyruq naqshi

SDK v3 da har amal — buyruq (command) obyekti, send bilan yuboriladi:

js
import { PutObjectCommand } from "@aws-sdk/client-s3";

await s3.send(new PutObjectCommand({
  Bucket: "vazifalar",
  Key: "vazifalar/7/eslatma.txt",
  Body: "Oʻzbekcha matn: gʻalaba\n",
  ContentType: "text/plain; charset=utf-8",
}));

Hamma buyruq shu shaklda: new XCommand({ Bucket, Key, … }). Parametr nomlari katta harf bilan — S3 HTTP API'sidagi nomlar.

5. Asosiy amallar: kanon tajribasi

5.1 Bucket yaratish

Kanon t04-vazifalar ni yaratdi, keyin xuddi shu nomni qayta yaratib ko'rdi:

text
yaratildi: t04-vazifalar
qayta yaratish: BucketAlreadyOwnedByYou: Your previous request to create the named bucket succeeded and you already own it. 409
noto'g'ri nom: InvalidBucketName: The specified bucket is not valid.

BucketAlreadyOwnedByYou — "bu bucket allaqachon sizniki", status 409 Conflict. Kanon modul shuning uchun avval HeadBucket ("bormi?") so'raydi, yo'q bo'lsa yaratadi. Production'da esa bucket kod ishga tushganda emas, infratuzilma bilan oldindan yaratiladi.

5.2 Yozish, ma'lumot, o'qish

Kanon matn obyektini metama'lumot bilan yozdi, keyin HeadObject (faqat ma'lumot, baytlarsiz) va GetObject (baytlar bilan) qildi:

text
ETag: "bd5b55efe3e9b38fc49e47c72b25fdab" | MD5: "bd5b55efe3e9b38fc49e47c72b25fdab"
Head: {
  ContentLength: 26,
  ContentType: 'text/plain; charset=utf-8',
  Metadata: { 'asl-nom': 'eslatma%20(1).txt', egasi: '7' },
  LastModified: 'object'
}
Get Body turi: ChecksumStream | matn: "Oʻzbekcha matn: gʻalaba\n"

Uch narsani o'qiymiz.

ETag. Obyektning "barmoq izi". Bitta so'rov bilan yozilgan obyektda u — tananing MD5 hash'i (crypto: hash). Tekshirib ko'ramiz:

js
import { createHash } from "node:crypto";

const matn = "Oʻzbekcha matn: gʻalaba\n";
console.log(Buffer.byteLength(matn));
console.log(createHash("md5").update(matn).digest("hex"));

Konsolda:

text
26
bd5b55efe3e9b38fc49e47c72b25fdab

Hajm 26 bayt — matnda 24 belgi, lekin ʻ UTF-8 da 2 bayt. MD5 kanon ETag'iga aynan teng. Lekin multipart yuklashda bunday emas — «Katta obyektlar: multipart» bo'limida ko'ramiz. ETag'ni HTTP keshi uchun ham ishlatsa bo'ladi (HTTP caching).

Metama'lumot (Metadata). O'zingizning kalit: qiymat yozuvlaringiz, HTTP'da x-amz-meta-* sarlavhalari bo'lib boradi. Sarlavhalar ASCII bo'lishi kerak, shuning uchun kanon asl nomni encodeURIComponent bilan yozdi:

js
const asl = "eslatma (1).txt";
const meta = encodeURIComponent(asl);
console.log(meta);
console.log(decodeURIComponent(meta) === asl);
console.log(encodeURIComponent("Oʻzbekcha hisobot.pdf"));

Konsolda:

text
eslatma%20(1).txt
true
O%CA%BBzbekcha%20hisobot.pdf

Metama'lumot bilan qidirib bo'lmaydi va hajmi cheklangan (AWS — 2 KB). Faylning egasi, asl nomi va holati asosan bazada saqlanadi; ombordagi metama'lumot — qo'shimcha.

Body. GetObject tanasi — oqim (kanon ko'rgan klass — ChecksumStream: SDK oqimni o'qish bilan birga tekshiruv yig'indisini ham hisoblaydi). Kichik obyektni await get.Body.transformToString() bilan satrga aylantirish mumkin. Katta faylni esa oqim sifatida mijozga uzatiladi (Fayl yuklab berish darsida).

5.3 Yo'q kalit: uch xil javob

text
yo'q kalit Get: NoSuchKey: The specified key does not exist. 404
yo'q kalit Head: NotFound: UnknownError 404
yo'q kalitni Delete: 204 (xato emas — idempotent)
  • GetObject — NoSuchKey, aniq xabar bilan.
  • HeadObject — NotFound: UnknownError. Nega "noma'lum"? HEAD javobida tana bo'lmaydi (HTTP metodlari) — xato tafsiloti yoziladigan joy yo'q, SDK faqat 404 ni biladi.
  • DeleteObject — yo'q kalitni o'chirish ham 204. O'chirish idempotent: ikki marta o'chirish bir marta o'chirish bilan bir xil natija.

Shuning uchun "topilmadimi?" tekshiruvini xato nomiga emas, bir nechta belgiga qarab qiling. Kanon funksiyasi:

ts
export function topilmadimi(e: unknown): boolean {
  return e instanceof S3ServiceException && (
    e.$metadata.httpStatusCode === 404 || e.name === "NoSuchKey" ||
    e.name === "NotFound" || e.name === "NoSuchBucket"
  );
}

Tekshirib ko'ring: Foydalanuvchi faylini o'chirish marshrutida avval HeadObject bilan "bormi?" deb tekshirib, keyin DeleteObject qilish kerakmi?

Javob

Ombor uchun shart emas: DeleteObject yo'q kalitda ham 204 qaytaradi. Ortiqcha so'rov — ortiqcha vaqt va poyga holati (tekshiruv va o'chirish orasida boshqa so'rov o'chirib qo'yishi mumkin). "Bu fayl bormi va u shu foydalanuvchinikimi?" degan savol esa bazada hal qilinadi.

6. Ro'yxat: prefiks va sahifalash

Kanon to'rtta kichik obyekt qo'shib, ListObjectsV2 ni uch xil chaqirdi:

text
Prefix vazifalar/7/: [ 'vazifalar/7/a.png', 'vazifalar/7/b.png', 'vazifalar/7/eslatma.txt' ]
Delimiter '/': [ 'avatar/', 'vazifalar/' ] + fayllar: []
MaxKeys 2: 2 IsTruncated true NextContinuationToken bor: true

Birinchi ikki qator — «"Papka" — illyuziya» bo'limidagi JS natijamizning aynan o'zi. Uchinchisi — sahifalash: bitta javobda ko'pi bilan 1 000 kalit (MaxKeys bilan kamroq). Davomi bo'lsa — IsTruncated: true va NextContinuationToken. Keyingi sahifa uchun shu tokenni ContinuationToken sifatida berasiz. Bu kursorli sahifalash ning o'zi.

Kanon modulda barcha kalitlarni yuruvchi async generator:

ts
async *kalitlar(prefiks: string) {
  let token: string | undefined;
  do {
    const l = await this.s3.send(new ListObjectsV2Command({
      Bucket: this.bucket, Prefix: prefiks,
      ...(token ? { ContinuationToken: token } : {}),
    }));
    for (const o of l.Contents ?? []) {
      if (o.Key) yield { kalit: o.Key, hajm: o.Size ?? 0 };
    }
    token = l.NextContinuationToken;
  } while (token);
}

Ishlatish: for await (const o of ombor.kalitlar("vaqtincha/")) { … } (Async iteratorlar). Chaqiruvchi sahifalar haqida o'ylamaydi — u shunchaki obyektlarni birin-ketin oladi. Kanon tajriba oxirida shu yo'l bilan bucket'ni tozaladi: o'chirildi: 207 ta obyekt.

7. Katta obyektlar: multipart

Oldingi darsda lib-storage Upload faylni 5 MB lik qismlarga bo'lishini ko'rdik. Kanon 20 MB obyektni shunday yukladi, keyin qismni 1 MB qilib ko'rdi:

text
ETag: "ad3dbd2a6bd4f8d42a9abc463ce9ca33-4" (oxirida -N: qismlar soni; MD5 EMAS) | progress hodisalari: 4
partSize 1 MB: Error: EntityTooSmall: Your proposed upload part size [1048576] is smaller than the minimum allowed size [5242880] (5MB)
  • ETag oxirida -4 — 4 ta qism. Bu butun faylning MD5 i emas (qismlar MD5 laridan hisoblanadi). Shuning uchun "ETag = MD5" ga tayanib butunlikni tekshirmang; butunlik uchun kanon o'zi sha256 hisoblaydi (Katta fayllarni stream bilan yuklash).
  • EntityTooSmall — "taklif qilingan qism hajmi 1 048 576 bayt, ruxsat etilgan minimal 5 242 880 (5MB)". Oxirgi qism kichik bo'lishi mumkin, qolganlari — kamida 5 MB.

Nusxalash (CopyObject) — baytlar server orqali o'tmaydi, ombor ichida ko'chiriladi:

text
copy OK: 20971520

20 MB obyekt doimiy/ prefiksiga nusxalandi. Vaqtinchalik yuklashni tasdiqlangandan keyin doimiy joyga ko'chirish naqshi — Fayl metama'lumoti darsida.

Tezlik (lokal Docker, 200 ta kichik obyekt):

text
200 ta kichik PutObject: mediana 15.18 ms, p95 31.76 ms
200 ta kichik GetObject: mediana 6.20 ms, p95 21.77 ms

Har amal — alohida HTTP so'rov. Bu lokal o'lchov; bulutda tarmoq har so'rovga o'nlab millisekund qo'shadi. Xulosa: sahifa uchun yuzlab obyektni ketma-ket o'qimang — ro'yxatni bazadan oling.

8. Private va public bucket

8.1 Sukut — yopiq

Kanon bucket'dagi avatar/7.webp ni kalitsiz oddiy fetch bilan ochmoqchi bo'ldi:

text
sukut: 403
avatar/* ga public-read siyosati: 200 | vazifalar/7/a.png: 403
siyosat o'chirildi: 403

Sukutda bucket private: kalitsiz so'rov — 403 Forbidden. Kanon faqat avatar/ prefiksi uchun o'qishni ochdi (bucket policy — bucket siyosati):

json
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "AWS": ["*"] },
    "Action": ["s3:GetObject"],
    "Resource": ["arn:aws:s3:::t04-vazifalar/avatar/*"]
  }]
}
  • Principal: * — hamma, kalitsiz ham.
  • Action: s3:GetObject — faqat o'qish (yozish va ro'yxat emas).
  • Resource — faqat avatar/ ostidagi obyektlar. "Version": "2012-10-17" — siyosat tilining versiyasi (sana emas, doimiy qiymat).

Natija: avatar ochildi (200), vazifa fayli baribir 403. Siyosat o'chirilgach — yana 403.

8.2 Qaysi biri qachon

Fayl Kim ko'radi Bucket
Avatar, mahsulot rasmi hamma public prefiks + CDN
Vazifa ilovasi, chek, pasport faqat egasi private
Hisobotlar, eksport egasi, muddatli private + presigned URL

Private faylni foydalanuvchiga qanday beramiz? Ikki yo'l: server o'zi o'qib uzatadi yoki muddatli presigned URL (oldindan imzolangan havola) beradi. Ikkinchisi — keyingi darsda.

CDN (Content Delivery Network — kontent yetkazish tarmog'i) — dunyo bo'ylab joylashgan kesh serverlari. Public obyekt bir marta CDN'ga tushadi, keyin foydalanuvchiga eng yaqin serverdan beriladi. Avatarlar uchun standart: AWS'da CloudFront, Cloudflare'da o'zining tarmog'i.

Diqqat: Butun bucket'ni public qilmang va s3:ListBucket ni ochmang. Ochiq ro'yxatda hujumchi hamma kalitlarni ko'radi — shaxsiy hujjatlar ham yuklab olinadi. Bunday sizishlar (leak) har yili yangilikka chiqadi.

9. AWS S3, Cloudflare R2 va boshqalar

Kod bir xil, sozlama farq qiladi:

Ombor endpoint region
MinIO (lokal) http://127.0.0.1:9000 istalgan, us-east-1
AWS S3 bermaysiz bucket hududi
Cloudflare R2 https://<ACCOUNT_ID>.r2.cloudflarestorage.com auto
  • Cloudflare R2 hujjatiga ko'ra (2026-10-28 da o'qildi): region auto; R2 dan tashqariga chiqadigan trafik (egress) uchun to'lov yo'q; bepul hajm — oyiga 10 GB. AWS S3 da esa chiquvchi trafik pullik — ko'p yuklab olinadigan fayllar uchun bu katta farq.
  • Boshqa S3 mos xizmatlar: Backblaze B2, DigitalOcean Spaces, Google Cloud Storage (XML API orqali).

Diqqat: Kanon faqat lokal MinIO bilan sinadi. AWS S3, Cloudflare R2 va CDN — sinalmagan (hisob yaratilmadi). Narx va limitlar o'zgaradi — tanlashdan oldin rasmiy narx sahifasini o'qing.

10. Hujumchi nigohi

Ombor bilan bog'liq eng ko'p uchraydigan teshiklar:

  1. Ochiq bucket. "Ishlamayapti" deb butun bucket public qilinadi. Himoya — sukut private, ochiq faqat kerakli prefiks va GetObject.
  2. Kalit kodda. S3_SIR GitHub'ga tushsa, botlar uni daqiqalar ichida topadi. Himoya — env, .gitignore, sirlar menejeri.
  3. Kalit brauzerda. Kalitni frontendga berish. Himoya — muddatli presigned URL (Presigned URL'lar).
  4. Taxmin qilinadigan kalit. vazifalar/7/chek.pdf — hujumchi 8, 9 ni sinaydi. Himoya — UUID kalit (Fayl yuklash xavfsizligi).
  5. Ortiqcha huquq. Ilova kaliti butun hisobga to'liq ruxsat bilan. Himoya — bitta bucket va kerakli amallar (PutObject, GetObject, DeleteObject, ListBucket).

11. Ko'p uchraydigan xatolar

Xato Sabab Davosi
MinIO'ga ulanib bo'lmaydi (bucket nomi host oldiga qo'shiladi) virtual-hosted manzil forcePathStyle: true
InvalidBucketName katta harf, _, juda qisqa nom nom qoidalari
BucketAlreadyOwnedByYou (409) bucket allaqachon bor avval HeadBucket
NotFound: UnknownError HeadObject yo'q kalitda 404 ni topilmadimi bilan ushlang
EntityTooSmall multipart qismi < 5 MB partSize ≥ 5 MB
Metama'lumotda ? yoki xato ASCII bo'lmagan qiymat encodeURIComponent
ETag MD5 ga teng emas obyekt multipart bilan yuklangan o'z sha256 ingiz
403 kalitsiz fetch da bucket private (to'g'ri!) presigned URL yoki server orqali

12. Mashqlar

Mashqlar kurs/mashqlar/29/04-s3/ da.

1-mashq (oson): bo'shliqlarni to'ldiring

  • S3 da fayllar idishi —
  • Obyektning bucket ichidagi to'liq nomi —
  • "Papka"larni ko'rsatish uchun ListObjectsV2 parametri —
  • Bitta so'rovda yuklangan obyektning ETag'i — tananing hash'i.
  • Yo'q kalitni o'chirganda status —
Yechim
  • bucket — savat, nomi qat'iy qoidalar bilan.
  • kalit (key) — oddiy satr, / bilan ham.
  • Delimiter — / gacha guruhlab CommonPrefixes beradi.
  • MD5 — multipart'da esa …-N.
  • 204 — o'chirish idempotent.

2-mashq (o'rta): obyektlarni "papka" bo'yicha sanash

hajmlarHisobi(obyektlar) funksiyasini yozing: { kalit, hajm } massivini olib, har birinchi darajali prefiks (birinchi / gacha, / bilan) uchun jami hajmni qaytarsin. / siz kalit — "(ildiz)" guruhiga. Natijani obyekt sifatida chiqaring. Ma'lumot: avatar/7.webp 120, avatar/8.webp 80, vazifalar/7/a.png 5000, vazifalar/8/c.pdf 3000, readme.txt 10. Ishora: indexOf("/"), oddiy obyekt va for…of yetarli (Obyekt bo'ylab yurish).

Yechim
js
const obyektlar = [
  { kalit: "avatar/7.webp", hajm: 120 },
  { kalit: "avatar/8.webp", hajm: 80 },
  { kalit: "vazifalar/7/a.png", hajm: 5000 },
  { kalit: "vazifalar/8/c.pdf", hajm: 3000 },
  { kalit: "readme.txt", hajm: 10 },
];

function hajmlarHisobi(royxat) {
  const natija = {};
  for (const { kalit, hajm } of royxat) {
    const i = kalit.indexOf("/");
    const guruh = i === -1 ? "(ildiz)" : kalit.slice(0, i + 1);
    natija[guruh] = (natija[guruh] ?? 0) + hajm;
  }
  return natija;
}

console.log(hajmlarHisobi(obyektlar));

Konsolda:

text
{ 'avatar/': 200, 'vazifalar/': 8000, '(ildiz)': 10 }

Haqiqiy bucket'da obyektlar kalitlar("") generatoridan keladi va millionlab bo'lishi mumkin — bunday hisobni har safar qilish qimmat. Production'da hajm hisobini bazada yuritasiz (har fayl yozuvida hajm ustuni bor) yoki ombor hisobotlaridan olasiz.

3-mashq (qiyin): sahifalarni yuruvchi generator

Haqiqiy S3 o'rniga sahifaOl(token) soxta funksiyasi berilgan: u kalitlarni 2 tadan qaytaradi va { Contents, NextContinuationToken } beradi. Kanon kalitlar metodiga o'xshash barchaKalitlar(prefiks) async generatorini yozing: barcha sahifalarni yurib, faqat prefiks bilan boshlanadigan kalitlarni yield qilsin. Nechta sahifa so'ralganini ham sanang va chiqaring.

js
const BARCHASI = ["avatar/7.webp", "vazifalar/7/a.png",
  "vazifalar/7/b.png", "vazifalar/8/c.png", "vaqtincha/1.png"];
let soralgan = 0;

async function sahifaOl(token = 0) {
  soralgan++;
  const bosh = Number(token);
  const Contents = BARCHASI.slice(bosh, bosh + 2)
    .map((Key) => ({ Key }));
  const keyingi = bosh + 2 < BARCHASI.length ? bosh + 2 : undefined;
  return {
    Contents,
    NextContinuationToken: keyingi === undefined
      ? undefined : String(keyingi),
  };
}
Yechim
js
const BARCHASI = ["avatar/7.webp", "vazifalar/7/a.png",
  "vazifalar/7/b.png", "vazifalar/8/c.png", "vaqtincha/1.png"];
let soralgan = 0;

async function sahifaOl(token = 0) {
  soralgan++;
  const bosh = Number(token);
  const Contents = BARCHASI.slice(bosh, bosh + 2)
    .map((Key) => ({ Key }));
  const keyingi = bosh + 2 < BARCHASI.length ? bosh + 2 : undefined;
  return {
    Contents,
    NextContinuationToken: keyingi === undefined
      ? undefined : String(keyingi),
  };
}

async function* barchaKalitlar(prefiks) {
  let token;
  do {
    const sahifa = await sahifaOl(token);
    for (const o of sahifa.Contents ?? []) {
      if (o.Key.startsWith(prefiks)) yield o.Key;
    }
    token = sahifa.NextContinuationToken;
  } while (token);
}

for await (const k of barchaKalitlar("vazifalar/")) console.log(k);
console.log("so'ralgan sahifalar:", soralgan);

Konsolda:

text
vazifalar/7/a.png
vazifalar/7/b.png
vazifalar/8/c.png
so'ralgan sahifalar: 3

5 ta kalit, sahifada 2 tadan — 3 ta so'rov. Haqiqiy S3 da filtrni klientda qilmang: Prefix parametrini bering, ombor o'zi faqat keraklilarini qaytaradi va so'rovlar kamayadi. Bu yerda filtr klientda — soxta funksiya Prefix ni bilmaydi. token boshida undefined: birinchi so'rov tokensiz ketadi, xuddi S3 dagidek.

13. Loyiha qadami: s3.ts — FaylOmbori klassi

Kanon omborni bitta klassga o'radi: modullar/src/fayl/s3.ts. Ilovaning qolgan qismi SDK buyruqlarini emas, FaylOmbori metodlarini ko'radi — omborni almashtirish yoki testda soxtasini qo'yish oson. Versiyalar: @aws-sdk/client-s3, lib-storage, s3-request-presigner, s3-presigned-post — hammasi 3.1153.0.

Telefon uchun qatorlarni bo'ldik, ma'no kanondagidek. Avval metodlar jadvalini ko'zdan kechiring (koddan keyin), keyin kodni o'qing. Oxirgi ikki metod va contentDisposition — keyingi darslar mavzusi:

ts
// s3.ts — S3 mos ombor (MinIO lokal, AWS S3 / Cloudflare R2 prodda)
// ustidagi yupqa qatlam. Bucket private: o'qish/yozish faqat server
// kaliti yoki muddatli imzolangan havola (presigned) bilan.
import { Readable } from "node:stream";
import {
  S3Client, CreateBucketCommand, HeadBucketCommand, GetObjectCommand,
  HeadObjectCommand, DeleteObjectCommand, CopyObjectCommand,
  ListObjectsV2Command, S3ServiceException,
} from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import {
  createPresignedPost, type PresignedPost,
} from "@aws-sdk/s3-presigned-post";

export type S3Sozlama = {
  endpoint?: string; region: string; kalit: string; sir: string;
  bucket: string; pathStyle?: boolean;
};

export type ObyektMa = {
  hajm: number; tur: string | undefined; etag: string | undefined;
  meta: Record<string, string>;
};

export class FaylOmbori {
  readonly s3: S3Client;
  readonly bucket: string;

  constructor(s: S3Sozlama) {
    this.bucket = s.bucket;
    this.s3 = new S3Client({
      region: s.region,
      ...(s.endpoint === undefined ? {} : { endpoint: s.endpoint }),
      // MinIO: http://host:port/bucket/kalit
      forcePathStyle: s.pathStyle ?? s.endpoint !== undefined,
      credentials: { accessKeyId: s.kalit, secretAccessKey: s.sir },
    });
  }

  /** Bucket bo'lmasa yaratadi (lokal/test uchun; prodda bucket
   * infratuzilma kodi bilan yaratiladi). */
  async tayyorla(): Promise<void> {
    try {
      await this.s3.send(
        new HeadBucketCommand({ Bucket: this.bucket }),
      );
    } catch (e) {
      if (!topilmadimi(e)) throw e;
      await this.s3.send(
        new CreateBucketCommand({ Bucket: this.bucket }),
      );
    }
  }

  /** Oqimni (yoki buferni) yuklaydi. Hajm noma'lum oqim uchun
   * multipart (`lib-storage`), `signal` bilan bekor qilinadi. */
  async yoz(
    kalit: string,
    tana: Readable | Uint8Array | string,
    tur: string,
    opts: {
      meta?: Record<string, string>;
      signal?: AbortSignal;
      progress?: (yuklandi: number) => void;
    } = {},
  ): Promise<{ etag: string | undefined }> {
    const up = new Upload({
      client: this.s3,
      params: {
        Bucket: this.bucket, Key: kalit, Body: tana, ContentType: tur,
        ...(opts.meta ? { Metadata: opts.meta } : {}),
      },
      partSize: 5 * 1024 * 1024,
      queueSize: 2,
      // xatoda AbortMultipartUpload — chala qismlar qolmaydi
      leavePartsOnError: false,
      ...(opts.signal
        ? { abortController: abortControllerDan(opts.signal) }
        : {}),
    });
    if (opts.progress) {
      const p = opts.progress;
      up.on("httpUploadProgress", (h) => { p(h.loaded ?? 0); });
    }
    const r = await up.done();
    return { etag: r.ETag };
  }

  async oqi(kalit: string, oraliq?: string): Promise<{
    oqim: Readable; hajm: number | undefined;
    tur: string | undefined; oraliq: string | undefined;
  }> {
    const r = await this.s3.send(new GetObjectCommand({
      Bucket: this.bucket, Key: kalit,
      ...(oraliq ? { Range: oraliq } : {}),
    }));
    if (!(r.Body instanceof Readable)) {
      throw new Error("S3 javobi Node oqimi emas");
    }
    return {
      oqim: r.Body, hajm: r.ContentLength, tur: r.ContentType,
      oraliq: r.ContentRange,
    };
  }

  /** Obyekt ma'lumoti yoki `undefined` (yo'q bo'lsa). */
  async ma(kalit: string): Promise<ObyektMa | undefined> {
    try {
      const h = await this.s3.send(
        new HeadObjectCommand({ Bucket: this.bucket, Key: kalit }),
      );
      return {
        hajm: h.ContentLength ?? 0, tur: h.ContentType, etag: h.ETag,
        meta: h.Metadata ?? {},
      };
    } catch (e) {
      if (topilmadimi(e)) return undefined;
      throw e;
    }
  }

  /** S3 da DeleteObject idempotent — yo'q kalit ham 204. */
  async ochir(kalit: string): Promise<void> {
    await this.s3.send(
      new DeleteObjectCommand({ Bucket: this.bucket, Key: kalit }),
    );
  }

  async kochir(manba: string, nishon: string): Promise<void> {
    await this.s3.send(new CopyObjectCommand({
      Bucket: this.bucket,
      CopySource: `${this.bucket}/${encodeURI(manba)}`,
      Key: nishon,
    }));
  }

  async *kalitlar(prefiks: string): AsyncGenerator<{
    kalit: string; hajm: number; vaqt: Date | undefined;
  }> {
    let token: string | undefined;
    do {
      const l = await this.s3.send(new ListObjectsV2Command({
        Bucket: this.bucket, Prefix: prefiks,
        ...(token ? { ContinuationToken: token } : {}),
      }));
      for (const o of l.Contents ?? []) {
        if (o.Key) {
          yield {
            kalit: o.Key, hajm: o.Size ?? 0, vaqt: o.LastModified,
          };
        }
      }
      token = l.NextContinuationToken;
    } while (token);
  }

  /** Brauzer to'g'ridan bucket'ga yuklashi uchun presigned POST
   * (keyingi darsda). */
  async yuklashFormasi(
    kalit: string, tur: string, maksHajm: number, muddatSoniya = 300,
  ): Promise<PresignedPost> {
    return createPresignedPost(this.s3, {
      Bucket: this.bucket,
      Key: kalit,
      Conditions: [
        ["content-length-range", 1, maksHajm],
        ["eq", "$Content-Type", tur],
      ],
      Fields: { "Content-Type": tur },
      Expires: muddatSoniya,
    });
  }

  /** Yuklab olish uchun muddatli havola (keyingi darsda). */
  async olishHavolasi(
    kalit: string, muddatSoniya = 300, faylNomi?: string,
  ): Promise<string> {
    return getSignedUrl(
      this.s3,
      new GetObjectCommand({
        Bucket: this.bucket, Key: kalit,
        ...(faylNomi ? {
          ResponseContentDisposition: contentDisposition(faylNomi),
        } : {}),
      }),
      { expiresIn: muddatSoniya },
    );
  }
}

/** `attachment; filename="ascii"; filename*=UTF-8''foiz-kodlangan` —
 * eski brauzerlar ASCII zaxirasini oladi. */
export function contentDisposition(
  nom: string, tur: "attachment" | "inline" = "attachment",
): string {
  let ascii = nom.normalize("NFKD").replace(/[^\x20-\x7E]/g, "")
    .replace(/["\\]/g, "_").trim();
  // "Ўқиш.txt" → ".txt" bo'lib qolmasin
  if (ascii === "" || ascii.startsWith(".")) ascii = `fayl${ascii}`;
  const utf8 = encodeURIComponent(nom).replace(/['()*]/g,
    (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase());
  return `${tur}; filename="${ascii}"; filename*=UTF-8''${utf8}`;
}

export function topilmadimi(e: unknown): boolean {
  return e instanceof S3ServiceException && (
    e.$metadata.httpStatusCode === 404 || e.name === "NoSuchKey" ||
    e.name === "NotFound" || e.name === "NoSuchBucket"
  );
}

function abortControllerDan(signal: AbortSignal): AbortController {
  const ac = new AbortController();
  if (signal.aborted) ac.abort(signal.reason);
  else {
    signal.addEventListener(
      "abort", () => { ac.abort(signal.reason); }, { once: true },
    );
  }
  return ac;
}
Metod S3 buyrug'i Nima uchun
tayyorla() HeadBucket → CreateBucket lokal/test bucket
yoz(kalit, tana, tur) Upload (multipart) oqim yoki bufer
oqi(kalit, oraliq?) GetObject yuklab berish, Range
ma(kalit) HeadObject bormi, hajmi, turi
ochir, kochir DeleteObject, CopyObject tozalash, ko'chirish
kalitlar(prefiks) ListObjectsV2 + token yetimlarni topish

Kanon modullar paketi npm run check dan o'tgan: tsc 0, eslint 0, testlar 82/82 (MinIO bilan). Biz Docker'ni yoqmadik: contentDisposition testi o'tdi, MinIO talab qiladigan to'plam S3_ENDPOINT yo'q sababi bilan o'tkazildi.

Commit xabari (modul uchun):

bash
git commit -m "feat(fayl): FaylOmbori — S3 mos ombor (MinIO, R2, AWS)"

vazifalar-api ga ulanishi 28-qism tugagach: ilova ishga tushganda bitta FaylOmbori yaratiladi, S3_* o'zgaruvchilari env validatsiyasiga (Env validatsiyasi) qo'shiladi.

14. Real ishda

  • Deyarli har zamonaviy ilova rasmlar, hujjatlar, zaxira nusxalar va loglarni S3 yoki uning muqobilida saqlaydi. S3 API — sohaning umumiy tili.
  • Narx tanlovi: saqlash, so'rovlar va chiquvchi trafik. Ko'p yuklab olinadigan ilovada trafik eng katta xarajat bo'lishi mumkin.
  • Intervyuda: "Fayllarni qayerda saqlaysiz va nega serverda emas?", "Private faylni foydalanuvchiga qanday berasiz?", "ETag nima?"

Xulosa

  • Object storage: bucket → kalit → obyekt (baytlar + metama'lumot); papkalar yo'q, faqat prefiks.
  • S3 API — standart; Node'da @aws-sdk/client-s3 v3: new XCommand({...}) + send.
  • Bitta so'rovli obyekt ETag'i = MD5, multipart'da …-N; minimal qism 5 MB.
  • Yo'q kalit: Get — NoSuchKey, Head — NotFound: UnknownError, Delete — 204 (idempotent).
  • Bucket sukutda private; ochiq faqat kerakli prefiks va GetObject.
  • MinIO community repozitoriyasi 2026-04 da arxivlangan, Docker Hub image'i endi yo'q — kod S3 API'ga yozilgani uchun boshqa omborga o'tish — sozlama o'zgarishi.

Keyingi dars: Presigned URL'lar — brauzer faylni serverni chetlab to'g'ridan omborga yuklaydi va yuklab oladi, server esa faqat muddatli ruxsat beradi.

Manbalar

Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!