Splitting up Swift types

Ý tưởng các ứng dụng khác nhau, các tính năng và hệ thống khác nhau nên được tách biệt rõ ràng về mặt trách nhiệm và mối quan tâm của chúng là điều mà được chấp nhận rộng rãi trên toàn bộ ngành công nghiệp phần mềm. Vì vậy, nhiều mẫu kiến trúc, kỹ thuật và nguyên tắc đã được phát minh trong nhiều năm qua nhằm hướng dẫn chúng ta viết code tách rời rõ ràng hơn - cả trong Swift và trong nhiều ngôn ngữ khác.

Tuy nhiên, bất kể loại kiến trúc nào chúng ta đã chọn để áp dụng trong bất kỳ dự án cụ thể nào, phải đảm bảo rằng mỗi loại của chúng tôi có một bộ trách nhiệm và được xác định rõ ràng đôi khi có thể khá khó khăn - đặc biệt là khi code tiếp tục phát triển với các tính năng mới và đáp ứng các thay đổi của nền tảng.

Trong bài viết này, chúng ta hãy xem một vài mẹo và kỹ thuật có thể giúp chúng ta làm điều đó, bằng cách tách các loại của chúng ta một khi trách nhiệm của chúng đã bắt đầu vượt ra ngoài phạm vi lý tưởng của single type.

States and scopes

Một nguồn thực sự phổ biến để đánh giá độ phức tạp mã là khi một loại duy nhất cần xử lý nhiều phạm vi và các trạng thái riêng biệt. Ví dụ, giả sử rằng chúng tôi làm việc trên networking layer của một ứng dụng và hiện tại chúng ta đã triển khai toàn bộ lớp đó trong một lớp duy nhất gọi là NetworkController:

class NetworkController {
    typealias Handler = (Result<Data, NetworkError>) -> Void

    var accessToken: AccessToken?
    
    ...

    func request(_ endpoint: Endpoint,
                 then handler: @escaping Handler) {
        var request = URLRequest(url: endpoint.url)

        if let token = accessToken {
            request.addValue("Bearer \(token)",
                forHTTPHeaderField: "Authorization"
            )
        }

        // Perform the request
        ...
    }
}

Mặc dù việc triển khai toàn bộ một tính năng hoặc hệ thống trong một lớp không nhất thiết là một điều xấu, nhưng trong trường hợp này, làm như vậy đã để lại cho chúng ta một cảm giác mơ hồ khá lớn. Vì chúng tôi đang sử dụng cùng một API để yêu cầu cả hai public endpoints, cũng như những điểm yêu cầu xác thực, mỗi nhà phát triển trong nhóm của chúng ta cần phải luôn nhớ endpoint nào thuộc về nhóm nào - nếu không chúng ta sẽ kết thúc với runtime errors khi endpoint được bảo vệ vô tình được request mà không có người dùng đăng nhập.

Sẽ tốt hơn rất nhiều nếu chúng ta có thể sử dụng Swift’s type system để ngăn chặn mọi endpoint yêu cầu xác thực được gọi mà không có mã access token hợp lệ. Bằng cách đó, chúng ta sẽ có thể xác thực networking code của mình kỹ lưỡng hơn nhiều trong compile time, và cũng làm cho hệ thống đó dễ sử dụng hơn - vì nó rõ ràng là endpoint có thể được yêu cầu trong bất kỳ phạm vi cụ thể nào.

Để thực hiện điều đó, hãy để bắt đầu bằng cách di chuyển tất cả các code liên quan đến xác thực và access tokens từ NetworkController vào một biến thể mới của lớp đó, chúng tôi đặt tên là AuthenticatedNetworkController. Giống như người tiền nhiệm của nó, controller mới của chúng ta sẽ cho phép chúng ta thực hiện các endpoint-based network calls - chỉ khác là lần này chúng ta sẽ khởi tạo nó với required tokens và chúng ta cũng sẽ đảm bảo rằng các tokens đó được cập nhật trước khi chúng ta thực hiện mỗi yêu cầu, như thế này:

class AuthenticatedNetworkController {
    typealias Handler = (Result<Data, NetworkError>) -> Void

    private var tokens: NetworkTokens
    ...

    init(tokens: NetworkTokens, ...) {
        self.tokens = tokens
        ...
    }

    func request(_ endpoint: AuthenticatedEndpoint,
                 then handler: @escaping Handler) {
        refreshTokensIfNeeded { tokens in
            var request = URLRequest(url: endpoint.url)

            request.addValue("Bearer \(tokens.access)",
                forHTTPHeaderField: "Authorization"
            )
            
            // Perform the request
            ...
        }
    }
}

Đáng chú ý là chúng ta cũng đã cung cấp cho network controller mới, endpoint type chuyên dụng của riêng mình - AuthenticatedEndpoint. Nó cũng phân tách rõ ràng các endpoint definitions của chúng ta, để một endpoint yêu cầu xác thực không thể vô tình được chuyển đến NetworkController trước đó của chúng ta.

Vì loại này không còn phải xử lý bất kỳ yêu cầu xác thực nào, chúng ta có thể đơn giản hóa rất nhiều và đổi tên nó (và endpoint type của nó) thành một cái gì đó mô tả tốt hơn vai trò mới của nó trong networking layer của chúng ta:

class NonAuthenticatedNetworkController {
    typealias Handler = (Result<Data, NetworkError>) -> Void
    
    ...

    func request(_ endpoint: NonAuthenticatedEndpoint,
                 then handler: @escaping Handler) {
        var request = URLRequest(url: endpoint.url)
        ...
    }
}

Tuy nhiên, trong khi loại phân tách ở trên có thể mang lại cho chúng ta một sự thúc đẩy lớn về mặt kiến trúc và độ rõ ràng của API, thì nó cũng có thể yêu cầu một số lượng sao chép mã hợp lý. Trong trường hợp này, cả hai network controllers của chúng ta cần tạo các phiên bản URLRequest và thực hiện chúng, cũng như xử lý các tác vụ như lưu trữ và các hoạt động liên quan đến mạng khác - vì vậy chúng vẫn có thể chia sẻ hầu hết các triển khai cơ bản của chúng, mặc dù chúng tôi muốn giữ chúng API riêng biệt.

Đối với người mới bắt đầu, thay vì có mỗi networking-related type khai báo completion handler closure của riêng nó, hãy để Định nghĩa một kiểu chung mà họ có thể dễ dàng chia sẻ:

typealias NetworkResultHandler<T> = (Result<T, NetworkError>) -> Void

Sau đó, chúng ta có thể bắt đầu chuyển các phần của việc triển khai mạng cơ bản ra khỏi controller và thành các loại nhỏ hơn, chuyên dụng hơn. Ví dụ: ở đây, cách chúng tôi có thể tạo loại NetworkRequestPerformer riêng mà cả hai controller của chúng ta có thể sử dụng để thực hiện các request - trong khi vẫn giữ các API top-level hoàn toàn tách biệt và type-safe:

private struct NetworkRequestPerformer {
    var url: URL
    var accessToken: AccessToken?
    var cache: Cache<URL, Data>

    func perform(then handler: @escaping NetworkResultHandler<Data>) {
        if let data = cache.data(forKey: url) {
            return handler(.success(data))
        }
                        
        var request = URLRequest(url: url)

        // This if-statement is no longer a problem, since it's now
        // hidden behind a type-safe abstraction that prevents
        // accidential misuse.
        if let token = accessToken {
            request.addValue("Bearer \(token)",
                forHTTPHeaderField: "Authorization"
            )
        }
        
        ...
    }
}

Với những điều đã nêu ở trên, giờ đây chúng ta có thể cho phép cả hai network controllers của mình chỉ tập trung vào việc cung cấp type-safe API để thực hiện các request - trong khi các triển khai cơ bản của chúng đang được giữ đồng bộ thông qua các privately shared utility types:

class AuthenticatedNetworkController {
    ...

    func request(
        _ endpoint: AuthenticatedEndpoint,
        then handler: @escaping NetworkResultHandler<Data>
    ) {
        refreshTokensIfNeeded { [cache] tokens in
            let performer = NetworkRequestPerformer(
                url: endpoint.url,
                accessToken: tokens.access,
                cache: cache
            )

            performer.perform(then: handler)
        }
    }
}

Những gì chúng ta thực hiện ở trên về cơ bản là sử dụng sức mạnh của composition, trong đó chúng tôi đã chia sẻ các triển khai khác nhau bằng cách kết hợp các loại nhỏ hơn vào các loại xác định API public của chúng ta. Tuy nhiên, trước khi chúng ta có thể hoàn thành chức năng của mình, trước tiên chúng ta phải phân tách single type mà chúng ta bắt đầu. Thực hiện loại phân tách đó trên cơ sở liên tục thường là chìa khóa để giữ cho code base ở tip-top shape, vì các loại của chúng ta có xu hướng phát triển tự nhiên khi chúng tôi thêm các tính năng và chức năng mới vào code base.

Loading versus managing objects

Tiếp theo, chúng ta sẽ xem xét một loại tình huống khác có thể làm cho một số phần nhất định của code base phức tạp hơn mức cần thiết - khi same type chịu trách nhiệm cho cả việc loading và managing một đối tượng nhất định. Một ví dụ rất phổ biến đó là khi mọi thứ liên quan đến user sessions đã được triển khai trong một loại duy nhất - chẳng hạn như UserManager. Ví dụ: ở đây một loại như vậy chịu trách nhiệm cho cả người dùng đăng nhập vào và ra khỏi ứng dụng của chúng tôi, cũng như giữ cho instance User hiện đang đăng nhập đồng bộ với server của chúng ta:

class UserManager {
    private(set) var user: User?
    
    ...

    func logIn(
        with credentials: LoginCredentials,
        then handler: @escaping NetworkResultHandler<User>
    ) {
        ...
    }

    func sync(then handler: @escaping NetworkResultHandler<User>) {
        ...
    }

    func logOut(then handler: @escaping NetworkResultHandler<Void>) {
        ...
    }
}

