انتقل إلى المحتوى
كل الملاحظات

3 د للقراءة

GridFS من Bun، بلا مراسم

لماذا كتبت bun-gridfs-storage، وما الذي أخطأ فيه محرّك Multer القديم تحت Bun، والقرارات الأربعة التي أعادت رفع الملفات إلى الرتابة المريحة.

كل منتج شحنته في السنوات الأربع الأخيرة يخزّن ملفات في مكان ما، وكلها بدأت بالحوار نفسه: تخزين كائنات، أم قاعدة البيانات التي نشغّلها أصلًا؟ في Keforo كان الجواب MongoDB. الملفات صغيرة، وتنتمي إلى مستندات تعيش هناك بالفعل، ونسخة احتياطية واحدة تغطي كل شيء. ذاك القرار كان سهلًا. أما تشغيل الرفع تحت Bun فلم يكن كذلك.

#ما الذي انكسر

لدى منظومة Node جواب لهذا: multer يحلّل جسم الطلب متعدد الأجزاء، ومحرّك تخزين يقرّر أين يذهب كل ملف. كان multer-gridfs-storage هو ذاك المحرّك لـ MongoDB، وخلفه سنوات من الإنتاج.

تحت Bun سقط في موضعين. معالجته للتدفقات افترضت تفاصيل داخلية في Node لا يحترمها تنفيذ Bun، وربطه لـ EventEmitter كان يُطلق الأحداث قبل أن تُرفق المستمعات. كانت عمليات الرفع تتجمّد، أو تنتهي بسجلّ ملف يشير إلى لا شيء. لم تصدر للحزمة نسخة منذ زمن طويل، ولم أرغب في نسخة متفرّعة برُقَع لن يشغّلها أحد غيري.

فكتبت محرّك تخزين يؤدي عملًا واحدًا بالضبط ويؤديه بالطريقة نفسها على بيئتَي التشغيل. وصار bun-gridfs-storage.

#ما الذي يجب على محرّك التخزين فعله حقًا

عقد Multer صغير. يستقبل المحرّك الطلب وتدفّق الملف الوارد، يكتبه في مكان ما، ثم يعيد النداء بما ينبغي أن ينتهي في req.file. بالنسبة إلى GridFS يعني ذلك فتح تدفّق رفع على دلو (bucket) وتوجيه البيانات إليه.

upload.tstypescript
import { BunGridFSStorage } from 'bun-gridfs-storage'
import multer from 'multer'
import mongoose from 'mongoose'

await mongoose.connect(process.env.MONGO_URL!)

const storage = new BunGridFSStorage({
  db: mongoose.connection.db,
  file: (req, file) => ({
    filename: `${Date.now()}-${file.originalname}`,
    bucketName: 'uploads',
  }),
})

const upload = multer({ storage, limits: { fileSize: 5 * 1024 * 1024 } })

app.post('/upload', upload.single('file'), (req, res) => {
  res.json({ file: req.file })
})

كل ما تبقى في الحزمة موجود ليجعل هذا رتيبًا في الإنتاج. أربعة قرارات أدّت معظم العمل.

#1. قاعدة البيانات قد تصل متأخرة

يُبنى محرّك التخزين عادةً عند تحميل الوحدة، قبل أن يُفتح الاتصال بوقت طويل. بدلًا من إلزام ترتيب معيّن للإقلاع، يقبل db إمّا Db وإمّا Promise<Db>. عمليات الرفع التي تصل قبل أن يُحلّ الوعد تنتظره.

typescript
const storage = new BunGridFSStorage({
  db: getDb(),
  file: (req, file) => ({ filename: file.originalname, bucketName: 'uploads' }),
})

async function getDb() {
  if (mongoose.connection.readyState === 1) return mongoose.connection.db
  return new Promise((resolve, reject) => {
    mongoose.connection.once('open', () => resolve(mongoose.connection.db))
    mongoose.connection.once('error', reject)
  })
}

#2. نداء واحد يقرّر كل شيء عن الملف

اسم الملف، والدلو، وحجم القطعة، ونوع المحتوى، والبيانات الوصفية تأتي جميعها من نداء file نفسه، ويمكن أن يكون غير متزامن. هناك يضع التطبيق الحقيقي معرّف المستخدم، أو المستأجر، أو بصمة محتوى حُسبت في مكان آخر.

typescript
const storage = new BunGridFSStorage({
  db: mongoose.connection.db,
  file: async (req, file) => ({
    filename: `${crypto.randomUUID()}-${file.originalname}`,
    bucketName: 'uploads',
    chunkSize: 255 * 1024,
    contentType: file.mimetype,
    metadata: {
      userId: req.user.id,
      originalName: file.originalname,
    },
  }),
})

#3. أحداث، لا سجلّات

يمدّ المحرّك EventEmitter ويُطلق connection وfile وstreamError وconnectionFailed. يبدو هذا تفصيلًا صغيرًا حتى تحتاج إلى عدّ عمليات الرفع الفاشلة لكل مستأجر، أو إطلاق فحص للفيروسات لحظة وصول الملف، من دون لمس معالج المسار.

typescript
storage.on('file', (file) => metrics.increment('uploads', { bucket: file.bucketName }))
storage.on('streamError', (error, config) => log.error({ error, config }, 'upload failed'))

#4. Bun أولًا، وNode أيضًا

الاختبارات تعمل تحت bun test، والبناء يشحن CommonJS وESM. يعمل Node 18 وما بعده دون تغيير. لا أريد قصّتَي رفع ملفات في قاعدة شيفرة واحدة لأن خدمةً ما تعمل على بيئة تشغيل مختلفة.

#القراءة من جديد

التحميل والحذف يمرّان مباشرة عبر دلو المُشغّل (driver). يكشفه المحرّك حتى لا تبني دلوًا ثانيًا أبدًا.

typescript
app.get('/files/:name', (req, res) => {
  const bucket = storage.getBucket()
  if (!bucket) return res.status(503).send('storage not ready')
  bucket
    .openDownloadStreamByName(req.params.name)
    .on('error', () => res.status(404).end())
    .pipe(res)
})

#متى يكون GridFS الجواب الخطأ

إذا كانت ملفاتك كبيرة، أو عامة، أو تُقدَّم لقرّاء كثيرين، فإن شبكة توزيع أمام تخزين كائنات ستتفوّق على هذا كل مرة، وبتكلفة أقل. يستحق GridFS مكانه حين تكون الملفات خاصة، متواضعة الحجم، مرتبطة بمستندات تستعلم عنها أصلًا، وحين تريد نسخة احتياطية واحدة ونموذجًا واحدًا للتحكم في الوصول وسلسلة اتصال واحدة. هكذا كان كل رفع في Keforo، ولهذا وُجدت الحزمة.

هل أفادك هذا؟

النقاش

تبني شيئًا من هذا القبيل؟

أخبرني بما تعمل عليه. أردّ في غضون يوم.

راسلني