Bạn có bao giờ tự hỏi điều gì xảy ra khi bạn chạy sshfs user@remote:~/ /mnt/remoteroot
không? Làm cách nào để các tệp từ máy chủ từ xa xuất hiện trên hệ thống cục bộ của bạn và đồng bộ hóa nhanh chóng như vậy? Bạn đã từng nghe đến WikipediaFS , tính năng cho phép bạn chỉnh sửa một bài viết trên Wikipedia như thể nó là một tập tin trong hệ thống tập tin của bạn chưa? Đó không phải là điều kỳ diệu—đó là sức mạnh của FUSE (Hệ thống tệp trong Không gian người dùng). FUSE cho phép bạn tạo hệ thống tập tin của riêng mình mà không cần kiến thức sâu về nhân hệ điều hành hoặc các ngôn ngữ lập trình cấp thấp.
Bài viết này giới thiệu một giải pháp thực tế sử dụng FUSE với Node.js và TypeScript. Chúng ta sẽ khám phá cách thức hoạt động của FUSE và chứng minh ứng dụng của nó bằng cách giải quyết một nhiệm vụ trong thế giới thực. Hãy cùng tôi tham gia vào chuyến phiêu lưu thú vị vào thế giới FUSE và Node.js.
Tôi chịu trách nhiệm về các tập tin media (chủ yếu là hình ảnh) trong công việc của mình. Điều này bao gồm nhiều thứ: biểu ngữ bên cạnh hoặc trên cùng, phương tiện trong cuộc trò chuyện, nhãn dán, v.v. Tất nhiên, có rất nhiều yêu cầu đối với những thứ này, chẳng hạn như "biểu ngữ là PNG hoặc WEBP, 300x1000 pixel". Nếu các yêu cầu không được đáp ứng, văn phòng hỗ trợ của chúng tôi sẽ không cho phép gửi hình ảnh. Và có một cơ chế sao chép đối tượng: không hình ảnh nào có thể vào cùng một dòng sông hai lần.
Điều này dẫn chúng ta đến tình huống có một bộ hình ảnh khổng lồ cho mục đích thử nghiệm. Tôi đã sử dụng shell one-liners hoặc bí danh để làm cho cuộc sống của tôi dễ dàng hơn.
Ví dụ:
convert -size 300x1000 xc:gray +noise random /tmp/out.png
Sự kết hợp giữa bash
và convert
là một công cụ tuyệt vời, nhưng rõ ràng đây không phải là cách thuận tiện nhất để giải quyết vấn đề. Thảo luận về tình hình của nhóm QA còn bộc lộ thêm những rắc rối. Ngoài thời gian đáng kể dành cho việc tạo hình ảnh, câu hỏi đầu tiên khi chúng tôi điều tra một vấn đề là "Bạn có chắc chắn mình đã tải lên một hình ảnh duy nhất không?" Tôi tin rằng bạn hiểu điều này khó chịu như thế nào.
Bạn có thể thực hiện một cách tiếp cận đơn giản: tạo một dịch vụ web phục vụ tuyến đường có tệp tự giải thích, như GET /image/1000x100/random.zip?imagesCount=100
. Tuyến đường sẽ trả về một tệp ZIP với một tập hợp các hình ảnh độc đáo. Điều này nghe có vẻ hay nhưng nó không giải quyết được vấn đề chính của chúng tôi: tất cả các tệp được tải lên cần phải là duy nhất để thử nghiệm.
Suy nghĩ tiếp theo của bạn có thể là "Chúng ta có thể thay thế tải trọng khi gửi nó không?" Nhóm QA sử dụng Postman cho các cuộc gọi API. Tôi đã điều tra nội bộ của Postman và nhận ra rằng chúng tôi không thể thay đổi nội dung yêu cầu một cách "nhanh chóng"
Một giải pháp khác là thay thế một tệp trong hệ thống tệp mỗi khi có thứ gì đó cố đọc tệp. Linux có một hệ thống con thông báo tên là Inotify, hệ thống này cảnh báo bạn về các sự kiện của hệ thống tệp như những thay đổi trong thư mục hoặc sửa đổi tệp. Nếu bạn nhận được thông báo "Visual Studio Code không thể theo dõi các thay đổi của tệp trong không gian làm việc rộng lớn này" thì có vấn đề với Inotify. Nó có thể kích hoạt một sự kiện khi thư mục bị thay đổi, tệp được đổi tên, tệp được mở, v.v.
Danh sách đầy đủ các sự kiện có thể được tìm thấy ở đây: https://sites.uclouvain.be/SystInfo/usr/include/linux/inotify.h.html
Vì vậy, kế hoạch là:
Nghe sự kiện IN_OPEN
và đếm các bộ mô tả tệp.
Nghe sự kiện IN_CLOSE
; nếu số đếm giảm xuống 0, chúng tôi sẽ thay thế tệp.
Nghe có vẻ hay, nhưng có một số vấn đề với việc này:
inotify
.
Để giải quyết những vấn đề này, chúng ta có thể viết hệ thống tập tin của riêng mình. Nhưng có một vấn đề khác: hệ thống tệp thông thường chạy trong không gian nhân hệ điều hành. Nó đòi hỏi chúng ta phải biết về nhân hệ điều hành và sử dụng các ngôn ngữ như C/Rust. Ngoài ra, đối với mỗi kernel, chúng ta nên viết một mô-đun (trình điều khiển) cụ thể.
Vì vậy, việc viết một hệ thống tập tin là quá mức cần thiết đối với vấn đề mà chúng ta muốn giải quyết; ngay cả khi còn một ngày cuối tuần dài phía trước. May mắn thay, có một cách để chế ngự con quái vật này: Hệ thống tập tin trong Use rspace (FUSE). FUSE là một dự án cho phép bạn tạo hệ thống tập tin mà không cần chỉnh sửa mã hạt nhân. Điều này có nghĩa là bất kỳ chương trình hoặc tập lệnh nào thông qua FUSE, không có bất kỳ logic phức tạp nào liên quan đến lõi, đều có thể mô phỏng flash, ổ cứng hoặc SSD.
Nói cách khác, một quy trình không gian người dùng thông thường có thể tạo hệ thống tệp riêng của nó, có thể được truy cập bình thường thông qua bất kỳ chương trình thông thường nào bạn muốn - Nautilus, Dolphin, ls, v.v.
Tại sao FUSE lại tốt trong việc đáp ứng các yêu cầu của chúng tôi? Các hệ thống tệp dựa trên FUSE được xây dựng dựa trên các quy trình không gian người dùng. Do đó, bạn có thể sử dụng bất kỳ ngôn ngữ nào bạn biết có ràng buộc với libfuse
. Ngoài ra, bạn còn nhận được giải pháp đa nền tảng với FUSE.
Tôi đã có nhiều kinh nghiệm với NodeJS và TypeScript và tôi muốn chọn sự kết hợp (tuyệt vời) này làm môi trường thực thi cho FS hoàn toàn mới của chúng tôi. Hơn nữa, TypeScript cung cấp nền tảng hướng đối tượng tuyệt vời. Điều này sẽ cho phép tôi hiển thị cho bạn không chỉ mã nguồn mà bạn có thể tìm thấy trên kho lưu trữ GitHub công khai mà còn cả cấu trúc của dự án.
Hãy để tôi cung cấp một trích dẫn phát biểu từ trang FUSE chính thức :
FUSE là một khung hệ thống tập tin không gian người dùng. Nó bao gồm một mô-đun hạt nhân (fuse.ko), thư viện không gian người dùng (libfuse.*) và tiện ích gắn kết (fusermount).
Một khung viết hệ thống tập tin nghe có vẻ thú vị.
Tôi nên giải thích ý nghĩa của từng phần FUSE:
fuse.ko
đang thực hiện tất cả các công việc cấp thấp liên quan đến kernel; điều này cho phép chúng tôi tránh sự can thiệp vào nhân hệ điều hành.
libfuse
là thư viện cung cấp lớp cấp cao để liên lạc với fuse.ko
.
fusermount
cho phép người dùng gắn kết/tháo gỡ các hệ thống tệp không gian người dùng (gọi tôi là Captain Obvious!).
Các nguyên tắc chung trông như thế này:
Quá trình không gian người dùng ( ls
trong trường hợp này) đưa ra yêu cầu tới nhân Hệ thống tệp ảo để định tuyến yêu cầu đến mô-đun hạt nhân FUSE. Đến lượt mình, mô-đun FUSE định tuyến yêu cầu quay trở lại không gian người dùng để hiện thực hóa hệ thống tệp ( ./hello
trong hình trên).
Đừng để bị lừa bởi tên Hệ thống tệp ảo. Nó không liên quan trực tiếp đến FUSE. Lớp phần mềm trong kernel cung cấp giao diện hệ thống tập tin cho các chương trình không gian người dùng. Để đơn giản, bạn có thể coi nó như một mẫu Composite .
libfuse
cung cấp hai loại API: cấp cao và cấp thấp. Chúng có những điểm tương đồng nhưng có những khác biệt quan trọng. Cái cấp thấp là không đồng bộ và chỉ hoạt động với inodes
. Trong trường hợp này, không đồng bộ có nghĩa là máy khách sử dụng API cấp thấp sẽ tự gọi các phương thức phản hồi.
Cái cấp cao cung cấp khả năng sử dụng các đường dẫn thuận tiện (ví dụ: /etc/shadow
) thay vì inodes
"trừu tượng" hơn và trả về các phản hồi theo cách đồng bộ hóa. Trong bài viết này, tôi sẽ giải thích cách thức hoạt động của cấp độ cao thay vì cấp độ thấp và inodes
.
Nếu bạn muốn triển khai hệ thống tệp của riêng mình, bạn nên triển khai một bộ phương thức chịu trách nhiệm phân phát các yêu cầu từ VFS. Các phương pháp phổ biến nhất là:
open(path, accessFlags): fd
-- mở file theo đường dẫn. Phương thức này sẽ trả về một mã định danh số, được gọi là Bộ mô tả tệp (từ đây fd
). Cờ truy cập là mặt nạ nhị phân mô tả hoạt động nào mà chương trình máy khách muốn thực hiện (chỉ đọc, chỉ ghi, đọc-ghi, thực thi hoặc tìm kiếm).
read(path, fd, Buffer, size, offset): count of bytes read
- đọc byte size
từ một tệp được liên kết với Bộ mô tả tệp fd
tới Bộ đệm đã truyền. Đối số path
bị bỏ qua vì chúng ta sẽ sử dụng fd.
write(path, fd, Buffer, size, offset): count of bytes written
-- ghi các byte size
từ Bộ đệm vào một tệp được liên kết với fd
.
release(fd)
-- đóng fd
.
truncate(path, size)
-- thay đổi kích thước tệp. Phương thức này phải được xác định nếu bạn muốn ghi lại tệp (và chúng tôi cũng làm như vậy).
getattr(path)
-- trả về các tham số tệp như kích thước, được tạo tại, được truy cập tại, v.v. Phương thức này là phương thức dễ gọi nhất bởi hệ thống tệp, vì vậy hãy đảm bảo bạn tạo phương thức tối ưu.
readdir(path)
- đọc tất cả các thư mục con.
Các phương pháp trên rất quan trọng đối với mỗi hệ thống tệp có thể hoạt động đầy đủ được xây dựng dựa trên API FUSE cấp cao. Nhưng danh sách chưa đầy đủ; danh sách đầy đủ bạn có thể tìm thấy trên https://libfuse.github.io/doxygen/structfuse__operations.html
Để xem lại khái niệm về bộ mô tả tệp: Trong các hệ thống giống UNIX, bao gồm cả MacOS, bộ mô tả tệp là một bản tóm tắt cho các tệp và các tài nguyên I/O khác như ổ cắm và đường ống. Khi một chương trình mở một tệp, HĐH sẽ trả về một mã định danh bằng số được gọi là bộ mô tả tệp. Số nguyên này đóng vai trò là chỉ mục trong bảng mô tả tệp của HĐH cho mỗi quy trình. Khi triển khai hệ thống tệp bằng FUSE, chúng ta sẽ cần tự tạo các bộ mô tả tệp.
Hãy xem xét luồng cuộc gọi khi máy khách mở tệp:
getattr(path: /random.png) → { size: 98 };
khách hàng đã nhận được kích thước tập tin.
open(path: /random.png) → 10;
đã mở tập tin theo đường dẫn; Việc triển khai FUSE trả về số mô tả tệp.
read(path: /random.png, fd: 10 buffer, size: 50, offset: 0) → 50;
đọc 50 byte đầu tiên.
read(path: /random.png, fd: 10 buffer, size: 50, offset: 50) → 48;
đọc 50 tiếp theo. 48 byte đã được đọc do kích thước tệp.
release(10);
tất cả dữ liệu đã được đọc, rất gần với fd.
Bước tiếp theo của chúng tôi là phát triển một hệ thống tệp tối thiểu dựa trên libfuse
để kiểm tra cách Postman sẽ tương tác với hệ thống tệp tùy chỉnh.
Yêu cầu chấp nhận đối với FS rất đơn giản: Phần gốc của FS phải chứa tệp random.txt
, nội dung của tệp này phải là duy nhất mỗi lần đọc (hãy gọi đây là "luôn đọc duy nhất"). Nội dung phải chứa UUID ngẫu nhiên và thời gian hiện tại ở định dạng ISO, được phân tách bằng một dòng mới. Ví dụ:
3790d212-7e47-403a-a695-4d680f21b81c 2012-12-12T04:30:30
Sản phẩm tối thiểu sẽ bao gồm hai phần. Đầu tiên là một dịch vụ web đơn giản sẽ chấp nhận các yêu cầu HTTP POST và in nội dung yêu cầu tới thiết bị đầu cuối. Đoạn mã này khá đơn giản và không đáng để chúng ta mất thời gian, chủ yếu là vì bài viết nói về FUSE chứ không phải Express. Phần thứ hai là việc triển khai hệ thống tập tin đáp ứng yêu cầu. Nó chỉ có 83 dòng mã.
Đối với mã, chúng tôi sẽ sử dụng thư viện node-fuse-binds, thư viện này cung cấp các liên kết với API cấp cao của libfuse
.
Bạn có thể bỏ qua mã bên dưới; Tôi sẽ viết một bản tóm tắt mã dưới đây.
const crypto = require('crypto'); const fuse = require('node-fuse-bindings'); // MOUNT_PATH is the path where our filesystem will be available. For Windows, this will be a path like 'D://' const MOUNT_PATH = process.env.MOUNT_PATH || './mnt'; function getRandomContent() { const txt = [crypto.randomUUID(), new Date().toISOString(), ''].join('\n'); return Buffer.from(txt); } function main() { // fdCounter is a simple counter that increments each time a file is opened // using this we can get the file content, which is unique for each opening let fdCounter = 0; // fd2ContentMap is a map that stores file content by fd const fd2ContentMap = new Map(); // Postman does not work reliably if we give it a file with size 0 or just the wrong size, // so we precompute the file size // it is guaranteed that the file size will always be the same within one run, so there will be no problems with this const randomTxtSize = getRandomContent().length; // fuse.mount is a function that mounts the filesystem fuse.mount( MOUNT_PATH, { readdir(path, cb) { console.log('readdir(%s)', path); if (path === '/') { return cb(0, ['random.txt']); } return cb(0, []); }, getattr(path, cb) { console.log('getattr(%s)', path); if (path === '/') { return cb(0, { // mtime is the file modification time mtime: new Date(), // atime is the file access time atime: new Date(), // ctime is the metadata or file content change time ctime: new Date(), size: 100, // mode is the file access flags // this is a mask that defines access rights to the file for different types of users // and the type of file itself mode: 16877, // file owners // in our case, it will be the owner of the current process uid: process.getuid(), gid: process.getgid(), }); } if (path === '/random.txt') { return cb(0, { mtime: new Date(), atime: new Date(), ctime: new Date(), size: randomTxtSize, mode: 33188, uid: process.getuid(), gid: process.getgid(), }); } cb(fuse.ENOENT); }, open(path, flags, cb) { console.log('open(%s, %d)', path, flags); if (path !== '/random.txt') return cb(fuse.ENOENT, 0); const fd = fdCounter++; fd2ContentMap.set(fd, getRandomContent()); cb(0, fd); }, read(path, fd, buf, len, pos, cb) { console.log('read(%s, %d, %d, %d)', path, fd, len, pos); const buffer = fd2ContentMap.get(fd); if (!buffer) { return cb(fuse.EBADF); } const slice = buffer.slice(pos, pos + len); slice.copy(buf); return cb(slice.length); }, release(path, fd, cb) { console.log('release(%s, %d)', path, fd); fd2ContentMap.delete(fd); cb(0); }, }, function (err) { if (err) throw err; console.log('filesystem mounted on ' + MOUNT_PATH); }, ); } // Handle the SIGINT signal separately to correctly unmount the filesystem // Without this, the filesystem will not be unmounted and will hang in the system // If for some reason unmount was not called, you can forcibly unmount the filesystem using the command // fusermount -u ./MOUNT_PATH process.on('SIGINT', function () { fuse.unmount(MOUNT_PATH, function () { console.log('filesystem at ' + MOUNT_PATH + ' unmounted'); process.exit(); }); }); main();
Tôi khuyên bạn nên làm mới kiến thức của mình về các bit cấp phép trong một tệp. Bit cấp phép là tập hợp các bit được liên kết với một tệp; chúng là biểu diễn nhị phân của người được phép đọc/ghi/thực thi tệp. "Ai" bao gồm ba nhóm: chủ sở hữu, nhóm chủ sở hữu và những người khác.
Quyền có thể được đặt cho từng nhóm riêng biệt. Thông thường, mỗi quyền được biểu thị bằng một số có ba chữ số: đọc (4 hoặc '100' trong hệ thống số nhị phân), ghi (2 hoặc '010') và thực thi (1 hoặc '001'). Nếu bạn cộng những số này lại với nhau, bạn sẽ tạo ra một quyền kết hợp. Ví dụ: 4 + 2 (hoặc '100' + '010') sẽ tạo thành 6 ('110'), nghĩa là quyền đọc + ghi (RO).
Nếu chủ sở hữu tệp có mặt nạ truy cập là 7 (111 ở dạng nhị phân, nghĩa là đọc, ghi và thực thi), thì nhóm có 5 (101, nghĩa là đọc và thực thi) và những người khác có 4 (100, nghĩa là chỉ đọc). Do đó, mặt nạ truy cập đầy đủ cho tệp là 754 ở dạng thập phân. Hãy nhớ rằng quyền thực thi sẽ trở thành quyền đọc đối với các thư mục.
Chúng ta hãy quay lại việc triển khai hệ thống tệp và tạo một phiên bản văn bản của điều này: Mỗi lần một tệp được mở (thông qua lệnh gọi open
), bộ đếm số nguyên sẽ tăng lên, tạo ra bộ mô tả tệp được trả về bởi lệnh gọi open. Sau đó, nội dung ngẫu nhiên được tạo và lưu trong kho lưu trữ khóa-giá trị với bộ mô tả tệp làm khóa. Khi lệnh gọi đọc được thực hiện, phần nội dung tương ứng sẽ được trả về.
Khi có lệnh phát hành, nội dung sẽ bị xóa. Hãy nhớ xử lý SIGINT
để ngắt kết nối hệ thống tập tin sau khi nhấn Ctrl+C. Nếu không, chúng ta sẽ phải thực hiện thủ công trong thiết bị đầu cuối bằng cách sử dụng fusermount -u ./MOUNT_PATH
.
Bây giờ, hãy nhảy vào thử nghiệm. Chúng tôi chạy máy chủ web, sau đó tạo một thư mục trống làm thư mục gốc cho FS sắp tới và chạy tập lệnh chính. Sau khi in dòng "Máy chủ nghe trên cổng 3000", hãy mở Người đưa thư và gửi liên tiếp một vài yêu cầu đến máy chủ web mà không thay đổi bất kỳ tham số nào.
Mọi thứ trông có vẻ tốt! Mỗi yêu cầu có nội dung tệp duy nhất, như chúng tôi đã dự đoán. Nhật ký cũng chứng minh rằng luồng lệnh gọi mở tệp được mô tả ở trên trong phần "Tìm hiểu sâu về FUSE" là chính xác.
Kho lưu trữ GitHub với MVP: https://github.com/pinkiesky/node-fuse-mvp . Bạn có thể chạy mã này trên môi trường cục bộ của mình hoặc sử dụng kho lưu trữ này làm bản soạn sẵn để triển khai hệ thống tệp của riêng bạn.
Phương pháp tiếp cận đã được kiểm tra - bây giờ đã đến lúc triển khai chính.
Trước khi thực hiện "luôn đọc duy nhất", điều đầu tiên chúng ta nên thực hiện là thao tác tạo và xóa các tệp gốc. Chúng tôi sẽ triển khai giao diện này thông qua một thư mục trong hệ thống tệp ảo của chúng tôi. Người dùng sẽ đặt các hình ảnh gốc mà họ muốn tạo thành "luôn duy nhất" hoặc "ngẫu nhiên" và hệ thống tệp sẽ chuẩn bị phần còn lại.
Ở đây và trong các phần sau, "luôn đọc duy nhất", "hình ảnh ngẫu nhiên" hoặc "tệp ngẫu nhiên" đề cập đến một tệp trả về nội dung duy nhất theo nghĩa nhị phân mỗi lần nó được đọc, trong khi về mặt trực quan, nó vẫn giống nhau nhất có thể đến bản gốc.
Thư mục gốc của hệ thống tệp sẽ chứa hai thư mục: Trình quản lý hình ảnh và Hình ảnh. Đầu tiên là thư mục để quản lý các tệp gốc của người dùng (bạn có thể coi nó như một kho lưu trữ CRUD). Thư mục thứ hai là thư mục không được quản lý theo quan điểm của người dùng, chứa các hình ảnh ngẫu nhiên.
Như bạn có thể thấy trong hình trên, chúng tôi cũng sẽ triển khai không chỉ những hình ảnh "luôn duy nhất" mà còn cả một trình chuyển đổi tệp! Đó là một phần thưởng bổ sung.
Ý tưởng cốt lõi trong quá trình triển khai của chúng tôi là chương trình sẽ chứa một cây đối tượng, trong đó mỗi nút và lá cung cấp các phương thức FUSE chung. Khi chương trình nhận được lệnh gọi FS, nó sẽ tìm một nút hoặc một lá trên cây theo đường dẫn tương ứng. Ví dụ: chương trình nhận lệnh gọi getattr(/Images/1/original/)
và sau đó cố gắng tìm nút mà đường dẫn được giải quyết.
Câu hỏi tiếp theo là chúng ta sẽ lưu trữ những hình ảnh gốc như thế nào. Một hình ảnh trong chương trình sẽ bao gồm dữ liệu nhị phân và thông tin meta (meta bao gồm tên tệp gốc, loại mime của tệp, v.v.). Dữ liệu nhị phân sẽ được lưu trữ trong bộ lưu trữ nhị phân. Hãy đơn giản hóa nó và xây dựng bộ lưu trữ nhị phân dưới dạng tập hợp các tệp nhị phân trong hệ thống tệp người dùng (hoặc máy chủ). Thông tin meta sẽ được lưu trữ tương tự: JSON bên trong các tệp văn bản trong hệ thống tệp người dùng.
Như bạn có thể nhớ, trong phần "Hãy viết một sản phẩm có khả năng tồn tại tối thiểu", chúng tôi đã tạo một hệ thống tệp trả về tệp văn bản theo mẫu. Nó chứa một UUID ngẫu nhiên cộng với ngày hiện tại, do đó tính duy nhất của dữ liệu không phải là vấn đề—tính duy nhất đạt được nhờ định nghĩa của dữ liệu. Tuy nhiên, kể từ thời điểm này, chương trình sẽ hoạt động với hình ảnh người dùng được tải sẵn. Vì vậy, làm cách nào chúng ta có thể tạo ra các hình ảnh tương tự nhưng luôn duy nhất (về byte và do đó là giá trị băm) dựa trên hình ảnh gốc?
Giải pháp tôi đề xuất khá đơn giản. Hãy đặt một hình vuông nhiễu RGB ở góc trên bên trái của hình ảnh. Hình vuông nhiễu phải là 16x16 pixel. Điều này cung cấp hình ảnh gần như giống nhau nhưng đảm bảo một chuỗi byte duy nhất. Liệu nó có đủ để đảm bảo có nhiều hình ảnh khác nhau không? Hãy làm một số phép toán. Kích thước của hình vuông là 16. 16×16 = 256 pixel RGB trong một hình vuông. Mỗi pixel có 256×256×256 = 16.777.216 biến thể.
Do đó, số ô vuông duy nhất là 16.777.216^256 - một con số có 1.558 chữ số, nhiều hơn số lượng nguyên tử trong vũ trụ quan sát được. Điều đó có nghĩa là chúng ta có thể giảm kích thước hình vuông? Thật không may, việc nén mất dữ liệu như JPEG sẽ làm giảm đáng kể số lượng ô vuông duy nhất, vì vậy 16x16 là kích thước tối ưu.
IFUSEHandler
là một giao diện phục vụ các cuộc gọi FUSE thông thường. Bạn có thể thấy rằng tôi đã thay thế read/write
bằng readAll/writeAll
tương ứng. Tôi làm điều này để đơn giản hóa các thao tác đọc và ghi: khi IFUSEHandler
thực hiện đọc/ghi cho toàn bộ một phần, chúng tôi có thể di chuyển logic đọc/ghi một phần sang một nơi khác. Điều này có nghĩa là IFUSEHandler
không cần biết gì về bộ mô tả tệp, dữ liệu nhị phân, v.v.
Điều tương tự cũng xảy ra với phương thức FUSE open
. Một khía cạnh đáng chú ý của cây là nó được tạo ra theo yêu cầu. Thay vì lưu trữ toàn bộ cây trong bộ nhớ, chương trình chỉ tạo các nút khi chúng được truy cập. Hành vi này cho phép chương trình tránh được sự cố khi xây dựng lại cây trong trường hợp tạo hoặc xóa nút.
Kiểm tra giao diện ObjectTreeNode
và bạn sẽ thấy rằng children
không phải là một mảng mà là một phương thức, vì vậy đây là cách chúng được tạo theo yêu cầu. FileFUSETreeNode
và DirectoryFUSETreeNode
là các lớp trừu tượng trong đó một số phương thức đưa ra lỗi NotSupported
(rõ ràng FileFUSETreeNode
không bao giờ nên triển khai readdir
).
FUSEFacade là lớp quan trọng nhất thực hiện logic chính của chương trình và liên kết các phần khác nhau lại với nhau. node-fuse-bindings
có API dựa trên cuộc gọi lại, nhưng các phương thức FUSEFacade được tạo bằng API dựa trên Promise. Để giải quyết sự bất tiện này, tôi đã sử dụng một đoạn mã như thế này:
const handleResultWrapper = <T>( promise: Promise<T>, cb: (err: number, result: T) => void, ) => { promise .then((result) => { cb(0, result); }) .catch((err) => { if (err instanceof FUSEError) { fuseLogger.info(`FUSE error: ${err}`); return cb(err.code, null as T); } fuseLogger.warn(err); cb(fuse.EIO, null as T); }); }; // Ex. usage: // open(path, flags, cb) { // handleResultWrapper(fuseFacade.open(path, flags), cb); // },
Các phương thức FUSEFacade
được gói trong handleResultWrapper
. Mỗi phương thức của FUSEFacade
sử dụng một đường dẫn chỉ cần phân tích đường dẫn đó, tìm một nút trong cây và gọi phương thức được yêu cầu.
Hãy xem xét một số phương thức từ lớp FUSEFacade
.
async create(path: string, mode: number): Promise<number> { this.logger.info(`create(${path})`); // Convert path `/Image Manager/1/image.jpg` in // `['Image Manager', '1', 'image.jpg']` // splitPath will throw error if something goes wrong const parsedPath = this.splitPath(path); // `['Image Manager', '1', 'image.jpg']` const name = parsedPath.pop()!; // 'image.jpg' // Get node by path (`/Image Manager/1` after `pop` call) // or throw an error if node not found const node = await this.safeGetNode(parsedPath); // Call the IFUSEHandler method. Pass only a name, not a full path! await node.create(name, mode); // Create a file descriptor const fdObject = this.fdStorage.openWO(); return fdObject.fd; } async readdir(path: string): Promise<string[]> { this.logger.info(`readdir(${path})`); const node = await this.safeGetNode(path); // As you see, the tree is generated on the fly return (await node.children()).map((child) => child.name); } async open(path: string, flags: number): Promise<number> { this.logger.info(`open(${path}, ${flags})`); const node = await this.safeGetNode(path); // A leaf node is a directory if (!node.isLeaf) { throw new FUSEError(fuse.EACCES, 'invalid path'); } // Usually checkAvailability checks access await node.checkAvailability(flags); // Get node content and put it in created file descriptor const fileData: Buffer = await node.readAll(); // fdStorage is IFileDescriptorStorage, we will consider it below const fdObject = this.fdStorage.openRO(fileData); return fdObject.fd; }
Trước khi thực hiện bước tiếp theo, chúng ta hãy xem xét kỹ hơn bộ mô tả tệp là gì trong ngữ cảnh chương trình của chúng ta.
ReadWriteFileDescriptor
là lớp lưu trữ các bộ mô tả tệp dưới dạng số và dữ liệu nhị phân dưới dạng bộ đệm. Lớp này có các phương thức readToBuffer
và writeToBuffer
cung cấp khả năng đọc và ghi dữ liệu vào bộ đệm mô tả tệp. ReadFileDescriptor
và WriteFileDescriptor
là các triển khai của bộ mô tả chỉ đọc và chỉ ghi.
IFileDescriptorStorage
là một giao diện mô tả việc lưu trữ bộ mô tả tệp. Chương trình chỉ có một triển khai cho giao diện này: InMemoryFileDescriptorStorage
. Như bạn có thể biết từ cái tên, nó lưu trữ các bộ mô tả tệp trong bộ nhớ vì chúng ta không cần sự lưu giữ lâu dài của các bộ mô tả.
Hãy kiểm tra cách FUSEFacade
sử dụng bộ mô tả và lưu trữ tệp:
async read( fd: number, // File descriptor to read from buf: Buffer, // Buffer to store the read data len: number, // Length of data to read pos: number, // Position in the file to start reading from ): Promise<number> { // Retrieve the file descriptor object from storage const fdObject = this.fdStorage.get(fd); if (!fdObject) { // If the file descriptor is invalid, throw an error throw new FUSEError(fuse.EBADF, 'invalid fd'); } // Read data into the buffer and return the number of bytes read return fdObject.readToBuffer(buf, len, pos); } async write( fd: number, // File descriptor to write to buf: Buffer, // Buffer containing the data to write len: number, // Length of data to write pos: number, // Position in the file to start writing at ): Promise<number> { // Retrieve the file descriptor object from storage const fdObject = this.fdStorage.get(fd); if (!fdObject) { // If the file descriptor is invalid, throw an error throw new FUSEError(fuse.EBADF, 'invalid fd'); } // Write data from the buffer and return the number of bytes written return fdObject.writeToBuffer(buf, len, pos); } async release(path: string, fd: number): Promise<0> { // Retrieve the file descriptor object from storage const fdObject = this.fdStorage.get(fd); if (!fdObject) { // If the file descriptor is invalid, throw an error throw new FUSEError(fuse.EBADF, 'invalid fd'); } // Safely get the node corresponding to the file path const node = await this.safeGetNode(path); // Write all the data from the file descriptor object to the node await node.writeAll(fdObject.binary); // Release the file descriptor from storage this.fdStorage.release(fd); // Return 0 indicating success return 0; }
Đoạn mã trên rất đơn giản. Nó xác định các phương thức để đọc, ghi vào và giải phóng các bộ mô tả tệp, đảm bảo bộ mô tả tệp hợp lệ trước khi thực hiện các thao tác. Phương thức phát hành cũng ghi dữ liệu từ một đối tượng mô tả tệp vào nút hệ thống tệp và giải phóng bộ mô tả tệp.
Chúng ta đã hoàn tất mã xung quanh libfuse
và cây. Đã đến lúc đi sâu vào mã liên quan đến hình ảnh.
ImageMeta
là một đối tượng lưu trữ thông tin meta về một hình ảnh. IImageMetaStorage
là giao diện mô tả nơi lưu trữ cho meta. Chương trình chỉ có một triển khai cho giao diện: lớp FSImageMetaStorage
triển khai giao diện IImageMetaStorage
để quản lý siêu dữ liệu hình ảnh được lưu trữ trong một tệp JSON.
Nó sử dụng bộ đệm để lưu trữ siêu dữ liệu trong bộ nhớ và đảm bảo bộ đệm được hydrat hóa bằng cách đọc từ tệp JSON khi cần. Lớp này cung cấp các phương thức để tạo, truy xuất, liệt kê và xóa siêu dữ liệu hình ảnh, đồng thời ghi các thay đổi trở lại tệp JSON để duy trì các bản cập nhật. Bộ đệm cải thiện hiệu suất bằng cách giảm số lượng thao tác IO.
ImageBinary
rõ ràng là một đối tượng có dữ liệu hình ảnh nhị phân. Giao diện Image
là sự kết hợp của ImageMeta
và ImageBinary
.
IBinaryStorage
là một giao diện để lưu trữ dữ liệu nhị phân. Bộ lưu trữ nhị phân phải được hủy liên kết khỏi hình ảnh và có thể lưu trữ bất kỳ dữ liệu nào: hình ảnh, video, JSON hoặc văn bản. Thực tế này rất quan trọng đối với chúng tôi và bạn sẽ hiểu tại sao.
IImageGenerator
là một giao diện mô tả một trình tạo. Trình tạo là một phần quan trọng của chương trình. Nó lấy dữ liệu nhị phân thô cộng với meta và tạo hình ảnh dựa trên nó. Tại sao chương trình cần máy phát điện? Chương trình có thể hoạt động mà không có chúng không?
Có thể, nhưng các trình tạo sẽ tăng thêm tính linh hoạt cho việc triển khai. Trình tạo cho phép người dùng tải lên hình ảnh, dữ liệu văn bản và nói rộng ra là bất kỳ dữ liệu nào mà bạn viết trình tạo.
Quy trình như sau: dữ liệu nhị phân được tải từ bộ lưu trữ ( myfile.txt
trong hình trên) và sau đó tệp nhị phân chuyển đến một trình tạo. Nó tạo ra một hình ảnh "nhanh chóng". Bạn có thể coi nó như một công cụ chuyển đổi từ định dạng này sang định dạng khác, thuận tiện hơn cho chúng tôi.
Hãy xem một ví dụ về máy phát điện:
import { createCanvas } from 'canvas'; // Import createCanvas function from the canvas library to create and manipulate images const IMAGE_SIZE_RE = /(\d+)x(\d+)/; // Regular expression to extract width and height dimensions from a string export class TextImageGenerator implements IImageGenerator { // method to generate an image from text async generate(meta: ImageMeta, rawBuffer: Buffer): Promise<Image | null> { // Step 1: Verify the MIME type is text if (meta.originalFileType !== MimeType.TXT) { // If the file type is not text, return null indicating no image generation return null; } // Step 2: Determine the size of the image const imageSize = { width: 800, // Default width height: 600, // Default height }; // Extract dimensions from the name if present const imageSizeRaw = IMAGE_SIZE_RE.exec(meta.name); if (imageSizeRaw) { // Update the width and height based on extracted values, or keep defaults imageSize.width = Number(imageSizeRaw[1]) || imageSize.width; imageSize.height = Number(imageSizeRaw[2]) || imageSize.height; } // Step 3: Convert the raw buffer to a string to get the text content const imageText = rawBuffer.toString('utf-8'); // Step 4: Create a canvas with the determined size const canvas = createCanvas(imageSize.width, imageSize.height); const ctx = canvas.getContext('2d'); // Get the 2D drawing context // Step 5: Prepare the canvas background ctx.fillStyle = '#000000'; // Set fill color to black ctx.fillRect(0, 0, imageSize.width, imageSize.height); // Fill the entire canvas with the background color // Step 6: Draw the text onto the canvas ctx.textAlign = 'start'; // Align text to the start (left) ctx.textBaseline = 'top'; // Align text to the top ctx.fillStyle = '#ffffff'; // Set text color to white ctx.font = '30px Open Sans'; // Set font style and size ctx.fillText(imageText, 10, 10); // Draw the text with a margin // Step 7: Convert the canvas to a PNG buffer and create the Image object return { meta, // Include the original metadata binary: { buffer: canvas.toBuffer('image/png'), // Convert canvas content to a PNG buffer }, }; } }
Lớp ImageLoaderFacade
là một mặt tiền kết hợp một cách hợp lý bộ lưu trữ và trình tạo–nói cách khác, nó triển khai luồng bạn đọc ở trên.
IImageVariant
là một giao diện để tạo các biến thể hình ảnh khác nhau. Trong ngữ cảnh này, một biến thể là một hình ảnh được tạo "nhanh chóng" sẽ được hiển thị cho người dùng khi xem các tệp trong hệ thống tệp của chúng tôi. Sự khác biệt chính so với trình tạo là nó lấy hình ảnh làm đầu vào chứ không phải dữ liệu thô.
Chương trình có ba biến thể: ImageAlwaysRandom
, ImageOriginalVariant
và ImageWithText
. ImageAlwaysRandom
trả về hình ảnh gốc với hình vuông nhiễu RGB ngẫu nhiên.
export class ImageAlwaysRandomVariant implements IImageVariant { // Define a constant for the size of the random square edge in pixels private readonly randomSquareEdgeSizePx = 16; // Constructor takes the desired output format for the image constructor(private readonly outputFormat: ImageFormat) {} // Asynchronous method to generate a random variant of an image async generate(image: Image): Promise<ImageBinary> { // Step 1: Load the image using the sharp library const sharpImage = sharp(image.binary.buffer); // Step 2: Retrieve metadata and raw buffer from the image const metadata = await sharpImage.metadata(); // Get image metadata const buffer = await sharpImage.raw().toBuffer(); // Get raw pixel data // the buffer size is plain array with size of image width * image height * channels count (3 or 4) // Step 3: Apply random pixel values to a small square region in the image for (let y = 0; y < this.randomSquareEdgeSizePx; y++) { for (let x = 0; x < this.randomSquareEdgeSizePx; x++) { // Calculate the buffer offset for the current pixel const offset = y * metadata.width! * metadata.channels! + x * metadata.channels!; // Set random values for RGB channels buffer[offset + 0] = randInt(0, 255); // Red channel buffer[offset + 1] = randInt(0, 255); // Green channel buffer[offset + 2] = randInt(0, 255); // Blue channel // If the image has an alpha channel, set it to 255 (fully opaque) if (metadata.channels === 4) { buffer[offset + 3] = 255; // Alpha channel } } } // Step 4: Create a new sharp image from the modified buffer and convert it to the desired format const result = await sharp(buffer, { raw: { width: metadata.width!, height: metadata.height!, channels: metadata.channels!, }, }) .toFormat(this.outputFormat) // Convert to the specified output format .toBuffer(); // Get the final image buffer // Step 5: Return the generated image binary data return { buffer: result, // Buffer containing the generated image }; } }
Tôi sử dụng thư viện sharp
như một cách thuận tiện nhất để thao tác trên hình ảnh trong NodeJS: https://github.com/lovell/sharp .
ImageOriginalVariant
trả về một hình ảnh mà không có bất kỳ thay đổi nào (nhưng nó có thể trả về một hình ảnh ở định dạng nén khác). ImageWithText
trả về một hình ảnh có văn bản viết trên đó. Điều này sẽ hữu ích khi chúng ta tạo các biến thể được xác định trước của một hình ảnh. Ví dụ: nếu chúng ta cần 10 biến thể ngẫu nhiên của một hình ảnh, chúng ta phải phân biệt các biến thể này với nhau.
Giải pháp ở đây là tạo 10 ảnh dựa trên ảnh gốc, trong đó chúng tôi hiển thị một số liên tiếp từ 0 đến 9 ở góc trên cùng bên trái của mỗi ảnh.
ImageCacheWrapper
có mục đích khác với các biến thể và hoạt động như một trình bao bọc bằng cách lưu vào bộ nhớ đệm các kết quả của lớp IImageVariant
cụ thể. Nó sẽ được sử dụng để bao bọc các thực thể không thay đổi, như trình chuyển đổi hình ảnh, trình tạo văn bản thành hình ảnh, v.v. Cơ chế bộ nhớ đệm này cho phép truy xuất dữ liệu nhanh hơn, chủ yếu khi cùng một hình ảnh được đọc nhiều lần.
Vâng, chúng tôi đã đề cập đến tất cả các phần chính của chương trình. Đã đến lúc kết hợp mọi thứ lại với nhau.
Sơ đồ lớp bên dưới thể hiện cách kết hợp các lớp cây với hình ảnh tương ứng của chúng. Sơ đồ nên được đọc từ dưới lên trên. RootDir
(để tôi tránh hậu tố FUSETreeNode
trong tên) là thư mục gốc cho hệ thống tệp mà chương trình đang triển khai. Di chuyển lên hàng trên, xem hai thư mục: ImagesDir
và ImagesManagerDir
. ImagesManagerDir
chứa danh sách hình ảnh của người dùng và cho phép kiểm soát chúng. Sau đó, ImagesManagerItemFile
là một nút cho một tệp cụ thể. Lớp này thực hiện các hoạt động CRUD.
Hãy coi ImagesManagerDir như một cách triển khai nút thông thường:
class ImageManagerDirFUSETreeNode extends DirectoryFUSETreeNode { name = 'Image Manager'; // Name of the directory constructor( private readonly imageMetaStorage: IImageMetaStorage, private readonly imageBinaryStorage: IBinaryStorage, ) { super(); // Call the parent class constructor } async children(): Promise<IFUSETreeNode[]> { // Dynamically create child nodes // In some cases, dynamic behavior can be problematic, requiring a cache of child nodes // to avoid redundant creation of IFUSETreeNode instances const list = await this.imageMetaStorage.list(); return list.map( (meta) => new ImageManagerItemFileFUSETreeNode( this.imageMetaStorage, this.imageBinaryStorage, meta, ), ); } async create(name: string, mode: number): Promise<void> { // Create a new image metadata entry await this.imageMetaStorage.create(name); } async getattr(): Promise<Stats> { return { // File modification date mtime: new Date(), // File last access date atime: new Date(), // File creation date // We do not store dates for our images, // so we simply return the current date ctime: new Date(), // Number of links nlink: 1, size: 100, // File access flags mode: FUSEMode.directory( FUSEMode.ALLOW_RWX, // Owner access rights FUSEMode.ALLOW_RX, // Group access rights FUSEMode.ALLOW_RX, // Access rights for all others ), // User ID of the file owner uid: process.getuid ? process.getuid() : 0, // Group ID for which the file is accessible gid: process.getgid ? process.getgid() : 0, }; } // Explicitly forbid deleting the 'Images Manager' folder remove(): Promise<void> { throw FUSEError.accessDenied(); } }
Từ nay trở đi, ImagesDir
chứa các thư mục con được đặt tên theo hình ảnh của người dùng. ImagesItemDir
chịu trách nhiệm cho từng thư mục. Nó bao gồm tất cả các biến thể có sẵn; như bạn nhớ, số lượng biến thể là ba. Mỗi biến thể là một thư mục chứa các tệp hình ảnh cuối cùng ở các định dạng khác nhau (hiện tại: jpeg, png và webm). ImagesItemOriginalDir
và ImagesItemCounterDir
bao bọc tất cả các phiên bản ImageVariantFile
được sinh ra trong bộ đệm.
Điều này là cần thiết để tránh việc mã hóa lại hình ảnh gốc liên tục vì việc mã hóa tiêu tốn CPU. Ở đầu sơ đồ là ImageVariantFile
. Nó là viên ngọc quý của việc triển khai và thành phần của IFUSEHandler
và IImageVariant
được mô tả trước đó. Đây là tập tin mà mọi nỗ lực của chúng tôi đã hướng tới.
Hãy kiểm tra cách hệ thống tệp cuối cùng xử lý các yêu cầu song song đối với cùng một tệp. Để thực hiện việc này, chúng tôi sẽ chạy tiện ích md5sum
trong nhiều luồng, tiện ích này sẽ đọc các tệp từ hệ thống tệp và tính toán giá trị băm của chúng. Sau đó, chúng ta sẽ so sánh các giá trị băm này. Nếu mọi thứ đều hoạt động chính xác thì giá trị băm sẽ khác.
#!/bin/bash # Loop to run the md5sum command 5 times in parallel for i in {1..5} do echo "Run $i..." # `&` at the end of the command runs it in the background md5sum ./mnt/Images/2020-09-10_22-43/always_random/2020-09-10_22-43.png & done echo 'wait...' # Wait for all background processes to finish wait
Tôi đã chạy tập lệnh và kiểm tra kết quả đầu ra sau (đã dọn dẹp một chút cho rõ ràng):
Run 1... Run 2... Run 3... Run 4... Run 5... wait... bcdda97c480db74e14b8779a4e5c9d64 0954d3b204c849ab553f1f5106d576aa 564eeadfd8d0b3e204f018c6716c36e9 73a92c5ef27992498ee038b1f4cfb05e 77db129e37fdd51ef68d93416fec4f65
Xuất sắc! Tất cả các giá trị băm đều khác nhau, nghĩa là hệ thống tập tin sẽ trả về một hình ảnh duy nhất mỗi lần!
Tôi hy vọng bài viết này đã truyền cảm hứng cho bạn để viết phần triển khai FUSE của riêng mình. Hãy nhớ rằng mã nguồn của dự án này có sẵn ở đây: https://github.com/pinkiesky/node-fuse-images .
Hệ thống tệp mà chúng tôi đã xây dựng được đơn giản hóa để thể hiện các nguyên tắc cốt lõi khi làm việc với FUSE và Node.js. Ví dụ: nó không tính đến ngày chính xác. Có rất nhiều chỗ để nâng cao. Hãy tưởng tượng việc bổ sung các chức năng như trích xuất khung từ tệp GIF của người dùng, chuyển mã video hoặc thậm chí thực hiện các tác vụ song song thông qua các nhân viên.
Tuy nhiên, hoàn hảo là kẻ thù của điều tốt. Bắt đầu với những gì bạn có, làm cho nó hoạt động và sau đó lặp lại. Chúc mừng mã hóa!