Skip to content

Instantly share code, notes, and snippets.

@jeonyeohun
Last active August 10, 2021 01:22
Show Gist options
  • Save jeonyeohun/88f4bf3529bfb04dda5439dd15a964a6 to your computer and use it in GitHub Desktop.
Save jeonyeohun/88f4bf3529bfb04dda5439dd15a964a6 to your computer and use it in GitHub Desktop.
d16_S051
//
// 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()
@jeonyeohun
Copy link
Author

1차 구현 완료

  1. 리다이렉션이 되는건지 모르겠다.. 카운트 하는건 해놨는데..
  2. 캐시는 naver에서는 안되는데 다른 사이트에서는 된다. naver에서 데이터를 잘못 파싱했거나, 중복되는 요청이 없는듯.

@jeonyeohun
Copy link
Author

Revision

  • 리팩토링
  • 주석추가
  • 캐싱 테스트를 위한 두번째 호출 로직

@jeonyeohun
Copy link
Author

jeonyeohun commented Aug 9, 2021

부족한 코드 읽어주셔서 감사합니다!

  • 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 {}
    }
  • 응답 대기 시간 측정 및 출력
  • 다운로드 시간 측정 및 출력
  • 요청 도메인 개수 측정 및 출력
  • 전체 요청 개수 측정 및 출력
  • 전체 이미지 개수 측정 및 출력
  • 전체 코드 개수 측정 및 출력
  • 전체 전송 용량 측정 및 출력
    스크린샷 2021-08-10 오전 1 45 46
    스크린샷 2021-08-10 오전 1 51 47
  • 리다이렉트 개수 측정 및 출력
    URLSessionTaskMetrics 로 URLSessionTask 동안 발생한 리다이렉션 횟수 수집
 func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) {
        totalRedirectionCount += metrics.redirectCount
}

스크린샷 2021-08-10 오전 1 48 50

  • 응답 - 리소스 메모리 캐싱 구현
    딕셔너리로 캐시데이터 관리
var cache : [URL : ([String], Data)] = [:]
let cacheAllowedTypes = ["css", "javascript", "png", "jpeg", "jpg", "gif"]

스크린샷 2021-08-10 오전 1 49 42

  • 캐싱 데이터 측정 및 출력
    스크린샷 2021-08-10 오전 1 50 04

@albireo3754
Copy link

albireo3754 commented Aug 10, 2021

코드 모듈화가 잘되어있어서 읽고 평가하기 편했던 것 같습니다 감사합니다. 거기에 이미 metrics에 redirect count가 있었네요 ㅠㅠ

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment