full_stack_101 إدارة المستخدمين في تطبيق Node.js باستعمال قواعد بيانات MongoDB ومكتبة Mongoose


ابراهيم الخضور

سنحتاج إلى وسيلة للتحقق من المستخدم ومن الصلاحيات الممنوحة له في تطبيقنا. وينبغي أن تُحفظ بيانات المستخدمين في قاعدة البيانات وأن ترتبط بالمستخدم الذي أنشأها. فلن يُسمح بتعديل أو حذف الملاحظة إلا من قبل المستخدم الذي أنشأها.

لنبدأ بإضافة معلومات عن المستخدمين إلى قاعدة البيانات. حيث ستظهر علاقة من الشكل "واحد إلى عدة" بين المستخدم User و الملاحظة Note:

user_note_relation_01.png

لو كنا نعمل مع قاعدة بيانات علاقيّة لكانت إضافة البيانات السابقة عملية مباشرة. حيث سيمتلك كلا الموردين جداول مستقلة في قاعدة البيانات وسيخزن معرّف المستخدم الذي أنشأ الملاحظة في جدول الملاحظات كمفتاح خارجي (foreign key).

سيختلف الأمر قليلًا عند العمل مع قاعدة بيانات المستندات لوجود طرق عدة للقيام بذلك.

يقتضي الحل الذي طبقناه في حالة الملاحظات بتخزين كل ملاحظة في تجمع للملاحظات notes collection ضمن قاعدة البيانات. فإن لم نشأ أن نغير التجمع، فمن المنطقي إنشاء تجمع جديد للمستخدمين على سبيل المثال.

كما هي العادة في جميع قواعد بيانات المستندات يمكن استخدام معرّف الكائن في Mongo كمرجع للمستندات في التجمعات المختلفة. وهذا أمر مشابه لاستخدام المفتاح الغريب في قواعد البيانات العلاقية.

لا تدعم قواعد البيانات التقليدية مثل Mongo الاستقصاء المشترك (joint query) المتاح في القواعد العلاقية، والذي يستخدم في تجميع البيانات من جداول عدة. لكن اعتبارًا من الإصدار 3.2، دعمت Mongo فكرة استقصاءات البحث المجمعة، والتي لن نتعامل معها في منهاجنا.

إن احتجنا إلى وظيفة مشابهة للاستقصاء المجمع في تطبيقنا فسنضيفها إلى الشيفرة بتنفيذ استقصاءات متعددة. على الرغم من أن Mongoose تتكفل في بعض الحالات بموضوع ضم وتجميع البيانات بما يوحي بالاستقصاء المشترك، إلا أن ما تجريه في الواقع، هي استقصاءات متعددة في الخفاء.

المراجع ما بين التجمعات

لو استخدمنا قاعدة بيانات علاقية، فستحتوي الملاحظة على مفتاح مرجعي إلى المستخدم الذي أنشأها. وبالمثل يمكننا القيام بأمر مشابه في قواعد بيانات المستندات. لنفترض أن تجمع المستخدمين users سيضم مستخدمين اثنين:

[
  {
    username: 'mluukkai',
    _id: 123456,
  },
  {
    username: 'hellas',
    _id: 141414,
  },
];

يضم تجمع الملاحظات notes ثلاثة ملاحظات، لكل منها حقل خاص يرتبط مرجعيًا بمستخدم من تجمع المستخدمين:

[
  {
    content: 'HTML is easy',
    important: false,
    _id: 221212,
    user: 123456,
  },
  {
    content: 'The most important operations of HTTP protocol are GET and POST',
    important: true,
    _id: 221255,
    user: 123456,
  },
  {
    content: 'A proper dinosaur codes with Java',
    important: false,
    _id: 221244,
    user: 141414,
  },
]

لا تتطلب قاعدة بيانات المستند تخزين مفتاح خارجي في حقول الملاحظة، بل يمكن أن يُخزّن في تجمع المستخدمين كما يمكن أن يخزن في كلا التجمعين:

[
  {
    username: 'mluukkai',
    _id: 123456,
    notes: [221212, 221255],
  },
  {
    username: 'hellas',
    _id: 141414,
    notes: [221244],
  },
]

طالما أن المستخدم قد ينشئ عدة ملاحظات، سنخزن معرفات هذه الملاحظات في مصفوفة ضمن الحقل notes في مورد المستخدم.

تقدم قواعد بيانات المستندات أيضًا طريقة مختلفة جذريًا في تنظيم البيانات: فمن المجدي في بعض الحالات، أن نشعِّب مصفوفة الملاحظات التي أنشأها المستخدم بأكملها كجزء من المستند في تجمع المستخدمين:

[
  {
    username: 'mluukkai',
    _id: 123456,
    notes: [
      {
        content: 'HTML is easy',
        important: false,
      },
      {
        content: 'The most important operations of HTTP protocol are GET and POST',
        important: true,
      },
    ],
  },
  {
    username: 'hellas',
    _id: 141414,
    notes: [
      {
        content:
          'A proper dinosaur codes with Java',
        important: false,
      },
    ],
  },
]

في هذا التخطيط لقاعدة البيانات، ستُشعّب الملاحظات تحت المستخدم، ولن تولّد قاعدة البيانات حينها أية معرفات لها.

إن بنية وتخطيط قواعد بيانات المستندات ليس واضحة بذاتها كما هي الحال في القواعد العلاقيّة. فيجب أن يخدم التخطيط الذي سنستعمله حالات التطبيق جمعيها بأفضل شكل. وبالطبع فهذا القرار ليس بسيطًا على الإطلاق لأن حالات استخدام التطبيق ليست معروفة تمامًا في فترة التصميم.

وللمفارقة، ستتطلب قواعد البيانات التي لا تمتلك تخطيطًا مثل Mongoose من المطور أن يتخذ قرارات جذرية جدًا حول تنظيم البيانات منذ البداية خلافًا للقواعد العلاقيّة. حيث تقدم القواعد العلاقية طرقًا أكثر أو أقل في تنظيم البيانات لعدة تطبيقات

تخطيط Mongoose للمستخدمين

لقد قررنا في حالتنا أن نخزن معرفات الملاحظات التي ينشئها مستخدم في مستند المستخدم. لذا سنعرف نموذجًا يمثل المستخدم ونضعه في الملف user.js ضمن المجلد models:

const mongoose = require('mongoose')

const userSchema = new mongoose.Schema({
  username: String,
  name: String,
  passwordHash: String,
  notes: [
    {
      type: mongoose.Schema.Types.ObjectId,
      ref: 'Note'
    }
  ],
})

userSchema.set('toJSON', {
  transform: (document, returnedObject) => {
    returnedObject.id = returnedObject._id.toString()
    delete returnedObject._id
    delete returnedObject.__v
    // the passwordHash should not be revealed
    delete returnedObject.passwordHash
  }
})

const User = mongoose.model('User', userSchema)

module.exports = User

خُزّنت معرفات الملاحظات ضمن مستند المستخدم كمصفوفة معرفات Mongoose. وللتعريف الشكل التالي:

{
  type: mongoose.Schema.Types.ObjectId,
  ref: 'Note'
}

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

لنوسع تخطيط الملاحظة المعرّف في الملف notre.js ضمن المجلد model لكي تضم معلومات على المستخدم الذي أنشأها:

const noteSchema = new mongoose.Schema({
  content: {
    type: String,
    required: true,
    minlength: 5
  },
  date: Date,
  important: Boolean,
  user: {    
      type: mongoose.Schema.Types.ObjectId,    
      ref: 'User'  
  }})

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

تسجيل مستخدمين جدد

لنضف مسارًا لإنشاء مستخدمين جدد. يمتلك المستخدم اسمًا واسمًا آخر لتسجيل الدخول (username) ومعلومة أخرى تدعى كلمة السر المرمّزة passwordHash. هذه الكلمة المرمزة هي نتاج دالة الترميز وحيدة الاتجاه عندما نطبقها على كلمة السر. فمن غير الملائم أن تخزن كلمة السر كنص عادي في قاعدة البيانات.

