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

Chuẩn hệ thống tệp giới thiệu một hệ thống tệp riêng tư gốc (OPFS) dưới dạng một điểm cuối bộ nhớ riêng tư đối với nguồn gốc của trang và không hiển thị với người dùng, 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 cho hiệu suất.

Hỗ trợ trình duyệt

Hệ thống tệp riêng gốc được các trình duyệt hiện đại hỗ trợ và được Nhóm làm việ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.

Hỗ trợ trình duyệt

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

Nguồn

Động lực

Khi nghĩ đến các tệp trên máy tính, bạn có thể nghĩ đến 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 có tên 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 và 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 thức truyền thống để làm việc với tệp trên web

Để chỉnh sửa danh sách việc cần làm trong ứ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á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 rồi tải tệp kết quả xuống bằng <a download="ToDo.txt> được chèn mà bạn click() theo phương thức lập trình thông qua 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 thực tế có hỗ trợ trình duyệt phổ biến.

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

Quy trình này không thể hiện cách người dùng nghĩ về việc chỉnh sửa tệp và có nghĩa là người dùng sẽ có bản sao đã tải xuống của tệp đầu vào. Do đó, API Truy cập hệ thống tệp đã giới thiệu ba phương thức bộ chọn – showOpenFilePicker(), showSaveFilePicker()showDirectoryPicker() – thực hiện đúng như tên gọi. Các thành phần này cho phép một luồng như sau:

  1. Mở ToDo.txt bằng showOpenFilePicker() và lấy đố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 tay cầm tệp.
  3. Sửa đổi tệp, sau đó gọi requestPermission({mode: 'readwrite'}) trên tay cầm.
  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 vào tệp gốc.
  5. Ngoài ra, hãy 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 đè.) Để lưu nhiều lần, bạn có thể giữ lại handle tệp để không phải hiển thị lại hộp thoại lưu tệp.

Các quy định 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 được thông qua các phương thức này nằm trong hệ thống tệp người dùng nhìn thấy. Các tệp được lưu từ web và đặc biệt là các tệp thực thi được đánh dấu bằng dấ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 thực thi một tệp có thể gây nguy hiểm. Để tăng cường bảo mật, các tệp lấy từ web cũng được Duyệt web an toàn bảo vệ. Để đơn giản hoá và trong bối cảnh của bài viết này, bạn có thể coi tính năng này là một công cụ quét virus trên đám mây. Khi bạn ghi dữ liệu vào tệp bằng API Truy cập hệ thống tệp, các hoạt động ghi sẽ không diễn ra tại chỗ mà sử dụng tệp tạm thời. Tệp sẽ không bị sửa đổi trừ phi vượt qua tất cả các bước kiểm tra bảo mật này. Như bạn có thể tưởng tượng, công việc này khiến các thao tác với tệp tương đối chậm, mặc dù các điểm cải tiến được áp dụng khi 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, trong phần nội dung, lệnh gọi này sẽ mở tệp, tìm đến độ dời đã 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. Một ví dụ khác là mipmap dùng trong quá trình xử lý hình ảnh. Mipmap là các trình tự hình ảnh được tính toán trước và được tối ưu hoá, mỗi trình tự là một bản trình bày có độ phân giải thấp hơn dần so với trình tự trước đó, giúp nhiều thao tác như thu phóng nhanh hơn. Vậy làm cách nào để các ứng dụng web có được lợi ích của tệp mà không phải trả chi phí 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.

Hệ thống tệp riêng hiển thị cho người dùng so với hệ thống tệp riêng gốc

Không giống như hệ thống tệp hiển thị cho người dùng được duyệt qua 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 xem. Như tên gọi, các tệp và thư mục trong hệ thống tệp riêng tư gốc là riêng tư và cụ thể hơn là riêng tư đối với nguồn gốc của 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 DevTools. Ví dụ: nguồn gốc của trang https://developer.chrome.com/articles/https://developer.chrome.com (tức là phần /articles không thuộc nguồn gốc). Bạn có thể đọc thêm về lý thuyết về nguồn gốc trong bài viết Tìm hiểu về "cùng trang web" và "cùng nguồn gốc". Tất cả các trang có cùng nguồn gốc đều có thể xem cùng một dữ liệu hệ thống tệp riêng tư 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 đều có hệ thống tệp riêng tư nguồn gốc độc lập, tức là hệ thống tệp riêng tư nguồn gốc của https://developer.chrome.com hoàn toàn khác với hệ thống tệp riêng tư nguồn gốc của https://web.dev. Trên Windows, thư mục gốc của hệ thống tệp hiển thị cho người dùng 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 cho mỗi gốc đượ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 hiển thị cho người dùng và hệ thống tệp riêng tư gốc, hãy xem sơ đồ sau. Sơ đồ cho thấy 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 hệ phân cấp tệp và thư mục để sắp xếp và bố trí khi cần cho nhu cầu về dữ liệu và bộ nhớ của bạn.

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

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

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ư gốc phải tuân theo các 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 thu được, hãy xem mục usage để biết ứng dụng của bạn đã sử dụng bao nhiêu bộ nhớ. Mức sử dụng này được phân tích theo cơ chế lưu trữ trong đối tượng usageDetails, trong đó bạn muốn xem cụ thể mục fileSystem. Vì người dùng không nhìn thấy hệ thống tệp riêng tư gốc, nên không có lời nhắc cấp quyền và không có quy trình kiểm tra Duyệt web an toàn.

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

Để có quyền truy cập vào thư mục gốc, hãy chạy lệnh sau. Cuối cùng, bạn sẽ có một handle thư mục trống, cụ thể 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 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 Trình chạy web. Trình thực thi web không thể chặn luồng chính, nghĩa 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. API đồng bộ có thể nhanh hơn vì không phải xử lý các lời hứa và các thao tác 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 các thao tác tệp nhanh nhất có thể hoặc bạn xử lý WebAssembly, hãy chuyển xuống phần Sử dụng hệ thống tệp riêng tư gốc trong một Worker trên web. Nếu không, bạn có thể đọc tiếp.

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

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

Sau khi bạn có thư mục gốc, hãy tạo tệp và thư mục tương ứng bằng phương thức getFileHandle()getDirectoryHandle(). 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 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 xuất phát.

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ẫu mã 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 các tệp và 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 vào 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 handle 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 sử dụng trong bất kỳ ngữ cảnh nào mà Blob có thể sử dụng. Cụ thể, FileReader, URL.createObjectURL(), createImageBitmap()XMLHttpRequest.send() chấp nhận cả BlobsFiles. Nếu muốn, bạn có thể lấy File từ FileSystemFileHandle để "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 hiển thị cho người dùng.

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 tệp bằng cách gọi createWritable(). Thao tác này sẽ tạo một FileSystemWritableFileStream, sau đó bạn 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 tay cầm thư mục. Để xoá một thư mục bao gồm tất cả thư mục con, hãy truyền tuỳ 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 và 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');

Giải quyết đường dẫn của tệp hoặc thư mục

Để tìm hiểu vị trí của một tệp hoặc thư mục nhất định liên quan đến thư mục tham chiếu, hãy sử dụng phương thức resolve(), truyền vào đó 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 được lấy qua navigator.storage.getDirectory().

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

Kiểm tra xem hai tay cầm 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 tên xử lý 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 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ợ các phương thức entries(), values()keys(). Bạn có thể chọn phương thức phù hợp tuỳ theo thông tin cần thiết:

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ộ được ghép nối với đệ quy rất dễ xảy ra lỗi. Hàm bên dưới có thể là điểm xuất phát để 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 tất cả tệp và kích thước của các tệp đó. Bạn có thể đơn giản hoá hàm này nếu không cần kích thước tệp, trong đó có nội dung directoryEntryPromises.push, không đẩy lời hứa handle.getFile() mà trực tiếp đẩy 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 của nguồn gốc trong một Worker trên web

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

Nhận một handle 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 tệp đồng bộ tại chỗ

Sau khi có một tay cầm truy cập đồng bộ, bạn sẽ có quyền truy cập vào các phương thức tệp tại chỗ nhanh chóng và tất cả đều đồ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 vùng đệm vào tệp, tuỳ ý tại một độ dời nhất định và trả về số byte đã ghi. Việc kiểm tra số byte đã ghi được trả về cho phép phương thức gọi phát hiện và xử lý lỗi cũng như các lần ghi một phần.
  • read(): Đọc nội dung của tệp vào vùng đệm, tuỳ ý tại một độ dời 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ả nội dung sửa đổi được thực hiện thông qua write().
  • close(): Đóng tay cầm truy cập.

Dưới đây là ví dụ sử dụng tất cả các phương thức nêu trên.

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 tệp từ hệ thống tệp riêng tư gốc sang hệ thống tệp hiển thị cho người dùng

Như đã đề cập ở trên, bạn không thể di chuyển tệp từ hệ thống tệp riêng tư gốc sang hệ thống tệp mà người dùng nhìn thấy, nhưng có thể sao chép tệp. Vì showSaveFilePicker() chỉ 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 tính năng hỗ trợ tích hợp sẵn cho DevTools được thêm vào (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ân tiện, ảnh chụp màn hình ở trên trong phần Tạo tệp và thư mục mới được lấy trực tiếp từ tiện ích này.

Tiện ích Công cụ của Chrome cho nhà phát triển OPFS Explorer trong Cửa hàng Chrome trực tuyến.

Sau khi cài đặt tiện ích, hãy mở Công cụ của Chrome cho nhà phát triển, chọn thẻ OPFS Explorer (Trình khám phá OPFS) rồi bạn có thể kiểm tra hệ phân cấp tệp. Lưu tệp từ hệ thống tệp riêng tư gốc vào hệ thống tệp hiển thị cho người dùng bằng cách nhấp vào tên tệp và xoá tệp và 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 Trình khám phá OPFS) trong một bản minh hoạ sử dụng hệ thống tệp này làm phần phụ trợ cho cơ sở dữ liệu SQLite được biên dịch thành WebAssembly. Hãy nhớ xem mã nguồn trên Glitch. Lưu ý cách phiên bản nhúng bên dưới không sử dụng phần phụ trợ hệ thống tệp riêng tư gốc (vì iframe là nhiều nguồn gốc), nhưng khi bạn mở bản minh hoạ trong một thẻ riêng biệt, thì phiên bản này sẽ sử dụng.

Kết luận

Hệ thống tệp riêng tư gốc, do WHATWG chỉ định, đã đị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. Điều này đã cho phép các trường hợp sử dụng mới không thể đạt được bằng hệ thống tệp hiển thị cho người dùng. 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 cộng tác rất lớn và ý kiến phản hồi của nhà phát triển và người dùng là yếu tố thiết yếu đối với tiến trình phát triển. Trong quá trình tiếp tục tinh chỉnh và cải thiện tiêu chuẩn này, chúng tôi hoan nghênh ý 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 kéo.

Lời cảm ơn

Bài viết này đã được Austin Sully, Etienne NoëlRachel Andrew xem xét. Hình ảnh chính của Christina Rumpf trên Unsplash.