| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010 |
- Imports System.Net.Sockets
- Imports System.Threading.Tasks
- Public Module ModNet
- #Region "网络请求"
- ''' <summary>
- ''' 发送一次网络请求并获取返回内容。
- ''' </summary>
- Public Function NetRequestByClient(Url As String, Optional Method As HttpMethod = Nothing,
- Optional Content As Object = Nothing, Optional ContentType As String = Nothing, Optional Accept As String = "*/*",
- Optional Timeout As Integer = 25000, Optional Headers As String(,) = Nothing, Optional RequireJson As Boolean = False,
- Optional Encoding As Encoding = Nothing, Optional SimulateBrowserHeaders As Boolean = False, Optional MakeLog As Boolean = True) As String
- If Method Is Nothing Then Method = HttpMethod.Get
- If MakeLog Then Logger.Info($"发起网络请求({Method.Method},{Url}),最大超时 {Timeout}")
- Try
- Dim HeaderDictionary = If(Headers, {}).ToDictionary
- If ContentType IsNot Nothing Then HeaderDictionary("Content-Type") = ContentType
- If Accept IsNot Nothing Then HeaderDictionary("Accept") = Accept
- Dim Result = SendRequest(Url, Method, Content, HeaderDictionary, Timeout:=Timeout, Encoding:=Encoding, SimulateBrowserHeaders:=SimulateBrowserHeaders).GetString(Encoding)
- '检查结果是否为完整 JSON(在 #6683 中可能返回空,在被 GFW 截断时可能不完整)
- '在此处检查并报错,可以让 NetRequestByClientRetry 等方法进行重试
- If RequireJson Then
- Result = Result.Trim((vbCrLf & " ").ToCharArray) 'Authlib-Injector 的结果会在末尾附带一个空行
- If Not (Result.StartsWithF("{") AndAlso Result.EndsWithF("}")) AndAlso
- Not (Result.StartsWithF("[") AndAlso Result.EndsWithF("]")) Then
- Throw New FormatException("返回结果并非 JSON 格式:" &
- If(Result.Length > 2000, Result.Substring(0, 1000) & "..." & Result.Substring(Result.Length - 1000), Result))
- End If
- End If
- Return Result
- Catch ex As FormatException
- Throw
- Catch ex As ThreadInterruptedException
- Throw
- Catch ex As Exception
- If MakeLog Then Logger.Warn(ex, "网络请求失败")
- Throw
- End Try
- End Function
- ''' <summary>
- ''' 发送网络请求并获取返回内容。
- ''' 会进行至多 45 秒 3 次的尝试,允许最长 30s 的超时。
- ''' </summary>
- ''' <param name="BackupUrl">如果第一次尝试失败,换用的备用 URL。</param>
- Public Function NetRequestByClientRetry(Url As String, Optional Method As HttpMethod = Nothing, Optional BackupUrl As String = Nothing,
- Optional Content As Object = Nothing, Optional ContentType As String = Nothing, Optional Accept As String = "*/*",
- Optional Headers As String(,) = Nothing, Optional RequireJson As Boolean = False,
- Optional Encoding As Encoding = Nothing, Optional SimulateBrowserHeaders As Boolean = False) As String
- Dim RetryCount As Integer = 0
- Dim RetryException As Exception = Nothing
- Dim StartTime As Long = GetTimeMs()
- Try
- Retry:
- Select Case RetryCount
- Case 0 '正常尝试
- Return NetRequestByClient(Url, Method, Content, ContentType, Accept, 10000, Headers, RequireJson, Encoding, SimulateBrowserHeaders)
- Case 1 '慢速重试
- Thread.Sleep(500)
- Return NetRequestByClient(Url, Method, Content, ContentType, Accept, 30000, Headers, RequireJson, Encoding, SimulateBrowserHeaders)
- Case Else '快速重试
- If GetTimeMs() - StartTime > 5500 Then
- '若前两次加载耗费 5 秒以上,才进行重试
- Thread.Sleep(500)
- Return NetRequestByClient(Url, Method, Content, ContentType, Accept, 4000, Headers, RequireJson, Encoding, SimulateBrowserHeaders)
- Else
- Throw RetryException
- End If
- End Select
- Catch ex As ThreadInterruptedException
- Throw
- Catch ex As Exception
- If TypeOf ex Is HttpRequestCodeException Then
- If CType(ex, HttpRequestCodeException).StatusCode = HttpStatusCode.Forbidden Then Throw
- If CType(ex, HttpRequestCodeException).StatusCode = HttpStatusCode.NotFound Then Throw
- If CType(ex, HttpRequestCodeException).StatusCode = 429 Then Thread.Sleep(10000) 'Too Many Requests
- End If
- '重试
- Select Case RetryCount
- Case 0
- RetryException = ex
- RetryCount += 1
- GoTo Retry
- Case 1
- RetryCount += 1
- GoTo Retry
- Case Else
- Throw
- End Select
- End Try
- End Function
- ''' <summary>
- ''' 生成数个线程,同时发送网络请求并获取返回内容。
- ''' 每个线程开始之前会略有延迟。
- ''' </summary>
- Public Function NetRequestByClientMultiple(Url As String, Optional Method As HttpMethod = Nothing, Optional ThreadCount As Integer = 3,
- Optional Content As Object = Nothing, Optional ContentType As String = Nothing, Optional Accept As String = "*/*",
- Optional Headers As String(,) = Nothing, Optional RequireJson As Boolean = False, Optional Timeout As Integer = 25000,
- Optional Encoding As Encoding = Nothing, Optional SimulateBrowserHeaders As Boolean = False) As String
- Dim Threads As New List(Of Thread)
- Dim RequestResult = Nothing
- Dim RequestEx As Exception = Nothing
- Dim FailCount As Integer = 0
- For i = 1 To ThreadCount
- Dim th As New Thread(
- Sub()
- Try
- RequestResult = NetRequestByClient(Url, Method, Content, ContentType, Accept, Timeout, Headers, RequireJson, Encoding, SimulateBrowserHeaders)
- Catch ex As Exception
- FailCount += 1
- RequestEx = ex
- End Try
- End Sub)
- th.Start()
- Threads.Add(th)
- Thread.Sleep(i * 250)
- If RequestResult IsNot Nothing Then GoTo RequestFinished
- Next
- Do While True
- If RequestResult IsNot Nothing Then
- RequestFinished:
- Try
- For Each th In Threads
- If th.IsAlive Then th.Interrupt()
- Next
- Catch
- End Try
- Return RequestResult
- ElseIf FailCount = ThreadCount Then
- Try
- For Each th In Threads
- If th.IsAlive Then th.Interrupt()
- Next
- Catch
- End Try
- Throw RequestEx
- End If
- Thread.Sleep(20)
- Loop
- Return Nothing
- End Function
- ''' <summary>
- ''' 以多线程下载网页文件的方式获取内容。
- ''' 不支持缓存协商;若需缓存协商,需要换用 NetRequestByClient。
- ''' </summary>
- Public Function NetRequestByLoader(Url As String, Optional Timeout As Integer = 45000, Optional IsJson As Boolean = False, Optional SimulateBrowserHeaders As Boolean = False) As String
- Return NetRequestByLoader({Url}, Timeout, IsJson, SimulateBrowserHeaders)
- End Function
- ''' <summary>
- ''' 以多线程下载网页文件的方式获取内容。
- ''' 不支持缓存协商;若需缓存协商,需要换用 NetRequestByClient。
- ''' </summary>
- Public Function NetRequestByLoader(Urls As IEnumerable(Of String), Optional Timeout As Integer = 45000, Optional IsJson As Boolean = False, Optional SimulateBrowserHeaders As Boolean = False) As String
- Dim Temp As String = RequestTaskTempFolder() & "download.txt"
- Dim NewTask As New LoaderDownload("源码获取 " & GetUuid() & "#", New List(Of NetFile) From {New NetFile(Urls, Temp, New FileChecker With {.IsJson = IsJson}, SimulateBrowserHeaders)})
- Try
- NewTask.WaitForExitTime(Timeout, TimeoutMessage:="连接服务器超时(第一下载源:" & Urls.First & ")")
- Dim Result = FileUtils.ReadAsString(Temp)
- FileUtils.Delete(Temp)
- Return Result
- Finally
- NewTask.Interrupt()
- End Try
- End Function
- #End Region
- #Region "文件下载"
- ''' <summary>
- ''' 通过网络请求直接下载小文件,若文件已存在将被覆盖。
- ''' 不建议用于下载大文件。
- ''' </summary>
- Public Sub NetDownloadByClient(Url As String, LocalPath As String, Optional SimulateBrowserHeaders As Boolean = False)
- Logger.Info($"通过网络请求直接下载小文件:{Url} → {LocalPath}")
- Dim RetryCount As Integer = 0
- Retry:
- Try
- FileUtils.Write(LocalPath, SendRequest(Url, HttpMethod.Get, SimulateBrowserHeaders:=SimulateBrowserHeaders))
- Catch ex As ThreadInterruptedException
- FileUtils.Delete(LocalPath)
- Throw
- Catch ex As Exception
- FileUtils.Delete(LocalPath)
- If RetryCount < 2 Then
- RetryCount += 1
- GoTo Retry
- End If
- Throw New HttpRequestException($"通过网络请求直接下载小文件失败({LocalPath})", ex)
- End Try
- End Sub
- ''' <summary>
- ''' 通过多线程下载引擎下载文件。
- ''' </summary>
- Public Sub NetDownloadByLoader(Url As String, LocalPath As String, Optional LoaderToSyncProgress As LoaderBase = Nothing, Optional Check As FileChecker = Nothing, Optional SimulateBrowserHeaders As Boolean = False)
- NetDownloadByLoader({Url}, LocalPath, LoaderToSyncProgress, Check, SimulateBrowserHeaders)
- End Sub
- ''' <summary>
- ''' 通过多线程下载引擎下载文件。
- ''' </summary>
- ''' <param name="Urls">文件的 Url 列表。</param>
- ''' <param name="LocalPath">下载的本地地址。</param>
- Public Sub NetDownloadByLoader(Urls As IEnumerable(Of String), LocalPath As String, Optional LoaderToSyncProgress As LoaderBase = Nothing, Optional Check As FileChecker = Nothing, Optional SimulateBrowserHeaders As Boolean = False)
- Dim NewTask As New LoaderDownload($"文件下载 {GetUuid()}#", New List(Of NetFile) From {New NetFile(Urls, LocalPath, Check, SimulateBrowserHeaders)})
- Try
- NewTask.WaitForExit(LoaderToSyncProgress:=LoaderToSyncProgress)
- Catch ex As Exception
- Throw New WebException($"多线程直接下载文件失败(第一下载源:" & Urls.First() & ")", ex)
- Finally
- NewTask.Interrupt()
- End Try
- End Sub
- #End Region
- #Region "基础请求引擎"
- '基础请求函数
- Private Function SendRequest(Url As String, Method As HttpMethod,
- Optional Content As Object = Nothing, Optional Headers As Dictionary(Of String, String) = Nothing,
- Optional SimulateBrowserHeaders As Boolean = False, Optional Timeout As Integer = 25000, Optional Encoding As Encoding = Nothing) As Byte()
- If RunInUi() AndAlso Not Url.Contains("//127.") Then Throw New Exception("在 UI 线程执行了网络请求")
- Dim Request As HttpRequestMessage = Nothing, CancelToken As CancellationTokenSource = Nothing,
- Response As HttpResponseMessage = Nothing, HostIp As String = Nothing
- Try
- Url = SecretCdnSign(Url)
- Request = New HttpRequestMessage(Method, Url)
- '写入 Content
- If Content IsNot Nothing AndAlso Request.Method <> HttpMethod.Get AndAlso Request.Method <> HttpMethod.Head Then
- If TypeOf Content Is HttpContent Then
- Request.Content = DirectCast(Content, HttpContent)
- ElseIf TypeOf Content Is Byte() Then
- Request.Content = New ByteArrayContent(DirectCast(Content, Byte()))
- ElseIf Content IsNot Nothing Then
- Request.Content = New ByteArrayContent(If(Encoding, Encoding.UTF8).GetBytes(Content.ToString()))
- End If
- End If
- '写入 Headers
- For Each Header In If(Headers, New Dictionary(Of String, String))
- If Header.Key.Lower = "content-type" Then
- Request.Content?.Headers.TryAddWithoutValidation(Header.Key, Header.Value)
- Else
- Request.Headers.TryAddWithoutValidation(Header.Key, Header.Value)
- End If
- Next
- SecretHeadersSign(Url, Request, SimulateBrowserHeaders)
- 'DNS 解析
- CancelToken = New CancellationTokenSource(Timeout)
- HostIp = DNSLookup(Request, CancelToken.Token)
- If HostIp IsNot Nothing Then IPReliability.GetOrAdd(HostIp, -0.01) '预先降低一点,这样快速的重复请求会使用不同的 IP 以提高成功率
- '发送请求
- Directory.CreateDirectory(PathTemp & "Cache\Http\")
- SyncLock RequestClientLock
- If RequestClient Is Nothing Then '延迟初始化,以避免在程序启动前加载 CacheCow 导致 DLL 加载失败
- RequestClient = CacheCow.Client.ClientExtensions.CreateClient(New CacheCow.Client.FileCacheStore.FileStore(PathTemp & "Cache/Http/"), New HttpClientHandler With {
- .AutomaticDecompression = DecompressionMethods.Deflate Or DecompressionMethods.GZip,
- .UseCookies = False '不设为 False 就不能从 Header 手动传入 Cookies
- })
- End If
- End SyncLock
- CancelToken.Dispose() : CancelToken = New CancellationTokenSource(Timeout)
- Response = RequestClient.SendAsync(Request, HttpCompletionOption.ResponseHeadersRead, CancelToken.Token).GetResultWithTimeout(CancelToken, Timeout)
- Dim ResponseBytes As Byte()
- CancelToken.Dispose() : CancelToken = New CancellationTokenSource(Timeout)
- Using ResponseStream = Response.Content.ReadAsStreamAsync().GetResultWithTimeout(CancelToken, Timeout)
- Using Stream As New MemoryStream
- ResponseStream.CopyToAsync(Stream, 81920, CancelToken.Token).GetResultWithTimeout(CancelToken, Timeout)
- ResponseBytes = Stream.ToArray()
- End Using
- End Using
- '输出
- If Response.IsSuccessStatusCode Then
- RecordIPReliability(HostIp, 0.5)
- Return ResponseBytes
- Else
- RecordIPReliability(HostIp, -0.7)
- Dim ResponseMessage = ResponseBytes.GetString(Encoding)
- Throw New HttpRequestCodeException(
- $"错误码 {Response.StatusCode} ({CInt(Response.StatusCode)}),{Method},{Url},{HostIp}" &
- If(String.IsNullOrEmpty(ResponseMessage), "", vbCrLf & ResponseMessage), Response.StatusCode, ResponseMessage)
- End If
- Catch ex As ThreadInterruptedException
- Throw
- Catch ex As HttpRequestException
- Throw
- Catch ex As FileNotFoundException
- '由于 CacheCow 读取缓存失败,然后系统调用不存在的 System.Net.Http.Formatting.resources 抛出(#8150)
- Throw New Exception($"网络请求时读取缓存失败", ex)
- Catch ex As Exception
- RecordIPReliability(HostIp, -1)
- If TypeOf ex Is OperationCanceledException OrElse TypeOf ex Is TimeoutException Then 'CancellationToken 超时
- Throw New WebException($"连接服务器超时,请稍后再试,或使用 VPN 改善网络环境({Method}, {Url},IP:{HostIp})", WebExceptionStatus.Timeout)
- ElseIf ex.IsBadNetwork Then
- Throw New WebException($"网络请求失败,请稍后再试,或使用 VPN 改善网络环境({Method}, {Url},IP:{HostIp})", WebExceptionStatus.Timeout)
- Else
- Throw New Exception($"网络请求出现意外异常({Method}, {Url},{HostIp})", ex)
- End If
- Finally
- Request?.Dispose()
- CancelToken?.Dispose()
- Response?.Dispose()
- End Try
- End Function
- Private RequestClient As HttpClient = Nothing
- Private RequestClientLock As New Object
- ''' <summary>
- ''' 进行 DNS 解析。它仅在选择的 IP 与系统默认的不一致时才对 URL 中的 Host 进行替换。
- ''' 返回请求时应使用的 IP。若解析失败,则返回 Nothing。
- ''' </summary>
- Private Function DNSLookup(Request As HttpRequestMessage, CancelToken As CancellationToken) As String
- Dim Host = Request.RequestUri.Host
- '不对部分有严格 SNI 限制的域名进行 DNS 解析(#8295)
- If {"mojang.com", "minecraft.net", "minecraftservices.com"}.Any(Function(h) Host.ContainsIgnoreCase(h)) Then Return Nothing
- '若一分钟内已失败过,则不再重复尝试解析,以减少断网时的 Exception 数量
- Dim LastFailureTime As Date
- If DNSFailureRecord.TryGetValue(Host, LastFailureTime) Then
- If Date.Now - LastFailureTime < New TimeSpan(0, 1, 0) Then
- Return Nothing
- Else
- DNSFailureRecord.Remove(Host)
- End If
- End If
- '初步 DNS 解析
- Dim Candidates As IPAddress()
- Try
- Dim AsyncResult = Dns.BeginGetHostAddresses(Host, Nothing, Nothing)
- If WaitHandle.WaitAny(New WaitHandle() {AsyncResult.AsyncWaitHandle, CancelToken.WaitHandle}) Then Throw New TimeoutException("DNS 解析超时")
- Candidates = Dns.EndGetHostAddresses(AsyncResult).Distinct.ToArray
- Catch ex As Exception
- Logger.Warn(ex, $"DNS 解析失败({Host})")
- DNSFailureRecord(Host) = Date.Now
- Return Nothing
- End Try
- If Not Candidates.Any Then
- Logger.Info($"DNS 解析无结果({Host})")
- Return Nothing
- End If
- '若同时存在 IPv4 和 IPv6 地址,仅选择其中一类(因为 GFW 可能只屏蔽了 IPv4 或 IPv6)
- Dim Reliabilities = Candidates.ToDictionary(Function(i) i, Function(i) IPReliability.GetOrDefault(i.ToString, 0))
- Dim IPv4Targets = Candidates.Where(Function(i) i.AddressFamily = AddressFamily.InterNetwork).ToArray
- Dim IPv6Targets = Candidates.Where(Function(i) i.AddressFamily = AddressFamily.InterNetworkV6).ToArray
- If IPv4Targets.Any AndAlso IPv6Targets.Any Then
- Dim IPv4Reliability = IPv4Targets.Max(Function(i) Reliabilities(i))
- Dim IPv6Reliability = IPv6Targets.Max(Function(i) Reliabilities(i))
- If Host = "api.modrinth.com" Then IPv6Reliability -= 0.1 '让 Modrinth 优先使用 IPv4 地址(#6887)
- Logger.Trace(Function() $"DNS IPv4/IPv6 选择({Host}),IPv4 {IPv4Reliability:0.000},IPv6 {IPv6Reliability:0.000}")
- Candidates = If(IPv4Reliability >= IPv6Reliability, IPv4Targets, IPv6Targets)
- End If
- '选择可靠度最高的 IP
- Dim Target As IPAddress = Candidates.MaxBy(Function(i) Reliabilities(i))
- Request.Headers.Host = Request.RequestUri.Authority
- Request.RequestUri = (New UriBuilder(Request.RequestUri) With {.Host = If(Target.AddressFamily = AddressFamily.InterNetworkV6, $"[{Target}]", Target.ToString)}).Uri
- Return Target.ToString
- End Function
- Private DNSFailureRecord As New ConcurrentDictionary(Of String, Date)
- ''' <summary>
- ''' 记录每个 IP 地址的请求可靠度。
- ''' 通常取值 -1 ~ +0.5,越高越好。未尝试过的 IP 应视为 0。
- ''' </summary>
- Private IPReliability As New ConcurrentDictionary(Of String, Double)
- ''' <summary>
- ''' 根据请求结果,记录 IP 地址的可靠度。
- ''' </summary>
- Private Sub RecordIPReliability(IP As String, Result As Double)
- If IP Is Nothing Then Return
- IPReliability.AddOrUpdate(IP,
- Function() Result * 0.5,
- Function(k, v) v * 0.5 + Result * 0.5
- )
- End Sub
- #End Region
- #Region "多线程下载引擎"
- Private ThreadClient As New HttpClient(New HttpClientHandler With {
- .AutomaticDecompression = DecompressionMethods.Deflate Or DecompressionMethods.GZip
- })
- ''' <summary>
- ''' 最大线程数。
- ''' </summary>
- Public ReadOnly Property NetTaskThreadLimit As Integer
- Get
- Return Settings.Get(Of Integer)("ToolDownloadThread") + 1
- End Get
- End Property
- ''' <summary>
- ''' 速度下限。
- ''' </summary>
- Public NetTaskSpeedLimitLow As Long = 256 * 1024L '256K/s
- ''' <summary>
- ''' 速度上限。若无限制则为 -1。
- ''' </summary>
- Public ReadOnly Property NetTaskSpeedLimitHigh As Long
- Get
- If _NetTaskSpeedLimitHigh Is Nothing Then
- Dim Setting As Integer = Settings.Get(Of Integer)("ToolDownloadSpeed")
- If Setting <= 14 Then
- _NetTaskSpeedLimitHigh = (Setting + 1) * 0.1 * 1024 * 1024L
- ElseIf Setting <= 31 Then
- _NetTaskSpeedLimitHigh = (Setting - 11) * 0.5 * 1024 * 1024L
- ElseIf Setting <= 41 Then
- _NetTaskSpeedLimitHigh = (Setting - 21) * 1024 * 1024L
- Else
- _NetTaskSpeedLimitHigh = -1
- End If
- End If
- Return _NetTaskSpeedLimitHigh.Value
- End Get
- End Property
- Private _NetTaskSpeedLimitHigh As Long? = Nothing
- Public Sub UpdateNetTaskSpeedLimitHigh(Value As Integer)
- _NetTaskSpeedLimitHigh = Nothing
- End Sub
- ''' <summary>
- ''' 基于限速,当前可以下载的剩余量。
- ''' </summary>
- Public NetTaskSpeedLimitLeft As Long = -1
- ''' <summary>
- ''' 正在运行中的线程数。
- ''' </summary>
- Public NetTaskThreadCount As Integer = 0
- ''' <summary>
- ''' 下载源。
- ''' </summary>
- Public Class NetSource
- Public Id As Integer
- Public Url As String
- Public FailCount As Integer
- Public Ex As Exception
- ''' <summary>
- ''' 若该下载源正在进行强制单线程下载,标记这个唯一的线程。
- ''' </summary>
- Public SingleThread As NetThread
- Public IsFailed As Boolean
- Public Overrides Function ToString() As String
- Return Url
- End Function
- End Class
- ''' <summary>
- ''' 下载进度标示。
- ''' </summary>
- Public Enum NetState
- ''' <summary>
- ''' 尚未进行已存在检查。
- ''' </summary>
- WaitingToCheck = -1
- ''' <summary>
- ''' 尚未开始。
- ''' </summary>
- WaitingToDownload = 0
- ''' <summary>
- ''' 正在连接,尚未获取文件大小。
- ''' </summary>
- Connecting = 1
- ''' <summary>
- ''' 已连接,正在尝试接收首个下载数据包。
- ''' </summary>
- Reading = 2
- ''' <summary>
- ''' 正在下载。
- ''' </summary>
- Downloading = 3
- ''' <summary>
- ''' 正在合并文件。
- ''' </summary>
- Merging = 4
- ''' <summary>
- ''' 已完成。
- ''' </summary>
- Finished = 5
- ''' <summary>
- ''' 已失败或中断。
- ''' </summary>
- Interrupted = 6
- End Enum
- ''' <summary>
- ''' 预下载检查行为。
- ''' </summary>
- Public Enum NetPreDownloadBehaviour
- ''' <summary>
- ''' 当文件已存在时,显示提示以提醒用户是否继续下载。
- ''' </summary>
- HintWhileExists
- ''' <summary>
- ''' 当文件已存在或正在下载时,直接退出下载函数执行,不对用户进行提示。
- ''' </summary>
- ExitWhileExistsOrDownloading
- ''' <summary>
- ''' 不进行已存在检查。
- ''' </summary>
- IgnoreCheck
- End Enum
- ''' <summary>
- ''' 下载线程。
- ''' </summary>
- Public Class NetThread
- Implements IEnumerable(Of NetThread), IEquatable(Of NetThread)
- ''' <summary>
- ''' 对应的下载任务。
- ''' </summary>
- Public Task As NetFile
- ''' <summary>
- ''' 对应的线程。
- ''' </summary>
- Public Thread As Thread
- ''' <summary>
- ''' 链表中的下一个线程。
- ''' </summary>
- Public NextThread As NetThread
- Private ReadOnly Iterator Property [Next]() As IEnumerable(Of NetThread)
- Get
- Dim CurrentChain As NetThread = Me
- While CurrentChain IsNot Nothing
- Yield CurrentChain
- CurrentChain = CurrentChain.NextThread
- End While
- End Get
- End Property
- Public Function GetEnumerator() As IEnumerator(Of NetThread) Implements IEnumerable(Of NetThread).GetEnumerator
- Return [Next].GetEnumerator()
- End Function
- Private Function IEnumerable_GetEnumerator() As IEnumerator Implements IEnumerable.GetEnumerator
- Return [Next].GetEnumerator()
- End Function
- ''' <summary>
- ''' 分配给任务中每个线程(无论其是否失败)的编号。
- ''' </summary>
- Public Uuid As Integer
- ''' <summary>
- ''' 是否为第一个线程。
- ''' </summary>
- Public ReadOnly Property IsFirstThread As Boolean
- Get
- Return DownloadStart = 0 AndAlso Task.FileSize = -2
- End Get
- End Property
- ''' <summary>
- ''' 该线程的缓存文件。
- ''' </summary>
- Public Temp As String
- ''' <summary>
- ''' 线程下载起始位置。
- ''' </summary>
- Public DownloadStart As Long
- ''' <summary>
- ''' 线程下载结束位置。
- ''' </summary>
- Public ReadOnly Property DownloadEnd As Long
- Get
- SyncLock Task.LockChain
- If NextThread Is Nothing Then
- If Task.IsUnknownSize Then
- Return 1000 * 1000 * 1000 * 1000L '约 1T
- Else
- Return Task.FileSize - 1
- End If
- Else
- Return NextThread.DownloadStart - 1
- End If
- End SyncLock
- End Get
- End Property
- ''' <summary>
- ''' 线程未下载的文件大小。
- ''' </summary>
- Public ReadOnly Property DownloadUndone As Long
- Get
- Return DownloadEnd - (DownloadStart + DownloadDone) + 1
- End Get
- End Property
- ''' <summary>
- ''' 线程已下载的文件大小。
- ''' </summary>
- Public DownloadDone As Long = 0
- ''' <summary>
- ''' 上次记速时的时间。
- ''' </summary>
- Private SpeedLastTime As Long = GetTimeMs()
- ''' <summary>
- ''' 上次记速时的已下载大小。
- ''' </summary>
- Private SpeedLastDone As Long = 0
- ''' <summary>
- ''' 当前的下载速度,单位为 Byte / 秒。
- ''' </summary>
- Public ReadOnly Property Speed As Long
- Get
- If GetTimeMs() - SpeedLastTime > 200 Then
- Dim DeltaTime As Long = GetTimeMs() - SpeedLastTime
- _Speed = (DownloadDone - SpeedLastDone) / (DeltaTime / 1000)
- SpeedLastDone = DownloadDone
- SpeedLastTime += DeltaTime
- End If
- Return _Speed
- End Get
- End Property
- Private _Speed As Long = 0
- ''' <summary>
- ''' 线程初始化时的时间。
- ''' </summary>
- Public InitTime As Long = GetTimeMs()
- ''' <summary>
- ''' 上次接受到有效数据的时间,-1 表示尚未有有效数据。
- ''' </summary>
- Public LastReceiveTime As Long = -1
- ''' <summary>
- ''' 当前线程的状态。
- ''' </summary>
- Public State As NetState = NetState.WaitingToDownload
- ''' <summary>
- ''' 是否已经结束。
- ''' </summary>
- Public ReadOnly Property IsEnded As Boolean
- Get
- Return State = NetState.Finished OrElse State = NetState.Interrupted
- End Get
- End Property
- ''' <summary>
- ''' 当前选取的是哪一个 Url。
- ''' </summary>
- Public Source As NetSource
- '允许进行 UUID 比较
- Public Overloads Function Equals(other As NetThread) As Boolean Implements IEquatable(Of NetThread).Equals
- Return other IsNot Nothing AndAlso Uuid = other.Uuid
- End Function
- Public Overrides Function Equals(obj As Object) As Boolean
- Return Equals(TryCast(obj, NetThread))
- End Function
- Public Shared Operator =(left As NetThread, right As NetThread) As Boolean
- Return EqualityComparer(Of NetThread).Default.Equals(left, right)
- End Operator
- Public Shared Operator <>(left As NetThread, right As NetThread) As Boolean
- Return Not left = right
- End Operator
- End Class
- ''' <summary>
- ''' 下载单个文件。
- ''' </summary>
- Public Class NetFile
- #Region "属性"
- ''' <summary>
- ''' 所属的文件列表任务。
- ''' </summary>
- Public Tasks As New ConcurrentList(Of LoaderDownload)
- ''' <summary>
- ''' 所有下载源。
- ''' </summary>
- Public Sources As ConcurrentList(Of NetSource)
- ''' <summary>
- ''' 用于在第一个线程出错时切换下载源。
- ''' </summary>
- Private FirstThreadSource As Integer = 0
- ''' <summary>
- ''' 所有已经被标记为失败的,但未完整尝试过的,不允许断点续传的下载源。
- ''' </summary>
- Public SourcesOnce As New ConcurrentList(Of NetSource)
- ''' <summary>
- ''' 仅当合并失败或首次下载失败时,会将所有下载源重新标记为不允许断点续传的下载源,逐个重新尝试下载。
- ''' 这一策略可以兼容多个下载源中的一部分返回错误的文件的情况,以及部分在多线程下载时会抽风的源。
- ''' </summary>
- Private Retried As Boolean = False
- ''' <summary>
- ''' 获取从某个源开始,第一个可用的源。
- ''' </summary>
- Private Function GetSource(Optional Id As Integer = 0) As NetSource
- If Id >= Sources.Count OrElse Id < 0 Then Id = 0
- SyncLock LockSource
- If HasAvailableSource(False) Then
- '存在多线程可用源
- Dim CurrentSource As NetSource = Sources(Id)
- While CurrentSource.IsFailed
- Id += 1
- If Id >= Sources.Count Then Id = 0
- CurrentSource = Sources(Id)
- End While
- Return CurrentSource
- ElseIf SourcesOnce.Any Then
- '仅存在单线程可用源
- Return SourcesOnce.First
- Else
- '没有可用源
- Return Nothing
- End If
- End SyncLock
- End Function
- ''' <summary>
- ''' 是否存在可用源。
- ''' </summary>
- Public Function HasAvailableSource(Optional AllowOnceSource As Boolean = True) As Boolean
- SyncLock LockSource
- If Sources.Any(Function(s) Not s.IsFailed) Then Return True '存在多线程可用源
- If AllowOnceSource AndAlso SourcesOnce.Any Then Return True '存在单线程可用源
- End SyncLock
- Return False
- End Function
- ''' <summary>
- ''' 存储在本地的带文件名的地址。
- ''' </summary>
- Public LocalPath As String = Nothing
- ''' <summary>
- ''' 存储在本地的文件名。
- ''' </summary>
- Public LocalName As String = Nothing
- ''' <summary>
- ''' 当前的下载状态。
- ''' </summary>
- Public State As NetState = NetState.WaitingToCheck
- ''' <summary>
- ''' 导致下载失败的原因。
- ''' </summary>
- Public Ex As New List(Of Exception)
- ''' <summary>
- ''' 作为文件组成部分的线程链表。
- ''' 如果没有线程,可以为 Nothing。
- ''' </summary>
- Public Threads As NetThread
- ''' <summary>
- ''' 文件的总大小。若为 -2 则为未获取,若为 -1 则为无法获取准确大小。
- ''' </summary>
- Public FileSize As Long = -2
- ''' <summary>
- ''' 该文件是否无法获取准确大小。
- ''' </summary>
- Public IsUnknownSize As Boolean = False
- ''' <summary>
- ''' 该文件是否不需要分割。
- ''' </summary>
- Public ReadOnly Property IsNoSplit As Boolean
- Get
- Return IsUnknownSize OrElse FileSize < 1024 * 1024 '小于 1MB 的文件不分割
- End Get
- End Property
- ''' <summary>
- ''' 为不需要分割的小文件在内存中提供临时存储。
- ''' </summary>
- Private Cache As MemoryStream
- ''' <summary>
- ''' 文件的已下载大小。
- ''' </summary>
- Public DownloadDone As Long = 0
- ''' <summary>
- ''' 文件的校验规则。
- ''' </summary>
- Public Check As FileChecker
- ''' <summary>
- ''' 是否模拟浏览器的 UserAgent 和 Referer。
- ''' </summary>
- Public SimulateBrowserHeaders As Boolean
- ''' <summary>
- ''' 上次记速时的时间。
- ''' </summary>
- Private SpeedLastTime As Long = GetTimeMs()
- ''' <summary>
- ''' 上次记速时的已下载大小。
- ''' </summary>
- Private SpeedLastDone As Long = 0
- ''' <summary>
- ''' 当前的下载速度,单位为 Byte / 秒。
- ''' </summary>
- Public ReadOnly Property Speed As Long
- Get
- If GetTimeMs() - SpeedLastTime > 200 Then
- Dim DeltaTime As Long = GetTimeMs() - SpeedLastTime
- _Speed = (DownloadDone - SpeedLastDone) / (DeltaTime / 1000)
- SpeedLastDone = DownloadDone
- SpeedLastTime += DeltaTime
- End If
- Return _Speed
- End Get
- End Property
- Private _Speed As Long = 0
- ''' <summary>
- ''' 该文件是否由本地文件直接复制完成。
- ''' </summary>
- Public IsCopy As Boolean = False
- ''' <summary>
- ''' 本文件的显示进度。
- ''' </summary>
- Public ReadOnly Property Progress As Double
- Get
- Select Case State
- Case NetState.WaitingToCheck
- Return 0
- Case NetState.WaitingToDownload
- Return 0.01
- Case NetState.Connecting
- Return 0.02
- Case NetState.Reading
- Return 0.04
- Case NetState.Downloading
- '正在下载中,对应 5% ~ 98%
- Dim OriginalProgress As Double = If(IsUnknownSize, 0.5, DownloadDone / Math.Max(FileSize, 1))
- OriginalProgress = 1 - (1 - OriginalProgress) ^ 0.9
- Return OriginalProgress * 0.93 + 0.05
- Case NetState.Merging
- Return 0.99
- Case NetState.Finished, NetState.Interrupted
- Return 1
- Case Else
- Throw New ArgumentOutOfRangeException("文件状态未知:" & State)
- End Select
- End Get
- End Property
- ''' <summary>
- ''' 各个线程建立连接成功的总次数。
- ''' </summary>
- Private ConnectCount As Integer = 0
- ''' <summary>
- ''' 各个线程建立连接成功的总时间。
- ''' </summary>
- Private ConnectTime As Long = 0
- ''' <summary>
- ''' 各个线程建立连接成功的平均时间,单位为毫秒,-1 代表尚未有成功连接。
- ''' </summary>
- Private ReadOnly Property ConnectAverage As Integer
- Get
- Dim CurrentConnectCount As Integer = ConnectCount '防止竞态条件
- Return If(CurrentConnectCount = 0, -1, ConnectTime / CurrentConnectCount)
- End Get
- End Property
- Private Const FilePieceLimit As Long = 256 * 1024
- Public ReadOnly LockState As New Object
- Public ReadOnly LockChain As New Object
- Public ReadOnly LockSource As New Object
- Public ReadOnly Uuid As Integer = GetUuid()
- Public Overrides Function Equals(obj As Object) As Boolean
- Dim file = TryCast(obj, NetFile)
- Return file IsNot Nothing AndAlso Uuid = file.Uuid
- End Function
- #End Region
- ''' <summary>
- ''' 新建一个需要下载的文件。
- ''' </summary>
- ''' <param name="LocalPath">包含文件名的本地地址。</param>
- Public Sub New(Urls As IEnumerable(Of String), LocalPath As String, Optional Checker As FileChecker = Nothing, Optional SimulateBrowserHeaders As Boolean = False)
- Dim Sources As New List(Of NetSource)
- Dim Count As Integer = 0
- Urls = Urls.Distinct.ToArray
- For Each Source As String In Urls
- Sources.Add(New NetSource With {.FailCount = 0, .Url = SecretCdnSign(Source.ReplaceLineEndings("").Trim), .Id = Count, .IsFailed = False, .Ex = Nothing})
- Count += 1
- Next
- Me.Sources = Sources
- Me.LocalPath = LocalPath
- Me.Check = Checker
- Me.SimulateBrowserHeaders = SimulateBrowserHeaders
- Me.LocalName = PathUtils.GetLastPart(LocalPath)
- End Sub
- ''' <summary>
- ''' 尝试开始一个新的下载线程。
- ''' 如果失败,返回 Nothing。
- ''' </summary>
- Public Function TryBeginThread() As NetThread
- Try
- Dim StartPosition As Long, StartSource As NetSource = Nothing
- Dim Th As Thread, ThreadInfo As NetThread
- '条件检测
- SyncLock LockSource
- If NetTaskThreadCount >= NetTaskThreadLimit OrElse Not HasAvailableSource() OrElse
- (IsNoSplit AndAlso Threads IsNot Nothing AndAlso Threads.State <> NetState.Interrupted) Then Return Nothing
- If State >= NetState.Merging OrElse State = NetState.WaitingToCheck Then Return Nothing
- SyncLock LockState
- If State < NetState.Connecting Then State = NetState.Connecting
- End SyncLock
- '初始化参数
- '获取线程起点与下载源
- '不分割
- If IsNoSplit Then GoTo StartSingleThreadDownload
- '只剩下单线程可用点
- If Not HasAvailableSource(False) Then
- If SourcesOnce.First.SingleThread IsNot Nothing AndAlso SourcesOnce.First.SingleThread.State <> NetState.Interrupted Then Return Nothing
- StartSingleThreadDownload:
- Reset()
- State = NetState.Reading
- End If
- End SyncLock
- SyncLock LockChain
- '首个开始点
- If Threads Is Nothing Then
- StartPosition = 0
- StartSource = GetSource(FirstThreadSource)
- FirstThreadSource = StartSource.Id + 1
- GoTo StartThread
- End If
- '寻找失败点
- For Each Thread As NetThread In Threads
- If Thread.State = NetState.Interrupted AndAlso Thread.DownloadUndone > 0 Then
- StartPosition = Thread.DownloadStart + Thread.DownloadDone
- StartSource = GetSource(Thread.Source.Id + 1)
- GoTo StartThread
- End If
- Next
- '是否禁用多线程,以及规定碎片大小
- Dim Source As NetSource = GetSource()
- If Source Is Nothing Then Return Nothing
- Dim TargetUrl As String = Source.Url
- If TargetUrl.Contains("pcl2-server") OrElse TargetUrl.Contains("meloong.com") OrElse TargetUrl.Contains("bmclapi") OrElse TargetUrl.Contains("github.com") OrElse
- TargetUrl.Contains("optifine.net") OrElse TargetUrl.Contains("momot.rs") Then Return Nothing
- '寻找最大碎片
- 'FUTURE: 下载引擎重做,计算下载源平均链接时间和线程下载速度,按最高时间节省来开启多线程
- Dim FilePieceMax As NetThread = Threads
- For Each Thread As NetThread In Threads
- If Thread.DownloadUndone > FilePieceMax.DownloadUndone Then FilePieceMax = Thread
- Next
- If FilePieceMax Is Nothing OrElse FilePieceMax.DownloadUndone < FilePieceLimit Then Return Nothing
- StartPosition = FilePieceMax.DownloadEnd - FilePieceMax.DownloadUndone * 0.4
- StartSource = GetSource()
- '开始线程
- StartThread:
- If (StartPosition > FileSize AndAlso FileSize >= 0 AndAlso Not IsUnknownSize) OrElse StartPosition < 0 OrElse IsNothing(StartSource) Then Return Nothing
- '构建线程
- Dim ThreadUuid As Integer = GetUuid()
- If Not Tasks.Any() Then Return Nothing '由于中断,已没有可用任务
- Th = New Thread(AddressOf Thread) With {.Name = $"下载 {Tasks(0).Uuid}/{Uuid}/{ThreadUuid}#", .Priority = ThreadPriority.BelowNormal}
- ThreadInfo = New NetThread With {.Uuid = ThreadUuid, .DownloadStart = StartPosition, .Thread = Th, .Source = StartSource, .Task = Me, .State = NetState.WaitingToDownload}
- '链表处理
- If ThreadInfo.IsFirstThread OrElse Threads Is Nothing Then
- Threads = ThreadInfo
- Else
- Dim CurrentChain As NetThread = Threads
- While CurrentChain.DownloadEnd <= StartPosition
- CurrentChain = CurrentChain.NextThread
- End While
- ThreadInfo.NextThread = CurrentChain.NextThread
- CurrentChain.NextThread = ThreadInfo
- End If
- End SyncLock
- '开始线程
- Interlocked.Increment(NetTaskThreadCount)
- SyncLock LockSource
- If Not HasAvailableSource(False) Then SourcesOnce.First.SingleThread = ThreadInfo
- End SyncLock
- Th.Start(ThreadInfo)
- Return ThreadInfo
- Catch ex As Exception
- Fail(New Exception($"尝试开始下载线程失败({LocalName})", ex))
- Return Nothing
- End Try
- End Function
- ''' <summary>
- ''' 每个下载线程执行的代码。
- ''' </summary>
- Private Sub Thread(Th As NetThread)
- Logger.Log($"{LocalName}:开始,起始点 {Th.DownloadStart},{Th.Source.Url}", If(Th.DownloadStart = 0, LogLevel.Info, LogLevel.Trace))
- Dim ResultStream As Stream = Nothing, HttpRequest As HttpRequestMessage = Nothing,
- Response As HttpResponseMessage = Nothing, ResponseStream As Stream = Nothing,
- CancelToken As CancellationTokenSource = Nothing, HostIp As String = Nothing
- '部分下载源真的特别慢,并且只需要一个请求,例如 Ping 为 20s,如果增长太慢,就会造成类似 2.5s 5s 7.5s 10s 12.5s... 的极大延迟
- '延迟过长会导致某些特别慢的链接迟迟不被掐死
- Dim Timeout As Integer = Math.Min(Math.Max(ConnectAverage, 15000) * (1 + Th.Source.FailCount), 30000)
- Th.State = NetState.Connecting
- Try
- Dim HttpDataCount As Integer = 0
- If SourcesOnce.Contains(Th.Source) AndAlso Th <> Th.Source.SingleThread Then GoTo SourceBreak
- HttpRequest = New HttpRequestMessage(HttpMethod.Get, Th.Source.Url)
- SecretHeadersSign(Th.Source.Url, HttpRequest, SimulateBrowserHeaders)
- CancelToken = New CancellationTokenSource(Timeout)
- HostIp = DNSLookup(HttpRequest, CancelToken.Token) 'DNS 预解析
- If Not Th.IsFirstThread Then HttpRequest.Headers.Range = New Headers.RangeHeaderValue(Th.DownloadStart, Nothing)
- Dim ContentLength As Long = 0
- Response = ThreadClient.SendAsync(HttpRequest, HttpCompletionOption.ResponseHeadersRead, CancelToken.Token).GetResultWithTimeout(CancelToken, Timeout)
- If Not Response.IsSuccessStatusCode Then '状态码检查
- Throw New HttpRequestCodeException($"错误码 {Response.StatusCode} ({CInt(Response.StatusCode)}),{Th.Source.Url}", Response.StatusCode, Nothing)
- End If
- If State = NetState.Interrupted Then GoTo SourceBreak '快速中断
- If Response.RequestMessage.RequestUri.ToString <> Th.Source.Url Then Logger.Trace(Function() $"{LocalName}:重定向至 {Response.RequestMessage.RequestUri}")
- '文件大小校验
- ContentLength = Response.Content.Headers.ContentLength.GetValueOrDefault(-1)
- If ContentLength < 0 Then
- If FileSize > 1 Then
- If Th.DownloadStart = 0 Then
- Logger.Info($"{LocalName}:文件大小未知,但已从其他下载源获取,不作处理")
- Else
- Logger.Info($"{LocalName}:ContentLength 返回了 {ContentLength},无法确定是否支持分段下载,视作不支持")
- GoTo NotSupportRange
- End If
- Else
- FileSize = -1 : IsUnknownSize = True
- Logger.Info($"{LocalName}:文件大小未知")
- End If
- ElseIf Th.IsFirstThread Then
- If Check IsNot Nothing Then
- If ContentLength < Check.MinSize AndAlso Check.MinSize > 0 Then
- Throw New Exception($"文件大小不足,获取结果为 {ContentLength},要求至少为 {Check.MinSize}。")
- End If
- If ContentLength <> Check.ActualSize AndAlso Check.ActualSize > 0 Then
- Throw New Exception($"文件大小不一致,获取结果为 {ContentLength},要求必须为 {Check.ActualSize}。")
- End If
- End If
- FileSize = ContentLength : IsUnknownSize = False
- Logger.Info($"{LocalName}:文件大小 {ContentLength}({FormatFileSize(ContentLength)})")
- '若文件大小大于 50 M,进行剩余磁盘空间校验
- If ContentLength > 50 * 1024 * 1024 Then
- For Each Drive As DriveInfo In DriveInfo.GetDrives
- Dim DriveName As String = Drive.Name.First.ToString
- Dim RequiredSpace = If(PathTemp.StartsWithF(DriveName), ContentLength * 1.1, 0) +
- If(LocalPath.StartsWithF(DriveName), ContentLength + 5 * 1024 * 1024, 0)
- If RequiredSpace > 0 AndAlso Drive.TotalFreeSpace < RequiredSpace Then
- Throw New Exception(DriveName & " 盘空间不足,无法进行下载。" & vbCrLf & "需要至少 " & FormatFileSize(RequiredSpace) & " 空间,但当前仅剩余 " & FormatFileSize(Drive.TotalFreeSpace) & "。" &
- If(PathTemp.StartsWithF(DriveName), vbCrLf & vbCrLf & "下载时需要与文件同等大小的空间存放缓存,你可以在设置中调整缓存文件夹的位置。", ""))
- End If
- Next
- End If
- ElseIf FileSize < 0 Then
- Throw New Exception("非首线程运行时,尚未获取文件大小")
- ElseIf Th.DownloadStart > 0 AndAlso ContentLength >= FileSize Then
- NotSupportRange:
- SyncLock LockSource
- If SourcesOnce.Contains(Th.Source) Then GoTo SourceBreak
- End SyncLock
- Throw New RangeNotSupportedException($"该下载源不支持分段下载:Range 起始于 {Th.DownloadStart},预期 ContentLength 为 {FileSize - Th.DownloadStart},返回 ContentLength 为 {ContentLength},总文件大小 {FileSize}")
- ElseIf Not FileSize - Th.DownloadStart = ContentLength Then
- Throw New RangeNotSupportedException($"获取到的分段大小不一致:Range 起始于 {Th.DownloadStart},预期 ContentLength 为 {FileSize - Th.DownloadStart},返回 ContentLength 为 {ContentLength},总文件大小 {FileSize}")
- End If
- 'Log($"[Download] {LocalName} {Info.Uuid}#:通过大小检查,文件大小 {FileSize},起始点 {Info.DownloadStart},ContentLength {ContentLength}")
- Th.State = NetState.Reading
- SyncLock LockState
- If State < NetState.Reading Then State = NetState.Reading
- End SyncLock
- '创建缓存文件
- If IsNoSplit Then
- Th.Temp = Nothing
- Cache = New MemoryStream
- ResultStream = Cache
- Else
- Th.Temp = $"{PathTemp}Download\{Uuid}_{Th.Uuid}_{RandomInteger(0, 999999)}.tmp"
- ResultStream = FileUtils.CreateAsStream(Th.Temp)
- End If
- '开始下载
- CancelToken.Dispose() : CancelToken = New CancellationTokenSource() '重置超时,不再让 CancellationToken 在固定时间后触发(#8488)
- ResponseStream = Response.Content.ReadAsStreamAsync().GetAwaiter().GetResult()
- If Settings.Get(Of Boolean)("SystemDebugDelay") Then Threading.Thread.Sleep(RandomInteger(50, 3000))
- Dim ResponseBytes As Byte() = New Byte(16384) {}
- HttpDataCount = ResponseStream.ReadAsync(ResponseBytes, 0, 16384, CancelToken.Token).GetResultWithTimeout(CancelToken, Timeout)
- While (IsUnknownSize OrElse Th.DownloadUndone > 0) AndAlso '判断是否下载完成
- HttpDataCount > 0 AndAlso Not IsProgramEnding AndAlso State < NetState.Merging AndAlso (Not Th.Source.IsFailed OrElse Th.Source.SingleThread = Th)
- '限速
- While NetTaskSpeedLimitHigh > 0 AndAlso NetTaskSpeedLimitLeft <= 0
- Threading.Thread.Sleep(16)
- End While
- Dim RealDataCount As Integer = If(IsUnknownSize, HttpDataCount, Math.Min(HttpDataCount, Th.DownloadUndone))
- If NetTaskSpeedLimitHigh > 0 Then Interlocked.Add(NetTaskSpeedLimitLeft, -RealDataCount)
- Dim DeltaTime = GetTimeMs() - Th.LastReceiveTime
- If DeltaTime > 100000000 Then DeltaTime = 1 '避免时间刻反转导致出现极大值
- If RealDataCount > 0 Then
- '有数据
- If Th.DownloadDone = 0 Then
- '第一次接受到数据
- Th.State = NetState.Downloading
- SyncLock LockState
- If State < NetState.Downloading Then State = NetState.Downloading
- End SyncLock
- Interlocked.Increment(ConnectCount)
- Interlocked.Add(ConnectTime, GetTimeMs() - Th.InitTime)
- End If
- Th.Source.FailCount = 0
- For Each Task In Tasks
- Task.FailCount = 0
- Next
- Interlocked.Add(NetManager.DownloadDone, RealDataCount)
- Interlocked.Add(DownloadDone, RealDataCount)
- Th.DownloadDone += RealDataCount
- ResultStream.Write(ResponseBytes, 0, RealDataCount)
- '已完成
- If Th.DownloadUndone = 0 AndAlso Not IsUnknownSize Then Exit While
- '检查速度是否过慢
- If Th.LastReceiveTime > 0 AndAlso DeltaTime > 5000 AndAlso DeltaTime > RealDataCount AndAlso '数据包间隔大于 5s,且速度小于 1K/s
- Th.Source.SingleThread Is Nothing Then '且并非单线程下载
- Throw New TimeoutException("由于速度过慢断开链接,下载 " & RealDataCount & " B,消耗 " & DeltaTime & " ms。")
- End If
- Th.LastReceiveTime = GetTimeMs()
- ElseIf Th.LastReceiveTime > 0 AndAlso DeltaTime > Timeout Then
- '无数据,且已超时
- Throw New TimeoutException("操作超时,无数据。")
- End If
- HttpDataCount = ResponseStream.ReadAsync(ResponseBytes, 0, 16384, CancelToken.Token).GetResultWithTimeout(CancelToken, Timeout)
- End While
- SourceBreak:
- If State = NetState.Interrupted OrElse (Th.Source.IsFailed AndAlso Th.Source.SingleThread <> Th) OrElse (Th.DownloadUndone > 0 AndAlso Not IsUnknownSize) Then
- '被外部中断
- Th.State = NetState.Interrupted
- Logger.Info($"{LocalName}:中断")
- ElseIf HttpDataCount = 0 AndAlso Th.DownloadUndone > 0 AndAlso Not IsUnknownSize Then
- '服务器无返回数据
- Throw New Exception($"返回的 ContentLength 过多:ContentLength 为 {ContentLength},但获取到的总数据量仅为 {Th.DownloadDone}(全文件总数据量 {DownloadDone})")
- Else
- '本线程完成
- Th.State = NetState.Finished
- Logger.Trace(Function() $"{LocalName}:完成,已下载 {Th.DownloadDone}({Th.DownloadStart}~{Th.DownloadEnd})")
- RecordIPReliability(HostIp, 0.5)
- End If
- Catch ex As Exception
- If ex.IsBadNetwork OrElse TypeOf ex Is TaskCanceledException Then
- Logger.Info(New TimeoutException($"{LocalName}:超时({Timeout}ms),{ex.Message},IP:{If(String.IsNullOrEmpty(HostIp), "自动", "")}"))
- Else
- Logger.Info(ex, $"{LocalName}:出错,IP:{HostIp}")
- End If
- RecordIPReliability(HostIp, -0.7)
- SourceFail(Th, ex, False)
- Finally
- '释放资源
- HttpRequest?.Dispose()
- Response?.Dispose()
- ResponseStream?.Dispose()
- CancelToken?.Dispose()
- If Not IsNoSplit Then ResultStream?.Dispose()
- '改变计数
- Interlocked.Decrement(NetTaskThreadCount)
- '合并
- If ((FileSize >= 0 AndAlso DownloadDone >= FileSize) OrElse (FileSize = -1 AndAlso DownloadDone > 0)) AndAlso
- State < NetState.Merging AndAlso Th.State <> NetState.Interrupted Then Merge(Th)
- End Try
- End Sub
- Private Sub SourceFail(Th As NetThread, ex As Exception, IsMergeFailure As Boolean)
- '状态变更
- Interlocked.Increment(Th.Source.FailCount)
- For Each Task In Tasks
- Interlocked.Increment(Task.FailCount)
- Next
- Th.State = NetState.Interrupted
- Th.Source.Ex = ex
- '根据情况判断,是否在多线程下禁用下载源(连续错误过多,或不支持断点续传)
- Dim IsRangeNotSupported As Boolean = TypeOf ex Is RangeNotSupportedException OrElse ex.Message.Contains("(416)")
- If IsMergeFailure OrElse IsRangeNotSupported OrElse
- ex.Message.Contains("(502)") OrElse ex.Message.Contains("(404)") OrElse
- ex.Message.Contains("未能解析") OrElse ex.Message.Contains("无返回数据") OrElse ex.Message.Contains("空间不足") OrElse
- ((ex.Message.Contains("(403)") OrElse ex.Message.Contains("(429)")) AndAlso Not Th.Source.Url.Contains("bmclapi")) OrElse 'BMCLAPI 的部分源在高频率请求下会返回 403/429,所以不应因此禁用下载源
- (Th.Source.FailCount >= NetTaskThreadLimit.Clamp(5, 30) AndAlso DownloadDone < 1) OrElse Th.Source.FailCount > NetTaskThreadLimit + 2 Then
- '当一个下载源有多个线程在下载时,只选择其中一个线程进行后续处理
- Dim IsThisFail As Boolean = False
- SyncLock LockSource
- If Not Th.Source.IsFailed OrElse Th.Source.SingleThread = Th Then
- IsThisFail = True
- Th.Source.IsFailed = True
- End If
- End SyncLock
- '……后续处理
- If IsThisFail Then
- Logger.Info($"{LocalName}:下载源被禁用({Th.Source.Id},Range 问题:{IsRangeNotSupported}):{Th.Source.Url}")
- Logger.Warn(ex, $"{If(SourcesOnce.FirstOrDefault?.SingleThread Is Nothing, "", "单线程")}下载源 {Th.Source.Id} 已被禁用")
- SyncLock LockSource
- SourcesOnce.Remove(Th.Source)
- End SyncLock
- If ex.Message.Contains("空间不足") Then
- '硬盘空间不足:强制失败
- Fail(ex)
- ElseIf HasAvailableSource() AndAlso Not IsMergeFailure Then
- '当前源失败,但还有下载源:正常地继续执行
- ElseIf Not Retried Then
- '合并失败或首次下载失败,未重试:将所有下载源重新标记为不允许断点续传的下载源,逐个重新尝试下载
- '若所有源均不支持 Range,也会走到这里重试
- If Not IsRangeNotSupported Then Logger.Warn($"{LocalName}:文件下载失败,正在自动重试……")
- Retried = True
- SyncLock LockSource
- SourcesOnce.Clear()
- For Each Source In Sources
- SourcesOnce.Add(Source)
- Source.IsFailed = True
- Next
- End SyncLock
- Reset()
- SyncLock LockState
- State = NetState.WaitingToDownload
- End SyncLock
- ElseIf HasAvailableSource() AndAlso IsMergeFailure Then
- '合并失败且单个源失败:继续下一个源
- Reset()
- SyncLock LockState
- State = NetState.WaitingToDownload
- End SyncLock
- Else
- '失败
- Logger.Info($"{LocalName}:已无可用下载源,下载失败")
- Dim ExampleEx As Exception = Nothing
- SyncLock LockSource
- For Each Source As NetSource In Sources
- Logger.Info($"已禁用的下载源:{Source.Url}")
- If Source.Ex IsNot Nothing Then
- ExampleEx = Source.Ex
- Logger.Warn(Source.Ex, "下载源禁用原因")
- End If
- Next
- End SyncLock
- Fail(ExampleEx)
- End If
- End If
- End If
- '清理当前已下载的内容
- If FileSize = -2 Then Reset()
- End Sub
- Private Sub Reset()
- FileSize = -2
- Cache?.Dispose() : Cache = Nothing
- SyncLock LockChain
- Threads = Nothing
- End SyncLock
- Interlocked.Add(NetManager.DownloadDone, -DownloadDone)
- DownloadDone = 0
- SpeedLastDone = 0
- End Sub
- '最终收束事件
- ''' <summary>
- ''' 下载完成。合并文件。
- ''' </summary>
- Private Sub Merge(Th As NetThread)
- '状态判断
- SyncLock LockState
- If State < NetState.Merging Then
- State = NetState.Merging
- Else
- Return
- End If
- End SyncLock
- Dim RetryCount As Integer = 0
- Try
- Retry:
- '创建文件夹
- DirectoryUtils.Create(PathUtils.RemoveLastPart(LocalPath))
- FileUtils.Delete(LocalPath)
- SyncLock LockChain
- '合并文件
- If IsNoSplit Then
- '仅有一个线程,从内存中输出
- Logger.Trace(Function() $"{LocalName}:下载结束,从内存输出文件")
- FileUtils.Write(LocalPath, Cache)
- ElseIf Threads.DownloadDone = DownloadDone AndAlso Threads.Temp IsNot Nothing Then
- '仅有一个文件,直接复制
- Logger.Trace(Function() $"{LocalName}:下载结束,仅有一个文件,无需合并")
- FileUtils.Copy(Threads.Temp, LocalPath)
- Else
- '有多个线程,合并
- Logger.Trace(Function() $"{LocalName}:下载结束,开始合并文件")
- Using MergeFile = FileUtils.CreateAsStream(LocalPath)
- Using AddWriter As New BinaryWriter(MergeFile)
- For Each Thread As NetThread In Threads
- If Thread.DownloadDone = 0 OrElse Thread.Temp Is Nothing Then Continue For
- Using fs = FileUtils.ReadAsStream(Thread.Temp)
- Using TempReader As New BinaryReader(fs)
- AddWriter.Write(TempReader.ReadBytes(Thread.DownloadDone))
- End Using
- End Using
- Next
- End Using
- End Using
- End If
- '检查文件
- Dim CheckResult As String = Check?.Check(LocalPath)
- If CheckResult Is Nothing AndAlso Not IsUnknownSize AndAlso Check IsNot Nothing Then
- If Check.ActualSize = -1 Then
- CheckResult = (New FileChecker With {.ActualSize = FileSize}).Check(LocalPath) '不修改原始的 Checker,以免污染原始实例
- ElseIf Check.ActualSize <> FileSize Then
- CheckResult = $"文件大小不一致:任务校验要求为 {Check.ActualSize},请求结果为 {FileSize}"
- End If
- End If
- If CheckResult IsNot Nothing Then
- Logger.Info($"{LocalName} 文件校验失败,下载线程细节:")
- For Each T As NetThread In Threads
- Logger.Info($"- {T.Uuid}#,状态 {T.State},范围 {T.DownloadStart}~{T.DownloadEnd},已下载 {T.DownloadDone},未下载 {T.DownloadUndone}")
- Next
- Throw New Exception(CheckResult)
- End If
- '后处理
- If Not IsNoSplit Then
- For Each Thread As NetThread In Threads
- If Thread.Temp IsNot Nothing Then FileUtils.Delete(Thread.Temp)
- Next
- End If
- Finish()
- End SyncLock
- Catch ex As Exception
- RetryCount += 1
- If State > NetState.Merging Then Return '通常是因为在合并时被中断了
- Logger.Warn(ex, $"合并文件出错,第 {RetryCount} 次尝试({LocalName})")
- '重新尝试合并
- If RetryCount < 3 Then
- Threading.Thread.Sleep(500 * RetryCount)
- GoTo Retry
- End If
- '失败,禁用当前下载源并重启下载
- Try
- FileUtils.Delete(LocalPath)
- Catch exx As Exception
- Logger.Warn(ex, $"删除合并失败的文件出错({LocalPath})")
- End Try
- SourceFail(Th, ex, True)
- Finally
- Cache?.Dispose()
- End Try
- End Sub
- ''' <summary>
- ''' 下载失败。
- ''' </summary>
- Private Sub Fail(Optional RaiseEx As Exception = Nothing)
- SyncLock LockState
- If State >= NetState.Finished Then Return
- If RaiseEx IsNot Nothing Then Ex.Add(RaiseEx)
- '凉凉
- Logger.Info($"{LocalName}:已失败,当前状态 {State}")
- State = NetState.Interrupted
- End SyncLock
- InterruptInternal()
- For Each Task In Tasks
- Task.OnFileFail(Me)
- Next
- End Sub
- ''' <summary>
- ''' 下载中断。
- ''' </summary>
- Public Sub Interrupt(InterruptedTask As LoaderDownload)
- '从特定任务中移除,如果它还属于其他任务,则继续下载
- Tasks.Remove(InterruptedTask)
- If Tasks.Any Then Return
- '确认中断
- SyncLock LockState
- If State >= NetState.Finished Then Return
- Logger.Info($"{LocalName}:已中断,当前状态 {State}")
- State = NetState.Interrupted
- End SyncLock
- InterruptInternal()
- End Sub
- Private Sub InterruptInternal()
- On Error Resume Next
- Reset()
- Interlocked.Decrement(NetManager.FileRemain)
- End Sub
- '状态改变接口
- ''' <summary>
- ''' 将该文件设置为已下载完成。
- ''' </summary>
- Public Sub Finish(Optional PrintLog As Boolean = True)
- SyncLock LockState
- If State >= NetState.Finished Then Return
- State = NetState.Finished
- End SyncLock
- Interlocked.Decrement(NetManager.FileRemain)
- If PrintLog Then Logger.Info($"{LocalName}:已完成")
- For Each Task In Tasks
- Task.OnFileFinish(Me)
- Next
- End Sub
- End Class
- Private Class RangeNotSupportedException
- Inherits HttpRequestException
- Public Sub New(Message As String)
- MyBase.New(Message)
- End Sub
- End Class
- ''' <summary>
- ''' 下载一系列文件的加载器。
- ''' </summary>
- Public Class LoaderDownload
- Inherits LoaderBase
- #Region "属性"
- ''' <summary>
- ''' 需要下载的文件。
- ''' </summary>
- Public Files As ConcurrentList(Of NetFile)
- ''' <summary>
- ''' 剩余未完成的文件数。(用于减轻 FilesLock 的占用)
- ''' </summary>
- Private FileRemain As Integer
- Private ReadOnly FileRemainLock As New Object
- ''' <summary>
- ''' 用于显示的百分比进度。
- ''' </summary>
- Public Overrides Property Progress As Double
- Get
- If State >= LoadState.Finished Then Return 1
- If Not Files.Any() Then Return 0 '必须返回 0,否则在获取列表的时候会错觉已经下载完了
- Return _Progress
- End Get
- Set(value As Double)
- Throw New Exception("文件下载不允许指定进度")
- End Set
- End Property
- Private _Progress As Double = 0
- ''' <summary>
- ''' 任务中的文件的连续失败计数。
- ''' </summary>
- Public Property FailCount As Integer
- Get
- Return _FailCount
- End Get
- Set(value As Integer)
- _FailCount = value
- If State = LoadState.Loading AndAlso value >= Math.Min(10000, Math.Max(FileRemain * 5.5, NetTaskThreadLimit * 5.5 + 3)) Then
- Logger.Warn($"由于同加载器中失败次数过多引发强制失败:连续失败了 {value} 次")
- On Error Resume Next
- Dim ExList As New List(Of Exception)
- For Each File In Files
- For Each Source In File.Sources
- If Source.Ex IsNot Nothing Then
- ExList.Add(Source.Ex)
- If ExList.Count > 10 Then GoTo FinishExCatch
- End If
- Next
- Next
- FinishExCatch:
- OnFail(ExList)
- End If
- End Set
- End Property
- Private _FailCount As Integer = 0
- #End Region
- ''' <summary>
- ''' 刷新公开属性。由 NetManager 每 0.1 秒调用一次。
- ''' </summary>
- Public Sub RefreshStat()
- '计算进度
- Dim NewProgress As Double = 0
- Dim TotalProgress As Double = 0
- For Each File In Files
- If File.IsCopy Then
- NewProgress += File.Progress * 0.2
- TotalProgress += 0.2
- Else
- NewProgress += File.Progress
- TotalProgress += 1
- End If
- Next
- If TotalProgress > 0 AndAlso Not Double.IsNaN(TotalProgress) Then NewProgress /= TotalProgress
- '刷新进度
- _Progress = NewProgress
- End Sub
- Public Sub New(Name As String, FileTasks As IEnumerable(Of NetFile))
- Me.Name = Name
- Files = New ConcurrentList(Of NetFile)(FileTasks)
- End Sub
- Public Overrides Sub Start(Optional Input As Object = Nothing, Optional IsForceRestart As Boolean = False)
- If Input IsNot Nothing Then Files = New ConcurrentList(Of NetFile)(Input)
- '去重
- Files = New ConcurrentList(Of NetFile)(Files.DistinctBy(Function(a) a.LocalPath))
- '设置剩余文件数
- SyncLock FileRemainLock
- FileRemain += Files.Where(Function(f) f.State <> NetState.Finished).Count
- End SyncLock
- State = LoadState.Loading
- '开始执行
- RunInNewThread(
- Sub()
- Try
- '输入检测
- If Not Files.Any() Then
- OnFinish()
- Return
- End If
- For Each File As NetFile In Files
- If File Is Nothing Then Throw New ArgumentException("存在空文件请求!")
- For Each Source As NetSource In File.Sources
- If Not (Source.Url.StartsWithF("https://", True) OrElse Source.Url.StartsWithF("http://", True)) Then
- Source.Ex = New ArgumentException("输入的下载链接不正确:" & Source.Url)
- Source.IsFailed = True
- End If
- Next
- If Not File.HasAvailableSource() Then Throw New ArgumentException("输入的下载链接不正确!")
- File.LocalPath = File.LocalPath.Replace("/", "\")
- If Not File.LocalPath.Lower.Contains(":\") Then Throw New ArgumentException("输入的本地文件地址不正确:" & File.LocalPath)
- If File.LocalPath.EndsWithF("\") Then Throw New ArgumentException("请输入含文件名的完整文件路径:" & File.LocalPath)
- DirectoryUtils.Create(PathUtils.RemoveLastPart(File.LocalPath)) '创建目标文件夹
- Next
- '接入下载管理器,获取新开始下载的文件列表
- Dim NewFiles = NetManager.Start(Me)
- '====================================
- ' 已存在文件查找
- '====================================
- '整理允许进行查找的文件
- Dim FilesToCheck As New List(Of NetFile)
- For Each File In NewFiles
- If File.Check?.CanUseExistsFile Then
- FilesToCheck.Add(File)
- Else '不允许,直接开始下载
- SyncLock LockState
- File.State = NetState.WaitingToDownload
- File.IsCopy = False
- End SyncLock
- End If
- Next
- If Not FilesToCheck.Any Then Return
- '获取 MC 文件夹列表
- Dim Folders As New List(Of String)
- Folders.Add(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) & "\.minecraft\") '总是添加官启文件夹,因为 HMCL 会把所有文件存在这里
- Folders.AddRange(McFolderList.Select(Function(f) f.Location))
- Folders = Folders.Distinct.Where(Function(f) DirectoryUtils.Exists(f)).ToList
- '平均分配到多个检查线程
- Dim ThreadCount As Integer = (FilesToCheck.Count \ 40).Clamp(1, 8) '每个线程至少 40 个文件,最多 8 线程
- If ThreadCount = 1 Then '只有一个线程,直接执行
- CheckExistingFiles(FilesToCheck, Folders)
- Else
- Dim BaseSize = FilesToCheck.Count \ ThreadCount
- Dim Remainder = FilesToCheck.Count Mod ThreadCount
- Dim Index = 0
- For i = 0 To ThreadCount - 1
- Dim Size = BaseSize + If(i < Remainder, 1, 0)
- Dim ThreadFiles = FilesToCheck.GetRange(Index, Size)
- Index += Size
- RunInNewThread(Sub() CheckExistingFiles(ThreadFiles, Folders), $"下载 文件复制 {Uuid}/{GetUuid()}")
- Next
- End If
- Catch ex As Exception
- OnFail(New List(Of Exception) From {New Exception("下载初始化失败", ex)})
- End Try
- End Sub, "L/下载 " & Uuid)
- End Sub
- Private Sub CheckExistingFiles(Files As List(Of NetFile), FolderList As List(Of String))
- Try
- Logger.Trace(Function() $"文件检查开始,本线程负责 {Files.Count} 个文件,首个文件为 {Files.FirstOrDefault?.LocalName}")
- '列出 MC 文件夹中的各个版本文件夹
- Dim VersionFolders As New List(Of String)
- For Each McFolder In FolderList
- For Each VersionFolder In DirectoryUtils.GetDirectories(McFolder & "versions\", True)
- VersionFolders.Add(PathUtils.AddSlashSuffix(VersionFolder))
- Next
- Next
- '处理每个文件
- For Each File As NetFile In Files
- Dim Target As String = CheckExistingFile(FolderList, VersionFolders, File)
- Logger.Trace(Function() $"本地文件匹配:{File.LocalName} → {If(Target, "无匹配")},当前状态 {File.State}")
- If File.State >= NetState.WaitingToDownload Then Continue For '中断
- '已找到相同文件
- If Target IsNot Nothing Then
- File.IsCopy = True
- Try
- FileUtils.Copy(Target, File.LocalPath)
- File.Finish(False)
- Continue For
- Catch
- End Try
- End If
- '回退到下载
- SyncLock LockState
- File.State = NetState.WaitingToDownload
- File.IsCopy = False
- End SyncLock
- Next
- Catch ex As Exception
- OnFail(New List(Of Exception) From {New Exception("下载已存在文件查找失败", ex)})
- End Try
- End Sub
- Private Function CheckExistingFile(FolderList As List(Of String), VersionFolders As List(Of String), File As NetFile) As String
- '目标文件已存在
- If File.Check.Check(File.LocalPath) Is Nothing Then Return File.LocalPath
- '在设置中禁用了复制
- If Settings.Get(Of Boolean)("SystemDebugSkipCopy") Then Return Nothing
- '没有可用的检查规则,只能开始下载
- If File.Check.Hash Is Nothing AndAlso File.Check.ActualSize < 0 Then Return Nothing
- '大致判断文件类别
- Dim TypeIndexes =
- {"\assets\", "\libraries\", "\versions\", "\mods\", "\coremods\", "\lib\", "\resourcepacks\", "\texturepacks\", "\shaderpacks\"}.
- Select(Function(FolderName) (FolderName, File.LocalPath.IndexOfF(FolderName, True))).
- Where(Function(kv) kv.Item2 >= 0).ToList
- If Not TypeIndexes.Any Then
- If File.LocalName.EndsWithF(".jar") Then
- TypeIndexes.Add(("\versions\", 1)) '总是对 jar 进行版本文件检查,以包括另存为 jar 的情况
- Else
- Return Nothing
- End If
- End If
- Dim Type = TypeIndexes.MaxBy(Function(kv) kv.Item2).FolderName.TrimStart("\"c)
- '根据类别进行查找
- Static Sizes As New ConcurrentDictionary(Of String, Long)
- Static CheckCandidate As Func(Of String, FileChecker, Boolean) =
- Function(Candidate, Checker)
- '快速进行大小校验
- Dim Length = Sizes.GetOrAdd(Candidate, Function(c) If(FileUtils.Exists(c), FileUtils.GetInfo(c).Length, -1))
- If Checker.ActualSize <> Length Then Return False
- '进一步进行 Hash 校验
- Return Checker.Check(Candidate) Is Nothing
- End Function
- Select Case Type
- Case "assets\", "libraries\"
- 'assets/libraries:查找 MC 文件夹下的相同路径
- For Each Folder In FolderList
- Dim Candidate = Folder & Type & File.LocalPath.AfterFirst(Type)
- If CheckCandidate(Candidate, File.Check) Then Return Candidate
- Next
- Return Nothing
- Case "versions\"
- '版本 jar 或 json:查找 MC 文件夹下的各个版本文件夹
- For Each VersionFolder In VersionFolders
- For Each Candidate In DirectoryUtils.GetFiles(VersionFolder, True, "*." & PathUtils.GetLastPart(File.LocalPath).AfterLast(".").Lower)
- If CheckCandidate(Candidate, File.Check) Then Return Candidate
- Next
- Next
- Case Else
- '社区资源
- If File.Check.ActualSize < 0 OrElse File.Check.Hash Is Nothing Then Return Nothing '必须要求指定了文件大小和 Hash
- For Each Folder In FolderList.Concat(VersionFolders)
- For Each Candidate In DirectoryUtils.GetFiles(Folder & Type, True)
- If CheckCandidate(Candidate, File.Check) Then Return Candidate
- Next
- Next
- End Select
- Return Nothing
- End Function
- Public Sub OnFileFinish(File As NetFile)
- '要求全部文件完成
- SyncLock FileRemainLock
- FileRemain -= 1
- If FileRemain > 0 Then Return
- End SyncLock
- OnFinish()
- End Sub
- Public Sub OnFinish()
- RaisePreviewFinish()
- SyncLock LockState
- If State > LoadState.Loading Then Return
- State = LoadState.Finished
- End SyncLock
- End Sub
- Public Sub OnFileFail(File As NetFile)
- '将下载源的错误加入主错误列表
- For Each Source In File.Sources
- If Not IsNothing(Source.Ex) Then File.Ex.Add(Source.Ex)
- Next
- OnFail(File.Ex)
- End Sub
- Public Overrides Sub Failed(Ex As Exception)
- OnFail(New List(Of Exception) From {Ex})
- End Sub
- Public Sub OnFail(ExList As List(Of Exception))
- SyncLock LockState
- If State >= LoadState.Finished Then Return
- If ExList Is Nothing OrElse Not ExList.Any() Then ExList = New List(Of Exception) From {New Exception("未知错误!")}
- '寻找有效的错误信息
- Dim UsefulExs = ExList.Where(Function(e) TypeOf e IsNot OperationCanceledException AndAlso TypeOf e IsNot TimeoutException AndAlso TypeOf e IsNot ThreadInterruptedException).ToList
- [Error] = If(UsefulExs.FirstOrDefault, ExList.FirstOrDefault)
- '获取实际失败的文件
- For Each File In Files
- If File.State <> NetState.Interrupted Then Continue For
- If File.Sources.All(Function(s) TypeOf s.Ex Is OperationCanceledException OrElse TypeOf s.Ex Is TimeoutException OrElse TypeOf s.Ex Is ThreadInterruptedException) Then Continue For
- Dim Detail As String = File.Sources.Select(Function(s) $"{If(s.Ex Is Nothing, "无错误信息。", s.Ex.GetDisplay(False))}({s.Url})").Join(vbCrLf)
- [Error] = New Exception("文件下载失败:" & File.LocalPath & vbCrLf &
- "各下载源的错误如下:" & vbCrLf & Detail, [Error])
- '上报
- Telemetry("文件下载失败",
- "FileName", File.LocalName,
- "Exception", Detail)
- Exit For
- Next
- '在设置 Error 对象后再更改为失败,避免 WaitForExit 无法捕获错误
- State = LoadState.Failed
- End SyncLock
- '中断所有文件
- For Each TaskFile In Files
- TaskFile.Interrupt(Me)
- Next
- '在退出同步锁后再进行日志输出
- Logger.Error(New AggregateException(ExList), "下载加载器失败", LogBehavior.None)
- End Sub
- Public Overrides Sub Interrupt()
- SyncLock LockState
- If State >= LoadState.Finished Then Return
- State = LoadState.Interrupted
- End SyncLock
- Logger.Info($"{Name} 已取消!")
- '中断所有文件
- For Each TaskFile In Files
- TaskFile.Interrupt(Me)
- Next
- End Sub
- End Class
- Public NetManager As New NetManagerClass
- ''' <summary>
- ''' 下载文件管理。
- ''' </summary>
- Public Class NetManagerClass
- #Region "属性"
- ''' <summary>
- ''' 需要下载的文件。为“本地地址 - 文件对象”键值对。
- ''' </summary>
- Public AllFiles As New ConcurrentDictionary(Of String, NetFile)
- ''' <summary>
- ''' 当前的所有下载任务。
- ''' </summary>
- Public Tasks As New ConcurrentList(Of LoaderDownload)
- ''' <summary>
- ''' 已下载完成的大小。
- ''' </summary>
- Public DownloadDone As Long = 0
- ''' <summary>
- ''' 尚未完成下载的文件数。
- ''' </summary>
- Public FileRemain As Integer = 0
- '这些属性由 RefreshStat 刷新
- ''' <summary>
- ''' 当前的全局下载速度,单位为 Byte / 秒。
- ''' </summary>
- Public Speed As Long = 0
- Public ReadOnly Uuid As Integer = GetUuid()
- #End Region
- ''' <summary>
- ''' 进度与下载速度由下载管理线程每隔约 0.1 秒刷新一次。
- ''' </summary>
- Private Sub RefreshStat()
- Try
- Dim DeltaTime As Long = GetTimeMs() - RefreshStatLast
- If DeltaTime = 0 Then Return
- RefreshStatLast += DeltaTime
- #Region "刷新整体速度"
- '计算瞬时速度
- Static SpeedLast As New List(Of Long) '记录至多最近 30 次下载速度的记录,较新的在前面
- Static SpeedLastDone As Long = 0 '上次记速时的已下载大小
- Dim ActualSpeed As Double = Math.Max(0, (DownloadDone - SpeedLastDone) / (DeltaTime / 1000))
- SpeedLast.Insert(0, ActualSpeed)
- If SpeedLast.Count >= 31 Then SpeedLast.RemoveAt(30)
- SpeedLastDone = DownloadDone
- '计算用于显示的速度
- Dim SpeedSum As Long = 0, SpeedDiv As Long = 0, Weight = SpeedLast.Count
- For Each SpeedRecord In SpeedLast
- SpeedSum += SpeedRecord * Weight
- SpeedDiv += Weight
- Weight -= 1
- Next
- Speed = If(SpeedDiv > 0, SpeedSum / SpeedDiv, 0)
- '计算新的速度下限
- Dim Limit As Long = 0
- If SpeedLast.Count >= 10 Then Limit = SpeedLast.Take(10).Average * 0.85 '取近 1 秒的平均速度的 85%
- If Limit > NetTaskSpeedLimitLow Then
- NetTaskSpeedLimitLow = Limit
- Logger.Info($"速度下限已提升到 {FormatFileSize(Limit)}")
- End If
- #End Region
- #Region "刷新下载任务属性"
- For Each Task In Tasks
- Task.RefreshStat()
- Next
- #End Region
- Catch ex As Exception
- Logger.Warn(ex, "刷新下载公开属性失败")
- End Try
- End Sub
- Private RefreshStatLast As Long
- ''' <summary>
- ''' 启动监控线程,用于新增下载线程。
- ''' </summary>
- Private Sub StartManager()
- Static IsStarted As Boolean = False
- If IsStarted Then Return
- IsStarted = True
- Dim ThreadStarter =
- Sub(Id As Integer) '0 或 1
- Try
- While True
- Thread.Sleep(20)
- '获取文件列表
- Dim WaitingFiles As New List(Of NetFile)
- Dim OngoingFiles As New List(Of NetFile)
- If Id = 0 AndAlso FileRemain = 0 Then AllFiles.Clear() '若已完成,则清空
- For Each File As NetFile In AllFiles.Values
- If File.Uuid Mod 2 = Id Then Continue For
- If File.State = NetState.WaitingToDownload Then
- WaitingFiles.Add(File)
- ElseIf File.State < NetState.Merging AndAlso File.State >= NetState.WaitingToDownload Then
- OngoingFiles.Add(File)
- End If
- Next
- '为等待中的文件开始线程
- For Each File As NetFile In WaitingFiles
- If NetTaskThreadCount >= NetTaskThreadLimit Then Continue While '最大线程数检查
- Dim NewThread = File.TryBeginThread()
- If NewThread IsNot Nothing AndAlso NewThread.Source.Url.Contains("bmclapi") Then Thread.Sleep(100) '减少 BMCLAPI 请求频率
- Next
- '为进行中的文件追加线程
- If Speed >= NetTaskSpeedLimitLow Then Continue While '下载速度足够,无需新增
- For Each File As NetFile In OngoingFiles
- If NetTaskThreadCount >= NetTaskThreadLimit Then Continue While '最大线程数检查
- '线程种类计数
- Dim PreparingCount = 0, DownloadingCount = 0
- If File.Threads IsNot Nothing Then
- For Each Thread As NetThread In File.Threads.ToList
- If Thread.State < NetState.Downloading Then
- PreparingCount += 1
- ElseIf Thread.State = NetState.Downloading Then
- DownloadingCount += 1
- End If
- Next
- End If
- '新增线程
- If PreparingCount > DownloadingCount Then Continue For '准备中的线程已多于下载中的线程,不再新增
- Dim NewThread = File.TryBeginThread()
- If NewThread IsNot Nothing AndAlso NewThread.Source.Url.Contains("bmclapi") Then Thread.Sleep(100) '减少 BMCLAPI 请求频率
- Next
- End While
- Catch ex As Exception
- Logger.Error(ex, $"下载管理启动线程 {Id} 出错", LogBehavior.AlertThenCrash)
- End Try
- End Sub
- RunInNewThread(Sub() ThreadStarter(0), "NetManager ThreadStarter 0")
- RunInNewThread(Sub() ThreadStarter(1), "NetManager ThreadStarter 1")
- RunInNewThread(
- Sub()
- Try
- Dim NextTick As Long = GetTimeMs()
- While True
- '增加限速余量
- If NetTaskSpeedLimitHigh > 0 Then Interlocked.Add(NetTaskSpeedLimitLeft, CLng(NetTaskSpeedLimitHigh / 10))
- '刷新公开属性
- RefreshStat()
- '等待 100 ms
- NextTick += 100
- Dim SleepTime = NextTick - GetTimeMs()
- If SleepTime > 0 Then
- Thread.Sleep(SleepTime)
- Else
- NextTick = GetTimeMs() '超时,直接追帧,不等待
- End If
- End While
- Catch ex As Exception
- Logger.Error(ex, "下载管理刷新线程出错", LogBehavior.AlertThenCrash)
- End Try
- End Sub, "NetManager StatRefresher")
- End Sub
- Private DownloadCacheLock As New Object
- Private IsDownloadCacheCleared As Boolean = False
- ''' <summary>
- ''' 开始一个下载任务。
- ''' </summary>
- Public Function Start(Task As LoaderDownload) As List(Of NetFile)
- StartManager()
- '清理缓存
- SyncLock DownloadCacheLock '防止同时开启多个下载任务时重复清理
- If Not IsDownloadCacheCleared Then
- Try
- Logger.Info("开始清理下载缓存")
- DirectoryUtils.Delete(PathTemp & "Download")
- Logger.Info("下载缓存已清理")
- Catch ex As Exception
- Logger.Warn(ex, "清理下载缓存失败")
- End Try
- IsDownloadCacheCleared = True
- End If
- End SyncLock
- '添加每个文件
- Dim NewFiles As New List(Of NetFile)
- For i = 0 To Task.Files.Count - 1
- Dim File As NetFile = Task.Files(i)
- Dim OngoingFile As NetFile = Nothing
- If AllFiles.TryGetValue(File.LocalPath, OngoingFile) AndAlso OngoingFile.State < NetState.Finished Then
- '该文件正在被另一个任务下载
- Logger.Trace(Function() $"{File.LocalName}:无需开始,该文件已在下载中,目标 {File.LocalPath}")
- File = OngoingFile '将列表中的文件替换成下载中的文件,即两个任务指向同一个文件;抛弃现在的 File 对象
- Task.Files(i) = File
- Else
- '常规情况(该文件可能已在其他任务中下载完成,但这并不影响)
- Logger.Trace(Function() $"{File.LocalName}:开始,目标 {File.LocalPath}")
- AllFiles(File.LocalPath) = File
- NewFiles.Add(File)
- Interlocked.Increment(FileRemain)
- End If
- File.Tasks.Add(Task)
- Next
- Tasks.Add(Task)
- Return NewFiles
- End Function
- End Class
- ''' <summary>
- ''' 是否有正在进行中、需要在下载管理页面显示的下载任务?
- ''' </summary>
- Public Function HasDownloadingTask(Optional IgnoreCustomDownload As Boolean = False) As Boolean
- Return LoaderTaskbar.Any(
- Function(Task) Task.Show AndAlso Task.State = LoadState.Loading AndAlso
- (Not IgnoreCustomDownload OrElse Not Task.Name.ToString.Contains("自定义下载")))
- End Function
- #End Region
- #Region "端口"
- ''' <summary>
- ''' 随机获取单个可用的端口。
- ''' </summary>
- Public Function FindFreePort() As Integer
- Dim Listener As New TcpListener(IPAddress.Loopback, 0)
- Listener.Start()
- Dim port As Integer = CType(Listener.LocalEndpoint, IPEndPoint).Port
- Listener.Stop()
- Return port
- End Function
- ''' <summary>
- ''' 获取当前已被占用的端口列表。
- ''' </summary>
- Public Function GetUsedPorts() As List(Of Integer)
- Dim IPProperties = NetworkInformation.IPGlobalProperties.GetIPGlobalProperties()
- Dim UsedPorts As New List(Of Integer)
- UsedPorts.AddRange(IPProperties.GetActiveTcpListeners().Select(Function(ep) ep.Port))
- UsedPorts.AddRange(IPProperties.GetActiveUdpListeners().Select(Function(ep) ep.Port))
- UsedPorts.AddRange(IPProperties.GetActiveTcpConnections().Select(Function(conn) conn.LocalEndPoint.Port))
- Return UsedPorts.Distinct().ToList()
- End Function
- ''' <summary>
- ''' 寻找数个连续编号的可用端口。
- ''' </summary>
- Public Function FindFreePorts(ConsecutiveCount As Integer, ParamArray ExtraBlackLists As Integer()) As List(Of Integer)
- Dim UsedPorts = GetUsedPorts().Concat(ExtraBlackLists)
- For port = 12000 + RandomInteger(0, 1000) To 65000 - ConsecutiveCount
- Dim Range = Enumerable.Range(port, ConsecutiveCount)
- If Not Range.Any(Function(p) UsedPorts.Contains(p)) Then Return Range.ToList
- Next
- Throw New Exception("未能找到可用的端口!")
- End Function
- #End Region
- ''' <summary>
- ''' 测试 Ping。失败则返回 -1。
- ''' </summary>
- Public Function Ping(Ip As String, Optional Timeout As Integer = 10000, Optional MakeLog As Boolean = True) As Integer
- Dim PingResult As NetworkInformation.PingReply
- Try
- PingResult = (New NetworkInformation.Ping).Send(Ip)
- Catch ex As Exception
- If MakeLog Then Logger.Info($"Ping {Ip} 失败:{ex.Message}")
- Return -1
- End Try
- If PingResult.Status = NetworkInformation.IPStatus.Success Then
- If MakeLog Then Logger.Info($"Ping {Ip} 结束:{PingResult.RoundtripTime}ms")
- Return PingResult.RoundtripTime
- Else
- If MakeLog Then Logger.Info($"Ping {Ip} 失败")
- Return -1
- End If
- End Function
- ''' <summary>
- ''' 设置是否应当在正版登录时校验 SSL 证书。
- ''' </summary>
- Public Sub ShouldValidateSslCertificateOnLogin(Enabled As Boolean)
- ServicePointManager.ServerCertificateValidationCallback =
- Function(Sender, Certificate, Chain, Failure)
- Dim Request As HttpWebRequest = TryCast(Sender, HttpWebRequest)
- If Failure = Net.Security.SslPolicyErrors.None Then Return True '已通过验证
- '基于 #3018 和 #5879,只在访问正版登录 API 时跳过证书验证
- Logger.Warn($"未通过 SSL 证书验证({Failure}),提供的证书为 {Certificate?.Subject},URL:{Request?.Address}")
- If Request Is Nothing Then
- Return Not Enabled
- ElseIf Request.Address.Host.Contains("xboxlive") OrElse Request.Address.Host.Contains("minecraftservices") Then
- Return Not Enabled '根据设置决定是否忽略错误
- Else
- Return False
- End If
- End Function
- End Sub
- End Module
|