Hệ thống tệp riêng tư gốc

Tiêu chuẩn hệ thống tệp giới thiệu một hệ thống tệp riêng tư gốc (OPFS) làm điểm cuối lưu trữ riêng tư cho nguồn gốc của trang và người dùng không nhìn thấy. Hệ thống này cung cấp quyền truy cập không bắt buộc vào một loại tệp đặc biệt được tối ưu hoá cao về hiệu suất.

Hỗ trợ trình duyệt

Hệ thống tệp riêng tư gốc được các trình duyệt hiện đại hỗ trợ và được Nhóm công tác về công nghệ ứng dụng siêu văn bản trên web (WHATWG) chuẩn hoá trong Tiêu chuẩn hiện hành về hệ thống tệp.

Browser Support

  • Chrome: 86.
  • Edge: 86.
  • Firefox: 111.
  • Safari: 15.2.

Source

Động lực

Khi nghĩ đến các tệp trên máy tính, có lẽ bạn sẽ nghĩ đến một hệ thống phân cấp tệp: các tệp được sắp xếp trong các thư mục mà bạn có thể khám phá bằng trình khám phá tệp của hệ điều hành. Ví dụ: trên Windows, đối với người dùng tên là Tom, Danh sách việc cần làm của họ có thể nằm trong C:\Users\Tom\Documents\ToDo.txt. Trong ví dụ này, ToDo.txt là tên tệp, còn Users, TomDocuments là tên thư mục. "C:" trên Windows đại diện cho thư mục gốc của ổ đĩa.

Cách làm việc thông thường với các tệp trên web

Để chỉnh sửa danh sách Việc cần làm trong một ứng dụng web, đây là quy trình thông thường:

  1. Người dùng tải tệp lên một máy chủ hoặc mở tệp đó trên máy khách bằng <input type="file">.
  2. Người dùng thực hiện các thay đổi, sau đó tải tệp kết quả xuống bằng một <a download="ToDo.txt> được chèn mà bạn click() theo phương thức lập trình bằng JavaScript.
  3. Để mở thư mục, bạn sử dụng một thuộc tính đặc biệt trong <input type="file" webkitdirectory>. Mặc dù có tên độc quyền, nhưng thuộc tính này có khả năng hỗ trợ trình duyệt gần như trên toàn cầu.

Cách thức hiện đại để làm việc với tệp trên web

Luồng này không thể hiện cách người dùng nghĩ về việc chỉnh sửa tệp, tức là người dùng sẽ có bản sao đã tải xuống của tệp đầu vào. Do đó, File System Access API đã giới thiệu 3 phương thức chọn:

Chúng cho phép một quy trình như sau:

  1. Mở ToDo.txt bằng showOpenFilePicker() và nhận đối tượng FileSystemFileHandle.
  2. Từ đối tượng FileSystemFileHandle, hãy lấy File bằng cách gọi phương thức getFile() của trình xử lý tệp.
  3. Sửa đổi tệp, sau đó gọi requestPermission({mode: 'readwrite'}) trên đối tượng xử lý.
  4. Nếu người dùng chấp nhận yêu cầu cấp quyền, hãy lưu các thay đổi trở lại tệp gốc.
  5. Hoặc gọi showSaveFilePicker() và cho phép người dùng chọn một tệp mới. (Nếu người dùng chọn một tệp đã mở trước đó, nội dung của tệp đó sẽ bị ghi đè.) Đối với các lần lưu lặp lại, bạn có thể giữ lại mã nhận dạng tệp để không phải hiện lại hộp thoại lưu tệp.

Các hạn chế khi làm việc với tệp trên web

Các tệp và thư mục có thể truy cập bằng những phương thức này nằm trong hệ thống tệp người dùng có thể thấy. Các tệp được lưu từ web và đặc biệt là các tệp thực thi sẽ được đánh dấu bằng dấu hiệu của web, vì vậy, hệ điều hành có thể hiển thị thêm một cảnh báo trước khi một tệp có khả năng gây nguy hiểm được thực thi. Là một tính năng bảo mật bổ sung, các tệp lấy từ web cũng được bảo vệ bằng tính năng Duyệt web an toàn. Để đơn giản và trong bối cảnh của tài liệu này, bạn có thể coi đây là một quy trình quét vi-rút dựa trên đám mây. Khi bạn ghi dữ liệu vào một tệp bằng File System Access API, các thao tác ghi sẽ không diễn ra tại chỗ mà sử dụng một tệp tạm thời. Bản thân tệp sẽ không bị sửa đổi trừ phi vượt qua tất cả các quy trình kiểm tra bảo mật này.

