Skip to content

Commit 968cc64

Browse files
authored
Merge pull request #50 from SW-Maestro-OSS/refactor/coinDetailPageDataSource
[CVW-038] 오더북 데이터 저장소 리팩토링
2 parents 486e170 + 3aeef9b commit 968cc64

File tree

29 files changed

+277
-761
lines changed

29 files changed

+277
-761
lines changed

Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ public extension ModuleDependency {
6464
public static let Swinject: TargetDependency = .external(name: "Swinject")
6565
public static let SwiftStructures: TargetDependency = .external(name: "SwiftStructures")
6666
public static let SimpleImageProvider: TargetDependency = .external(name: "SimpleImageProvider")
67+
public static let AdvancedSwift: TargetDependency = .external(name: "AdvancedSwift")
6768
}
6869
}
6970

Projects/App/Sources/DI/Assembly/DataAssembly.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public class DataAssembly: Assembly {
2121

2222
public func assemble(container: Swinject.Container) {
2323

24-
// MARK: DataSource
24+
// MARK: Servcie
2525
container.register(UserConfigurationService.self) { _ in
2626
DefaultUserConfigurationService()
2727
}
@@ -31,6 +31,11 @@ public class DataAssembly: Assembly {
3131
}
3232
.inObjectScope(.container)
3333

34+
container.register(HTTPService.self) { _ in
35+
DefaultHTTPService()
36+
}
37+
.inObjectScope(.container)
38+
3439
container.register(ExchangeRateService.self) { _ in
3540
DefaultExchangeRateService()
3641
}

Projects/Data/DataSource/Sources/Service/Network/HTTP/HTTPService.swift

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,14 @@
66
//
77

88
import Foundation
9+
import Combine
910

10-
public struct HTTPService {
11+
public protocol HTTPService {
12+
func request<DTO: Decodable>(_ requestBuilder: URLRequestBuilder, dtoType: DTO.Type, retry: Int) async throws -> SuccessResponse<DTO>
13+
func request<DTO>(_ requestBuilder: URLRequestBuilder, dtoType: DTO.Type) -> Future<SuccessResponse<DTO>, NetworkServiceError> where DTO : Decodable
14+
}
15+
16+
public struct DefaultHTTPService: HTTPService {
1117

1218
private let networkSession: URLSession = .init(configuration: .default)
1319
private let jsonDecoder: JSONDecoder = .init()
@@ -46,4 +52,36 @@ public struct HTTPService {
4652
}
4753
}
4854
}
55+
56+
public func request<DTO>(_ requestBuilder: URLRequestBuilder, dtoType: DTO.Type) -> Future<SuccessResponse<DTO>, NetworkServiceError> where DTO : Decodable {
57+
Future { [weak networkSession] promise in
58+
guard let request = requestBuilder.build() else {
59+
return promise(.failure(.requestCreationFailed))
60+
}
61+
networkSession?.dataTask(with: request) { data, response, error in
62+
if let error {
63+
promise(.failure(.underlying(error: error)))
64+
} else {
65+
if let httpResponse = response as? HTTPURLResponse {
66+
let statusCode = httpResponse.statusCode
67+
if !(200..<300).contains(statusCode) {
68+
// 상태코드가 비정상인 경우
69+
let httpError = HTTPResponseException(status: .create(code: statusCode))
70+
promise(.failure(.invalidStatusCode(exception: httpError)))
71+
} else {
72+
// 상태코드가 정상인 경우
73+
var successResponse = SuccessResponse<DTO>()
74+
successResponse.set(headers: httpResponse.allHeaderFields)
75+
if let data, let dto = try? jsonDecoder.decode(DTO.self, from: data) {
76+
// DTO획득에 성공한 경우
77+
successResponse.set(body: dto)
78+
}
79+
promise(.success(successResponse))
80+
}
81+
}
82+
}
83+
}
84+
.resume()
85+
}
86+
}
4987
}

Projects/Data/Repository/Sources/BinanceOrderbookRepository.swift

Lines changed: 88 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,98 @@
66
//
77

88
import Foundation
9+
import Combine
910

1011
import DomainInterface
1112
import DataSource
1213
import CoreUtil
1314

14-
final public class BinanceOrderbookRepository: OrderbookRepository {
15+
public final class BinanceOrderbookRepository: OrderbookRepository {
1516
// Dependency
16-
@Injected var webSocketService: WebSocketService
17-
private let httpService: HTTPService = .init()
17+
@Injected private var webSocketService: WebSocketService
18+
@Injected private var httpService: HTTPService
19+
20+
// Store
21+
private let bidOrderbookStore = ThreadSafeOrderbookHashMap()
22+
private let askOrderbookStore = ThreadSafeOrderbookHashMap()
1823

1924
public init() { }
25+
}
26+
27+
28+
// MARK: OrderbookRepository
29+
public extension BinanceOrderbookRepository {
30+
func getOrderbookTable(symbolPair: String) -> AnyPublisher<OrderbookTable, Error> {
31+
let httpRequest = getWholeOrderbookTable(symbolPair: symbolPair).share()
32+
let httpTableUpdate = httpRequest
33+
.unretained(self)
34+
.asyncTransform { repo, dto -> Void in
35+
// 최초저장전 저장소 초기화
36+
await repo.clearStore()
37+
await repo.adaptToStore(orderbooks: dto.bids, storeType: .bid)
38+
await repo.adaptToStore(orderbooks: dto.asks, storeType: .ask)
39+
}
40+
let webSocketTableUpdate = httpRequest
41+
.map(\.lastUpdateId)
42+
.unretained(self)
43+
.flatMap { repo, lastUpdateId in
44+
repo.webSocketService
45+
.getMessageStream()
46+
.filter { (dto: BinacneOrderbookUpdateDTO) in dto.symbol.lowercased() == symbolPair.lowercased() }
47+
.filter { dto in dto.finalUpdateId > lastUpdateId }
48+
.unretained(self)
49+
.asyncTransform { repo, dto -> Void in
50+
await repo.adaptToStore(orderbooks: dto.bids, storeType: .bid)
51+
await repo.adaptToStore(orderbooks: dto.asks, storeType: .ask)
52+
}
53+
}
54+
return Publishers
55+
.Merge(
56+
httpTableUpdate,
57+
webSocketTableUpdate
58+
)
59+
.unretainedOnly(self)
60+
.asyncTransform { repo in
61+
OrderbookTable(
62+
bidOrderbooks: await repo.bidOrderbookStore.hashMap.copy(),
63+
askOrderbooks: await repo.askOrderbookStore.hashMap.copy()
64+
)
65+
}
66+
.mapError { $0 }
67+
.eraseToAnyPublisher()
68+
}
69+
}
70+
71+
72+
73+
// MARK: Update orderbook store
74+
private extension BinanceOrderbookRepository {
75+
func clearStore() async {
76+
await bidOrderbookStore.removeAll()
77+
await askOrderbookStore.removeAll()
78+
}
79+
80+
enum StoreType { case bid, ask }
81+
func adaptToStore(orderbooks: [[String]], storeType: StoreType) async {
82+
var store: ThreadSafeOrderbookHashMap
83+
switch storeType {
84+
case .bid:
85+
store = bidOrderbookStore
86+
case .ask:
87+
store = askOrderbookStore
88+
}
89+
for orderbook in orderbooks {
90+
let price = CVNumber(Decimal(string: orderbook[0])!)
91+
let qty = CVNumber(Decimal(string: orderbook[1])!)
92+
if qty.wrappedNumber <= 0 {
93+
await store.removeValue(price)
94+
} else {
95+
await store.insert(key: price, value: qty)
96+
}
97+
}
98+
}
2099

21-
public func getWholeTable(symbolPair: String) async throws -> OrderbookUpdateVO {
100+
func getWholeOrderbookTable(symbolPair: String) -> AnyPublisher<BinanceOrderbookTableDTO, NetworkServiceError> {
22101
let requestBuiler = URLRequestBuilder(
23102
base: .init(string: "https://api.binance.com/api/v3")!,
24103
httpMethod: .get
@@ -28,25 +107,10 @@ final public class BinanceOrderbookRepository: OrderbookRepository {
28107
"symbol": symbolPair.uppercased(),
29108
"limit": "5000"
30109
])
31-
let dto = try await httpService.request(requestBuiler, dtoType: BinanceOrderbookTableDTO.self, retry: 1)
32-
return dto.body!.toEntity()
33-
}
34-
35-
public func getUpdate(symbolPair: String) -> AsyncStream<DomainInterface.OrderbookUpdateVO> {
36-
let publisher = webSocketService
37-
.getMessageStream()
38-
.filter({ (dto: BinacneOrderbookUpdateDTO) in
39-
dto.symbol.lowercased() == symbolPair.lowercased()
40-
})
41-
.map({ $0.toEntity() })
42-
return AsyncStream { continuation in
43-
let cancellable = publisher
44-
.sink(receiveValue: { entity in
45-
continuation.yield(entity)
46-
})
47-
continuation.onTermination = { @Sendable [cancellable] _ in
48-
cancellable.cancel()
49-
}
50-
}
110+
return httpService
111+
.request(requestBuiler, dtoType: BinanceOrderbookTableDTO.self)
112+
.compactMap(\.body)
113+
.eraseToAnyPublisher()
51114
}
52115
}
116+

Projects/Data/Repository/Sources/ToEntityExtension/BinacneOrderbookUpdateDTO+toEntity.swift

Lines changed: 0 additions & 23 deletions
This file was deleted.

Projects/Data/Repository/Sources/ToEntityExtension/BinanceOrderbookTableDTO+toEntity.swift

Lines changed: 0 additions & 23 deletions
This file was deleted.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
//
2+
// ThreadSafeOrderbookHashMap.swift
3+
// Data
4+
//
5+
// Created by choijunios on 4/21/25.
6+
//
7+
8+
import CoreUtil
9+
10+
actor ThreadSafeOrderbookHashMap {
11+
let hashMap: HashMap<CVNumber, CVNumber> = .init()
12+
13+
subscript (_ key: CVNumber) -> CVNumber? {
14+
get { hashMap[key] }
15+
}
16+
17+
func insert(key: CVNumber, value: CVNumber) {
18+
hashMap[key] = value
19+
}
20+
21+
func removeValue(_ forKey: CVNumber) {
22+
hashMap.removeValue(forKey)
23+
}
24+
25+
func removeAll() { hashMap.clear() }
26+
}

Projects/Domain/Concrete/UseCase/DefaultCoinDetailPageUseCase.swift

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ final public class DefaultCoinDetailPageUseCase: CoinDetailPageUseCase {
2222
}
2323

2424

25-
// MARK: Orderbook
25+
// MARK: CoinDetailPageUseCase
2626
public extension DefaultCoinDetailPageUseCase {
2727
func connectToOrderbookStream(symbolPair: String) {
2828
webSocketHelper.requestSubscribeToStream(streams: ["\(symbolPair.lowercased())@depth"], mustDeliver: true)
@@ -45,19 +45,29 @@ public extension DefaultCoinDetailPageUseCase {
4545
], mustDeliver: false)
4646
}
4747

48-
func getWholeOrderbookTable(symbolPair: String) async throws -> OrderbookUpdateVO {
49-
try await orderbookRepository.getWholeTable(symbolPair: symbolPair)
50-
}
51-
52-
func getChangeInOrderbook(symbolPair: String) -> AsyncStream<OrderbookUpdateVO> {
53-
orderbookRepository.getUpdate(symbolPair: symbolPair)
54-
}
55-
5648
func get24hTickerChange(symbolPair: String) -> AsyncStream<Twenty4HourTickerForSymbolVO> {
5749
singleTickerRepository.request24hTickerChange(pairSymbol: symbolPair)
5850
}
5951

6052
func getRecentTrade(symbolPair: String) -> AsyncStream<CoinTradeVO> {
6153
coinTradeRepository.getSingleTrade(symbolPair: symbolPair)
6254
}
55+
56+
func getOrderbookTable(symbolPair: String, rowCount: UInt) -> AnyPublisher<OrderbookTableVO, Error> {
57+
orderbookRepository
58+
.getOrderbookTable(symbolPair: symbolPair)
59+
.map { orderbookTable in
60+
let bidOrderbookList = orderbookTable.bidOrderbooks
61+
.keys(order: .DESC, maxCount: rowCount)
62+
.map { Orderbook(price: $0, quantity: orderbookTable.bidOrderbooks[$0]!) }
63+
let askOrderbookList = orderbookTable.askOrderbooks
64+
.keys(order: .ASC, maxCount: rowCount)
65+
.map { Orderbook(price: $0, quantity: orderbookTable.askOrderbooks[$0]!) }
66+
return OrderbookTableVO(
67+
bidOrderbooks: bidOrderbookList,
68+
askOrderbooks: askOrderbookList
69+
)
70+
}
71+
.eraseToAnyPublisher()
72+
}
6373
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
//
2+
// Orderbook.swift
3+
// Domain
4+
//
5+
// Created by choijunios on 4/21/25.
6+
//
7+
8+
import CoreUtil
9+
10+
public struct Orderbook: Sendable {
11+
public let price: CVNumber
12+
public let quantity: CVNumber
13+
public init(price: CVNumber, quantity: CVNumber) {
14+
self.price = price
15+
self.quantity = quantity
16+
}
17+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
//
2+
// OrderbookTable.swift
3+
// Domain
4+
//
5+
// Created by choijunios on 4/20/25.
6+
//
7+
8+
import CoreUtil
9+
10+
public struct OrderbookTable {
11+
public let bidOrderbooks: HashMap<CVNumber, CVNumber>
12+
public let askOrderbooks: HashMap<CVNumber, CVNumber>
13+
14+
public init(bidOrderbooks: HashMap<CVNumber, CVNumber>, askOrderbooks: HashMap<CVNumber, CVNumber>) {
15+
self.bidOrderbooks = bidOrderbooks
16+
self.askOrderbooks = askOrderbooks
17+
}
18+
}

0 commit comments

Comments
 (0)