Trang Chủ Lập trình Reactive programming là gì? Tại sao tôi nên dùng nó?

Reactive programming là gì? Tại sao tôi nên dùng nó?

7726

Trong bài viết này, tôi sẽ giải thích lý do tại sao reactive programming lại là 1 trong những design patterns quan trọng nhất khi lập trình ứng dụng thông qua 3 tình huống quen thuộc trong lập trình – những tình huống ảnh hưởng đến thời gian lập trình, thường tạo ra bug và khiến quá trình design và refactoring gặp khó khăn. Chính lúc này, reactive programming sẽ chỉ ra những chỗ rườm rà, loại bỏ những yếu tố không an toàn và restructure code để tăng khả năng maintain.

Những tình huống chuyên biệt mà tôi sẽ giải thích gồm:

  • Một button có status isEnabled phụ thuộc vào 2 state value khác nhau
  • Một type storage thread-safe
  • Một task bất đồng bộ với 1 timeout

Về reactive programming

Reactive programming được định nghĩa theo nguyên tắc sau:

Bất kì “Getter” nào dành cho mutable state cũng gây ra nhiều vấn đề. Thay vì sử dụng getters, các state values đã calculate, đã generate, đã tải hoặc đã nhận về nên được gửi ngay lập tức vào 1 channel và bất cứ phần nào của program muốn tiếp cận những values đó phải đăng kí channel

Ý tưởng ở đây là chúng ta phải remove state ra khỏi những phần đã exposed của chương trình, thay vào đó sẽ đóng gói (encapsulate) vào trong các channels. Các channels sẽ hiển thị rõ các data dependencies và effects, giúp bạn hiểu rõ hơn các thay đổi, maintain dễ hơn, cũng như đơn giản hơn cách chúng ta modify state của ứng dụng.

React programming hỗ trợ nguyên tắc cơ bản này với cách tiếp cận tập trung vào các compositions nối tiếp và song song của channels để chuyển đổi các dòng dữ liệu khi chúng được tải đi và hợp nhất các thay đổi có thể xảy ra đồng thời hoặc trong các patterns giao nhau.

Nói 1 cách đơn giản, reactive programming quản lý các dòng dữ liệu bất đồng bộ giữa các nguồn dữ liệu và components cần phải react với dữ liệu.

Một button phụ thuộc vào 2 state values

Hãy bắt đầu 1 task cơ bản trong lập trình ứng dụng: set trạng thái isEnabled của 1 button.

Giả dụ bạn chỉ có thể kích hoạt 1 button “Add to favorites” trong giao diện của mình nếu (và chỉ nếu) 2 điều kiện sao được thỏa mãn:

1/ User đã đăng nhập

2/ Buộc chọn ít nhất một file

Nếu thêm 1 yếu tố phức tạp: tình trạng đăng nhập được cập nhật thông thường trong 1 thread background:

Giả như cập nhật của các values có liên quan được gửi đi sử dụng Key-Value-Observing thì view controller của chúng ta có thể chứa đoạn code sau:

Như bạn thấy, cập nhật trạng thái isEnabled cho 1 button đơn tốn đến 20 dòng code – thậm chí chừng này còn chưa đủ. Một vài lỗi nhỏ có thể sẽ xảy ra, không phải vì chúng ta là những lập trình viên tồi mà vì chúng ta code để ít resistance nhất. Chúng ta sẽ fix lỗi nếu các lỗi đó quá rõ ràng, nhưng nếu code vẫn hoạt động được – như đoạn code ở trên – thì nhiều khả năng nó sẽ vượt qua được vòng testing.

Vậy vấn đề nào trong đoạn code này sẽ khiến bạn phải đau đầu sau đó?

  1. Thread unsafe: với tình trạng đăng nhập được cập nhật trong thread background, observeValue dành cho #keyPath(LoginStatus.isLoggedIn) là memory không an toàn khi nó tiếp cận giá trị folderView.selection.isEmpty ở sai thread. Tình huống này cũng cập nhật addToFavoritesButton.isEnabled sai trong thread background.
  2. Không thể hòa hợp getters và observeValues: Một thay đổi đăng nhập có thể xảy ra giữa call đến loginStatus.isLoggedIn và các call addObserver tương ứng trong hàm viewDidLoad. Có thể thay đổi thứ tự của chúng nhưng sau đó, chúng ta có thể lấy được những cập nhật trước khi khởi tạo. Tương tự, vì loginStatus.isLoggedInđược cập nhật trên nhiều thread khác nhau, nên có thể getter sẽ trả về 1 giá trị từ điểm khác kịp thời, tương ứng với observeValue vừa được cập nhật cho giá trị đó – điều này dẫn đến tình trạng các updates bị thừa và có khả năng gây ra những thay đổi state không cần thiết (gồm cả state có thể bỏ qua các transitions hoặc tiến ngược). Giải pháp duy nhất chính là sử dụng NSKeyValueObservingOptions.initial cẩn thận và tự caching các giá trị đã biết cuối cùng – tránh hoàn toàn các getters.
  3. Sử dụng KVO sai: Sử dụng method observeValue (bằng cách chuyển về  keyPath và/hoặc object) là 1 giải pháp không đáng tin cậy. Bất kể những collisions trên keypath hoặc object, hầu hết các observations đều phải unique, vì thế 1 đối tượng context sẽ là giải pháp tốt hơn – nhưng nó đòi hỏi bạn phải chỉ định và lưu trữ 1 context trên mỗi observation và sau đó release khi hoàn thành. Vì vậy, chúng tôi phải tránh dùng nó.
  4. Refactor khó: Chúng tôi cập nhật giá trị addToFavoritesButton.isEnabled ở 3 nơi khác nhau. Nếu dependency khác được thêm vào giá trị này, chúng tôi phải nhớ cập nhật cả 3 nơi.
  5. Không có lifecycle notifications: nếu các đối tượng loginStatus hoặc folderView bất ngờ release, chúng tôi sẽ không nhận được notification và button vẫn bị kích hoạt không đúng.