Thao tác này khiến các hoạt động trên tệp diễn ra tương đối chậm, mặc dù chúng tôi đã áp dụng các điểm cải tiến ở những nơi có thể, chẳng hạn như trên macOS. Tuy nhiên, mọi lệnh gọi write() đều độc lập, vì vậy, ở chế độ nền, lệnh gọi này sẽ mở tệp, tìm đến độ lệch đã cho và cuối cùng là ghi dữ liệu.

Tệp là nền tảng của quá trình xử lý

Đồng thời, tệp là một cách tuyệt vời để ghi lại dữ liệu. Ví dụ: SQLite lưu trữ toàn bộ cơ sở dữ liệu trong một tệp duy nhất. Một ví dụ khác là mipmap được dùng trong quá trình xử lý hình ảnh. Mipmap là các chuỗi hình ảnh được tối ưu hoá và tính toán trước, mỗi chuỗi là một phiên bản có độ phân giải thấp hơn dần của phiên bản trước, giúp nhiều thao tác như thu phóng diễn ra nhanh hơn. Vậy làm cách nào để các ứng dụng web có thể tận dụng lợi ích của tệp mà không phải trả giá về hiệu suất khi xử lý tệp trên web? Câu trả lời là hệ thống tệp riêng tư gốc.

Người dùng nhìn thấy so với hệ thống tệp riêng tư gốc

Không giống như hệ thống tệp mà người dùng thấy khi duyệt bằng trình khám phá tệp của hệ điều hành, với các tệp và thư mục mà bạn có thể đọc, ghi, di chuyển và đổi tên, hệ thống tệp riêng tư gốc không dành cho người dùng. Các tệp và thư mục trong hệ thống tệp riêng tư gốc, như tên gọi cho thấy, là riêng tư và cụ thể hơn là riêng tư đối với nguồn gốc của một trang web. Khám phá nguồn gốc của một trang bằng cách nhập location.origin vào Bảng điều khiển Công cụ cho nhà phát triển. Ví dụ: nguồn gốc của trang https://developer.chrome.com/articles/https://developer.chrome.com. Đọc thêm về lý thuyết nguồn gốc trong bài viết Tìm hiểu về "same-site" và "same-origin".

Tất cả các trang có cùng nguồn gốc đều có thể xem dữ liệu hệ thống tệp riêng tư có cùng nguồn gốc, vì vậy https://developer.chrome.com/docs/extensions/mv3/getstarted/extensions-101/ có thể xem cùng một thông tin chi tiết như ví dụ trước. Mỗi nguồn gốc có hệ thống tệp riêng tư độc lập của riêng mình, nghĩa là hệ thống tệp riêng tư của nguồn gốc https://developer.chrome.com hoàn toàn khác biệt với hệ thống tệp riêng tư của, chẳng hạn như https://web.dev. Trên Windows, thư mục gốc của hệ thống tệp mà người dùng nhìn thấy là C:\\.

Tương đương với hệ thống tệp riêng tư gốc là một thư mục gốc ban đầu trống theo mỗi nguồn được truy cập bằng cách gọi phương thức không đồng bộ navigator.storage.getDirectory().

Để so sánh hệ thống tệp mà người dùng nhìn thấy và hệ thống tệp riêng tư gốc, hãy xem sơ đồ sau. Sơ đồ cho thấy rằng ngoài thư mục gốc, mọi thứ khác đều giống nhau về mặt khái niệm, với một hệ thống phân cấp các tệp và thư mục để sắp xếp và bố trí khi cần cho nhu cầu lưu trữ và dữ liệu của bạn.

Sơ đồ về hệ thống tệp mà người dùng nhìn thấy và hệ thống tệp riêng tư gốc với 2 hệ thống phân cấp tệp mẫu.
Điểm truy cập cho hệ thống tệp hiển thị cho người dùng là một ổ cứng tượng trưng, điểm truy cập cho hệ thống tệp riêng tư gốc là việc gọi phương thức navigator.storage.getDirectory.

