Cách thiết kế cấu trúc dữ liệu (database design) khi sử dụng firebase realtime database

Bài Học, Firebase

Bài viết bàn luận tới 1 số khái niệm then chốt trong việc thiết kế cấu trúc dữ liệu và đưa 1 số best practices khi cấu trúc hóa JSON data trong firebase realtime database.

Để xây dựng cấu trúc database phù hợp đòi hỏi bạn cần tư duy, tính toán đến việc xử lý dữ liệu. Quan trọng nhất là bạn cần vạch định được cách dữ liệu data được lưu và cách lấy ra dữ liệu data sau đó để khiến tiến trình xử lý dữ liệu trở nên dễ dàng nhất có thể.

Cách dữ liệu data được cấu trúc hóa là 1 cây JSON tree

Tất cả dữ liệu data được lưu trữ trong firebase realtime database là các JSON objects. Bạn có thể nghĩ về database như là 1 cây JSON tree được lưu trữ trên cloud. Không giống như SQL database, firebase không có các khái niệm như tables và records. Khi bạn thêm mới dữ liệu vào JSON tree, nó sẽ trở thành 1 node trong cấu trúc JSON đã có, và được định vị bởi 1 khóa key. Bạn có thể tự tạo ra khóa key giống như user IDs, hay các tên names có nghĩa, hoặc để firebase tự tạo ra cho bạn bằng childByAutoId.

Chú ý: nếu bạn tự tạo ra khóa keys, chúng phải là các ký tự đã được mã hóa UTF-8 encoded, và có độ dài tối đa là 768 bytes và không thể chứa các kí tự ., $, #, [, ], /, hay ASCII nằm trong khoảng 0–31 hay 127.

Ví dụ, xét 1 ứng dụng chat cho phép users lưu trữ thông tin profile của họ và danh sách liên hệ contact list. 1 user profile được định vị bởi 1 đường dẫn path, giống như /user/$uid. alovelace user trong database sẽ trong giống như sau:

{
  "users": {
    "alovelace": {
      "name": "Ada Lovelace",
      "contacts": { "ghopper": true },
    },
    "ghopper": { ... },
    "eclarke": { ... }
  }
}

Mặc dù database sử dụng 1 cây JSON tree, nhưng kiểu data type được lưu trữ trong database có thể được biểu diễn theo các kiểu dữ liệu cơ bản tương ứng với các kiểu JSON types.

Các best practices khi thiết kế cấu trúc dữ liệu

Tránh cấu trúc dữ liệu lồng nhau (nesting data)

Do firebase realtime database cho phép dữ liệu lồng nhau (nesting data) với độ sâu lên tới 32 cấp, do vậy rất có thể bạn sẽ suy nghĩ theo thiên hướng nên thiết kế cấu trúc dữ liệu theo kiểu lồng nhau. Tuy nhiên, khi bạn lấy ra dữ liệu tại 1 node trong database, bạn cũng phải lấy ra hết các child nodes của nó. Thêm nữa, khi bạn phân quyền cho 1 vài người nào đó được phép đọc, viết hay truy cập vào 1 node trong database, bạn phải phần quyền cho họ truy cập vào toàn bộ dữ liệu trong node đó. Do vậy, cách tốt nhất là giữ cấu trúc dữ liệu phẳng nhất (flatten) có thể.

Để hình dung về việc tại sao cấu trúc dữ liệu lồng nhau lại không tốt, ta xét ví dụ sau:

{
  // Đây là cấu trúc dữ liệu lồng nhau, mỗi node con (cuộc hội     thoại) 
  // trong chats node sẽ
  // chứa kèm theo toàn bộ messages của cuộc hội thoại bên trong. Để 
  // lấy về title của cuả cuộc hội thoại, cần tải về hằng trăm 
  // megabytes dữ liệu của messages
  "chats": {
    "one": {
      "title": "Historical Tech Pioneers",
      "messages": {
        "m1": { "sender": "ghopper", "message": "Relay malfunction found. Cause: moth." },
        "m2": { ... },
        // 1 danh sách rất dài các đoạn messages
      }
    },
    "two": { ... }
  }
}

Với cách thiết kế dữ liệu lồng nhau như trên, thì việc duyệt dữ liệu sẽ có vấn đề. Ví dụ, việc liệt kê ra titles của cuộc hội thoại chat sẽ cần lấy ra chats node: bao gồm toàn bộ members và messages cần tải xuống client (thông tin messages là dư thừa trong nghiệp vụ này, và không cần thiết phải tải xuống client).

Phẳng hóa cấu trúc dữ liệu (flatten data structures)

Nếu dữ liệu data thay vì thiết kế lồng nhau mà được phân tách ra thành các vị trí path riêng rẽ (Quá trình này được gọi là denormalization), thì dữ liệu sẽ được tải xuống client 1 cách hiệu quả hơn theo các lời gọi riêng rẽ (Đây là điều cần thiết phải thực hiện). Ta xét cấu trúc dữ liệu đã được phẳng hóa (flatten) sau:

{
  // Cuộc hội thoại chats chỉ nên chứa thông tin meta nhỏ gọn liên quan tới đoạn hội thoại mà thôi. 
  // thông tin này được lưu trữ bên trong 1 chats's unique ID
  "chats": {
    "one": {
      "title": "Historical Tech Pioneers",
      "lastMessage": "ghopper: Relay malfunction found. Cause: moth.",
      "timestamp": 1459361875666
    },
    "two": { ... },
    "three": { ... }
  },
  // Thông tin về members tham gia vào cuộc hội thoại sẽ trở nên dễ dàng truy cập hơn, 
  // và được lưu trữ trong 1 chat conversation ID
  "members": {
    // we'll talk about indices like this below
    "one": {
      "ghopper": true,
      "alovelace": true,
      "eclarke": true
    },
    "two": { ... },
    "three": { ... }
  },
  // Messages được phân tách ra, do ta muốn duyệt nó nhanh hơn, nhưng vẫn dễ dàng cho việc phân trang và truy vấn, 
  // Hay nhóm gộp theo chat conversation ID
  "messages": {
    "one": {
      "m1": {
        "name": "eclarke",
        "message": "The relay seems to be malfunctioning.",
        "timestamp": 1459361875337
      },
      "m2": { ... },
      "m3": { ... }
    },
    "two": { ... },
    "three": { ... }
  }
}

Giờ bạn có thể duyệt danh sách các rooms bằng cách tải về 1 vài bytes trên mỗi cuộc hội thoại, nhanh chóng lấy được thông tin metadata cho việc liệt kê hay hiện thị rooms trong 1 giao diện UI. Messages có thể được lấy 1 cách riêng rẽ và hiện thị ra khi chúng tới, cho phép UI phản hồi 1 cách nhanh chóng và hiệu quả.

Cách tạo ra dữ liệu mà có thể mở rộng (scales)

Khi xây dựng apps, tốt hơn hết là chỉ nên tải 1 tập con của 1 danh sách dữ liệu. Đây là 1 trường hợp rất phổ biến khi danh sách dữ liệu bao gồm hàng nghì bản ghi. Khi mỗi quan hệ relationship là tĩnh và 1 chiều, thì bạn có thể chỉ đơn giản là lồng các child objects vào trong 1 parent node.

Thỉnh thoảng, relationship lại trở nên động hơn, và có thể cần phải tối ưu hóa cấu trúc dữ liệu để thao tác xử lý trở nên tối ưu.

Ta xét ví dụ về mối quan hệ relationship 2 chiều (nhiều-nhiều) giữa users và groups. Users có thể thuộc 1 hoặc nhiều groups và groups có thể bao gồm 1 danh sách các users (users có quan hệ n-n với groups). Khi ta cần xem xét xem những groups nào mà users thuộc vào, thì vấn đề sẽ trở nên phức tạp hóa.

Cách hay nhất là ta liệt kê được ra danh sách các groups mà user thuộc vào và chỉ cần lấy ra data cho các groups đó. 1 danh sách chỉ mục index của các groups nằm trong user có thể làm việc xử lý này trở nên dễ dàng hơn:

// users có quan hệ n-n với groups
{
  "users": {
    "alovelace": {
      "name": "Ada Lovelace",
      // alovelace user có chứa thông tin lưu mối quan hệ với groups
      "groups": {
         // giá trị value của các trường fields tại đây không quan
         // trọng, bạn chỉ cần quan tâm trường fields có tồn tại hay   
         //không mà thôi
         "techpioneers": true,
         "womentechmakers": true
      }
    },
    ...
  },
  "groups": {
    "techpioneers": {
      "name": "Historical Tech Pioneers",
      "members": {
        "alovelace": true,
        "ghopper": true,
        "eclarke": true
      }
    },
    ...
  }
}

Bạn có thể lưu ý thấy rằng có sự dư thừa dữ liệu bằng cách lưu trữ mối quan hệ relationship ở cả 2 phía user và group. Giờ alovelace user được lưu trữ bên trong group và techpioneers group cũng được lưu trữ trong user. Do vậy khi xóa alovelace khỏi group, thì bạn cần update cả ở 2 nơi.

Đây là 1 sự dư thừa dữ liệu cần thiết cho mối quan hệ relationship 2 chiều (n-n), nó cho phép ta lấy được thông tin alovelace’s memberships 1 cách hiệu quả và nhanh chóng, hay ngay cả khi lấy ra danh sách users hay groups thì nó vẫn phát huy được tính hiệu quả.

Theo cách tiếp cận này, thì việc duyệt dữ liệu data bằng cách liệt kê ra các IDs giống như khóa keys và thiết lập giá trị value của chúng bằng true, cách kiểm tra khóa key 1 cách đơn giản bằng cách đọc /users/$uid/groups/$group_id và kiểm tra xem nó có null hay không.

Nguồn: https://firebase.google.com/docs/database/web/structure-data


Trả lời

Email của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *