Câu chuyện của tôi bắt đầu cách đây hơn một năm. Hồi đó, tôi cứ quanh quẩn với mấy đoạn code nhỏ xíu, mấy cái script vụn vặt, chẳng có dự án phức tạp nào ra hồn. Thú thật, đầu óc tôi lúc đó cần một 'cú sốc điện' để tỉnh táo lại, nếu không thì cứ ù lì mãi thôi. Với ý định nghiêm túc chuyển sang làm lập trình viên full-time, tôi quyết định tự tay xây dựng một dự án hoàn chỉnh từ A đến Z để mài giũa kỹ năng. Mục tiêu là phải vừa thử thách, vừa gần gũi với cuộc sống hằng ngày. Và thế là ý tưởng về một ứng dụng ghi chú ra đời! Ai mà chẳng ghi chú mỗi ngày, đúng không? Tôi tự nhủ: "Chắc dễ òm à!". Ôi thôi, cái câu "Chúng ta làm việc này không phải vì nó dễ, mà vì chúng ta nghĩ nó sẽ dễ" đúng là dành cho tôi! Hóa ra có cả tỉ thứ để học, nhưng chính điều đó lại là động lực để tôi lao vào.Ngay từ đầu, tôi đã đặt ra vài mục tiêu cốt lõi cho 'đứa con' này: ứng dụng phải chạy mượt mà trên mọi thiết bị, hoạt động được cả khi offline, và quan trọng nhất là phải có tính năng 'hoàn tác/làm lại' (undo/redo) đầy đủ lịch sử. Xây dựng một ứng dụng web là lựa chọn hợp lý nhất. Tuy nhiên, một 'tảng đá' khổng lồ cứ lởn vởn trong đầu tôi: làm sao để xử lý xung đột khi nhiều người cùng chỉnh sửa văn bản theo thời gian thực? Dùng mấy thư viện có sẵn như ShareDB, Etherpad, Yjs, hay Automerge thì quá dễ rồi, nhưng làm thế thì còn gì là thử thách học hỏi nữa! Không, tôi phải tự tìm ra lời giải mới chịu!Tôi biết ngay là cái kiểu 'thay thế văn bản đơn giản' sẽ chẳng ăn thua. Giải pháp phải là chỉ gửi đi những thay đổi mà người dùng thực sự đã làm. Tự mình nghĩ ra một thuật toán từ con số 0 ư? Chắc chắn sẽ tốn rất nhiều thời gian mà hiệu quả thì... hên xui. Sau một hồi 'đào bới' không ngừng nghỉ, cuối cùng tôi cũng tìm thấy 'viên ngọc quý': cuốn <a href='https://github.com/knemerzitski/notes/blob/article/packages/collab/docs/easysync-full-description.pdf'>Etherpad EasySync Technical Manual</a> trong repo GitHub của Etherpad. Cuốn sách này giải thích rất chi tiết về Operational Transformation (OT) – một khái niệm nghe là lạ mà quen đó. Đọc xong, tôi như được khai sáng, mọi khúc mắc đều được gỡ bỏ, đủ để tôi bắt tay vào việc. À mà để tôi nói rõ luôn nhé: tôi không hề nhìn vào mã nguồn của Etherpad đâu, vì tôi muốn tự mình hiểu và giải quyết vấn đề.Trong bài viết này, tôi sẽ tập trung kể về những thử thách cam go khi thiết kế và triển khai OT từ đầu, đồng thời giải thích một chút về cách OT hoạt động 'trong thực tế' nhé.Operational Transformation (OT) là gì? Chỉ là dịch chuyển các chỉ số thôi mà, đúng không?Nghe cái tên Operational Transformation (OT) thì có vẻ "nguy hiểm", nhưng ban đầu nó nghe thật đơn giản: hai người dùng cùng chỉnh sửa một tài liệu, bạn chỉ cần điều chỉnh các 'chỉ số' (vị trí) sao cho các thay đổi của họ không... giẫm đạp lên nhau. Dễ như ăn kẹo ấy nhỉ?Vâng, đó chính xác là cái suy nghĩ "ngây thơ" của tôi lúc ban đầu!Tôi lao vào cuộc chiến này chỉ với mỗi cuốn Etherpad Manual trong tay ảo. Không dùng pseudocode, không dùng thư viện nào hết. Cuối cùng, tôi phải mất cả tá lần thử và sai chỉ để viết cho đúng một hàm transform đơn giản. Nhưng cái 'cơn đau đầu' lớn nhất lại là triển khai tính năng hoàn tác/làm lại (undo/redo) cho từng người dùng mà không làm hỏng tính nhất quán của tài liệu hay ý định của người dùng. Thêm cả chỉnh sửa offline, đồng bộ qua WebSocket, và theo dõi vị trí con trỏ nữa... ôi thôi, bạn sẽ thấy mình đang 'chơi đùa' với một mớ các bộ phận chuyển động liên tục. Sự phức tạp ở đây không nằm nhiều ở bản thân thuật toán, mà là ở toàn bộ hệ thống xung quanh nó.Thế là cái thứ ban đầu chỉ là một "just a side project" đã biến thành dự án đòi hỏi kỹ thuật cao nhất mà tôi từng xây dựng. Nhưng tôi mừng vì mình đã không 'đi đường tắt', bởi vì chính nó đã cho tôi một sự hiểu biết sâu sắc về cách các hệ thống cộng tác thực sự hoạt động.Để hiểu rõ hơn về OT, hãy cùng 'nghía' qua vài ví dụ thực tế nhé!Ví dụ 1: Xử lý chỉnh sửa đồng thời (Không xung đột)Khi hai người dùng cùng 'nhảy' vào chỉnh sửa tài liệu, máy chủ phải làm sao để hòa giải các thay đổi của họ, đảm bảo rằng cả hai đều có cùng một kết quả cuối cùng.Tình huống ban đầu: Alice và Bob cùng có tài liệu là "Hello!".Alice làm gì? Alice chèn thêm " world" ngay trước dấu "!".Bob làm gì? Bob chèn thêm " Bob!" ngay sau dấu "!".Thứ tự xử lý trên máy chủ:Máy chủ áp dụng thay đổi của Alice trước. Tài liệu giờ là "Hello world!".Khi xử lý thay đổi của Bob, máy chủ sẽ 'biến đổi' nó, có tính đến thay đổi của Alice.Kết quả: Cả Alice và Bob đều thấy tài liệu là "Hello world! Bob!".Bạn có thể hình dung chuỗi sự kiện này qua sơ đồ dưới đây:<img src='https://truyentranh.letranglan.top/api/v1/proxy?url=https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7451u3fwej34mbwl6f8g.webp' alt='Sơ đồ chỉnh sửa không xung đột của Alice và Bob'>Mặc dù không có xung đột trực tiếp, nhưng thay đổi của Bob vẫn cần được điều chỉnh vì nó được thực hiện trên một tài liệu... 'cũ rích' (chưa có thay đổi của Alice). Cả hai thay đổi ban đầu đều tham chiếu đến cùng một tài liệu "Hello!". Vì Alice đã chèn " world" vào, nên cái " Bob!" của Bob cần phải 'dịch sang phải' 6 ký tự để giữ nguyên ý định ban đầu của anh ấy.Đây là cách máy chủ 'nhìn thấy' thay đổi của Bob:Thay đổi của Bob ban đầu được áp dụng cho "Hello!".Thay đổi của Alice đã được xử lý trước đó.Thay đổi của Bob được 'biến đổi' (transform) so với thay đổi của Alice, dịch chuyển vị trí sang phải 6 ký tự.Máy chủ thêm thay đổi đã được điều chỉnh của Bob vào danh sách.<img src='https://truyentranh.letranglan.top/api/v1/proxy?url=https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzf58jvxq8m6hmznh4fb9.webp' alt='Dịch chuyển thay đổi của Bob'>Bằng cách áp dụng phép biến đổi này, cả Alice và Bob đều sẽ thấy cùng một tài liệu cuối cùng: "Hello world! Bob!". Thật vi diệu!Ví dụ 2: Xử lý chỉnh sửa đồng thời (Có xung đột)Xung đột có thể xảy ra khi hai người dùng cùng chèn văn bản vào... chính xác cùng một vị trí. Hãy xem một biến thể nhỏ của ví dụ trước để thấy điều này nhé:Tình huống ban đầu: Alice và Bob cùng có tài liệu là "Hello!".Alice làm gì? Alice chèn thêm " world" ngay trước dấu "!".Bob làm gì? Bob chèn thêm " cat" ngay trước dấu "!".Thứ tự xử lý trên máy chủ:Máy chủ áp dụng thay đổi của Alice trước. Tài liệu giờ là "Hello world!".Khi xử lý thay đổi của Bob, máy chủ sẽ 'biến đổi' nó, có tính đến thay đổi của Alice.Kết quả: Cả Alice và Bob đều thấy tài liệu là "Hello world cat!".Chuỗi sự kiện này được minh họa trong sơ đồ dưới đây:<img src='https://truyentranh.letranglan.top/api/v1/proxy?url=https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxkfknrhdvwqp8ao8e6xd.webp' alt='Sơ đồ chỉnh sửa có xung đột của Alice và Bob'>Bạn có thể thắc mắc tại sao kết quả lại là "Hello world cat!" thay vì "Hello cat world!". Cả hai kết quả đều hợp lệ và không cái nào phản ánh 'ý định' của người dùng một cách chính xác hơn. Khi triển khai OT, bạn phải chọn một 'xu hướng' (bias) để xử lý các thao tác chèn đồng thời vào cùng một vị trí. Trong trường hợp này, thao tác chèn mà máy chủ xử lý trước sẽ được đặt ở bên trái. Chúng ta gọi đó là 'ưu tiên bên trái' (left bias).Tại sao lại tự triển khai OT từ đầu? Thử thách và Bài học xương máu!Tôi không tự xây dựng OT từ đầu vì... bị ép buộc. Một ứng dụng ghi chú đơn thuần thì có vẻ không đủ 'sâu' để tôi học hỏi, nên việc đào sâu vào sự phức tạp của cộng tác thời gian thực dường như là một thử thách rất 'vừa tầm'. Dùng một thư viện có sẵn ư? Tuyệt đối không! Nó giống như một 'hộp đen' bí ẩn vậy, tôi không muốn thế. Thay vào đó, tôi đã dũng cảm chấp nhận thử thách, dù lúc đó cũng không biết mình đang 'đâm đầu' vào cái gì! Để giữ cho phạm vi dự án không 'phình to' quá mức, tôi đã đặt ra một vài ràng buộc:Chỉ xử lý văn bản thuần túy (không có định dạng phức tạp như in đậm hay cỡ chữ khác nhau).Máy chủ tập trung (để tránh rắc rối của kiến trúc phi tập trung).'Ưu tiên theo thứ tự từ điển' – Sai lầm đầu đời!Một trong những 'bước đi hụt' đầu tiên của tôi là chọn kiểu ưu tiên theo thứ tự từ điển (lexicographical bias) cho các thao tác chèn đồng thời. Lúc đó, tôi thấy nó có vẻ hợp lý vì các ký tự có thứ tự rõ ràng mà. Nhưng hóa ra, nó lại tạo ra những lỗi không nhất quán rất 'tinh vi', cực kỳ khó gỡ lỗi. Ví dụ, việc triển khai tính năng lịch sử (history) sẽ bị 'toang' vì các thao tác không phải lúc nào cũng áp dụng theo một thứ tự có thể dự đoán được. Và để 'thêm dầu vào lửa', sau này tôi mới phát hiện ra rằng một số hệ thống còn xử lý thứ tự ký tự khác nhau khi gặp các trường hợp như chữ có dấu hoặc quy tắc tùy chỉnh theo địa phương. Đó thực sự là một bài học đắt giá về việc một quyết định thiết kế quan trọng, nếu sai, có thể khiến bạn 'ngã sấp mặt' như thế nào. Cuối cùng, tôi đành quay về với một lựa chọn 'dễ tính' hơn: ưu tiên bên trái (left bias).Mutable State – Cơn ác mộng!Quản lý trạng thái (state management) hóa ra lại là một 'ổ bug' không ngừng nghỉ. Phiên bản đầu tiên của tôi dùng 'trạng thái có thể thay đổi' (mutable state). Điều này biến việc gỡ lỗi thành một cơn ác mộng thực sự. Khi trạng thái bị hỏng, tôi phải 'cày' qua hàng tá file log chỉ để tìm ra nguyên nhân lỗi. Chuyển sang dùng 'trạng thái bất biến' (immutable state) với sự trợ giúp của <a href='https://www.npmjs.com/package/immer'>immer.js</a> đã tạo ra một sự thay đổi khổng lồ. Các lỗi trở nên dễ dàng cô lập hơn, và hành vi của hệ thống cũng trở nên dễ đoán. Dù bất biến có thể làm dấy lên lo ngại về hiệu suất, nhưng đó là một sự đánh đổi rất đáng giá. Nếu hiệu suất có bao giờ trở thành vấn đề, bạn luôn có thể tối ưu từng chút một khi đã có một logic vững chắc và được kiểm thử kỹ càng.API Tối giản – Đừng 'phô trương' quá nhiều!Giao diện (API) mà tôi dùng để 'phơi bày' các chức năng của OT ban đầu được thiết kế tệ hại. Có quá nhiều cách để tương tác với OT, dẫn đến việc sử dụng không nhất quán, các lỗi 'ẩn mình' và rất khó bảo trì. Thậm chí, tôi còn vô tình 'lộ' ra một số chức năng thông qua các đối tượng nội bộ. Sau nhiều lần 'cải cách' và với cái nhìn sâu sắc hơn về hệ thống, tôi đã 'gọt giũa' API chỉ còn những cái thiết yếu nhất. Điều này giúp hệ thống dễ bảo trì hơn và mã nguồn cũng dễ hiểu hơn rất nhiều.Hoàn tác/Làm lại (Undo/Redo) khi cộng tác – Không đơn giản đâu nhé!Tính năng hoàn tác/làm lại (undo/redo) khi làm việc một mình thì đơn giản thôi: cứ đẩy các thao tác vào một 'ngăn xếp' (stack) rồi lấy ra khi cần – mọi thứ đều tĩnh và tuyến tính. Nhưng trong môi trường cộng tác, nó lại không hề dễ dàng chút nào. Bạn phải 'biến đổi' (transform) các thao tác đó so với các thay đổi bên ngoài xảy ra đồng thời. Thậm chí còn có trường hợp phức tạp hơn là khôi phục các thao tác cũ từ máy chủ. Điều này đòi hỏi tôi phải thử nghiệm với rất nhiều thiết kế ngăn xếp lịch sử khác nhau, một quá trình đầy rẫy thử nghiệm, sai lầm và những suy nghĩ... 'xoắn não'.Viết lại toàn bộ – Thà đau một lần!Thực tế là, với tất cả những vấn đề kể trên gộp lại, cuối cùng tôi đã quyết định viết lại toàn bộ phần triển khai OT. Lần này, tôi dùng trạng thái bất biến (immutable state) và một giao diện (interface) đơn giản hơn. Chính lần viết lại này đã biến một bản prototype 'mong manh dễ vỡ' thành một hệ thống vững chắc và dễ bảo trì.Tóm lại, việc tự xây dựng OT từ đầu là một hành trình học hỏi cực kỳ 'khắc nghiệt' nhưng đã mang lại cho tôi những hiểu biết vô giá về các hệ thống chỉnh sửa cộng tác. Nếu bạn đang cân nhắc tự triển khai thư viện OT của riêng mình, hãy chuẩn bị tinh thần cho một đường cong học tập 'dốc đứng' và ít nhất thì... làm ơn hãy dùng trạng thái bất biến nhé! Chỉ nhắc nhẹ thôi, các thư viện hiện có có thể giúp bạn tránh được rất nhiều 'nỗi đau' đó. Còn với tôi, tự làm từ đầu đã trở thành một trong những nền tảng của dự án này và là minh chứng rõ nhất cho kỹ năng kỹ thuật của tôi.Đào sâu kỹ thuật: Phép Hợp (Composition), Phép Biến Đổi (Transform), và Hoàn tác (Undo)Phần này chúng ta sẽ cùng 'mổ xẻ' các khối xây dựng cốt lõi của OT: Hợp nhất các thay đổi (composing changes), Biến đổi các thay đổi đồng thời (transforming concurrent changes), và Xử lý hoàn tác (handling undo). Đây không phải là bản sao của Etherpad Manual, mà là cái nhìn thực tế về cách tôi đã hiểu và áp dụng các khái niệm này vào thực tiễn.1. Phép Hợp (Composition) – Khi các thay đổi 'hòa quyện' vào nhauPhép Hợp là quá trình áp dụng một thay đổi lên một thay đổi khác, ký hiệu là A ⋅ B, có nghĩa là B được áp dụng/hợp nhất lên A.Ví dụ: Phép Hợp đơn giảnHãy xem một ví dụ minh họa về cách hai thay đổi được hợp nhất với nhau:Thay đổi A: chèn "Hello"Thay đổi B: giữ lại (retain) "Hello", chèn thêm " world!"Kết quả A ⋅ B: chèn "Hello world!"Bạn có thể hình dung điều này qua sơ đồ dưới đây, trong đó văn bản được chia thành các ký tự, mỗi ký tự nằm trong một ô. Các mũi tên chỉ mang tính trang trí, hướng đến nguồn gốc của ký tự đó.<img src='https://truyentranh.letranglan.top/api/v1/proxy?url=https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F755oupvghbgkhr89q0ih.webp' alt='Sơ đồ hợp nhất các thay đổi'>Thay đổi A chèn "Hello", và thay đổi B giữ lại tất cả từ A rồi chèn thêm " world!". Sự hợp nhất của chúng cho ra kết quả là "Hello world!". Dễ hiểu đúng không nào?2. Thuật toán Biến đổi (Transform) – Trái tim của OTHàm `transform` chính là 'linh hồn' của OT. Khi có hai thay đổi đồng thời trên cùng một tài liệu, hàm `transform` sẽ điều chỉnh chúng sao cho dù áp dụng theo thứ tự nào, chúng ta vẫn nhận được cùng một kết quả cuối cùng. Trong thực tế, điều này có nghĩa là dịch chuyển các chỉ số và thay thế các thao tác chèn bằng các ký tự được giữ lại.Trong Etherpad Manual và trong mã nguồn của tôi, hàm để điều chỉnh các thao tác được gọi là `follow(A,B)` hoặc `f(A,B)`. Nhưng trong bài viết này (và để dễ hiểu hơn), tôi sẽ dùng thuật ngữ `transform` và ký hiệu là `t(A,B)`.Thuật toán này dựa trên ba quy tắc 'vàng' sau:R1. Các thao tác chèn trong A sẽ trở thành các ký tự được giữ lại trong `t(A, B)`.R2. Các thao tác chèn trong B sẽ vẫn là các thao tác chèn trong `t(A, B)`.R3. Giữ lại (retain) bất kỳ ký tự nào được giữ lại ở cả A và B.(Được điều chỉnh đôi chút từ Etherpad và EasySync Technical Manual, AppJet, Inc., có sửa đổi bởi Etherpad Foundation)Ví dụ: Biến đổi trong hành độngHãy cùng xem các quy tắc này được sử dụng trong một tình huống hoàn chỉnh nhé:Tài liệu gốc T: "is long cute tail".Thay đổi X của Bob: Thay thế "is long " bằng "big " → T ⋅ X = "big cute tail".Thay đổi Y của Alice: Thay thế "is " bằng "has ", và thay thế "cute " bằng "round " → T ⋅ Y = "has long round tail".Các thay đổi này được thể hiện trong sơ đồ dưới đây. Tài liệu T là điểm xuất phát. Thay đổi X của Bob đi lên, và thay đổi Y của Alice đi xuống.<img src='https://truyentranh.letranglan.top/api/v1/proxy?url=https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj2rk93q9sonph9srp4q5.webp' alt='Ví dụ thuật toán biến đổi'>`t(X, Y)` là thay đổi của Bob đã được điều chỉnh bởi Alice: chèn "has ", giữ lại "big ", chèn "round ", xóa "cute ", và giữ lại "tail".`t(Y, X)` là thay đổi của Alice đã được điều chỉnh bởi Bob: giữ lại "has ", chèn "big ", và giữ lại "round tail".Cuối cùng, cả Alice và Bob đều sẽ thấy "has big round tail" trên màn hình của họ. Nó bao gồm tất cả những gì được chèn bởi cả hai người dùng và loại bỏ tất cả những gì một trong số họ đã xóa.Một chi tiết tinh tế khác: máy chủ coi các thao tác chèn mới nhận được là 'nguyên tử' (atomic), nghĩa là chúng không thể bị chia cắt bởi những người dùng khác. Ví dụ, khi Alice thay thế "is" bằng "has". Bob không thể chèn "rnes" vào giữa "has" để thành "harness". Thay vào đó, kết quả sẽ là "hasrnes". Suy cho cùng, làm sao Bob có thể chèn thứ gì đó vào giữa đoạn văn bản mà anh ta còn chưa thấy cơ chứ?Pseudocode: Hàm Transform – Bạn muốn 'biến hình' thế nào?Đoạn pseudocode dưới đây minh họa hàm `transform` hoạt động bằng cách lặp qua từng vị trí một. Tuy nhiên, bạn hoàn toàn có thể tối ưu nó để duyệt theo từng khoảng (range) nhằm giảm độ phức tạp về thời gian nhé!```// Hàm này dùng để biến đổi các thay đổi A và B, với tham số isAfirst là ưu tiên thứ tự của A// Kết quả là một thay đổi có thể hợp nhất được trên A, phản ánh ý định của B.Function transform(A, B, isAfirst) Khởi tạo một danh sách rỗng tên là result // Duyệt qua cả hai thay đổi, từng vị trí một For mỗi bộ ba (opA, posA, opB) trong walk(A, B) Do If opA.type là 'retain' và opB.type là 'retain' Then // R3. Giữ lại, vì cả hai thay đổi đều muốn giữ lại Thêm posA vào result Else If opA.type là 'insert' và opB.type là 'insert' Then If isAfirst Then // R1. Các thao tác chèn trong A trở thành các ký tự được giữ lại Thêm posA vào result // R2. Các thao tác chèn trong B vẫn là các thao tác chèn Thêm opB vào result Else // R2. Các thao tác chèn trong B vẫn là các thao tác chèn Thêm opB vào result // R1. Các thao tác chèn trong A trở thành các ký tự được giữ lại Thêm posA vào result End If Else If opA.type là 'insert' Then // R1. Các thao tác chèn trong A trở thành các ký tự được giữ lại Thêm posA vào result Else If opB.type là 'insert' Then // R2. Các thao tác chèn trong B vẫn là các thao tác chèn Thêm opB vào result End If End For Return resultEnd Function```3. Hoàn tác (Undo) giữa muôn vàn thay đổi bên ngoài – 'Khó nhằn' hơn bạn tưởng!Hoàn tác (undo) nghe có vẻ tầm thường cho đến khi bạn phải tính đến những thay đổi bên ngoài do người dùng khác tạo ra. Bạn phải 'biến đổi' một thao tác hoàn tác để 'chống lại' các thay đổi bên ngoài đó, nếu không thì tài liệu của bạn sẽ 'loạn xạ' mất.Ví dụ: Hoàn tác sau khi có thay đổi bên ngoàiHãy cùng xem một ví dụ mà Bob hoàn tác thay đổi mới nhất của mình sau khi Alice đã gửi một thay đổi bên ngoài nhé.Bắt đầu với một tài liệu trống rỗng.Bob gõ "abc" → Bob's undo stack: [chèn "a", chèn "b", chèn "c"].Alice chèn "hi" vào đầu tài liệu → Tài liệu hiện tại: "hiabc".Bob nhấn hoàn tác (undo). Thao tác ngược của chèn "c" là xóa tại vị trí 2, nhưng nếu áp dụng trực tiếp nó sẽ xóa sai ký tự (sẽ thành "hibc" thay vì "hiab").Thay vào đó, chúng ta 'biến đổi' thao tác xóa tại vị trí 2 bằng cách dịch chuyển nó sang phải 2 vị trí → xóa tại vị trí 4 → kết quả là xóa đúng ký tự "c", cho ra "hiab".Đây là sơ đồ minh họa cách trạng thái của Bob được điều chỉnh khi anh ấy nhấn hoàn tác:<img src='https://truyentranh.letranglan.top/api/v1/proxy?url=https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhfpswxcynj6zu2a2eom2.webp' alt='Thao tác hoàn tác'>Mỗi thao tác hoàn tác có thể có danh sách các thay đổi bên ngoài riêng của nó. Nhưng trong thực tế, những thay đổi đó sẽ 'chuyển xuống' trong ngăn xếp khi các thao tác hoàn tác được 'bật' ra. Bằng cách đó, hoàn tác chỉ được điều chỉnh khi cần thiết.Hoàn tác/Làm lại (Undo/Redo) – Cặp đôi 'đối xứng' nhưng không hoàn hảoHoàn tác và làm lại gần như đối xứng hoàn toàn, nhưng có một điểm khác biệt quan trọng: các 'thao tác rỗng' (no-ops). Một thao tác trở thành 'no-op' nếu nó không tạo ra bất kỳ hiệu ứng nào nhìn thấy được (ví dụ: xóa một đoạn văn bản đã bị xóa rồi).Một thao tác hoàn tác 'no-op' vẫn có thể điền vào ngăn xếp làm lại (khi thao tác làm lại vẫn có ý nghĩa).Thao tác làm lại 'no-op' thì luôn bị loại bỏ.Điều này có thể rất hữu ích để khôi phục văn bản bị người dùng khác xóa. Ví dụ:Alice chèn "abc"Bob xóa "c" (thay đổi bên ngoài)Alice bây giờ thấy "ab"Alice nhấn hoàn tác (undo) và thấy "a"Alice nhấn làm lại (redo) và thấy "ab"Alice nhấn làm lại lần nữa và thấy "abc" (thao tác xóa "c" của Bob đã được khôi phục)Thật thông minh phải không?Pseudocode: Hàm Undo – Cách để 'trở về quá khứ' một cách an toàn!Đây là pseudocode cho hàm `undo`. Nó sẽ hoàn tác thao tác cuối cùng bằng cách áp dụng 'thao tác ngược' đã được biến đổi của nó, đồng thời cập nhật ngữ cảnh hoàn tác tiếp theo với các biến đổi bên ngoài.```Procedure undo() Gỡ bỏ thao tác cuối cùng khỏi ngăn xếp hoàn tác và gán nó cho op Xem qua (nhưng không gỡ bỏ) thao tác tiếp theo trên ngăn xếp hoàn tác và gán nó cho nextOp Gán applyOp bằng inverse(op) // Tạo thao tác ngược lại của op For mỗi external (thay đổi bên ngoài) trong op.externalChanges Do // Biến đổi external bằng cách dùng applyOp và lưu trữ nó cho lần hoàn tác tiếp theo Thêm transform(external, applyOp) vào nextOp.externalChanges // Cập nhật applyOp để phản ánh thay đổi bên ngoài Gán applyOp bằng transform(applyOp, external) End For Đẩy applyOp vào ngăn xếp làm lại (redo stack) Áp dụng applyOp vào tài liệuEnd Procedure```Lời kết – Chấm dứt hành trình 'khó nhằn'!Việc triển khai OT từ đầu hóa ra 'khó nhằn' hơn tôi tưởng rất nhiều. Có những lúc tôi đã tự hỏi liệu mình có 'chọn nhầm đường' không, nhưng cuối cùng, đó là một trải nghiệm vô cùng đáng giá và giúp tôi trưởng thành vượt bậc. Chắc chắn rồi, các thư viện có sẵn đã có thể 'cứu' tôi khỏi hàng tá 'nỗi đau', nhưng tự tay xây dựng nó đã mang lại cho tôi sự hiểu biết sâu sắc hơn rất nhiều về cách giải quyết xung đột và cộng tác thời gian thực.Dự án này vẫn chưa hoàn hảo đâu. Vẫn còn vài điểm chưa 'nhẵn nhụi' và vài tính năng tôi có thể thêm vào sau này. Nhưng tôi tự hào về những gì mình đã làm được và giờ thì vui vẻ 'nghỉ xả hơi' thôi. Nếu bạn tò mò, hãy thử 'nghía' qua <a href='https://notes.knemerzitski.com/'>bản demo trực tiếp</a> hoặc 'đào bới' <a href='https://github.com/knemerzitski/notes'>repo GitHub</a> nhé.Rất mong được nghe suy nghĩ hoặc câu hỏi của bạn, đừng ngại ngần liên hệ với tôi nha!
Khám phá Định luật Conway và cách nó thay đổi cách nhìn về kiến trúc phần mềm, đặc biệt với những dự án cá nhân. Liệu "monolith" có phải là lựa chọn tối ưu cho solo dev? Học cách định nghĩa lại nợ kỹ thuật và thoát khỏi bẫy cầu toàn.
Tìm hiểu cách một người không biết lập trình đã xây dựng thành công phần mềm nén Pagonic với sự trợ giúp 100% từ ChatGPT và GitHub Copilot, từ kế hoạch, kiểm thử đến tối ưu hiệu suất, cùng những bài học 'xương máu' trên hành trình này.