لنثبت الحزمة bcrypt التي ستتولى أمر توليد كلمات السر المرمزة:

npm install bcrypt --save

سننشئ مستخدمين جدد بما يتوافق مع توجيهات RESTful التي ناقشناها في القسم 3، وذلك بإرسال طلب HTTP POST إلى عنوان الموقع users.

لنعرف متحكمًا بالمسار يتعامل مع المستخدمين ولنضعه في ملف جديد باسم users.js ضمن المجلد controllers. سنجعله قابلًا للاستخدام بإدراجه في الملف app.js، حيث سيعالج الطلبات المرسلة إلى العنوان api/users/:

const usersRouter = require('./controllers/users')

// ...

app.use('/api/users', usersRouter)

يحتوي الملف الذي يعرّف المتحكم بالمسار الشيفرة التالية:

const bcrypt = require('bcrypt')
const usersRouter = require('express').Router()
const User = require('../models/user')

usersRouter.post('/', async (request, response) => {
  const body = request.body

  const saltRounds = 10
  const passwordHash = await bcrypt.hash(body.password, saltRounds)

  const user = new User({
    username: body.username,
    name: body.name,
    passwordHash,
  })

  const savedUser = await user.save()

  response.json(savedUser)
})

module.exports = usersRouter

لا تُخزَّن كلمة السر التي تُرسل ضمن الطلب في قاعدة البيانات، بل النسخة المعمّاة التي تولّدها الدالة bcrypt.hash. إن أساسيات تخزين كلمات المرور في قواعد البيانات موضوع خارج نطاق منهاجنا، ولن نناقش ما الذي يعنيه إسناد الرقم السحري 10 إلى المتغيّر saltRounds، لكن يمكن الاطلاع على معلومات أكثر عبر الإنترنت.

لا تحتوي الشيفرة الحالية على معالجات أخطاء أو معالجات تقييم، لتتحقق من كون اسم المستخدم وكلمة المرور بالشكل الصحيح.

يجب أن تُختبر الميزة الجديدة يدويًا بشكل مبدئي باستخدام أدوات مثل Postman. لكن سرعان ما سيغدو الأمر مربكًا وخاصة عندما نضيف الوظيفة التي تجبر المستخدم على إدخال اسم مستخدم فريد. لذلك فكتابة الاختبارات الآلية لن يحتاج جهدًا كبيرًا، وسيجعل تطوير التطبيق أكثر سهولة.

قد يبدو اختبارنا المبدئي كالتالي:

const bcrypt = require('bcrypt')
const User = require('../models/user')

//...

describe('when there is initially one user in db', () => {
  beforeEach(async () => {
    await User.deleteMany({})

    const passwordHash = await bcrypt.hash('sekret', 10)
    const user = new User({ username: 'root', passwordHash })

    await user.save()
  })

  test('creation succeeds with a fresh username', async () => {
    const usersAtStart = await helper.usersInDb()

    const newUser = {
      username: 'mluukkai',
      name: 'Matti Luukkainen',
      password: 'salainen',
    }

    await api
      .post('/api/users')
      .send(newUser)
      .expect(200)
      .expect('Content-Type', /application\/json/)

    const usersAtEnd = await helper.usersInDb()
    expect(usersAtEnd).toHaveLength(usersAtStart.length + 1)

    const usernames = usersAtEnd.map(u => u.username)
    expect(usernames).toContain(newUser.username)
  })
})

تستخدم الاختبارات الدالة المساعدة ()usersInDb التي أضفناها إلى الملف test_helper.js الموجود في المجلد tests. ستساعدنا الدالة على التحقق من حالة قاعدة البيانات عند إنشاء مستخدم جديد:

const User = require('../models/user')

// ...

const usersInDb = async () => {
  const users = await User.find({})
  return users.map(u => u.toJSON())
}

module.exports = {
  initialNotes,
  nonExistingId,
  notesInDb,
  usersInDb,
}

تُضيف الكتلة beforeEach مستخدمًا له اسم المستخدم إلى قاعدة البيانات. يمكننا كتابة اختبار جديد يتحقق من عدم تسجيل اسم مستخدم جديد موجود أصلًا:

describe('when there is initially one user in db', () => {
  // ...

  test('creation fails with proper statuscode and message if username already taken', async () => {
    const usersAtStart = await helper.usersInDb()

    const newUser = {
      username: 'root',
      name: 'Superuser',
      password: 'salainen',
    }

    const result = await api
      .post('/api/users')
      .send(newUser)
      .expect(400)
      .expect('Content-Type', /application\/json/)

    expect(result.body.error).toContain('`username` to be unique')

    const usersAtEnd = await helper.usersInDb()
    expect(usersAtEnd).toHaveLength(usersAtStart.length)
  })
})

لن ينجح الاختبار في هذه الحالة حاليًا.

نتدرب من خلال الأمثلة السابقة على ما يسمى التطوير المقاد بالاختبارات test-driven development (TDD)، حيث تُكتب الاختبارات للوظائف الجديدة قبل إضافتها إلى التطبيق.

لنقيّم تفرّد اسم المستخدم بمساعدة مقيمات المكتبة Mongoose. لا تمتلك المكتبة كما أشرنا في التمرين 3.19 على مقيمات مدمجة معها للتحقق من وحدانية القيمة لحقل محدد. لكن يمكننا أن نجد حلًا جاهزًا في حزمة npm mongoose-unique-validator. لنثبت هذه الحزمة إذًا.

npm install --save mongoose-unique-validator

كما ينبغي أن نجري التعديلات التالية على الملف user.js الموجود ضمن المجلد models:

const mongoose = require('mongoose')
const uniqueValidator = require('mongoose-unique-validator')
const userSchema = new mongoose.Schema({
  username: {
    type: String,
    unique: true  },
  name: String,
  passwordHash: String,
  notes: [
    {
      type: mongoose.Schema.Types.ObjectId,
      ref: 'Note'
    }
  ],
})

userSchema.plugin(uniqueValidator)
// ...

يمكننا إضافة العديد من الاختبارت لتقييم حالة المستخدم الجديد، كالتحقق من طول اسم المستخدم إن كان كافيًا، أو أنه يحوي على محارف غير مسموحة، أو أن كلمة السر قوية بما يكفي. لقد تركنا تلك الوظائف كتمرينات اختيارية.

قبل أن نكمل طريقنا، سنضيف معالج مسار يعيد كل المستخدمين المسجلين في قاعدة البيانات:

usersRouter.get('/', async (request, response) => {
  const users = await User.find({})
  response.json(users)
})

ستبدو القائمة كالتالي:

user_list_02.png

يمكنك إيجاد شيفرة التطبيق بأكملها في الفرع part4-7 ضمن المستودع الخاص بالتطبيق على GitHub.

إنشاء ملاحظة جديدة

ينبغي أن نعدل شيفرة إنشاء ملاحظة جديدة لكي نربطها بالمستخدم الذي أنشأها.

لنوسع الشيفرة بحيث تُرسل معلومات عن المستخدم الذي أنشأ الشيفرة ضمن الحقل userId من جسم الطلب:

const User = require('../models/user')

//...

notesRouter.post('/', async (request, response, next) => {
  const body = request.body

  const user = await User.findById(body.userId)
  const note = new Note({
    content: body.content,
    important: body.important === undefined ? false : body.important,
    date: new Date(),
    user: user._id  })

  const savedNote = await note.save()
  user.notes = user.notes.concat(savedNote._id)  await user.save()  
  response.json(savedNote)
})

لن يؤثر التغيير الذي حصل على الكائن user في شيء. وسيُخزَّن معرف الملاحظة في الحقل notes.

const user = await User.findById(body.userId)

// ...

user.notes = user.notes.concat(savedNote._id)
await user.save()

لنحاول إنشاء ملاحظة جديدة:

new_note_03.png

تبدو العملية ناجحة. لنضف ملاحظة أخرى ثم نحضر بيانات كل المستخدمين باستخدام المسار المخصص لذلك:

second_note_04.png

