-
-
Save jeonyeohun/88f4bf3529bfb04dda5439dd15a964a6 to your computer and use it in GitHub Desktop.
d16_S051
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// main.swift | |
// HTTP-Analyzer | |
// | |
// Created by 전여훈 on 2021/08/09. | |
// | |
import Foundation | |
import SwiftSoup | |
// 단위 변환 extension | |
extension Int { | |
var kilobytes: Double { | |
return Double(self) / 1_024 | |
} | |
var megabytes: Double { | |
return kilobytes / 1_024 | |
} | |
func toDataSize() -> String{ | |
switch self { | |
case 1_024..<(1_024 * 1_024): | |
return "\(String(format: "%.2f", kilobytes))KB" | |
case 1_024..<(1_024 * 1_024 * 1_024): | |
return "\(String(format: "%.2f", megabytes))MB" | |
default: | |
return String(self) | |
} | |
} | |
} | |
// 캐시 메모리 | |
var cache : [URL : ([String], Data)] = [:] | |
let cacheAllowedTypes = ["css", "javascript", "png", "jpeg", "jpg", "gif"] | |
// 두 Date 를 받아 차이를 millisecond로 변환하고 소수점 둘째자리까지 잘라서 반환하는 함수 | |
func calcTimeInterval(start: Date, end: Date) -> Double { | |
return (Double(end.timeIntervalSince(start) * 1000) * 100).rounded() / 100 | |
} | |
class HttpAnalyzer: NSObject, URLSessionDataDelegate { | |
var domains : Set<String> = [] // 중복되지 않은 도메인 셋 | |
var images : Array<String> = [] // 이미지 리소스 url 배열 | |
var codes : Array<String> = [] // 스크립트, css 리소스 url 배열 | |
var totalRedirectionCount = 0 // 리다이렉션이 몇번 발생횟수 | |
var totalTransmissionSize = 0 // 전체 전송용량 | |
var totalLoadingTime = 0.0 // 전송시간 + 대기시간 | |
var maxContentLength : (name: String , size: Int ) = ("", 0) // 용량이 가장 큰 데이터 | |
var maxWaitingTime : (name: String , time: Double ) = ("", 0) // 대기시간이 가장 긴 데이터 | |
var maxDownloadingTime : (name: String , time: Double ) = ("", 0) // 다운로드 시간이 가장 긴 데이터 | |
var totalRequestCount = 0 // 총 요청 횟수 | |
// 모든 통신 종료 후 결과 출력함수 | |
func printInfo () { | |
print("\n==============================\n") | |
print("도메인 개수 : \(domains.count)개") | |
print("요청 개수 : \(totalRequestCount)개") | |
print("이미지(png, gif, jpg) 개수 : \(images.count)개") | |
print("코드(css, js) 개수 : \(codes.count)개") | |
print("전송 용량 : \(totalTransmissionSize.toDataSize())") | |
print("리다이렉트 개수 : \(totalRedirectionCount)개") | |
print("캐시 데이터 개수 : \(cache.count)개") | |
print("전체 로딩 시간 : \((totalLoadingTime * 100).rounded() / 100)ms") | |
print("\n\n가장 큰 용량 : \(maxContentLength.name) \(maxContentLength.size.toDataSize())") | |
print("가장 오랜 대기 시간 : \(maxWaitingTime.name) \(maxWaitingTime.time)ms") | |
print("가장 오랜 다운로드 시간 : \(maxDownloadingTime.name) \(maxDownloadingTime.time)ms") | |
} | |
public let urlConfiguration = URLSessionConfiguration.default | |
public var urlSession: URLSession? | |
override init() { | |
super.init() | |
self.urlSession = URLSession(configuration: urlConfiguration, delegate: self, delegateQueue: OperationQueue.main) | |
} | |
// 타입별로 나눠서 개수와 url 저장 | |
func addTypes(type: String, url: String) { | |
if type == "png" || type == "gif" || type == "jpeg" || type == "jpg" { | |
images.append(url) | |
} else if type == "css" || type == "javascript" || type == "js" { | |
codes.append(url) | |
} | |
} | |
// 통신이 완료되었을 때의 동작 설정 | |
func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { | |
totalRedirectionCount += metrics.redirectCount | |
// 로컬 캐시때문에 같은 데이터를 네트워크에 한 번, 로컬에 한 번 요청하는 스냅샷이 찍힌다. 마지막 통신 기록만 사용 | |
let metric = metrics.transactionMetrics.last! | |
print(">> \(task.currentRequest!.url!)") | |
// 도메인 출력 및 저장 | |
if let host = metric.response?.url?.host { | |
print("도메인 \(host)") | |
domains.insert(host) | |
} | |
// 스킴 출력 및 저장 | |
if let scheme = metric.response?.url?.scheme { | |
print("스킴 \(scheme)") | |
} | |
// 경로 출력 및 저장 | |
if let path = metric.response?.url?.relativePath { | |
print("경로 \(path)") | |
} | |
// MIME 타입 출력 및 저장 | |
if let type = metric.response?.mimeType?.components(separatedBy: "/").last { | |
print("종류 \(type)") | |
addTypes(type: type, url: (metric.response?.url!.absoluteString)!) | |
} | |
// 데이터 사이즈 출력 및 최대 사이즈 데이터 업데이트 | |
let size = Int(task.countOfBytesReceived) | |
print("용량 \(size.toDataSize())") | |
if size > maxContentLength.size { | |
maxContentLength.name = (metric.response?.suggestedFilename)! | |
maxContentLength.size = size | |
} | |
totalTransmissionSize += Int(size) | |
// 대기 시간 출력 및 최대 대기시간 데이터 업데이트 (응답이 시작된 시간 - 요청이 시작된 시간) | |
if let start = metric.requestStartDate, let end = metric.responseStartDate { | |
let waitingTime = calcTimeInterval(start: start, end: end) | |
print("대기시간 \(waitingTime)ms") | |
if waitingTime > maxWaitingTime.time { | |
maxWaitingTime.name = (metric.response?.suggestedFilename)! | |
maxWaitingTime.time = waitingTime | |
} | |
totalLoadingTime += waitingTime | |
} | |
// 전송 시간 출력 및 최대 전송시간 데이터 업데이트 (응답이 끝난 시간 - 응답이 시작된 시간) | |
if let start = metric.responseStartDate, let end = metric.responseEndDate { | |
let downloadingTime = calcTimeInterval(start: start, end: end) | |
print("다운로드 시간 \(downloadingTime)ms") | |
if downloadingTime > maxDownloadingTime.time { | |
maxDownloadingTime.name = (metric.response?.suggestedFilename)! | |
maxDownloadingTime.time = downloadingTime | |
} | |
totalLoadingTime += downloadingTime | |
} | |
print() | |
} | |
// DOMElement를 읽어서 새로운 요청을 생성 | |
func readDOMElements(data: Data, root: URL, dispatchGroup: DispatchGroup){ | |
do{ | |
if let html = String(data:data, encoding: .utf8) { | |
let doc = try SwiftSoup.parse(html) | |
let linkElements = try doc.select("link") | |
let scriptElements = try doc.select("script") | |
let imageElements = try doc.select("img") | |
// link 태그에서 href, src 속성 정보 수집 후 요청 생성 | |
for link in linkElements { | |
if let subUrl = URL(string: try link.attr("href")) { | |
self.makeRequest(url: subUrl, root: root, dispatchGroup: dispatchGroup) | |
} | |
if let subUrl = URL(string: try link.attr("src")) { | |
self.makeRequest(url: subUrl, root: root, dispatchGroup: dispatchGroup) | |
} | |
} | |
// script 태그에서 src 속성 정보 수집 후 요청 생성 | |
for script in scriptElements { | |
if let subUrl = URL(string: try script.attr("src")) { | |
self.makeRequest(url: subUrl, root: root, dispatchGroup: dispatchGroup) | |
} | |
} | |
// img 태그에서 src, data-src 속성 정보 수집 후 요청 생성 | |
for img in imageElements { | |
if let subUrl = URL(string: try img.attr("src")){ | |
self.makeRequest(url: subUrl, root: root, dispatchGroup: dispatchGroup) | |
} | |
if let subUrl = URL(string: try img.attr("data-src")){ | |
self.makeRequest(url: subUrl, root: root, dispatchGroup: dispatchGroup) | |
} | |
} | |
} | |
} catch {} | |
} | |
// 캐시 데이터가 들어오면 요청을 새로 보내지 않고 기존 데이터를 그대로 사용 | |
func makeRequestWithCachedData(url: URL, root: URL, dispatchGroup: DispatchGroup) { | |
let data = cache[url]!.1 | |
readDOMElements(data: data, root: root, dispatchGroup: dispatchGroup) | |
} | |
// 두 URL을 받아 suffix url의 형식이 http로 시작하지 않으면 prefix url과 합쳐 새로운 url 생성 | |
// favicon.ico를 위한 예외처리 | |
func combineURLs(prefix: URL, suffix: URL) -> URL{ | |
if suffix.absoluteString.hasPrefix("/") { | |
var rootUrl = prefix.absoluteString | |
if rootUrl.last! == "/" { | |
rootUrl.removeLast() | |
} | |
return URL(string: rootUrl + suffix.absoluteString)! | |
} | |
return suffix | |
} | |
// 지정된 타입의 데이터가 들어오면 캐시에 정보를 넣는다 | |
func addCache(httpRequest: HTTPURLResponse, requestUrl: URL, data: Data){ | |
if let type = httpRequest.mimeType?.components(separatedBy: "/").last | |
, cacheAllowedTypes.contains(type){ | |
cache[requestUrl] = ( | |
[(httpRequest.url!.host)!, | |
(httpRequest.url!.scheme)!, | |
(httpRequest.url?.relativePath)!, | |
type, | |
String(data.count)], | |
data | |
) | |
} | |
} | |
// 캐시에 URL에 대한 정보가 있다면 그대로 사용한 뒤 true를 반환하고, URL에 대한 저장된 정보가 없으면 false를 반환 | |
func lookUpCache(requestUrl: URL, root: URL, dispatchGroup: DispatchGroup) -> Bool { | |
if let cachedData = cache[requestUrl] { | |
print("도메인 \(cachedData.0[0])") | |
domains.insert(cachedData.0[0]) | |
print("스킴 \(cachedData.0[1])") | |
print("경로 \(cachedData.0[2])") | |
print("종류 \(cachedData.0[3])") | |
addTypes(type: cachedData.0[3], url: requestUrl.absoluteString) | |
let size = Int(cachedData.0[4])! | |
print("용량 \(size.toDataSize())") | |
if size > maxContentLength.size { | |
maxContentLength.size = size | |
maxContentLength.name = cachedData.0[2].components(separatedBy: "/").last! + "(캐시됨)" | |
} | |
print(">> 캐시됨\n") | |
makeRequestWithCachedData(url: requestUrl, root: root, dispatchGroup: dispatchGroup) | |
return true | |
} | |
return false | |
} | |
// 요청 생성 | |
func makeRequest (url: URL, root: URL, dispatchGroup: DispatchGroup) { | |
totalRequestCount += 1 | |
// favicon.ico 처리 | |
let requestUrl = combineURLs(prefix: root, suffix: url) | |
// 캐시정보 확인 | |
if lookUpCache(requestUrl: requestUrl, root: root, dispatchGroup: dispatchGroup) { return } | |
// URLSession DataTask 생성 | |
let task = self.urlSession?.dataTask(with: requestUrl){ [self] data, response, error in | |
if error != nil { | |
dispatchGroup.leave() | |
return | |
} | |
guard let httpRequst = response as? HTTPURLResponse else { | |
dispatchGroup.leave() | |
return | |
} | |
guard let resultData = data else { | |
dispatchGroup.leave() | |
return | |
} | |
self.addCache(httpRequest: httpRequst, requestUrl: requestUrl, data: data!) | |
self.readDOMElements(data: resultData, root: root, dispatchGroup: dispatchGroup) | |
dispatchGroup.leave() | |
} | |
dispatchGroup.enter() | |
task?.resume() | |
} | |
} | |
print(">", terminator: "") | |
guard let url = readLine() else { | |
print("잘못된 입력입니다.") | |
exit(0) | |
} | |
var requstUrl = url | |
if !url.hasPrefix("http") { | |
requstUrl = "https://" + url | |
} | |
// 테스트1: https://m.naver.com/ 요청 | |
func request() { | |
let dispatchGroup = DispatchGroup() | |
let analyzer = HttpAnalyzer() | |
analyzer.makeRequest(url: URL(string: url)!, root: URL(string: url)!, dispatchGroup: dispatchGroup) | |
dispatchGroup.notify(queue: DispatchQueue.main) { | |
analyzer.printInfo() | |
reRequest() | |
} | |
} | |
// 테스트2: 캐시 정보를 유지한 상태로 https://m.naver.com/ 재요청 | |
func reRequest(){ | |
let dispatchGroup = DispatchGroup() | |
let analyzer = HttpAnalyzer() | |
analyzer.makeRequest(url: URL(string: url)!, root: URL(string: url)!, dispatchGroup: dispatchGroup) | |
dispatchGroup.notify(queue: DispatchQueue.main) { | |
analyzer.printInfo() | |
exit(0) | |
} | |
} | |
request() | |
RunLoop.current.run() |
Revision
- 리팩토링
- 주석추가
- 캐싱 테스트를 위한 두번째 호출 로직
부족한 코드 읽어주셔서 감사합니다!
- SwiftSoup 때문에 프로젝트 파일을 압축해서 올렸습니다! 압축 풀고 실행하시면 됩니다!
- 몇 군데 테스트를 해봤는데 잘 되는 사이트가 있고 잘 안되는 사이트가 있어요ㅠ https://m.naver.com/ 으로 테스트 부탁드립니다..!
체크포인트 & 결과
- URL 입력 후 HTTP 요청 보내기 구현
URLSession을 이용한 요청 보내기 구현
let task = self.urlSession?.dataTask(with: requestUrl){ [self] data, response, error in
...
}
- HTML 파싱 - src 속성 탐색 구현
SwiftSoup을 통해 파싱 후 새로운 요청 생성
func readDOMElements(data: Data, root: URL, dispatchGroup: DispatchGroup){
do{
if let html = String(data:data, encoding: .utf8) {
let doc = try SwiftSoup.parse(html)
let linkElements = try doc.select("link")
let scriptElements = try doc.select("script")
let imageElements = try doc.select("img")
// link 태그에서 href, src 속성 정보 수집 후 요청 생성
for link in linkElements {
if let subUrl = URL(string: try link.attr("href")) {
self.makeRequest(url: subUrl, root: root, dispatchGroup: dispatchGroup)
}
if let subUrl = URL(string: try link.attr("src")) {
self.makeRequest(url: subUrl, root: root, dispatchGroup: dispatchGroup)
}
}
// script 태그에서 src 속성 정보 수집 후 요청 생성
for script in scriptElements {
if let subUrl = URL(string: try script.attr("src")) {
self.makeRequest(url: subUrl, root: root, dispatchGroup: dispatchGroup)
}
}
// img 태그에서 src, data-src 속성 정보 수집 후 요청 생성
for img in imageElements {
if let subUrl = URL(string: try img.attr("src")){
self.makeRequest(url: subUrl, root: root, dispatchGroup: dispatchGroup)
}
if let subUrl = URL(string: try img.attr("data-src")){
self.makeRequest(url: subUrl, root: root, dispatchGroup: dispatchGroup)
}
}
}
} catch {}
}
- 응답 대기 시간 측정 및 출력
- 다운로드 시간 측정 및 출력
- 요청 도메인 개수 측정 및 출력
- 전체 요청 개수 측정 및 출력
- 전체 이미지 개수 측정 및 출력
- 전체 코드 개수 측정 및 출력
- 전체 전송 용량 측정 및 출력
- 리다이렉트 개수 측정 및 출력
URLSessionTaskMetrics 로 URLSessionTask 동안 발생한 리다이렉션 횟수 수집
func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) {
totalRedirectionCount += metrics.redirectCount
}
- 응답 - 리소스 메모리 캐싱 구현
딕셔너리로 캐시데이터 관리
var cache : [URL : ([String], Data)] = [:]
let cacheAllowedTypes = ["css", "javascript", "png", "jpeg", "jpg", "gif"]
코드 모듈화가 잘되어있어서 읽고 평가하기 편했던 것 같습니다 감사합니다. 거기에 이미 metrics에 redirect count가 있었네요 ㅠㅠ
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
1차 구현 완료