Những vấn đề này có thể sửa được nhưng chúng tôi phải viết code nhiều hơn.

Trong reactive programming, giả sử tất cả các state values gửi những thay đổi của chúng thông qua các reactive programming channels, thay vì Key-Value-Observing, bạn sẽ chỉ cần đoạn code sau:

“App scenario – dynamic view properties” xuất hiện trong CwlSignal.playground. Những vấn đề đề cập ở trên đã được giải quyết: đoạn code này hoàn toàn threadsafe, không có các notifications thừa, không phụ thuộc vào @objc để observe key value và dễ dàng refactor lẫn maintain.

Maintain 1 dictionary values thread-safe

Bạn có thể sử dụng 1 dictionary như “model” trong 1 ứng dụng nhỏ. Dù yêu cầu 1 bộ nhớ hiện đại hơn dictionary, pattern cập nhật và notify được hiển thị ở đây sẽ tương tự như bất kì app nào được viết tốt.

Giải quyết vấn đề với các classes Cocoa chuẩn DispatchQueue và NotificationCenter như sau:

Tôi đã cẩn thận sao chép các values trong và ngoài mutext và nó giúp bộ nhớ an toàn trong mọi trường hợp. Class này sử dụng notifications để các interface khác có thể nhận được các updates.

Vậy vấn đề ở đây là gì?

  1. Khuyến khích thói quen xấu: Interface khác sẽ dễ dàng tiếp cận property values hiện tại nhưng để observe notification DocumentValues.changed chính xác đòi hỏi nhiều thứ hơn nữa, vì vậy bạn phải khuyến khích các dependent interfaces quên đi để observe những thay đổi 1 cách chính xác và không đồng bộ nữa.
  2. Không có cách nào để khởi tạo và subscribe an toàn: nếu bạn lấy values sau đó bắt đầu observe các notifications, nhiều khả năng 1 thay đổi có thể xảy ra giữa 2 actions đó (khiến bạn bỏ lỡ 1 notification). Nếu bạn observe các notifications trước, sau đó lấy values, thì bạn có thể nhận 1 notification đầu tiên trước khi khởi tạo. NSKeyValueObservingOptions.initial có thể fix vấn đề đó cho KVO nhưng với  Notifications, bạn sẽ cần vài dòng code rõ ràng để giải quyết vấn đề này.
  3. Dễ gặp deadlocks: HàmremoveValue trong storage xóa 1 arbitrary value trong 1 mutex. Nếu có 1 deinit trong giá trị đã xóa này và deinit cố gắng thay đổi DocumentValues (re-enter mutex), bạn đã tạo 1 deadlock rồi.
  4. Khó refactor: Tất cả những thay đổi liên quan đến storage rất vô nghĩa. Nếu bạn cần thêm functionality trong tương lai – như write DocumentValues vào 1 file trong mỗi thay đổi – bạn sẽ phải cẩn thận tích hợp thay đổi này vào nhiều nơi.
  5. Không có lifecycle notifications: Nếu đối tượng DocumentValues bị xóa, theo mặc định bạn sẽ không nhận được notifications

Những vấn đề này khá tương tự những vấn đề trong ví dụ trước. Cũng tương tự, mỗi giải pháp sẽ cần bạn bổ sung code, kéo theo nhiều thứ khác phức tạp. Hệ quả là ban đầu bạn có nhận ra các vấn đề đó, nhưng do mức độ tinh xảo của chúng mà bạn có thể không nhận ra trong quá trình testing.

Một implementation sử dụng reactive programming sẽ thay thế propertyvalues với 1 channel truyền đi value hiện tại, theo sau là các cập nhật tương lai, như 1 stream.

