Author: LamNS

  • Result type trong xử lý Networking

    Result type trong xử lý Networking

    1. Giới Thiệu

    Chào mọi người, mình là Lâm.

    Hôm này mình xin viết 1 bài về Result type trong Swift và cách nó kết hợp với xử lý Networking để viết ra những đoạn code ngắn gọn, súc tích, dễ đọc, dễ maintain. Trước Swift 5 thì nó là 1 custom type phải tự viết, tuy nhiên sau đấy chắc mn thấy nó hay nên từ Swift 5 thì nó đã trở thành 1 type build-in của Swift luôn (https://developer.apple.com/documentation/swift/result) .

    Có thể thấy ngôn ngữ Swift phát triển liên tục và nhiều trong số đấy là từ những ý tưởng đóng góp của cộng đồng developer

    2. Ý tưởng

    Khi xử lý những tác vụ trong lập trình, chúng ta luôn mong muốn là khi input 1 cái gì đấy vào, thì output của nó ra sẽ chỉ là 1 trong 2 case: success hoặc fail. Như vậy thì luồng xử lý của chúng ta sau đấy sẽ rất clear và gọn gàng.
    Tuy nhiên success thì có nhiều kiểu giá trị trả về, fail thì cũng có nhiều kiểu lỗi bắn ra, vậy thì mình cần phải có 1 type để uniform được các trường hợp, quy về 1. Thì đấy chính là mục đích của Result type.

    Ý tưởng của Result type rất đơn giản, nó là 1 generic Enum, gồm 2 generic là Success và Failure, và có 2 case: 1 cho success và 1 cho failure.
    – Case success sẽ chứa value mà chúng ta expect.
    – Case failure sẽ chứa error bắn ra.

    enum Result < Success, Failure: Error > {
        case success(Success)
        case failure(Failure)
    }

    3. Áp dụng

    Nói về xử lý Networking, ví dụ trong mô hình MVC, thường chúng ta sẽ tạo ra 1 func thế này ở Model:

    func loadData(url: URL, completion: @escaping (Data?, Error?) -> Void) {
        let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
            if let error = error {
                completion(nil, error)
            }
             if let data = data {
                 completion(data, nil)
            }
        }
        task.resume()
    
    }


    Rồi ở bên ViewController chúng ta sẽ hứng và xử lý tiếp như thế này:

    model.loadData(url: url) { [weak self] (data, error) in
        guard let self = self else { return }
        if let error = error {
            // handle error...
        }
         if let data = data {
            // handle response...
        }
    }

    Có 2 cái ko hay ở đây:
    – Cái ko hay thứ 1 là với completion: (Data?, Error?) chúng ta sẽ có tổng cộng 4 case xảy ra:
    + (data, error)
    + (data, nil)
    + (nil, error)
    + (nil, nil)
    Case đầu tiên (data, error) và case cuối cùng (nil, nil) là 2 case mà sẽ không bao giờ mong muốn. Vì output ko thể nào vừa có data lại vừa có error, hay output ko có gì cả, như vậy thì sẽ rất confuse.

    – Cái ko hay thứ 2 là ở đầu bên kia (ViewController) khi nhận response từ Model sẽ phải xử lý 2 parameter với type là optional (data và error), rồi lại if/else 1 hồi để handle đủ case.

    Bây giờ nếu chúng ta sử dụng Result type vào thì nó sẽ như thế này:

    func loadData(url: URL, handler: @escaping (Result<Data, Error>) -> Void) {
    
            let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
                if let error = error {
                    completion(.failure(error))
                }
                if let data = data {
                    completion(.success(data))
                }
            }
        }

    và đầu bên kia xử lý sẽ như thế này:

            model.loadData(url: url) { [weak self] result in
                guard let self = self else { return }
                switch result {
                case .failure(let error):
                    // handle error...
                case .success(let data):
                    // handle success...
                }
            }

    sẽ chỉ phải xử lý 1 parameter là result: Result, và Result chỉ có 2 case là success/fail, đúng với những gì cta mong muốn, code nhìn clear hơn nhiều, thay đống if/else bằng switch/case cũng làm code dễ đọc hơn.

    4. Mở rộng

    Về cơ bản là như vậy, mở rộng hơn thì chúng ta có thể define chi tiết hơn về lỗi.
    Nhìn lại declaration:

    enum Result < Success, Failure: Error > {
        case success(Success)
        case failure(Failure)
    }

    chúng ta thấy Failure đang đơn giản adopt vào protocol Error. Error thì rất common, nó khá là chung chung, gọi là lỗi gì vứt vào đây cũng được. Bây giờ muốn specific hơn về lỗi, cta có thể tạo 1 custom type, vì Result của cta nhận vào generic là 1 Error nên custom type của cta chỉ cần adopt vào protocol Error là được:

    enum VideoError: Error {
        case networkFailure(Error)
        case exceedSize
        case invalidFormat
    }

    khi gọi API:

        func loadData(url: URL, completion: @escaping (Result<Data, VideoError>) -> Void) {
    
            let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
    
                if let error = error {
                    completion(.failure(.networkFailure(error)))
                    return
                }
    
                if exeedSize {
                    completion(.failure(.exceedSize))
                    return
                }
    
                if invalidFormat {
                    completion(.failure(.invalidFormat))
                    return
                }
    
                if let data = data {
                    completion(.success(data))
                }
            }
        }

    và đầu bên kia xử lý:

           model.loadData(url: url) { [weak self] result in
                guard let self = self else { return }
                switch result {
                case .success(let data):
                    // handle success...
                case .failure(.networkFailure(let error)):
                    // handle networking error...
                case .failure(.exceedSize):
                    // handle exceedSize...
                case .failure(.invalidFormat):
                    // handle invalid format...
                }
            }

    có thể thấy luồng đi rất clear.

    Bên trên là Result mình tự viết, đối với built-in của Swift 5 thì type còn có 1 số những methods:
    get: để lấy ra success value
    map/flatMap: để transform success value thành 1 dạng khác
    mapError/flatMapError: để transform failure error thành 1 dạng khác
    ==, != : để so sánh xem 2 result có giống nhau hay ko
    Mn có thể đọc chi tiết trong docs:
    https://developer.apple.com/documentation/swift/result

    Nói chung nó sẽ hỗ trợ để mình xử lý thêm cho data/error trả về (hình dung mình có thể viết 1 cái extension để decode success value thành object luôn chẳng hạn).

    OK, hy vọng mn cảm thấy hữu ích.
    Thanks for reading.

  • Generic Cache từ NSCache

    Generic Cache từ NSCache

    Chào mọi người, mình là Lâm.

    Trước đây mình có đọc 1 bài viết hay về việc tự custom 1 class để phục vụ cho việc cache dữ liệu (nguồn bài viết: https://www.swiftbysundell.com/articles/caching-in-swift/ )
    Mình đã áp dụng nó vào 1 số project mini của mình, thấy hữu ích nên ở bài viết này mình xin được trình bày lại, cũng như có 1 số phân tích của bản thân để đâu đấy có thể giúp clear hơn, giúp mn tiết kiệm thời gian đọc hơn.

    Note: source code mình có để ở link git bên dưới bài viết.

    Tóm tắt:

    • Cache là 1 chủ đề quen thuộc của mobile app, cache dữ liệu để ko phải load lại nhiều lần, tối ưu cho app, or khi không có mạng thì mình vẫn xem được những nội dung đã cache.

    • Ở bài viết này mình sẽ trình bày cách viết 1 class Cache based trên 1 class build-in của iOS là NSCache (https://developer.apple.com/documentation/foundation/nscache).
      NSCache nó có 1 cái rất hay là nó sẽ tự động remove record khỏi cache khi bộ nhớ của app bị warning, hay nói đơn giản app đang ăn quá nhiều RAM, vì nó là build-in class nên việc này chắc chắn là đã được optimize rồi, nó còn 1 số optimize khác nữa mn đọc trong document refer ở bên trên.

    • Câu hỏi là sao ko dùng luôn NSCache mà phải viết 1 class trên nó làm gì? Lý do là bởi vì NSCache nó là class thuộc về Objective-C,  khi cache nó yêu cầu key phải là 1 NSObject, và value phải là 1 class instance (ko cần phải là NSObject nhưng phải là instance của 1 class).
    • ( NSString là 1 NSObject, và MyClass là 1 custom class)
    • Swift thì lại hay dùng struct, bây giờ muốn cache được cả class, struct… thì phải đi đường vòng 1 chút. Ý tưởng là mình sẽ wrap key/value thực sự của mình vào trong NSObject/Class để có thể adapt được với NSCache.

    1. Tạo Custom class:

    Tạo 1 class tên Cache, với 2 generics là Key và Value. Key phải là Hashable thì nó với làm key được, vì nó phải là unique.

    Tiếp theo tạo 2 inner class để wrap key/value:


    Class WrappedKey là 1 subclass của NSObject để nó có thể trở thành key của NSCache, có property key bên trong là key thực sự mà mình sẽ dùng, type của key là Key được resolve từ outer class là Cache.

    Đến đây mn cũng có thể hình dung vì sao WrappedKey phải là inner class của Cache, vì nó phải resolved cái generic Key của Cache, nếu nó là 1 class nằm bên ngoài Cache thì nó sẽ ko nhìn thấy generic của Cache.  Tiếp theo, mình sẽ override lại property hash:Int và func isEqual(_ object: Any) của NSObject và implement lại dưới cái key của mình (vì đây với là cái key thực sự).

    Tương tự với class Entry (hay có thể gọi là WrappedValue, gọi Entry nghe ngắn hơn). Entry thì ko cần phải unique nên ko cần override lại hash, và cũng ko cần là subclass của NSObject, cứ wrap value của nó vào là được.

    Note:

    2. Cache’s methods:

    OK, với 3 class được dựng ở trên thì mình có thể viết 1 số API được rồi:

    nên viết 1 subscript để lúc sử dụng code nó ngắn gọn, đẹp hơn.

    3. Thêm constraint cho Cache

    Bây giờ mình muốn có thêm nhiều control hơn nữa với cache của mình:

    • Cache 1 số lượng record nhất định
    • Cache trong 1 khoảng thời gian nhất định

    NSCache có 1 property gọi là countLimit: Int, muốn cache bao nhiêu record thì mình gán giá trị cho nó, khi cache vượt quá số record này thì nó sẽ tự động remove record cũ đi. NSCache còn có 1 property gọi là totalCostLimit để đặt ra threshold về số byte sẽ được cache, tuy nhiên vì nó đã auto handle memory rồi nên cái này chắc cũng hiếm dùng.
    Về thời gian cache, mình sẽ tạo biến entryLifeTime để giữ lượng thời gian, tính bằng giây (TimeInterval).

    cần phải update lại Entry, lúc này Entry của ngoài giữ value còn giữ cả expirationDate nữa, mỗi entry sẽ có expirationDate của nó:

    Sau đấy update lại method của Cache:

    • Với method insert, trước khi insert mình sẽ tính thêm expirationDate cho entry
    • Với method value(forKey: ), mình sẽ check xem entry đấy đã bị expired hay chưa, nếu rồi thì remove nó.

    4. Persistent Cache

    OK, khá ngon rồi, nhưng mà cache hiện vẫn đang chỉ là in-memory, tức là trên RAM thôi. Bây giờ mong muốn là phải save được nó xuống disk, để sau này lấy ra.
    Vấn đề của NSCache là nó ko public ra cho mình thông tin keys  –> dẫn đến việc giả sử mình có save được cache rồi, lúc lấy lại ra cũng ko thể lấy được value, vì mình ko có keys –> mình phải tự control list key của mình bằng cách tạo thêm 1 inner class KeyTracker.


    class này sẽ giữ 1 set keys, và nó sẽ là delegate cho NSCache. Sau này khi NSCache tự động remove record, nó sẽ bắn về delegate method là cache(_ cache: , willEvictObject: Any), response của mình với method này là sẽ remove key tương ứng với record đấy ra khỏi set keys.

    Tuy nhiên delegate này trả về cho mình object sẽ bị remove (ở đây là Entry), làm sao mình biết được là key nào đang associated với object đấy? Mình đang bị lack data,  ở Entry mình cần phải giữ thêm key associated với nó.

    và update method khi insert data (add thêm key vào khi init entry):

    OK, vậy là đã xử lý xong vấn đề về keys, bước cuối để có thể save được cache là cache của mình phải serialize được thành data để lưu, và lúc mình lấy cục data đấy ra thì mình phải deserialize ngược lại được thành object,  hay nói đơn giản là encode/decode. Vì mình làm việc với json từ API, nên dùng Codable ở đây là hợp lý nhất.

    Đầu tiên phải serialize được Entry, vì vậy nên sẽ Entry sẽ phải là Codable.  Tuy nhiên mình nếu fix như vậy thì generic của mình sẽ bị cứng, khi tạo cache sẽ require Entry bắt buộc phải là Codable, ko hay. Thế nên ở đây sẽ dùng kỹ thuật gọi là protocol conditional conform, hay nói đơn giản là 1 type sẽ chỉ conform vào protocol dưới 1 số điều kiện nhất định.

    Ở đây mình chỉ muốn Entry conform vào Codable dưới điều kiện là Key và Value của nó Codable:

    Trong quá trình encode/decode, sẽ cần cả insert và retrieve những entries hiện tại, nên sẽ tạo 2 methods để handle việc này, nên đặt tên khác 1 chút để  phân biệt tránh bị nhầm lẫn với API của Cache, và nên mark nó là private luôn, vì nó ko phải API của cache.


    Với 2 methods này bây giờ mình có thể encode cục Cache rồi:

    • Khi encode thì sẽ sử dụng list keys ở keyTracker + map với method entry(forKey key: Key) để ra 1 list Entry, và encode list này bằng singleValueContainer
    • Khi decode thì lấy ngược lại list Entry đã encode và add lại vào cache bằng method insert(_ entry: Entry).

    Có thể đọc thêm về encode/decode bằng singleValueContainer ở đây: https://medium.com/swiftly-swift/swift-4-decodable-beyond-the-basics-990cc48b7375

    Công việc còn lại chỉ là write cái cục vừa encode đấy xuống file, để ý là write vào folder cache (.cachesDirectory) luôn cho nó chuẩn:

    5. Demo with App

    Để demo, hãy viết 1 app đơn giản lấy dữ liệu từ 1 open API: https://developers.themoviedb.org/3

    đây là API về phim, mình mê phim cho nên là quyết định dùng API này:D

    mỗi phim thì sẽ chưa thông tin gồm: Id/Name/Overview/posterImage

    Như vậy mình sẽ cần 2 cache: 1 cho thông tin về phim, 2 cho posterImage load được

    đang để access global luôn

    Khi lấy được dữ liệu từ API thì mình sẽ ghi nó vào cache:

    đây là khi API đã trả về response

    Và khi app xuống background thì mình sẽ save nó xuống disk, tuỳ vào bài toán, mình có thể đặt 1 cái timer cho nó 5 phút save 1 lần cũng được, ở đây mình làm thế này cho dễ test.

    M.n có để ý là với movieCache mình có thể gọi method saveToDisk được, nhưng với imageCache thì lại ko có method đấy? Lý do là vì protocol conditional conformance, nếu mn nhìn lại sẽ thấy method saveToDisk() chỉ được conform với điều kiện Key và Value đều Codable. imageCache value của nó là UIImage, UIImage ko Codable –> imageCache sẽ ko conform vào method này, và sẽ ko visible luôn, có thể đổi UIImage thành Data thì sẽ lại conform.

    OK, sau khi mà save cache xuống disk rồi thì nó sẽ nằm ở folder cache: 

    bây giờ sẽ lấy ngược lại cache ra khi mở app, ở đây mình lấy ở application didFinishLaunching và ở main thread luôn:

    và mình viết thêm 1 computed property nữa để lấy ra list Movie đã cache, 1 cái conditional conformances nữa, property  listCacheMovie này sẽ chỉ visible với movieCache, hợp lý đúng ko?

    bây giờ giả sử trong case API fail thì mình sẽ dùng cache, ví dụ vậy đi:

    Lúc đầu mình có nói là nếu mình đặt countLimit cho cache thì vượt quá số đấy nó sẽ tự delete record đi, để test mn có thể pull source về rồi đặt print ra console để xem new recrod được insert vào và old record bị remove đi liên tục nhé.

    Link source: https://github.com/nguyenlam96/CacheIt

    Bài viết cũng đã khá dài rồi, nhưng mà mình tin nó cũng gói gọn được 1 vấn đề, hy vọng mn cảm thấy hữu ích. 
    Thanks for reading.