Thông tin cụ thể về hệ thống tệp riêng tư gốc

Cũng giống như các cơ chế lưu trữ khác trong trình duyệt (ví dụ: localStorage hoặc IndexedDB), hệ thống tệp riêng tư theo nguồn gốc phải tuân theo các quy định hạn chế về hạn mức của trình duyệt. Khi người dùng xoá tất cả dữ liệu duyệt web hoặc tất cả dữ liệu trang web, hệ thống tệp riêng tư gốc cũng sẽ bị xoá.

Gọi navigator.storage.estimate() và trong đối tượng phản hồi kết quả, hãy xem mục usage để biết ứng dụng của bạn đã sử dụng bao nhiêu bộ nhớ. Thông tin này được chia nhỏ theo cơ chế lưu trữ trong đối tượng usageDetails, trong đó bạn muốn xem cụ thể mục fileSystem. Vì hệ thống tệp riêng tư gốc không hiển thị cho người dùng, nên không có lời nhắc về quyền và không có các chế độ kiểm tra Duyệt web an toàn.

Truy cập vào thư mục gốc

Để truy cập vào thư mục gốc, hãy chạy lệnh sau. Bạn sẽ nhận được một mã nhận dạng thư mục trống, cụ thể hơn là FileSystemDirectoryHandle.

const opfsRoot = await navigator.storage.getDirectory();
// A FileSystemDirectoryHandle whose type is "directory"
// and whose name is "".
console.log(opfsRoot);

Luồng chính hoặc Worker trên web

Có hai cách sử dụng hệ thống tệp riêng tư gốc: trên luồng chính hoặc trong Web Worker. Web Worker không thể chặn luồng chính, tức là trong ngữ cảnh này, các API có thể đồng bộ, một mẫu thường không được phép trên luồng chính. Các API đồng bộ có thể nhanh hơn vì chúng không phải xử lý các promise và các thao tác với tệp thường đồng bộ trong các ngôn ngữ như C có thể được biên dịch thành WebAssembly.

// This is synchronous C code.
FILE *f;
f = fopen("example.txt", "w+");
fputs("Some text\n", f);
fclose(f);

Nếu bạn cần thực hiện các thao tác nhanh nhất có thể với tệp hoặc bạn xử lý WebAssembly, hãy chuyển đến phần Sử dụng hệ thống tệp riêng tư gốc trong Web Worker.

Sử dụng hệ thống tệp riêng tư gốc trên luồng chính

Tạo tệp và thư mục mới

Sau khi có thư mục gốc, hãy tạo tệp và thư mục bằng cách sử dụng getFileHandle()getDirectoryHandle() tương ứng. Bằng cách truyền {create: true}, tệp hoặc thư mục sẽ được tạo nếu chưa có. Tạo một hệ phân cấp các tệp bằng cách gọi các hàm này bằng một thư mục mới tạo làm điểm bắt đầu.

const fileHandle = await opfsRoot
    .getFileHandle('my first file', {create: true});
const directoryHandle = await opfsRoot
    .getDirectoryHandle('my first folder', {create: true});
const nestedFileHandle = await directoryHandle
    .getFileHandle('my first nested file', {create: true});
const nestedDirectoryHandle = await directoryHandle
    .getDirectoryHandle('my first nested folder', {create: true});

Hệ phân cấp tệp thu được từ mã mẫu trước đó.

Truy cập vào các tệp và thư mục hiện có

Nếu bạn biết tên của tệp hoặc thư mục, hãy truy cập vào các tệp và thư mục đã tạo trước đó bằng cách gọi phương thức getFileHandle() hoặc getDirectoryHandle(), truyền tên của tệp hoặc thư mục.

const existingFileHandle = await opfsRoot.getFileHandle('my first file');
const existingDirectoryHandle = await opfsRoot
    .getDirectoryHandle('my first folder');

Lấy tệp được liên kết với một mã nhận dạng tệp để đọc

FileSystemFileHandle đại diện cho một tệp trên hệ thống tệp. Để lấy File được liên kết, hãy sử dụng phương thức getFile(). Đối tượng File là một loại Blob cụ thể và có thể được dùng trong mọi ngữ cảnh mà Blob có thể.