Giống như bài viết Managing objects using Locks and Keys in Swift, một vấn đề chính với cách tiếp cận ở trên là nó buộc chúng ta phải thực hiện User property của mình như một option - do đó yêu cầu chúng ta unwrap optional đó trong tất cả các tính năng bằng 1 cách nào đó liên quan đến người dùng hiện đang đăng nhập - hầu hết trong số đó có thể dựa vào giá trị đó để thực sự thực hiện công việc.

Vì vậy, nếu không áp dụng đầy đủ Locks and Keys-based architecture trong ứng dụng của chúng ta - làm thế nào chúng ta có thể sử dụng một số nguyên tắc từ design pattern đó để chia UserManager thành các loại nhỏ hơn, tập trung hơn?

Điều đầu tiên chúng tôi sẽ làm là trích xuất tất cả các code liên quan đến việc loading các User instances từ UserManager và vào một loại mới, riêng biệt. Chúng ta sẽ gọi nó là UserLoader và nó sẽ sử dụng AuthenticatedNetworkController của chúng ta từ trước để request server’s user endpoint của chúng ta, cùng với yêu cầu xác thực:

struct UserLoader {
    var networkController: AuthenticatedNetworkController

    func loadUser(
        withID id: User.ID,
        then handler: @escaping NetworkResultHandler<User>
    ) {
        networkController.request(.user(withID: id)) { result in
            // Decode the network result into a User instance,
            // then call the passed handler with the end result.
            ...
        }
    }
}

Bằng cách phân tách UserManager của chúng tôi thành các building blocks nhỏ hơn, như chúng ta đã làm ở trên, chúng ta có thể cho phép nhiều chức năng của chúng ta được triển khai dưới dạng stateless structs - vì các loại đó sẽ đơn giản thực hiện các tác vụ thay cho các đối tượng khác (giống như NetworkRequestPerformer của chúng ta trước đó).

Chúng tôi cũng có thể tiếp tục làm điều tương tự với login và logout code của mình, ví dụ như bằng cách tạo một LoginPerformer sử dụng non-authenticated network controller của chúng ta để gửi một credentials xác thực đến server endpoint được sử dụng để người dùng đăng nhập vào ứng dụng của chúng ta:

struct LoginPerformer {
    var networking: NonAuthenticatedNetworkController

    func login(
        using credentials: LoginCredentials,
        then handler: @escaping NetworkResultHandler<NetworkTokens>
    ) {
        // Send the passed credentials to our server's login
        // endpoint, and then call the passed completion handler
        // with the tokens that were returned.
        ...
    }
}

Cái hay của cách tiếp cận trên là giờ đây chúng ta có thể sử dụng một trong hai loại mới của mình bất cứ khi nào chúng ta cần thực hiện nhiệm vụ cụ thể của loại đó, thay vì luôn phải sử dụng cùng một loại UserManager bất kể khi nào chúng ta đăng nhập, đăng xuất hay đơn giản cập nhật người dùng hiện tại.

Ví dụ: Trong code đăng nhập của chúng ta, chúng ta có thể sử dụng trực tiếp LoginPerformer — và sau đó chúng ta có thể sử dụng UserLoader to load thông tin người dùng mới đăng nhập trước khi đưa cả hai trường hợp đó vào UserManager - hiện chỉ có một trách nhiệm duy nhất, để quản lý current User instance:

class UserManager {
    private(set) var user: User
    private let loader: UserLoader

    init(user: User, loader: UserLoader) {
        self.user = user
        self.loader = loader
    }

    func sync(then handler: @escaping NetworkResultHandler<User>) {
        loader.loadUser(withID: user.id) { [weak self] result in
            if let user = try? result.get() {
                self?.user = user
            }

            handler(result)
        }
    }
}

Công cụ tái cấu trúc ở trên không chỉ cho phép chúng ta loại bỏ một option không cần thiết - mà còn cho phép chúng ta loại bỏ rất nhiều câu lệnh ifguard khỏi bất kỳ code nào phụ thuộc vào người dùng đang đăng nhập - nó cũng sẽ cung cấp cho chúng ta mức độ linh hoạt cao hơn, vì giờ đây chúng ta có thể chọn mức độ trừu tượng liên quan đến User mà chúng ta muốn làm việc trong mỗi tính năng mới mà chúng ta sẽ xây dựng.

Conclusion

Composition là một khái niệm cực kỳ mạnh mẽ, nhưng trước khi chúng ta có thể sử dụng nó trong các ứng dụng của mình, trước tiên chúng ta cần phân tách một số loại lớn hơn thành các building blocks nhỏ hơn - sau đó có thể được lắp ráp thành nhiều combinations and configurations khác nhau. Tất nhiên, chúng ta luôn phải cố gắng cân bằng giữa việc phân tách mọi thứ và vẫn giữ cho code base của chúng ta nhất quán và dễ điều hướng - vì vậy mục tiêu chắc chắn không phải là phân chia mọi thứ nhiều nhất có thể, mà là tạo ra các loại có một tập hợp trách nhiệm hẹp, sau đó có thể được kết hợp thành các higher-level abstractions.

Hy vọng bài viết sẽ có ích với các bạn

Reference: https://www.swiftbysundell.com/articles/splitting-up-swift-types/