Notice
Recent Posts
Recent Comments
Link
관리 메뉴

김종권의 iOS 앱 개발 알아가기

[iOS - swift] Moya, RxMoya, Networking 레이어 본문

iOS framework

[iOS - swift] Moya, RxMoya, Networking 레이어

jake-kim 2021. 12. 13. 23:08

해당 글에서 편리함을 위해 사용된 다른 프레임워크 참고

Moya 프레임워크

  • Alamofire를 Wrapping한 모듈 (Moya는 직접적인 네트워킹을 수행하지 않고 단순히 Alamofire의 추상화)
  • Moya로 구현한 layer가 Networking layer 자체가 되므로, 별도의 Networking layer를 만들지 않아도 되는 간편함이 존재
  • Moya는 Request에 관한 endpoint가 enum으로 정의하는 구조이므로 type-safe 방식으로 네트워킹 요청가능
  • testable한 네트워킹 레이어 구축이 편리 (stub 이용)

예제에서 사용할 API

flickr API

Moya - Request 할 때 필요한 Endpoint 정의 (TargetType)

  • Error 처리 시 사용되는 공통 기능 정의 "Moya+NetworkError.swift"
    // Moya+NetworkError.swift
    
    import Alamofire
    import Moya
    
    extension TargetType {
      static func converToURLError(_ error: Error) -> URLError? {
        switch error {
        case let MoyaError.underlying(afError as AFError, _):
          fallthrough
        case let afError as AFError:
          return afError.underlyingError as? URLError
        case let MoyaError.underlying(urlError as URLError, _):
          fallthrough
        case let urlError as URLError:
          return urlError
        default:
          return nil
        }
      }
      
      static func isNotConnection(error: Error) -> Bool {
        Self.converToURLError(error)?.code == .notConnectedToInternet
      }
      
      static func isLostConnection(error: Error) -> Bool {
        switch error {
        case let AFError.sessionTaskFailed(error: posixError as POSIXError)
          where posixError.code == .ECONNABORTED: // eConnAboarted: Software caused connection abort.
          break
        case let MoyaError.underlying(urlError as URLError, _):
          fallthrough
        case let urlError as URLError:
          guard urlError.code == URLError.networkConnectionLost else { fallthrough } // A client or server connection was severed in the middle of an in-progress load.
          break
        default:
          return false
        }
        return true
      }
    }​
  • Request - TargetType 프로토콜을 준수하는 enum 정의
  • API endpoint 케이스 정의 "MyAPI.swift"
    • getBaseURL(), getPath(), getMethod(), getTask()는 별도의 파일에서 정의
    • 별도의 파일에서 task, request, path, method, baseURL을 정의하여 get-으로 사용하는 이유는, case가 많아지면 복잡해지므로 역할의 분리를 위함
      // MyAPI.swift
      
      import Moya
      
      enum MyAPI {
        case getPhotos(PhotoRequest)
      }
      
      // MARK: MyAPI+TargetType
      extension MyAPI: Moya.TargetType {
        var baseURL: URL { self.getBaseURL() }
        var path: String { self.getPath() }
        var method: Method { self.getMethod() }
        var sampleData: Data { Data() }
        var task: Task { self.getTask() }
        var headers: [String : String]? { ["Content-Type": "application/json"] }
      }​
  • Request할 때 필요한 데이터를 불러오는 (task) 메소드 정의 "MyAPI+Task"
    // MyAPI+Task.swift
    
    import Moya
    
    extension MyAPI {
      func getTask() -> Task {
        switch self {
        case .getPhotos(let request):
          return .requestParameters(parameters: request.toDictionary(), encoding: URLEncoding.queryString)
        }
      }
    }
    
    extension Encodable {
      func toDictionary() -> [String: Any] {
        do {
          let jsonEncoder = JSONEncoder()
          let encodedData = try jsonEncoder.encode(self)
          
          let dictionaryData = try JSONSerialization.jsonObject(
            with: encodedData,
            options: .allowFragments
          ) as? [String: Any]
          return dictionaryData ?? [:]
        } catch {
          return [:]
        }
      }
    }​
  • MoyaProvider인스턴를 이용하여 request하는 기능 정의 + Error Handleing "MyAPI+Request"
    • Error Type 정의 
      // MyAPI+Request.swift
      
      import RxSwift
      import Moya
      import Alamofire
      import Then
      
      // MARK: Error Type
      enum MyAPIError: Error {
        case empty
        case requestTimeout(Error)
        case internetConnection(Error)
        case restError(Error, statusCode: Int? = nil, errorCode: String? = nil)
        
        var statusCode: Int? {
          switch self {
          case let .restError(_, statusCode, _):
            return statusCode
          default:
            return nil
          }
        }
        var errorCodes: [String] {
          switch self {
          case let .restError(_, _, errorCode):
            return [errorCode].compactMap { $0 }
          default:
            return []
          }
        }
        var isNoNetwork: Bool {
          switch self {
          case let .requestTimeout(error):
            fallthrough
          case let .restError(error, _, _):
            return MyAPI.isNotConnection(error: error) || MyAPI.isLostConnection(error: error)
          case .internetConnection:
            return true
          default:
            return false
          }
        }
      }​
    • wrapper 정의 
      • 해당 부분에서 PluginType을 삽입
        (주의: Logger Plugin은 따로 넣지 않고, #file, #line, #function을 파라미터로 받아서 해당 위치도 알 수 있게끔 request하는 곳에서 직접 로깅)
        // MyAPI+Request.swift
        
        // MARK: Moya Wrapper
        extension MyAPI {
          struct Wrapper: TargetType {
            let base: MyAPI
            
            var baseURL: URL { self.base.baseURL }
            var path: String { self.base.path }
            var method: Moya.Method { self.base.method }
            var sampleData: Data { self.base.sampleData }
            var task: Task { self.base.task }
            var headers: [String : String]? { self.base.headers }
          }
          
          private enum MoyaWrapper {
            struct Plugins {
              var plugins: [PluginType]
              
              init(plugins: [PluginType] = []) {
                self.plugins = plugins
              }
              
              func callAsFunction() -> [PluginType] { self.plugins }
            }
            
            static var provider: MoyaProvider<MyAPI.Wrapper> {
              let plugins = Plugins(plugins: [])
              
              let configuration = URLSessionConfiguration.default
              configuration.timeoutIntervalForRequest = 30
              configuration.urlCredentialStorage = nil
              let session = Session(configuration: configuration)
              
              return MoyaProvider<MyAPI.Wrapper>(
                endpointClosure: { target in
                  MoyaProvider.defaultEndpointMapping(for: target)
                },
                session: session,
                plugins: plugins()
              )
            }
          }
        }​
    • Error Handling 정의
      • 인터넷 연결 에러, TimeOut 에러, 일반 에러를 처리하는 메소드 정의
        (request의 응답값에서 해당 에러처리 메소드를 부르도록 설계)
        // Moya+Request.swift
        
        // MARK: Error Handling
        extension MyAPI {
          private func handleInternetConnection<T: Any>(error: Error) throws -> Single<T> {
            guard
              let urlError = Self.converToURLError(error),
              Self.isNotConnection(error: error)
            else { throw error }
            throw MyAPIError.internetConnection(urlError)
          }
            
            private func handleTimeOut<T: Any>(error: Error) throws -> Single<T> {
              guard
                let urlError = Self.converToURLError(error),
                urlError.code == .timedOut
              else { throw error }
              throw MyAPIError.requestTimeout(urlError)
            }
          
          private func handleREST<T: Any>(error: Error) throws -> Single<T> {
            guard error is MyAPIError else {
              throw MyAPIError.restError(
                error,
                statusCode: (error as? MoyaError)?.response?.statusCode,
                errorCode: (try? (error as? MoyaError)?.response?.mapJSON() as? [String: Any])?["code"] as? String
              )
            }
            throw error
          }
        }​
    • request하는 메소드 정의
      • 인수로 #file, #function, #line을 받기 때문에 별도의 LoggerPlugin을 사용하지 않고 해당 request에서 로그 출력
      • log를 해당 부분에서 출력
        // MARK: Moya Request
        extension MyAPI {
          static let moya = MoyaWrapper.provider
          
          static var jsonDecoder: JSONDecoder {
            let decoder = JSONDecoder()
            return decoder
          }
          
          func request(
            file: StaticString = #file,
            function: StaticString = #function,
            line: UInt = #line
          ) -> Single<Response> {
            
            let endpoint = MyAPI.Wrapper(base: self)
            let requestString = "\(endpoint.method) \(endpoint.baseURL) \(endpoint.path)"
        
            return Self.moya.rx.request(endpoint)
              .filterSuccessfulStatusCodes()
              .catch(self.handleInternetConnection)
              .catch(self.handleTimeOut)
              .catch(self.handleREST)
              .do(
                onSuccess: { response in
                  let requestContent = "🛰 SUCCESS: \(requestString) (\(response.statusCode))"
                  print(requestContent, file, function, line)
                },
                onError: { rawError in
                  switch rawError {
                  case MyAPIError.requestTimeout:
                    print("TODO: alert MyAPIError.requestTimeout")
                  case MyAPIError.internetConnection:
                    print("TODO: alert MyAPIError.internetConnection")
                  case let MyAPIError.restError(error, _, _):
                    guard let response = (error as? MoyaError)?.response else { break }
                    if let jsonObject = try? response.mapJSON(failsOnEmptyData: false) {
                      let errorDictionary = jsonObject as? [String: Any]
                      guard let key = errorDictionary?.first?.key else { return }
                      let message: String
                      if let description = errorDictionary?[key] as? String {
                        message = "🛰 FAILURE: \(requestString) (\(response.statusCode)\n\(key): \(description)"
                      } else if let description = (errorDictionary?[key] as? [String]) {
                        message = "🛰 FAILURE: \(requestString) (\(response.statusCode))\n\(key): \(description)"
                      } else if let rawString = String(data: response.data, encoding: .utf8) {
                        message = "🛰 FAILURE: \(requestString) (\(response.statusCode))\n\(rawString)"
                      } else {
                        message = "🛰 FAILURE: \(requestString) (\(response.statusCode)"
                      }
                      print(message)
                    }
                  default:
                    break
                  }
                },
                onSubscribe: {
                  let message = "REQUEST: \(requestString)"
                  print(message, file, function, line)
                }
              )
          }
        }

엔터티 - Request / Response Model 정의

  • 공통 모델
    • Then: 인스턴스 초기화 시 사용하기 편리함을 위해 선언
    • Codable: Encodable, Decodable
    • Equatable: RxDataSources 사용 시 필요한 종속성
      import Then
      
      protocol ModelType: Then, Codable, Equatable {}​
  • Request 모델
    • query parameter에 들어갈 값
      struct PhotoRequest: ModelType {
        var tags = "landscape, portrait"
        var tagmode = "any"
        var format = "json"
        var nojsoncallback = "1"
      }​
  • Response 모델
    struct Photo: ModelType {
      let title: String
      let link: String
      let items: [Item]
      
      struct Item: ModelType {
        let title: String
        let link: String
        let media: Media
        let author: String
        let authorID: String
        
        enum CodingKeys: String, CodingKey {
          case title
          case link
          case media
          case author
          case authorID = "author_id"
        }
        
        static func == (lhs: Item, rhs: Item) -> Bool {
          lhs.link == rhs.link
        }
        
        struct Media: Codable {
          let m: String
        }
      }
      
      static func == (lhs: Photo, rhs: Photo) -> Bool {
        lhs.link == rhs.link
      }
    }​

SectionModel 정의

import RxDataSources

enum PhotoSection {
  case result([PhotoSectionItem])
}

enum PhotoSectionItem: Equatable {
  case result(Photo.Item)
}

extension PhotoSection: SectionModelType {
  var items: [PhotoSectionItem] {
    switch self {
    case .result(let photos): return photos
    }
  }
  
  init(original: PhotoSection, items: [PhotoSectionItem]) {
    switch original {
    case .result: self = .result(items)
    }
  }
}

extension PhotoSection: Equatable {
  static func == (lhs: PhotoSection, rhs: PhotoSection) -> Bool {
    lhs.items == rhs.items
  }
}

Moya API + RxDataSources 연동

  • ViewController 준비
    // ViewController.swift
    
    import UIKit
    import Then
    import SnapKit
    import RxDataSources
    import RxCocoa
    import Reusable
    import RxSwift
    import Moya
    
    class ViewController: UIViewController {
    
    }​
  • 데이터가 저장될 PhotoDataSource 정의
    private let photoDataSource = BehaviorRelay<[PhotoSection]>(value: [])​
  • RxDataSources 정의
    // viewDidLoad() 에서 호출
    private func setupCollectionViewDataSource() {
      let collectionViewDataSource = RxCollectionViewSectionedReloadDataSource<PhotoSection> { _, collectionView, indexPath, sectionItem in
        Self.collectionViewConfigureCell(
          collectionView: collectionView,
          indexPath: indexPath,
          item: sectionItem
        )
      }
      
      self.photoDataSource
        .bind(to: self.collectionView.rx.items(dataSource: collectionViewDataSource))
        .disposed(by: disposeBag)
    }​
    
    // MARK: DataSources
    private static func collectionViewConfigureCell(
      collectionView: UICollectionView,
      indexPath: IndexPath,
      item: PhotoSectionItem
    ) -> UICollectionViewCell {
      switch item {
      case let .result(photo):
        let cell = collectionView.dequeueReusableCell(for: indexPath) as PhotoCell
        cell.setImage(photo: photo)
        return cell
      }
    }
  • Moya를 사용한 Networking 요청
    • 요청 후 json 형태에 escape characters (백슬래쉬)가 들어있으므로 해당 부분을 제거하는 기능 extension String에 추가
      extension String {
        var removedEscapeCharacters: String {
          /// remove: \"
          let removedEscapeWithQuotationMark = self.replacingOccurrences(of: "\\\"", with: "")
          /// remove: \
          let removedEscape = removedEscapeWithQuotationMark.replacingOccurrences(of: "\\", with: "")
          return removedEscape
        }
      }​
    • Moya를 통해서 Request를 한 후 Decoding 전에 escape를 삭제하는 코드 삽입
      // 버튼을 누른 경우 호출
      private func loadImage() {
        let photoRequest = PhotoRequest()
        MyAPI.getPhotos(photoRequest)
          .request()
          .map {
            let jsonString = try $0.mapString().removedEscapeCharacters
            guard let value = jsonString.data(using: .utf8) else { return $0 }
            let newResponse = Response(
              statusCode: $0.statusCode,
              data: value,
              request: $0.request,
              response: $0.response
            )
            return newResponse
          }
          .map(Photo.self, using: MyAPI.jsonDecoder)
          .asObservable()
          .bind(onNext: self.updatePhoto)
          .disposed(by: disposeBag)
      }​
      
      private func updatePhoto(_ photo: Photo) {
          let previusPhotos = photoDataSource.value
          let newSectionItems = photo.items.map(PhotoSectionItem.result)
          photoDataSource.accept(previusPhotos + [PhotoSection.result(newSectionItems)])
      }

* 전체 소스 코드: https://github.com/JK0369/ExMoya

Comments