Cụ thể, FileReader, URL.createObjectURL(), createImageBitmap()XMLHttpRequest.send() đều chấp nhận cả BlobsFiles. Việc lấy một File từ FileSystemFileHandle sẽ "giải phóng" dữ liệu, nhờ đó bạn có thể truy cập và cung cấp dữ liệu đó cho hệ thống tệp mà người dùng có thể thấy.

const file = await fileHandle.getFile();
console.log(await file.text());

Ghi vào tệp bằng cách truyền trực tuyến

Truyền dữ liệu vào một tệp bằng cách gọi createWritable(). Thao tác này sẽ tạo ra một FileSystemWritableFileStream mà sau đó bạn sẽ write() nội dung. Cuối cùng, bạn cần close() luồng.

const contents = 'Some text';
// Get a writable stream.
const writable = await fileHandle.createWritable();
// Write the contents of the file to the stream.
await writable.write(contents);
// Close the stream, which persists the contents.
await writable.close();

Xoá tệp và thư mục

Xoá tệp và thư mục bằng cách gọi phương thức remove() cụ thể của tệp hoặc thư mục đó. Để xoá một thư mục bao gồm tất cả các thư mục con, hãy truyền lựa chọn {recursive: true}.

await fileHandle.remove();
await directoryHandle.remove({recursive: true});

Ngoài ra, nếu bạn biết tên của tệp hoặc thư mục cần xoá trong một thư mục, hãy sử dụng phương thức removeEntry().

directoryHandle.removeEntry('my first nested file');

Di chuyển và đổi tên tệp cũng như thư mục

Đổi tên và di chuyển tệp và thư mục bằng phương thức move(). Bạn có thể di chuyển và đổi tên cùng lúc hoặc riêng biệt.

// Rename a file.
await fileHandle.move('my first renamed file');
// Move a file to another directory.
await fileHandle.move(nestedDirectoryHandle);
// Move a file to another directory and rename it.
await fileHandle
    .move(nestedDirectoryHandle, 'my first renamed and now nested file');

Phân giải đường dẫn của một tệp hoặc thư mục

Để biết một tệp hoặc thư mục nhất định nằm ở đâu so với một thư mục tham chiếu, hãy sử dụng phương thức resolve(), truyền cho phương thức này một FileSystemHandle làm đối số. Để lấy đường dẫn đầy đủ của một tệp hoặc thư mục trong hệ thống tệp riêng tư gốc, hãy sử dụng thư mục gốc làm thư mục tham chiếu thu được bằng cách sử dụng navigator.storage.getDirectory().

const relativePath = await opfsRoot.resolve(nestedDirectoryHandle);
// `relativePath` is `['my first folder', 'my first nested folder']`.

Kiểm tra xem hai đối tượng xử lý tệp hoặc thư mục có trỏ đến cùng một tệp hoặc thư mục hay không

Đôi khi bạn có hai giá trị nhận dạng và không biết liệu chúng có trỏ đến cùng một tệp hoặc thư mục hay không. Để kiểm tra xem đây có phải là trường hợp của bạn hay không, hãy sử dụng phương thức isSameEntry().

fileHandle.isSameEntry(nestedFileHandle);
// Returns `false`.

Liệt kê nội dung của một thư mục

FileSystemDirectoryHandle là một trình lặp không đồng bộ mà bạn lặp lại bằng vòng lặp for await... of. Là một trình lặp không đồng bộ, trình lặp này cũng hỗ trợ entries(), values() và phương thức keys(). Bạn có thể chọn phương thức tuỳ thuộc vào thông tin bạn cần:

for await (let [name, handle] of directoryHandle) {}
for await (let [name, handle] of directoryHandle.entries()) {}
for await (let handle of directoryHandle.values()) {}
for await (let name of directoryHandle.keys()) {}

Liệt kê đệ quy nội dung của một thư mục và tất cả thư mục con

Việc xử lý các vòng lặp và hàm không đồng bộ kết hợp với đệ quy rất dễ bị sai. Hàm sau đây có thể đóng vai trò là điểm bắt đầu để liệt kê nội dung của một thư mục và tất cả thư mục con của thư mục đó, bao gồm cả tất cả các tệp và kích thước của chúng. Bạn có thể đơn giản hoá hàm nếu không cần kích thước tệp bằng cách, trong đó có directoryEntryPromises.push, không đẩy lời hứa handle.getFile() mà đẩy trực tiếp handle.

const getDirectoryEntriesRecursive = async (
  directoryHandle,
  relativePath = '.',
) => {
  const fileHandles = [];
  const directoryHandles = [];
  const entries = {};
  // Get an iterator of the files and folders in the directory.
  const directoryIterator = directoryHandle.values();
  const directoryEntryPromises = [];
  for await (const handle of directoryIterator) {
    const nestedPath = `${relativePath}/${handle.name}`;
    if (handle.kind === 'file') {
      fileHandles.push({ handle, nestedPath });
      directoryEntryPromises.push(
        handle.getFile().then((file) => {
          return {
            name: handle.name,
            kind: handle.kind,
            size: file.size,
            type: file.type,
            lastModified: file.lastModified,
            relativePath: nestedPath,
            handle
          };
        }),
      );
    } else if (handle.kind === 'directory') {
      directoryHandles.push({ handle, nestedPath });
      directoryEntryPromises.push(
        (async () => {
          return {
            name: handle.name,
            kind: handle.kind,
            relativePath: nestedPath,
            entries:
                await getDirectoryEntriesRecursive(handle, nestedPath),
            handle,
          };
        })(),
      );
    }
  }
  const directoryEntries = await Promise.all(directoryEntryPromises);
  directoryEntries.forEach((directoryEntry) => {
    entries[directoryEntry.name] = directoryEntry;
  });
  return entries;
};

Sử dụng hệ thống tệp riêng tư gốc trong Web Worker

Như đã trình bày trước đó, Web Worker không thể chặn luồng chính. Đó là lý do trong ngữ cảnh này, các phương thức đồng bộ được cho phép.

Nhận một mã truy cập đồng bộ

Điểm truy cập vào các thao tác tệp nhanh nhất có thể là FileSystemSyncAccessHandle, được lấy từ FileSystemFileHandle thông thường bằng cách gọi createSyncAccessHandle().

const fileHandle = await opfsRoot
    .getFileHandle('my highspeed file.txt', {create: true});
const syncAccessHandle = await fileHandle.createSyncAccessHandle();

Phương thức đồng bộ hoá tệp tại chỗ

Sau khi có một hàm xử lý truy cập đồng bộ, bạn sẽ có quyền truy cập vào các phương thức tệp nhanh tại chỗ đều là đồng bộ.

  • getSize(): Trả về kích thước của tệp tính bằng byte.
  • write(): Ghi nội dung của một vùng đệm vào tệp (không bắt buộc) tại một độ lệch nhất định và trả về số byte đã ghi. Việc kiểm tra số lượng byte đã ghi được trả về cho phép các phương thức gọi phát hiện và xử lý lỗi cũng như các thao tác ghi một phần.
  • read(): Đọc nội dung của tệp vào một vùng đệm, không bắt buộc ở một độ lệch nhất định.
  • truncate(): Đổi kích thước tệp thành kích thước đã cho.
  • flush(): Đảm bảo rằng nội dung của tệp chứa tất cả các nội dung sửa đổi được thực hiện thông qua write().
  • close(): Đóng mã nhận dạng truy cập.

Sau đây là ví dụ sử dụng từng phương thức.

const opfsRoot = await navigator.storage.getDirectory();
const fileHandle = await opfsRoot.getFileHandle('fast', {create: true});
const accessHandle = await fileHandle.createSyncAccessHandle();

const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();

// Initialize this variable for the size of the file.
let size;
// The current size of the file, initially `0`.
size = accessHandle.getSize();
// Encode content to write to the file.
const content = textEncoder.encode('Some text');
// Write the content at the beginning of the file.
accessHandle.write(content, {at: size});
// Flush the changes.
accessHandle.flush();
// The current size of the file, now `9` (the length of "Some text").
size = accessHandle.getSize();

// Encode more content to write to the file.
const moreContent = textEncoder.encode('More content');
// Write the content at the end of the file.
accessHandle.write(moreContent, {at: size});
// Flush the changes.
accessHandle.flush();
// The current size of the file, now `21` (the length of
// "Some textMore content").
size = accessHandle.getSize();

// Prepare a data view of the length of the file.
const dataView = new DataView(new ArrayBuffer(size));

// Read the entire file into the data view.
accessHandle.read(dataView);
// Logs `"Some textMore content"`.
console.log(textDecoder.decode(dataView));