يمكن أن نرى أن المستخدم قد أنشأ ملاحظتين. كذلك الأمر يمكن رؤية معرفات المستخدمين الذين أنشأوا الملاحظات إذا ما أحضرنا كل الملاحظات باستخدام المسار المخصص لذلك.

all_notes_05.png

استخدام التابع Populate

نريد لواجهتنا البرمجية أن تعمل بطريقة محددة بحيث تضع محتويات الملاحظات التي أنشأها المستخدم داخل الكائن user عند إرسال طلب HTTP GET، وليس فقط المعرفات الفريدة الخاصة بها. يمكن إنجاز ذلك في قواعد البيانات العلاقيّة باستخدام الاستقصاء المشترك.

لا تدعم قواعد بيانات المستندات الاستقصاء المشترك بين التجمعات كما أشرنا سابقًا، لكن يمكن للمكتبة Mongoose أن تنفذ ببعض الأمور المشابهة. حيث تنجز المكتبة الاستقصاء المشترك بتنفيذها استقصاءات متعددة تختلف عن الاستقصاء المشترك في القواعد العلاقية. فلا تتأثر حالة هذه الأخيرة عندما نجري الاستقصاء المشترك، بينما لا يمكن أن نضمن ثبات الحالة بين التجمعين اللذين ضمهما الاستقصاء عند استخدام Mongoose. ويعني هذا أن حالة التجمعين قد تتغير أثناء الاستقصاء، إذا قمنا باستقصاء مشترك بين المستخدمين والملاحظات.

تستخدم Mongoose التابع populate في ضم التجمعات. لنعدل المسار الذي يعيد كل المستخدمين أولًا:

usersRouter.get('/', async (request, response) => {
  const users = await User.find({}).populate('notes')
  
  response.json(users)
})

يظهر التابع populate في السلسة بعد أن ينفذ التابع find الاستقصاء. يحدد الوسيط الذي يُمرر إلى التابع أن معرفات كائنات الملاحظة في الحقل notes من المستند user ستُستبدل بمستند note المرتبط معه مرجعيًا.

ستبدو النتيجة الآن كما نريد تقريبًا:

populate_mongoose_06.png

يمكن استخدام معامل التابع populate لاختيار الحقول التي نريد إضافتها من المستندات المختلفة، ويتم ذلك باستخدام تعبير Mong التالي:

usersRouter.get('/', async (request, response) => {
  const users = await User
    .find({}).populate('notes', { content: 1, date: 1 })

  response.json(users)
});

ستكون النتيجة الآن كما نريد تمامًا:

populate_exact_res_07.png

لننشر معلومات المستخدم ضمن الملاحظات بشكل ملائم:

notesRouter.get('/', async (request, response) => {
  const notes = await Note
    .find({}).populate('user', { username: 1, name: 1 })

  response.json(notes)
});

وهكذا ستُضاف معلومات المستخدم في الحقل user من كائن الملاحظة.

add_user_info_08.png

من المهم معرفة أن قاعدة البيانات لاتعلم أن المعرفات المخزنة في الحقل user من الملاحظات مرتبطة مرجعيًا مع تجمع المستخدمين.

تعتمد وظيفة التابع populate على حقيقة أننا عرّفنا أنماطًا مرجعية في تخطيط Mongoose مزودة بالخيار ref:

const noteSchema = new mongoose.Schema({
  content: {
    type: String,
    required: true,
    minlength: 5
  },
  date: Date,
  important: Boolean,
  user: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User'
  }
})

يمكنك إيجاد شيفرة التطبيق بأكملها في الفرع part4-8 ضمن المستودع الخاص بالتطبيق على GitHub.

ترجمة -وبتصرف- للفصل User Administration من سلسلة Deep Dive Into Modern Web Development





تفاعل الأعضاء


لا توجد أيّة تعليقات بعد



يجب أن تكون عضوًا لدينا لتتمكّن من التعليق

انشاء حساب جديد

يستغرق التسجيل بضع ثوان فقط


سجّل حسابًا جديدًا

تسجيل الدخول

تملك حسابا مسجّلا بالفعل؟


سجّل دخولك الآن