diff --git a/SyncTool/Client/SyncOptions.cs b/SyncTool/Client/SyncOptions.cs index 9538394..f55fb58 100644 --- a/SyncTool/Client/SyncOptions.cs +++ b/SyncTool/Client/SyncOptions.cs @@ -1,6 +1,14 @@ -using dotnetCampus.Cli; +using System; +using System.Configuration.Assemblies; +using System.Net; +using System.Reflection; +using dotnetCampus.Cli; + +using SyncTool.Configurations; using SyncTool.Context; +using SyncTool.Server; +using SyncTool.Utils; namespace SyncTool.Client; @@ -49,25 +57,57 @@ public async Task Run() Console.WriteLine($"开始执行文件夹同步。同步地址:{Address} 同步文件夹{syncFolder}"); - using var httpClient = new HttpClient(); + var httpClient = new HttpClient(); httpClient.BaseAddress = new Uri(Address); + // 客户端允许等着服务端慢慢返回,不要不断发送请求 + httpClient.Timeout = ServerConfiguration.MaxFreeTime; // 记录本地的字典值。首次同步的时候需要用到 Dictionary syncFileDictionary = InitLocalInfo(syncFolder); ulong currentVersion = 0; + bool isFirstQuery = true; + var clientName = Environment.MachineName; while (true) { try { - var syncFolderInfo = await httpClient.GetFromJsonAsync("/"); + var queryFileStatusRequest = new QueryFileStatusRequest(clientName, currentVersion, isFirstQuery); + using var httpResponseMessage = await httpClient.PostAsJsonAsync("/", queryFileStatusRequest); + if (httpResponseMessage.StatusCode == HttpStatusCode.NotFound) + { + // 服务端是不是还没开启 是不是开启错版本了 + var assemblyVersion = + GetType().Assembly.GetCustomAttribute()! + .InformationalVersion; + Console.WriteLine($"服务器返回 404 可能访问错误的服务,或 SyncTool 服务器版本过低。当前 SyncTool 客户端版本:{assemblyVersion}"); + + // 同步结束 + return; + } + + httpResponseMessage.EnsureSuccessStatusCode(); + + var queryFileStatusResponse = + await httpResponseMessage.Content.ReadFromJsonAsync(); + var syncFolderInfo = queryFileStatusResponse?.SyncFolderInfo; + if (syncFolderInfo is null || syncFolderInfo.Version == currentVersion) { + // 这里不需要等待,继续不断发起请求就可以 + // 为什么不怕发送太多,影响性能?服务端不会立刻返回 + //await Task.Delay(TimeSpan.FromSeconds(1)); continue; } + + isFirstQuery = false; currentVersion = syncFolderInfo.Version; - Console.WriteLine($"[{currentVersion}] 开始同步"); - await SyncFolderAsync(syncFolderInfo.SyncFileList, currentVersion); + Console.WriteLine($"[{currentVersion}] 开始同步 - {DateTimeHelper.DateTimeNowToLogMessage()}"); + await SyncFolderAsync(syncFolderInfo.SyncFileList, syncFolderInfo.SyncFolderPathInfoList, currentVersion); + + Console.WriteLine($"[{currentVersion}] 同步完成 - {DateTimeHelper.DateTimeNowToLogMessage()}"); + Console.WriteLine($"同步地址:{Address} 同步文件夹{syncFolder}"); + Console.WriteLine("=========="); // 更新本地字典信息 syncFileDictionary.Clear(); @@ -75,15 +115,26 @@ public async Task Run() { syncFileDictionary[syncFileInfo.RelativePath] = syncFileInfo; } + + _ = ReportCompleted(currentVersion); + } + catch (HttpRequestException e) + { + if (e.HttpRequestError == HttpRequestError.ConnectionError) + { + // 可能是服务器还没开启 + Console.WriteLine($"【同步失败】连接服务器失败,同步地址:{Address} 同步文件夹{syncFolder}"); + await Task.Delay(TimeSpan.FromSeconds(1)); + } } catch (Exception e) { // 大不了下次再继续 - Console.WriteLine(e); + Console.WriteLine($"【同步失败】同步地址:{Address} 同步文件夹{syncFolder}\r\n{e}"); } } - async Task SyncFolderAsync(List remote, ulong version) + async Task SyncFolderAsync(List remote, List syncFolderPathInfoList, ulong version) { Dictionary local = syncFileDictionary; @@ -138,15 +189,26 @@ async Task SyncFolderAsync(List remote, ulong version) Console.WriteLine($"同步 {remoteSyncFileInfo.RelativePath} 失败,正在重试"); } - await Task.Delay(200); + // 快速下载完成 + //await Task.Delay(200); } } - await RemoveRedundantFile(remote, version); + foreach (var folderPathInfo in syncFolderPathInfoList) + { + if (version != currentVersion) + { + return; + } + + // 如果文件夹不存在,则创建文件夹 + var localFilePath = Path.Join(syncFolder, folderPathInfo.RelativePath); + Directory.CreateDirectory(localFilePath); + } - Console.WriteLine($"[{version}] 同步完成"); - Console.WriteLine($"同步地址:{Address} 同步文件夹{syncFolder}"); - Console.WriteLine("=========="); + // 先删除多余的文件,再删除空文件夹,除非空文件夹是在记录里面的 + await RemoveRedundantFile(remote, version); + await RemoveRedundantFolder(syncFolderPathInfoList, version); } async Task RemoveRedundantFile(List remote, ulong version) @@ -166,18 +228,27 @@ async Task RemoveRedundantFile(List remote, ulong version) return; } + var relativePath = Path.GetRelativePath(syncFolder, file); + // 用来兼容 Linux 系统 + relativePath = relativePath.Replace('\\', '/'); + for (int i = 0; i < 1000; i++) { - var relativePath = Path.GetRelativePath(syncFolder, file); - // 用来兼容 Linux 系统 - relativePath = relativePath.Replace('\\', '/'); try { - if (!updatedList.Contains(relativePath)) + if (updatedList.Contains(relativePath)) + { + break; + } + else { // 本地存在,远端不存在,删除 File.Delete(file); Console.WriteLine($"删除 {relativePath}"); + if (!File.Exists(file)) + { + break; + } } } catch (Exception e) @@ -193,6 +264,62 @@ async Task RemoveRedundantFile(List remote, ulong version) } } + async Task RemoveRedundantFolder(List syncFolderPathInfoList, ulong version) + { + var updatedList = new HashSet(syncFolderPathInfoList.Count); + foreach (var syncFileInfo in syncFolderPathInfoList) + { + updatedList.Add(syncFileInfo.RelativePath); + } + + foreach (var folder in Directory.GetDirectories(syncFolder, "*", SearchOption.AllDirectories)) + { + if (version != currentVersion) + { + return; + } + + if (Directory.EnumerateFiles(folder,"*",SearchOption.AllDirectories).Any()) + { + // 如果存在文件,则不是空文件夹,不能删除 + continue; + } + + // 没有任何文件的空文件夹,如果不在列表里面,则需要删除文件夹 + var relativePath = Path.GetRelativePath(syncFolder, folder); + // 用来兼容 Linux 系统 + relativePath = relativePath.Replace('\\', '/'); + + for (int i = 0; i < 100; i++) + { + try + { + if (updatedList.Contains(relativePath)) + { + break; + } + else + { + Directory.Delete(folder); + if (!Directory.Exists(folder)) + { + break; + } + } + } + catch (Exception e) + { + if (i == 100 - 1) + { + Console.WriteLine($"第{i}次删除 {relativePath} 失败 {e}"); + } + + await Task.Delay(100); + } + } + } + } + async Task DownloadFile(SyncFileInfo remoteSyncFileInfo) { // 发起请求,使用 Post 的方式,解决 GetURL 的字符不支持 @@ -209,6 +336,19 @@ async Task DownloadFile(SyncFileInfo remoteSyncFileInfo) return downloadFilePath; } + + async Task ReportCompleted(ulong version) + { + try + { + var syncCompletedRequest = new SyncCompletedRequest(clientName, version); + await httpClient.PostAsJsonAsync("/SyncCompleted", syncCompletedRequest); + } + catch + { + // 只是报告而已,失败就失败 + } + } } /// diff --git a/SyncTool/Configurations/ServerConfiguration.cs b/SyncTool/Configurations/ServerConfiguration.cs new file mode 100644 index 0000000..1ebae18 --- /dev/null +++ b/SyncTool/Configurations/ServerConfiguration.cs @@ -0,0 +1,9 @@ +namespace SyncTool.Configurations; + +static class ServerConfiguration +{ + /// + /// 如果服务端没有更新,最多会空挂机 1 分钟以内 + /// + public static TimeSpan MaxFreeTime => TimeSpan.FromMinutes(1); +} \ No newline at end of file diff --git a/SyncTool/Context/QueryFileStatus.cs b/SyncTool/Context/QueryFileStatus.cs new file mode 100644 index 0000000..8798bfa --- /dev/null +++ b/SyncTool/Context/QueryFileStatus.cs @@ -0,0 +1,11 @@ +namespace SyncTool.Context; + +/// +/// 查询文件状态的请求 +/// +record QueryFileStatusRequest(string ClientName, ulong CurrentVersion, bool IsFirstQuery); + +/// +/// 查询文件状态的响应 +/// +record QueryFileStatusResponse(SyncFolderInfo SyncFolderInfo); \ No newline at end of file diff --git a/SyncTool/Context/SyncCompleted.cs b/SyncTool/Context/SyncCompleted.cs new file mode 100644 index 0000000..9f78ea6 --- /dev/null +++ b/SyncTool/Context/SyncCompleted.cs @@ -0,0 +1,4 @@ +namespace SyncTool.Context; + +record SyncCompletedRequest(string ClientName, ulong CurrentVersion); +record SyncCompletedResponse(); diff --git a/SyncTool/Context/SyncFileInfo.cs b/SyncTool/Context/SyncFileInfo.cs index e2efb5b..1376eaf 100644 --- a/SyncTool/Context/SyncFileInfo.cs +++ b/SyncTool/Context/SyncFileInfo.cs @@ -6,4 +6,10 @@ /// 文件的相对路径。这里为了兼容 Linux 系统,采用的是 / 字符 /// /// -record SyncFileInfo(string RelativePath, long FileSize, DateTime LastWriteTimeUtc); \ No newline at end of file +record SyncFileInfo(string RelativePath, long FileSize, DateTime LastWriteTimeUtc); + +/// +/// 文件夹信息,防止空文件夹没有被同步过去 +/// +/// +record SyncFolderPathInfo(string RelativePath); \ No newline at end of file diff --git a/SyncTool/Context/SyncFolderInfo.cs b/SyncTool/Context/SyncFolderInfo.cs index 62246a0..2073890 100644 --- a/SyncTool/Context/SyncFolderInfo.cs +++ b/SyncTool/Context/SyncFolderInfo.cs @@ -5,7 +5,7 @@ /// /// 同步版本 /// 同步的文件列表 -record SyncFolderInfo(ulong Version, List SyncFileList) +record SyncFolderInfo(ulong Version, List SyncFileList, List SyncFolderPathInfoList) { /// /// 同步的文件字典,用来给服务端快速获取文件对应 diff --git a/SyncTool/Properties/launchSettings.json b/SyncTool/Properties/launchSettings.json index 2c19f89..f4b3e65 100644 --- a/SyncTool/Properties/launchSettings.json +++ b/SyncTool/Properties/launchSettings.json @@ -5,11 +5,13 @@ }, "Serve": { "commandName": "Project", - "commandLineArgs": "serve -p 56621" + "commandLineArgs": "serve -p 5625", + "workingDirectory": "C:\\lindexi\\SyncFile_Serve\\" }, "Sync": { "commandName": "Project", - "commandLineArgs": "sync -a http://127.0.0.1:56621" + "commandLineArgs": "sync -a http://127.0.0.1:56622", + "workingDirectory": "C:\\lindexi\\SyncFile_Client\\" } } } \ No newline at end of file diff --git a/SyncTool/Server/ServeOptions.cs b/SyncTool/Server/ServeOptions.cs index 97329f0..ef55922 100644 --- a/SyncTool/Server/ServeOptions.cs +++ b/SyncTool/Server/ServeOptions.cs @@ -1,12 +1,19 @@ -using System.Net; +using System.Diagnostics; +using System.Net; using System.Net.Mime; using System.Net.NetworkInformation; using System.Net.Sockets; +using System.Text; + using dotnetCampus.Cli; + using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.FileProviders.Physical; + +using SyncTool.Configurations; using SyncTool.Context; +using SyncTool.Utils; namespace SyncTool.Server; @@ -43,14 +50,14 @@ public async Task Run() var port = Port ?? GetAvailablePort(IPAddress.Any); - Console.WriteLine($"Listening on: http://0.0.0.0:{port}"); + string listenInfo = $"Listening on: http://0.0.0.0:{port}\r\n"; try { foreach (var networkInterface in NetworkInterface.GetAllNetworkInterfaces()) { foreach (var unicastIpAddressInformation in networkInterface.GetIPProperties().UnicastAddresses) { - Console.WriteLine($"Listening on: http://{unicastIpAddressInformation.Address.ToString()}:{port}"); + listenInfo += $"Listening on: http://{unicastIpAddressInformation.Address.ToString()}:{port}\r\n"; } } } @@ -58,7 +65,7 @@ public async Task Run() { // 忽略异常,只是为了方便开发者了解当前的网络信息,不用每次都去看自己内网地址 } - Console.WriteLine($"SyncFolder: {syncFolder}"); + Console.WriteLine($"{listenInfo}SyncFolder: {syncFolder}"); var builder = WebApplication.CreateBuilder(); builder.WebHost.UseUrls($"http://*:{port}"); @@ -75,8 +82,57 @@ public async Task Run() options.SerializerOptions.PropertyNameCaseInsensitive = false; }); + var clientInfoList = new Dictionary(); + var outputStatusStopwatch = Stopwatch.StartNew(); + var webApplication = builder.Build(); webApplication.MapGet("/", () => syncFolderManager.CurrentFolderInfo); + webApplication.MapPost("/", async ([FromBody] QueryFileStatusRequest request, [FromServices] ILogger logger) => + { + logger.LogInformation($"[{request.CurrentVersion}] 收到 {request.ClientName} 的同步请求"); + clientInfoList[request.ClientName] = new ClientInfo(request.ClientName, request.CurrentVersion); + var taskCompletionSource = new TaskCompletionSource(); + syncFolderManager.CurrentFolderInfoChanged += OnCurrentFolderInfoChanged; + + try + { + // 如果没有更新,则进入等待,不立刻返回客户端 + if (syncFolderManager.CurrentFolderInfo == null || + (syncFolderManager.CurrentFolderInfo.Version == request.CurrentVersion && !request.IsFirstQuery)) + { + // 防止客户端超过时间,设置为一半时间 + var mainDelayTask = Task.Delay(ServerConfiguration.MaxFreeTime / 2); + + while (!mainDelayTask.IsCompleted && !taskCompletionSource.Task.IsCompleted) + { + await Task.WhenAny(mainDelayTask, taskCompletionSource.Task, Task.Delay(TimeSpan.FromSeconds(1))); + + if (syncFolderManager.CurrentFolderInfo?.Version != request.CurrentVersion) + { + break; + } + } + } + + if (syncFolderManager.CurrentFolderInfo is not null) + { + return new QueryFileStatusResponse(syncFolderManager.CurrentFolderInfo); + } + else + { + return null; + } + } + finally + { + syncFolderManager.CurrentFolderInfoChanged -= OnCurrentFolderInfoChanged; + } + + void OnCurrentFolderInfoChanged(object? sender, SyncFolderInfo e) + { + taskCompletionSource.TrySetResult(); + } + }); webApplication.MapPost("/Download", ([FromBody] DownloadFileRequest request, [FromServices] ILogger logger) => { var currentFolderInfo = syncFolderManager.CurrentFolderInfo; @@ -95,6 +151,12 @@ public async Task Run() logger.LogInformation($"Download NotFound {request.RelativePath}"); return Results.NotFound(); }); + webApplication.MapPost("/SyncCompleted", (SyncCompletedRequest request) => + { + clientInfoList[request.ClientName] = new ClientInfo(request.ClientName, request.CurrentVersion); + OutputStatus(); + return new SyncCompletedResponse(); + }); webApplication.UseStaticFiles(new StaticFileOptions() { FileProvider = new PhysicalFileProvider(syncFolder, ExclusionFilters.System), @@ -104,7 +166,42 @@ public async Task Run() RedirectToAppendTrailingSlash = true, DefaultContentType = MediaTypeNames.Application.Octet, }); + + _ = Task.Run(() => + { + while (true) + { + Console.ReadLine(); + OutputStatus(); + } + }); + await webApplication.RunAsync(); + + void OutputStatus() + { + var stringBuilder = new StringBuilder(); + stringBuilder + .AppendLine() + .Append(listenInfo) + .AppendLine($"SyncFolder: {syncFolder}") + .AppendLine($"Version: {syncFolderManager.CurrentFolderInfo?.Version} {DateTimeHelper.DateTimeNowToLogMessage()}") + .AppendLine() + .AppendLine("连接的客户端同步状态:"); + foreach (var clientInfo in clientInfoList.Values.ToList()) + { + if (DateTime.Now - clientInfo.UpdateTime > TimeSpan.FromMinutes(10)) + { + clientInfoList.Remove(clientInfo.ClientName); + } + + stringBuilder.AppendLine($"{DateTimeHelper.ToLogMessage(clientInfo.UpdateTime)} ClientName={clientInfo.ClientName} Version={clientInfo.Version}"); + } + + webApplication.Logger.LogInformation(stringBuilder.ToString()); + + outputStatusStopwatch.Restart(); + } } public static int GetAvailablePort(IPAddress ip) @@ -112,8 +209,13 @@ public static int GetAvailablePort(IPAddress ip) using var socket = new Socket(SocketType.Stream, ProtocolType.Tcp); socket.Bind(new IPEndPoint(ip, 0)); socket.Listen(1); - var ipEndPoint = (IPEndPoint)socket.LocalEndPoint!; + var ipEndPoint = (IPEndPoint) socket.LocalEndPoint!; var port = ipEndPoint.Port; return port; } + + record ClientInfo(string ClientName, ulong Version) + { + public DateTime UpdateTime { get; } = DateTime.Now; + } } \ No newline at end of file diff --git a/SyncTool/Server/SyncFolderManager.cs b/SyncTool/Server/SyncFolderManager.cs index b942c5b..35f40db 100644 --- a/SyncTool/Server/SyncFolderManager.cs +++ b/SyncTool/Server/SyncFolderManager.cs @@ -1,12 +1,27 @@ using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +using SyncTool.Client; using SyncTool.Context; +using SyncTool.Utils; namespace SyncTool.Server; class SyncFolderManager { - public SyncFolderInfo? CurrentFolderInfo { get; private set; } + [DisallowNull] + public SyncFolderInfo? CurrentFolderInfo + { + get => _currentFolderInfo; + private set + { + _currentFolderInfo = value; + CurrentFolderInfoChanged?.Invoke(this, value); + } + } + private FileSystemWatcher? _watcher; + public event EventHandler? CurrentFolderInfoChanged; public void Run(string watchFolder) { @@ -43,6 +58,7 @@ void UpdateChangeInner() } private ulong _currentVersion; + private SyncFolderInfo? _currentFolderInfo; private void UpdateChange(string watchFolder) { @@ -77,7 +93,22 @@ private void UpdateChange(string watchFolder) syncFileList.Add(syncFileInfo); } - CurrentFolderInfo = new SyncFolderInfo(currentVersion, syncFileList); + var syncFolderPathInfoList = new List(); + foreach (var folder in Directory.EnumerateDirectories(watchFolder, "*", SearchOption.AllDirectories)) + { + if (!Enable()) + { + return; + } + + var relativePath = Path.GetRelativePath(watchFolder, folder); + // 用来兼容 Linux 系统 + relativePath = relativePath.Replace('\\', '/'); + + syncFolderPathInfoList.Add(new SyncFolderPathInfo(relativePath)); + } + + CurrentFolderInfo = new SyncFolderInfo(currentVersion, syncFileList, syncFolderPathInfoList); } catch (IOException e) { @@ -85,7 +116,7 @@ private void UpdateChange(string watchFolder) Debug.WriteLine(e); } - Console.WriteLine($"检测到更新"); + Console.WriteLine($"检测到更新 - {DateTimeHelper.DateTimeNowToLogMessage()}"); }); bool Enable() => Interlocked.Read(ref _currentVersion) == currentVersion; diff --git a/SyncTool/Utils/DateTimeHelper.cs b/SyncTool/Utils/DateTimeHelper.cs new file mode 100644 index 0000000..fe67b8e --- /dev/null +++ b/SyncTool/Utils/DateTimeHelper.cs @@ -0,0 +1,16 @@ +namespace SyncTool.Utils; + +static class DateTimeHelper +{ + public static string DateTimeNowToLogMessage() + { + return ToLogMessage(DateTime.Now); + } + + public static string ToLogMessage(DateTime time) + { + return time.ToString(DefaultLogTimeFormat); + } + + public const string DefaultLogTimeFormat = "yyyy-MM-dd HH:mm:ss,fff"; +} \ No newline at end of file