Tiêu chuẩn hệ thống tệp đưa ra hệ thống tệp gốc riêng tư (OPFS) làm điểm cuối lưu trữ riêng tư cho máy chủ 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 tuỳ chọn vào một loại tệp đặc biệt được tối ưu hoá hiệu suất cao.
Hỗ trợ trình duyệt
Hệ thống tệp riêng tư theo nguồn gốc được các trình duyệt hiện đại hỗ trợ và được chuẩn hoá theo Nhóm công nghệ ứng dụng siêu văn bản web (WhatWG) trong Tiêu chuẩn sống của hệ thống tệp.
Động lực
Khi nhắc đến tệp trên máy tính, có thể bạn sẽ nghĩ đến hệ 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 trong hệ điều hành của mình. Ví dụ: trên Windows, đối với người dùng có 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
, Tom
và Documents
là tên thư mục. "C:" trên Windows là thư mục gốc của ổ đĩa.
Cách làm việc truyền 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:
- Người dùng tải tệp lên máy chủ hoặc mở tệp trên ứng dụng bằng
<input type="file">
. - Người dùng thực hiện thay đổi, sau đó tải tệp kết quả xuống bằng
<a download="ToDo.txt>
đã chèn mà bạn đã lập trìnhclick()
thông qua JavaScript. - Để mở thư mục, bạn sử dụng một thuộc tính đặc biệt trong
<input type="file" webkitdirectory>
. Thuộc tính này thực tế hỗ trợ trình duyệt phổ biến, mặc dù thuộc tính này thuộc quyền sở hữu riêng của thư mục.
Cách làm việc hiện đại với tệp trên web
Quy trình này không thể hiện suy nghĩ của người dùng về việc chỉnh sửa tệp mà có nghĩa là người dùng sẽ thấy các bản sao tệp nhập của họ đã tải xuống. Do đó, API Truy cập hệ thống tệp đã ra mắt 3 phương thức bộ chọn là showOpenFilePicker()
, showSaveFilePicker()
và showDirectoryPicker()
nhằm thực hiện đúng tên hàm. Chúng cho phép luồng như sau:
- Mở
ToDo.txt
bằngshowOpenFilePicker()
và nhận đối tượngFileSystemFileHandle
. - Từ đối tượng
FileSystemFileHandle
, hãy lấyFile
bằng cách gọi phương thứcgetFile()
của tên người dùng tệp. - Sửa đổi tệp, sau đó gọi
requestPermission({mode: 'readwrite'})
trên tên người dùng. - Nếu người dùng chấp nhận yêu cầu cấp quyền, hãy lưu nội dung thay đổi vào tệp gốc.
- Ngoài ra, bạn có thể 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 tên người 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 mà có thể truy cập được 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à riêng các tệp có thể thực thi được đánh dấu bằng dấu trang web. Vì vậy, hệ điều hành sẽ có thêm một cảnh báo trước khi 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 thu được từ web cũng được bảo vệ bằng tính năng Duyệt web an toàn. Tính năng này có thể được coi là quá trình quét vi-rút trên đám mây để đơn giản hoá và trong bối cảnh của bài viết này. Khi bạn ghi dữ liệu vào một tệp bằng API Truy cập hệ thống tệp, hoạt động ghi không có tại chỗ mà chỉ sử dụng tệp tạm thời. Tệp này 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ể thấy, thao tác này làm cho hoạt động của tệp tương đối chậm, mặc dù có một số 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, mỗi lệnh gọi sẽ mở tệp, tìm đến độ lệch đã cho và cuối cùng ghi dữ liệu.
Tệp là nền tảng cho quá trình xử lý
Đồng thời, các tệp là cách rất hiệu quả để 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ính toán trước và tối ưu hoá, mỗi hình ảnh là một bản trình bày có độ phân giải giảm dần so với trước đó, giúp thực hiện 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ó thể nhận được các lợi ích của tệp mà không phải trả chi phí hiệu suất của việc xử lý tệp dựa 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 tư dành cho người dùng 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 nhìn thấy được duyệt qua 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 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ư ở nguồn gốc (như tên 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 trang web. Khám phá nguồn gốc của 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/
là https://developer.chrome.com
(tức là phần /articles
không phải là nguồn gốc của trang này). Bạn có thể đọc thêm về lý thuyết nguồn gốc trong phần Tìm hiểu về "same-site" (cùng trang web) và "same-origin". 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ư ở nguồn gốc, vì vậy https://developer.chrome.com/docs/extensions/mv3/getstarted/extensions-101/
có thể xem thông tin chi tiết giống như ví dụ trước. Mỗi nguồn gốc đều có hệ thống tệp riêng tư dành cho 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
sẽ hoàn toàn khác biệt với hệ thống tệp gốc https://web.dev
chẳng hạn. Trên Windows, thư mục gốc của hệ thống tệp mà người dùng thấy là C:\\
.
Tương đương cho hệ thống tệp riêng tư của nguồn gốc là thư mục gốc trống ban đầu cho mỗi nguồn 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 mà người dùng thấy được và hệ thống tệp riêng tư gốc, hãy xem sơ đồ dưới đây. Sơ đồ này cho thấy ngoài thư mục gốc, mọi thứ khác về mặt lý thuyết đều giống nhau, với hệ phân cấp tệp và thư mục để sắp xếp cũng như sắp xếp khi cần cho dữ liệu và nhu cầu lưu trữ của bạn.
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 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ư của nguồn 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 nhìn thấy mục usage
để biết mức dung lượng lưu trữ mà ứng dụng của bạn đã tiêu thụ. Mức bộ nhớ này được phân chia theo cơ chế lưu trữ trong đối tượng usageDetails
, nơi bạn muốn xem cụ thể mục fileSystem
. Vì người dùng không thấy hệ thống tệp riêng tư ở máy chủ gốc, nên sẽ không có lời nhắc cấp quyền cũng như không có hoạt động kiểm tra của tính năng 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. Bạn sẽ nhận được một tên người 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 Web Worker
Có 2 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 Workers không thể chặn luồng chính. Điều này có nghĩa là trong ngữ cảnh này, các API có thể đồng bộ – mẫu thường không được phép trên luồng chính. API đồng bộ có thể nhanh hơn vì chúng tránh được việc phải thực hiện theo hứa hẹn. Đồng thời, hoạt động của tệp thường được đồng bộ bằng 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 nhanh nhất có thể đối với tệp 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 Web Worker. Nếu không, bạn có thể đọc tiếp.
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
Khi bạn có thư mục gốc, hãy tạo tệp và thư mục bằng phương thức getFileHandle()
và 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ó. Xây dựng một hệ phân cấp tệp bằng cách gọi các hàm này, trong đó sử dụ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});
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 các 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 tên người 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 cụ thể của Blob
và có thể được dùng trong mọi ngữ cảnh mà Blob
có thể. Cụ thể, FileReader
, URL.createObjectURL()
, createImageBitmap()
và XMLHttpRequest.send()
chấp nhận cả Blobs
và Files
. Nếu có, bạn sẽ nhận được File
từ FileSystemFileHandle
"miễn phí" dữ liệu đó để 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 một tệp bằng cách truy cập trực tuyến
Truyền trực tuyế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 một FileSystemWritableFileStream
cho tệp đó, sau đó write()
nội dung. Khi kết thúc, bạn cần close()
phát trực tiếp.
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á các tệp và thư mục bằng cách gọi phương thức remove()
cụ thể của tên người dùng 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 chuyển tuỳ chọn {recursive: true}
.
await fileHandle.remove();
await directoryHandle.remove({recursive: true});
Thay vào đó, 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 cũng như 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
Để tìm hiểu vị trí của một tệp hoặc thư mục cụ thể trong mối quan hệ với một 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 dùng thư mục gốc làm thư mục tham chiếu lấy được qua navigator.storage.getDirectory()
.
const relativePath = await opfsRoot.resolve(nestedDirectoryHandle);
// `relativePath` is `['my first folder', 'my first nested folder']`.
Kiểm tra xem hai ô điều khiển 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 người dùng và không biết liệu chúng có trỏ đến cùng một tệp hay thư mục hay không. Để kiểm tra xem điều này có đúng như vậy 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 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ộ, lớp này cũng hỗ trợ các phương thức entries()
, values()
và keys()
mà bạn có thể chọn 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ê theo quy tắc đệ quy nội dung của một thư mục và tất cả cá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ễ gặp lỗi. Hàm dưới đây có thể đóng vai trò là điểm khởi đầu để liệt kê nội dung của một thư mục và tất cả các thư mục con trong thư mục đó, bao gồm tất cả các tệp và kích thước của thư mục đó. Bạn có thể đơn giản hoá hàm này nếu không cần kích thước tệp bằng cách hiện directoryEntryPromises.push
, không chuyển lời hứa handle.getFile()
mà chỉ gửi 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 Workers không thể chặn luồng chính, đó là lý do tại sao chúng tôi cho phép các phương thức đồng bộ trong ngữ cảnh này.
Lấy trình điều khiển truy cập đồng bộ
Điểm truy cập đến các thao tác nhanh nhất có thể trong tệp là FileSystemSyncAccessHandle
, 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 trình 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ỗ 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 (không bắt buộc) ở một vị trí bù trừ cho sẵn 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ư dữ liệu 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 tại một giá trị bù trừ nhất định.truncate()
: Đổi kích thước tệp thành kích thước đã cho.flush()
: Đảm bảo 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 quawrite()
.close()
: Đóng ô điều khiển quyền 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 một 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
Như đã đề cập ở trên, bạn không thể di chuyển các 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ưng bạn có thể sao chép các tệp. Vì showSaveFilePicker()
chỉ hiển thị trên luồng chính, chứ không phải trong luồng Worker, bạn 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 thêm tính năng hỗ trợ Công cụ cho nhà phát triển tích hợp sẵn (xem crbug/1284595), hãy sử dụng tiện ích của Chrome OPFS Explorer để gỡ lỗi hệ thống tệp riêng tư theo nguồn gốc. Ảnh chụp màn hình ở trên trong phần Tạo tệp và thư mục mới được chụp ngay từ tiện ích.
Sau khi cài đặt tiện ích này, hãy mở Công cụ của Chrome cho nhà phát triển, chọn thẻ OPFS Explorer. Khi đó, bạn có thể kiểm tra hệ phân cấp tệp. Lưu các tệp từ hệ thống tệp riêng tư gốc vào hệ thống tệp mà người dùng thấy được bằng cách nhấp vào tên tệp rồi 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 cách hoạt động của hệ thống tệp riêng tư gốc (nếu bạn cài đặt tiện ích OPFS Explorer) trong 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 sang WebAssembly. Hãy nhớ xem mã nguồn trên Glitch. Hãy lưu ý rằng phiên bản được nhúng bên dưới không sử dụng phần phụ trợ của hệ thống tệp riêng tư gốc (vì iframe có nhiều nguồn gốc), nhưng khi bạn mở bản minh hoạ trong một thẻ riêng, thì tính năng này sẽ hoạt động.
Kết luận
Hệ thống tệp riêng tư gốc, như được 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. API này đã mang lại các trường hợp sử dụng mới mà hệ thống tệp hiển thị cho người dùng không thể làm được. Tất cả các nhà cung cấp trình duyệt lớn – như Apple, Mozilla và Google – đều đang tham gia và có chung tầm nhìn. Việc phát triển hệ thống tệp riêng tư ở nguồn gốc rất cần sự cộng tác và phản hồi của các nhà phát triển và người dùng là rất cần thiết cho quá trình phát triển. Trong quá trình tiếp tục tinh chỉnh và cải tiế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ữ WhatsApp/fs dưới dạng Vấn đề hoặc Yêu cầu lấy dữ liệu.
Đường liên kết có liên quan
- Quy cách tiêu chuẩn của hệ thống tệp
- Kho lưu trữ tiêu chuẩn hệ thống tệp
- The File System API with Origin Private File System WebKit bài đăng
- Tiện ích OPFS Explorer
Xác nhận
Bài viết này do Austin Sully, Etienne Noël và Rachel Andrew đánh giá. Hình ảnh chính của Christina Rumpf trên Unsplash.