Lỗi
Một lỗi kết liễu cả luồng — nên bắt nó ở đâu mới chính là thiết kế
Xin nhắc lại câu ở phần mở đầu, vì mọi thứ ở đây đều suy ra từ nó: một lỗi kết liễu cái luồng mà nó chạm tới. Không phải giá trị. Mà là cả luồng. Một khi một lỗi đi qua một điểm trong chuỗi, mọi thứ phía dưới nhận một tín hiệu kết thúc và subscription chấm dứt.
Điều đó khiến việc xử lý lỗi trong Combine là câu hỏi về vị trí đặt nhiều hơn hẳn so với câu hỏi dùng toán tử nào.
Các toán tử
publisher.replaceError(with: []) // cứu bằng một giá trị, Failure thành Never
publisher.catch { error in Just(fallback) } // cứu bằng một publisher khác
publisher.mapError { APIError.network($0) } // chuyển đổi kiểu lỗi
publisher.retry(3) // đăng ký lại khi hỏng, tối đa 3 lần
publisher.setFailureType(to: APIError.self) // Never thành kiểu lỗi thật, không tác dụng lúc chạy
publisher.assertNoFailure() // sập khi có lỗi — chỉ dùng lúc debug
replaceError và catch đều chấm dứt phần lỗi, và cả hai cũng chấm dứt luôn cả luồng. Giá trị
cứu hộ được phát ra, rồi .finished theo sau ngay lập tức. Không có phiên bản nào của chúng cho phép
nguồn trên ban đầu chạy tiếp.
Vị trí đặt là toàn bộ câu chuyện
Hãy xét một ô tìm kiếm điều khiển các request:
// Hỏng: một request lỗi giết chết ô tìm kiếm trong suốt phiên làm việc
searchTerms
.map { api.search($0) }
.switchToLatest()
.catch { _ in Just([]) } // cứu một lần, rồi luồng kết thúc
.sink { showResults($0) }
Lỗi mạng đầu tiên phát ra một mảng rỗng, kết thúc luồng, và mọi phím gõ sau đó đi vào hư không. Không sập, không log, tính năng đơn giản là chết.
// Đúng: lỗi được giam bên trong publisher con
searchTerms
.map { term in
api.search(term)
.catch { _ in Just([]) } // luồng con này kết thúc; searchTerms thì không
}
.switchToLatest()
.sink { showResults($0) }
Quy tắc: hãy bắt lỗi càng gần publisher hỏng càng tốt. Một lỗi bắt bên trong closure của
flatMap hay của map cộng switchToLatest chỉ kết liễu đúng một request con đó. Một lỗi bắt ở
cuối chuỗi thì kết liễu tất cả.
Cảnh báo
Đây là cơ chế đứng sau phần lớn các lỗi kiểu “chạy được một lần rồi tịt”. Nếu một tính năng chạy
bằng Combine ngừng phản hồi sau một lỗi và không bao giờ hồi phục, hãy tìm một catch hay
replaceError nằm ngoài publisher con thay vì nằm trong.
Lỗi dưới dạng giá trị
Với bất kỳ luồng sống lâu nào nuôi giao diện, thiết kế bền hơn là từ bỏ kênh Failure và mô hình hóa
lỗi thành một giá trị:
enum LoadState<T> {
case loading
case loaded(T)
case failed(String)
}
searchTerms
.map { term in
api.search(term)
.map { LoadState.loaded($0) }
.catch { Just(LoadState.failed($0.localizedDescription)) }
.prepend(.loading)
}
.switchToLatest()
.receive(on: DispatchQueue.main)
.sink { state in render(state) }
Failure == Never ở luồng ngoài nghĩa là nó không thể kết thúc, nghĩa là tính năng không thể chết
lặng lẽ. Cái prepend(.loading) cho giao diện một vòng xoay chờ miễn phí, và ba case ánh xạ thẳng
sang ba thứ mà màn hình có thể hiển thị.
Hình dạng này đáng lấy làm mặc định cho bất cứ thứ gì điều khiển một view. Kiểu Failure hữu ích ở
bên trong một pipeline và là gánh nặng ở rìa của nó.
retry, và những gì nó không làm
retry(3) đăng ký lại nguồn trên khi hỏng, tối đa thêm ba lần. Hai điều cần biết trước khi dùng:
Nó thử lại ngay lập tức. Không có độ lùi tăng dần. Ba lần thử lại tức thì vào một máy chủ đang sập là ba lần hỏng nữa trong vài mili giây, và đó là hành vi ít có khả năng giúp ích nhất.
Nó chỉ chạy được trên publisher lạnh. Đăng ký lại một subject hay một publisher đã share() thì
chẳng chạy lại gì cả, vì không có công việc riêng cho từng subscriber để mà chạy lại.
Độ lùi tăng dần phải tự dựng:
func fetchWithBackoff(_ url: URL, attempts: Int = 3) -> AnyPublisher<Data, URLError> {
URLSession.shared.dataTaskPublisher(for: url)
.map(\.data)
.catch { error -> AnyPublisher<Data, URLError> in
guard attempts > 1 else {
return Fail(error: error).eraseToAnyPublisher()
}
let delay = pow(2.0, Double(4 - attempts))
return Just(())
.delay(for: .seconds(delay), scheduler: DispatchQueue.global())
.setFailureType(to: URLError.self)
.flatMap { fetchWithBackoff(url, attempts: attempts - 1) }
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
Đệ quy cộng delay là hình dạng thành ngữ. Đây cũng là chỗ mà async/await bắt đầu trông dễ đọc hơn
hẳn — một vòng for với một try await Task.sleep làm đúng việc đó trong sáu dòng, và đây chính là
kiểu so sánh mà chương cuối nói tới.
Đừng với tới assertNoFailure
Nó biến một lỗi thành một cú sập. Trong bản debug thì thỉnh thoảng đó là một phép khẳng định hữu ích
về một bất biến; khi phát hành, nó là một báo cáo sự cố sinh ra từ một lỗi mạng. Nếu một luồng thật
sự không thể hỏng, hãy diễn đạt điều đó trong kiểu bằng cách dùng một publisher có Failure == Never,
thay vì khẳng định nó lúc chạy.