// Read starting at offset 9 into the data view.
accessHandle.read(dataView, {at: 9});
// Logs `"More content"`.
console.log(textDecoder.decode(dataView));

// Truncate the file after 4 bytes.
accessHandle.truncate(4);

Sao chép một tệp từ hệ thống tệp riêng tư ban đầu sang hệ thống tệp mà người dùng có thể thấy

Như đã đề cập ở trên, bạn không thể di chuyển tệp từ hệ thống tệp riêng tư ban đầu sang hệ thống tệp mà người dùng nhìn thấy, nhưng bạn có thể sao chép tệp. Vì showSaveFilePicker() chỉ được hiển thị trên luồng chính, chứ không phải trong luồng Worker, hãy nhớ chạy mã ở đó.

// On the main thread, not in the Worker. This assumes
// `fileHandle` is the `FileSystemFileHandle` you obtained
// the `FileSystemSyncAccessHandle` from in the Worker
// thread. Be sure to close the file in the Worker thread first.
const fileHandle = await opfsRoot.getFileHandle('fast');
try {
  // Obtain a file handle to a new file in the user-visible file system
  // with the same name as the file in the origin private file system.
  const saveHandle = await showSaveFilePicker({
    suggestedName: fileHandle.name || ''
  });
  const writable = await saveHandle.createWritable();
  await writable.write(await fileHandle.getFile());
  await writable.close();
} catch (err) {
  console.error(err.name, err.message);
}

Gỡ lỗi hệ thống tệp riêng tư gốc

Cho đến khi Chrome thêm tính năng hỗ trợ DevTools tích hợp sẵn (xem crbug/1284595), hãy sử dụng tiện ích Chrome OPFS Explorer để gỡ lỗi hệ thống tệp riêng tư gốc. Ảnh chụp màn hình trong phần Tạo tệp và thư mục mới được chụp trực tiếp từ tiện ích.

Tiện ích OPFS Explorer của Chrome DevTools trong Cửa hàng Chrome trực tuyến.

Sau khi cài đặt tiện ích này, hãy mở Chrome DevTools, chọn thẻ OPFS Explorer (Trình khám phá OPFS) và bạn đã sẵn sàng kiểm tra hệ thống phân cấp tệp. Lưu tệp từ hệ thống tệp riêng tư ban đầu vào hệ thống tệp mà người dùng có thể thấy bằng cách nhấp vào tên tệp và xoá tệp cũng như thư mục bằng cách nhấp vào biểu tượng thùng rác.

Bản minh hoạ

Xem hệ thống tệp riêng tư gốc đang hoạt động (nếu bạn cài đặt tiện ích OPFS Explorer) trong một bản minh hoạ sử dụng hệ thống này làm phần phụ trợ cho cơ sở dữ liệu SQLite được biên dịch thành WebAssembly. Nhớ kiểm tra mã nguồn trên GitHub. Lưu ý rằng phiên bản được nhúng không sử dụng phần phụ trợ hệ thống tệp riêng tư gốc (vì iframe là đa nguồn), nhưng khi bạn mở bản minh hoạ trong một thẻ riêng, phiên bản này sẽ sử dụng.

Kết luận

Hệ thống tệp riêng tư gốc, theo chỉ định của WHATWG, đã định hình cách chúng ta sử dụng và tương tác với các tệp trên web. Nó đã cho phép các trường hợp sử dụng mới mà không thể đạt được với hệ thống tệp mà người dùng nhìn thấy. Tất cả các nhà cung cấp trình duyệt lớn (Apple, Mozilla và Google) đều tham gia và có chung một tầm nhìn. Việc phát triển hệ thống tệp riêng tư gốc là một nỗ lực hợp tác rất lớn và ý kiến phản hồi của nhà phát triển cũng như người dùng là yếu tố cần thiết cho sự tiến bộ của hệ thống này.

Khi chúng tôi tiếp tục tinh chỉnh và cải thiện tiêu chuẩn này, chúng tôi rất mong nhận được ý kiến phản hồi về kho lưu trữ whatwg/fs dưới dạng Vấn đề hoặc Yêu cầu hợp nhất.

Lời cảm ơn

Tài liệu này được Austin Sully, Etienne NoëlRachel Andrew xem xét.