“App scenario – threadsafe key-value storage” xuất hiện trong CwlSignal.playground. Kích cỡ code không có nhiều khác biệt lớn (27 dòng không-trống, không-comment so với 23 dòng sau đó) nhưng trong trường hợp này, mỗi vấn đề đề cập ở trên đã được giải quyết gọn gàng.

  1. Khối lượng công việc để tiếp cận 1 giá trị 1 lần hoặc subscribe chính xác vẫn giữ nguyên
  2. Nếu cần phải tách việc xử lý giá trị ban đầu và các giá trị kế tiếp (như sử dụng 1 trình tự capture và subscribe), stream sẽ được dừng chính xác để bạn không bỏ lỡ notification.
  3. Mọi thứ đều threadsafe (closure map sẽ không bao giờ bị gọi đồng thời và re-entrancy sẽ không xảy ra)
  4. Tất cả những thay đổi xảy ra trong hàm map và có thể được coordinated ở đó
  5. Một message SignalError.cancelled được tự động gửi đến các subscribers nếu input được release

Bên cạnh threadsafe, lưu ý rằng sẽ không còn mutable variables trong class; state được đóng gói (encapsulated) trong class; state được đóng gói trong Signal.

Ngoài ra, bạn sẽ phạm ít lỗi implementation hơn; code sẽ declarative và được contained nhiều hơn và bạn không cần phải quản trị và giải quyết mutex nào nữa.

Một task bất đồng bộ với 1 timeout

Trước đây, tôi đã từng đề cập đoạn code bên dưới trong bài Testing Actions over Time. Đoạn code gồm 1 class với 1 hàm connect thực hiện 2 việc sau:

  1. Gọi 1 hàm underlyingConnect cần callback và invoke nó trong completion
  2. Nếu code hỏng trước khi hàm underlyingConnect gọi completion handler, hãy khởi động 1 timer để hủy hàm underlyingConnect đó.

Tôi đã tạo class khá phức tạp bằng cách cho phép người dùng gọi connect nhiều lần trong class Service – tuy lần call connect trước đó vẫn có những tasks bất đồng bộ cần phải giải quyết

Bây giờ, đoạn code trên đã hoạt động được và như tôi thấy, không hề có bugs. Nhưng đây là 1 đoạn code quá lớn khi mà nó chỉ đưa 1 timeout vào 1 hàm cơ bản.

Hầu hết size code phụ thuộc vào việc phải coding cẩn thận để tránh gây ra lỗi. Sau khi hàmconnect bắt đầu underlyingAction và timer, cần phải lưu trữ underlyingAction và timertrong timerAndAction tùy chỉnh (để ràng buộc các lifetimes với nhau). Có vài cách để xử lý cẩn thận previousAction khi nó được release bên ngoài queue.sync (để ngăn các vấn đề về re-entrancy). Cả handler closure của underlyingAction và handler closure của timer cần phải tiếp cận hand closure khác (để hủy mọi thứ 1 cách chính xác), vì thế sẽ có vài reference yếu xảy ra.

Chúng ta không cần phải code cẩn thận quanh quá nhiều vấn đề. Chúng ta cần đoạn code nhỏ hơn. Chúng ta cần nó đơn giản hơn.

Reactive programming đã xuất hiện.

“Parallel composition – operators” xuất hiện trong CwlSignal.playground. Sự khác biệt quả thực rất đáng kinh ngạc, nó thậm chí còn không giống class gốc. Tuy nhiên, class này lại hoạt động như nhau, chỉ là thông qua các reactive programming channels, hay vì qua các callbacks với state và mutexes. Với reactive programming, tất cả các threading và lifetime management gây rất nhiều vấn đề cho implementation trước đều đã được giải quyết – chúng vẫn ở đó nhưng chúng đã được quản lý tự động.

Để có thể làm quen với các reactive programming operators như switchLatest và materialize có thể sẽ tốn của bạn kha khá thời gian. Tất cả các reactive programming operators sẽ được chỉ dẫn trong CwlSignal via Xcode quick help nhưng vì cũng tương tự như ReactiveX implementations, nên bạn cũng có thể tham khảo tài liệu đó để hiểu thêm về insight.

Trong trường hợp bạn nghĩ sử dụng hàm built-in cho timeout là 1 hành vi “gian lận” thì bạn có thể đơn giản thay đổi 1 dòng đó với:

Nhiều tiểu tiết hơn 1 chút nhưng vẫn không quá phức tạp.

Kết luận

Reactive programming đã thay đổi cách lưu trữ dữ liệu, cách dữ liệu “chảy” trong program của bạn và cách mà các yếu tố trong program của bạn được kết nối. Kết quả nhận được là rất nhiều cải tiến sau:

  • Thread safety
  • Coordinating các tasks bất đồng bộ đồng thời
  • Loose coupling của các components
  • Data dependencies

Lợi ích lớn nhất đến từ việc bạn nhận ra rằng khi áp dụng 1 giải pháp vào chỉ 1 trong các vấn đề đó, bạn sẽ thu hoạch được 1 giải pháp miễn phí cho 3 vấn đề còn lại.

Đối với các tình huống mà code đã giải quyết hợp lý các vấn đề, thì reactive programming có thể giúp bạn tiết kiệm được kha khá các dòng code.

Kết quả cuối chính là viết code dễ hơn và dễ maintain hơn.

Nguồn: Techtalk via cocoawithlove

CHIA SẺ