diff --git a/29WxOrderWpf/MainWindow.xaml.cs b/29WxOrderWpf/MainWindow.xaml.cs index cb61924..a74ec32 100644 --- a/29WxOrderWpf/MainWindow.xaml.cs +++ b/29WxOrderWpf/MainWindow.xaml.cs @@ -106,10 +106,10 @@ namespace _29WxOrderWpf pddBuyerOrderReadCtrl.Visibility = Visibility.Visible; tbBuyerOrderReadCtrl.Visibility = Visibility.Visible; pddBuyerSelectCtrl.Visibility = Visibility.Visible; - Task.Run(() => + Task.Run(async () => { - GetServerShop(); - StartTicketRealtimeMonitor();//启动工单监控 + GetServerShop(); + await StartTicketRealtimeMonitor();//启动工单监控 GetShopSupplier(GlobalConfig.ShopLoginList.ToList()); }); Task.Run(() => @@ -609,12 +609,12 @@ namespace _29WxOrderWpf _ticketMonitor.MarkAllRead(); } - private void StartTicketRealtimeMonitor() + private async Task StartTicketRealtimeMonitor() { try { var options = GetTicketOptions().Clone(); - _ticketMonitor.Start(options); + await _ticketMonitor.Start(options); UpdateOrderSupportBadge(); _ = LoadHistoricalTicketUnreadAsync(options); } @@ -637,7 +637,7 @@ namespace _29WxOrderWpf } catch (Exception ex) { - LogSave.Add("TicketUnreadLoadError:" + ex); + NLogger.Instance.Error("TicketUnreadLoadError:" + ex); } } @@ -652,9 +652,6 @@ namespace _29WxOrderWpf AppIds = GlobalConfig.ShopLoginList.Select(a => a.ShopId).ToList(), Orders = [] }; - - - //OrderSupportTicketView // .CreateDefaultOptions(BaseClass.UserName) // .Clone(); diff --git a/AllBase/BaseClass.cs b/AllBase/BaseClass.cs index 47a5aed..e888177 100644 --- a/AllBase/BaseClass.cs +++ b/AllBase/BaseClass.cs @@ -37,7 +37,7 @@ namespace AllBase /// 版本ID /// - public static int VersionCount = 337; + public static int VersionCount = 339; public static string ServerUrl { get; set; } = "http://1.13.195.131:9000"; public static string DebugUrl { get; set; } = "http://localhost:9000"; diff --git a/OrderSupportTicket/LaunchDarkly.EventSource/Configuration.cs b/OrderSupportTicket/LaunchDarkly.EventSource/Configuration.cs index 5bc1aaa..bc44ea7 100644 --- a/OrderSupportTicket/LaunchDarkly.EventSource/Configuration.cs +++ b/OrderSupportTicket/LaunchDarkly.EventSource/Configuration.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Net.Http; using System.Text; +using WxOrder.Framework.Helpers; namespace LaunchDarkly.EventSource { @@ -114,6 +115,7 @@ namespace LaunchDarkly.EventSource /// This is never null; if logging is not configured, it will be LaunchDarkly.Logging.Logs.None. /// /// + public ILogger Logger { get; } /// /// The maximum amount of time to wait before attempting to reconnect. @@ -174,13 +176,14 @@ namespace LaunchDarkly.EventSource { Uri = builder._uri; - + var logger = builder._logger; BackoffResetThreshold = builder._backoffResetThreshold; HttpClient = builder._httpClient; HttpMessageHandler = (builder._httpClient != null) ? null : builder._httpMessageHandler; InitialRetryDelay = builder._initialRetryDelay; LastEventId = builder._lastEventId; + Logger = logger; MaxRetryDelay = builder._maxRetryDelay; Method = builder._method; PreferDataAsUtf8Bytes = builder._preferDataAsUtf8Bytes; diff --git a/OrderSupportTicket/LaunchDarkly.EventSource/ConfigurationBuilder.cs b/OrderSupportTicket/LaunchDarkly.EventSource/ConfigurationBuilder.cs index 6915a7a..dd06c36 100644 --- a/OrderSupportTicket/LaunchDarkly.EventSource/ConfigurationBuilder.cs +++ b/OrderSupportTicket/LaunchDarkly.EventSource/ConfigurationBuilder.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Net.Http; using System.Text; using System.Threading; +using WxOrder.Framework.Helpers; namespace LaunchDarkly.EventSource { @@ -30,6 +31,7 @@ namespace LaunchDarkly.EventSource internal TimeSpan _initialRetryDelay = Configuration.DefaultInitialRetryDelay; internal TimeSpan _backoffResetThreshold = Configuration.DefaultBackoffResetThreshold; internal string _lastEventId; + internal ILogger _logger; internal IDictionary _requestHeaders = new Dictionary(); internal HttpMessageHandler _httpMessageHandler; internal HttpClient _httpClient; @@ -230,45 +232,6 @@ namespace LaunchDarkly.EventSource return this; } - /// - /// Sets the logging implementation to be used for all EventSource log output. - /// - /// - /// - /// This uses the ILogAdapter abstraction from the LaunchDarkly.Logging library, - /// which provides several basic implementations such as Logs.ToConsole and an integration - /// with the .NET Core logging framework. For more about this and about adapters to other logging - /// frameworks, see LaunchDarkly.Logging. - /// - /// - /// LaunchDarkly.Logging defines logging levels of Debug, Info, Warn, and Error. If you do not - /// want detailed Debug-level logging, use the Level() modifier to set a minimum level of Info - /// or above, as shown in the code example (unless you are using an adapter to another logging - /// framework that has its own way of doing log filtering). - /// - /// - /// Log messages will use as the logger name. If you - /// want to specify a different logger name, use . - /// - /// - /// If you don't specify or , - /// EventSource will not do any logging. - /// - /// - /// - /// using LaunchDarkly.Logging; - /// - /// // Send log output to the console (standard error), suppressing Debug messages - /// var config = new ConfigurationBuilder(uri). - /// LogAdapter(Logs.ToConsole.Level(LogLevel.Info)). - /// Build(); - /// - /// a LaunchDarkly.Logging.ILogAdapter - /// the builder - public ConfigurationBuilder LogAdapter() - { - return this; - } /// /// Sets a custom logger to be used for all EventSource log output. @@ -297,8 +260,9 @@ namespace LaunchDarkly.EventSource /// /// a LaunchDarkly.Logging.Logger instance /// the builder - public ConfigurationBuilder Logger() + public ConfigurationBuilder Logger(ILogger logger) { + _logger = logger; return this; } diff --git a/OrderSupportTicket/LaunchDarkly.EventSource/EventSource.cs b/OrderSupportTicket/LaunchDarkly.EventSource/EventSource.cs index 5d1dd7d..51516bd 100644 --- a/OrderSupportTicket/LaunchDarkly.EventSource/EventSource.cs +++ b/OrderSupportTicket/LaunchDarkly.EventSource/EventSource.cs @@ -5,504 +5,514 @@ using System.IO; using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using WxOrder.Framework.Helpers; namespace LaunchDarkly.EventSource { - /// - /// Provides an EventSource client for consuming Server-Sent Events. Additional details on the Server-Sent Events spec - /// can be found at https://html.spec.whatwg.org/multipage/server-sent-events.html - /// - public class EventSource : IEventSource, IDisposable - { - #region Private Fields - - private readonly Configuration _configuration; - private readonly HttpClient _httpClient; - - private List _eventDataStringBuffer; - private MemoryStream _eventDataUtf8ByteBuffer; - private string _eventName; - private string _lastEventId; - private readonly ExponentialBackoffWithDecorrelation _backOff; - private readonly TimeSpan _configuredInitialRetryDelay; - private readonly TimeSpan _configuredMaxRetryDelay; - private CancellationTokenSource _currentRequestToken; - private readonly CancellationTokenSource _shutdownTokenSource = new CancellationTokenSource(); - private static readonly TimeSpan MaxSleepTime = TimeSpan.FromMilliseconds(int.MaxValue); - private readonly Stopwatch _connectionTimer = new Stopwatch(); - private ReadyState _readyState; - - #endregion - - #region Public Events - - /// - public event EventHandler Opened; - - /// - public event EventHandler Closed; - - /// - public event EventHandler MessageReceived; - - /// - public event EventHandler CommentReceived; - - /// - public event EventHandler Error; - - #endregion Public Events - - #region Public Properties - - /// - public ReadyState ReadyState - { - get - { - lock (this) - { - return _readyState; - } - } - private set - { - lock (this) - { - _readyState = value; - } - } - } - - internal TimeSpan BackOffDelay - { - get; - private set; - } - - // Exposed for tests, so that retry-bounds behavior can be asserted directly - internal ExponentialBackoffWithDecorrelation BackOff => _backOff; - - #endregion - - #region Public Constructors - - /// - /// Initializes a new instance of the class. - /// - /// the configuration - public EventSource(Configuration configuration) - { - _readyState = ReadyState.Raw; - - _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); - - - _configuredInitialRetryDelay = _configuration.InitialRetryDelay; - _configuredMaxRetryDelay = _configuration.MaxRetryDelay; - - _backOff = new ExponentialBackoffWithDecorrelation(_configuredInitialRetryDelay, - _configuredMaxRetryDelay); - - _httpClient = _configuration.HttpClient ?? CreateHttpClient(); - } - - /// - /// Shortcut for initializing an with only a stream URI - /// and no custom properties. - /// - /// the stream URI - /// if the URI is null - public EventSource(Uri uri) : this(Configuration.Builder(uri).Build()) {} - - #endregion - - #region Public Methods - - /// - public async Task StartAsync() - { - bool firstTime = true; - while (ReadyState != ReadyState.Shutdown) - { - if (!firstTime) - { - if (_connectionTimer.IsRunning) - { - if (_connectionTimer.Elapsed >= _configuration.BackoffResetThreshold) - { - // Sustained healthy operation reverts any temporary bounds and resets - // n. Reverting takes precedence over bounds that were - // set during the preceding fault window. - ClearTemporaryRetryDelayBounds(); - _backOff.ResetBackoffN(); - } - _connectionTimer.Reset(); - } - await MaybeWaitWithBackOff(); - } - firstTime = false; - - CancellationTokenSource newRequestTokenSource = null; - CancellationToken cancellationToken; - lock (this) - { - if (_readyState == ReadyState.Shutdown) - { - // in case Close() was called in between the previous ReadyState check and the creation of the new token - return; - } - newRequestTokenSource = new CancellationTokenSource(); - _currentRequestToken?.Dispose(); - _currentRequestToken = newRequestTokenSource; - } - - if (ReadyState == ReadyState.Connecting || ReadyState == ReadyState.Open) - { - throw new InvalidOperationException(string.Format(Resources.ErrorAlreadyStarted, ReadyState)); - } - - SetReadyState(ReadyState.Connecting); - cancellationToken = newRequestTokenSource.Token; - - try - { - await ConnectToEventSourceAsync(cancellationToken); - - // ConnectToEventSourceAsync normally doesn't return, unless it detects that the request has been cancelled. - Close(ReadyState.Closed); - } - catch (Exception e) - { - CancelCurrentRequest(); - - // If the user called Close(), ReadyState = Shutdown, so errors are irrelevant. - if (ReadyState != ReadyState.Shutdown) - { - Exception realException = e; - if (realException is AggregateException ae && ae.InnerExceptions.Count == 1) - { - realException = ae.InnerException; - } - if (realException is OperationCanceledException oe) - { - // This exception could either be the result of us explicitly cancelling a request, in which case we don't - // need to do anything else, or it could be that the request timed out. - if (oe.CancellationToken.IsCancellationRequested) - { - realException = null; - } - else - { - realException = new TimeoutException(); - } - } - - if (realException != null) - { - OnError(new ExceptionEventArgs(realException)); - } - Close(ReadyState.Closed); - } - } - } - } - - private async Task MaybeWaitWithBackOff() { - TimeSpan sleepTime = _backOff.GetNextBackOff(); - if (sleepTime > MaxSleepTime) - { - // Task.Delay throws for anything above the platform timer ceiling, and this method - // runs outside the reconnect loop's exception handling, so an unclamped value - // would fault StartAsync and stop the stream permanently. - sleepTime = MaxSleepTime; - } - if (sleepTime > TimeSpan.Zero) - { - BackOffDelay = sleepTime; - try - { - await Task.Delay(sleepTime, _shutdownTokenSource.Token); - } - catch (OperationCanceledException) - { - // Cancellation happened during the wait, likely intentional close - } - } - } - - /// - public void Restart(bool resetBackoffDelay) - { - lock (this) - { - if (_readyState != ReadyState.Open) - { - return; - } - if (resetBackoffDelay) - { - _backOff.ResetBackoffN(); - } - } - CancelCurrentRequest(); - } - - /// - public void SetTemporaryRetryDelayBounds(TimeSpan initialDelay, TimeSpan maxDelay) - { - _backOff.SetBounds(initialDelay, maxDelay); - } - - /// - public void ClearTemporaryRetryDelayBounds() - { - _backOff.SetBounds(_configuredInitialRetryDelay, _configuredMaxRetryDelay); - } - - /// - /// Closes the connection to the SSE server. The EventSource cannot be reopened after this. - /// - public void Close() - { - if (ReadyState != ReadyState.Shutdown) - { - Close(ReadyState.Shutdown); - } - CancelCurrentRequest(); - _shutdownTokenSource.Cancel(); - - // do not dispose httpClient if it is user provided - if (_configuration.HttpClient == null) - { - _httpClient.Dispose(); - } - } - - /// - /// Equivalent to calling . - /// - public void Dispose() - { - Dispose(true); - } - - private void Dispose(bool disposing) - { - if (disposing) - { - Close(); - } - } - - #endregion - - #region Private Methods - - private HttpClient CreateHttpClient() - { - var client =_configuration.HttpMessageHandler is null ? - new HttpClient() : - new HttpClient(_configuration.HttpMessageHandler, false); - client.Timeout = _configuration.ResponseStartTimeout; - return client; - } - - private void CancelCurrentRequest() - { - CancellationTokenSource requestTokenSource = null; - lock (this) - { - requestTokenSource = _currentRequestToken; - _currentRequestToken = null; - } - if (requestTokenSource != null) - { - requestTokenSource.Cancel(); - requestTokenSource.Dispose(); - } - } - - internal virtual EventSourceService GetEventSourceService(Configuration configuration) - { - return new EventSourceService(configuration, _httpClient); - } - - private async Task ConnectToEventSourceAsync(CancellationToken cancellationToken) - { - _eventDataStringBuffer = null; - _eventDataUtf8ByteBuffer = null; - - var svc = GetEventSourceService(_configuration); - - svc.ConnectionOpened += (o, e) => { - _connectionTimer.Restart(); - SetReadyState(ReadyState.Open, OnOpened, e.Headers); - }; - svc.ConnectionClosed += (o, e) => { SetReadyState(ReadyState.Closed, OnClosed); }; - - await svc.GetDataAsync( - ProcessResponseLineString, - ProcessResponseLineUtf8, - _lastEventId, - cancellationToken - ); - } - - private void Close(ReadyState state) - { - SetReadyState(state, OnClosed); - } - - private void ProcessResponseLineString(string content) - { - if (content == null) - { - // StreamReader may emit a null if the stream has been closed; there's nothing to - // be done at this level in that case - return; - } - if (content.Length == 0) - { - DispatchEvent(); - } - else - { - HandleParsedLine(EventParser.ParseLineString(content)); - } - } - - private void ProcessResponseLineUtf8(Utf8ByteSpan content) - { - if (content.Length == 0) - { - DispatchEvent(); - } - else - { - HandleParsedLine(EventParser.ParseLineUtf8Bytes(content)); - } - } - - - private void SetReadyState(ReadyState state, Action action = null, - IEnumerable>> headers = null) - { - lock (this) - { - if (_readyState == state || _readyState == ReadyState.Shutdown) - { - return; - } - - _readyState = state; - } - - if (action != null) - { - action(new StateChangedEventArgs(state, headers)); - } - } - - private void HandleParsedLine(EventParser.Result result) - { - if (result.IsComment) - { - OnCommentReceived(new CommentReceivedEventArgs(result.GetValueAsString())); - } - else if (result.IsDataField) - { - if (result.ValueString != null) - { - if (_eventDataStringBuffer is null) - { - _eventDataStringBuffer = new List(2); - } - _eventDataStringBuffer.Add(result.ValueString); - _eventDataStringBuffer.Add("\n"); - } - else - { - if (_eventDataUtf8ByteBuffer is null) - { - _eventDataUtf8ByteBuffer = new MemoryStream(result.ValueBytes.Length + 1); - } - _eventDataUtf8ByteBuffer.Write(result.ValueBytes.Data, result.ValueBytes.Offset, result.ValueBytes.Length); - _eventDataUtf8ByteBuffer.WriteByte((byte)'\n'); - } - } - else if (result.IsEventField) - { - _eventName = result.GetValueAsString(); - } - else if (result.IsIdField) - { - _lastEventId = result.GetValueAsString(); - } - else if (result.IsRetryField) - { - if (long.TryParse(result.GetValueAsString(), out var retry)) - { - // Clamp while the value is still an integer. - var millis = Math.Max(0, - Math.Min(retry, Constants.MaxServerDirectedRetryDelayMillis)); - _backOff.SetServerDirectedMinDelay(TimeSpan.FromMilliseconds(millis)); - } - } - } - - private void DispatchEvent() - { - var name = _eventName ?? Constants.MessageField; - _eventName = null; - MessageEvent message; - - if (_eventDataStringBuffer != null) - { - if (_eventDataStringBuffer.Count == 0) - { - return; - } - // remove last item which is always a trailing newline - _eventDataStringBuffer.RemoveAt(_eventDataStringBuffer.Count - 1); - var dataString = string.Concat(_eventDataStringBuffer); - message = new MessageEvent(name, dataString, _lastEventId, _configuration.Uri); - - _eventDataStringBuffer.Clear(); - } - else - { - if (_eventDataUtf8ByteBuffer is null || _eventDataUtf8ByteBuffer.Length == 0) - { - return; - } - var dataSpan = new Utf8ByteSpan(_eventDataUtf8ByteBuffer.GetBuffer(), 0, - (int)_eventDataUtf8ByteBuffer.Length - 1); // remove trailing newline - message = new MessageEvent(name, dataSpan, _lastEventId, _configuration.Uri); - - // We've now taken ownership of the original buffer; null out the previous - // reference to it so a new one will be created next time - _eventDataUtf8ByteBuffer = null; - } - - OnMessageReceived(new MessageReceivedEventArgs(message)); - } - - private void OnOpened(StateChangedEventArgs e) => - Opened?.Invoke(this, e); - - private void OnClosed(StateChangedEventArgs e) => - Closed?.Invoke(this, e); - - private void OnMessageReceived(MessageReceivedEventArgs e) => - MessageReceived?.Invoke(this, e); - - private void OnCommentReceived(CommentReceivedEventArgs e) => - CommentReceived?.Invoke(this, e); - - private void OnError(ExceptionEventArgs e) => - Error?.Invoke(this, e); - - #endregion - } + /// + /// Provides an EventSource client for consuming Server-Sent Events. Additional details on the Server-Sent Events spec + /// can be found at https://html.spec.whatwg.org/multipage/server-sent-events.html + /// + public class EventSource : IEventSource, IDisposable + { + #region Private Fields + + private readonly Configuration _configuration; + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + private List _eventDataStringBuffer; + private MemoryStream _eventDataUtf8ByteBuffer; + private string _eventName; + private string _lastEventId; + private readonly ExponentialBackoffWithDecorrelation _backOff; + private readonly TimeSpan _configuredInitialRetryDelay; + private readonly TimeSpan _configuredMaxRetryDelay; + private CancellationTokenSource _currentRequestToken; + private readonly CancellationTokenSource _shutdownTokenSource = new CancellationTokenSource(); + private static readonly TimeSpan MaxSleepTime = TimeSpan.FromMilliseconds(int.MaxValue); + private readonly Stopwatch _connectionTimer = new Stopwatch(); + private ReadyState _readyState; + + #endregion + + #region Public Events + + /// + public event EventHandler Opened; + + /// + public event EventHandler Closed; + + /// + public event EventHandler MessageReceived; + + /// + public event EventHandler CommentReceived; + + /// + public event EventHandler Error; + + #endregion Public Events + + #region Public Properties + + /// + public ReadyState ReadyState + { + get + { + lock (this) + { + return _readyState; + } + } + private set + { + lock (this) + { + _readyState = value; + } + } + } + + internal TimeSpan BackOffDelay + { + get; + private set; + } + + // Exposed for tests, so that retry-bounds behavior can be asserted directly + internal ExponentialBackoffWithDecorrelation BackOff => _backOff; + + #endregion + + #region Public Constructors + + /// + /// Initializes a new instance of the class. + /// + /// the configuration + public EventSource(Configuration configuration) + { + _readyState = ReadyState.Raw; + + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + + _logger = _configuration.Logger; + + _configuredInitialRetryDelay = _configuration.InitialRetryDelay; + _configuredMaxRetryDelay = _configuration.MaxRetryDelay; + + _backOff = new ExponentialBackoffWithDecorrelation(_configuredInitialRetryDelay, + _configuredMaxRetryDelay); + + _httpClient = _configuration.HttpClient ?? CreateHttpClient(); + } + + /// + /// Shortcut for initializing an with only a stream URI + /// and no custom properties. + /// + /// the stream URI + /// if the URI is null + public EventSource(Uri uri) : this(Configuration.Builder(uri).Build()) { } + + #endregion + + #region Public Methods + + /// + public async Task StartAsync() + { + bool firstTime = true; + while (ReadyState != ReadyState.Shutdown) + { + if (!firstTime) + { + if (_connectionTimer.IsRunning) + { + if (_connectionTimer.Elapsed >= _configuration.BackoffResetThreshold) + { + // Sustained healthy operation reverts any temporary bounds and resets + // n. Reverting takes precedence over bounds that were + // set during the preceding fault window. + ClearTemporaryRetryDelayBounds(); + _backOff.ResetBackoffN(); + } + _connectionTimer.Reset(); + } + await MaybeWaitWithBackOff(); + } + firstTime = false; + + CancellationTokenSource newRequestTokenSource = null; + CancellationToken cancellationToken; + lock (this) + { + if (_readyState == ReadyState.Shutdown) + { + // in case Close() was called in between the previous ReadyState check and the creation of the new token + return; + } + newRequestTokenSource = new CancellationTokenSource(); + _currentRequestToken?.Dispose(); + _currentRequestToken = newRequestTokenSource; + } + + if (ReadyState == ReadyState.Connecting || ReadyState == ReadyState.Open) + { + throw new InvalidOperationException(string.Format(Resources.ErrorAlreadyStarted, ReadyState)); + } + + SetReadyState(ReadyState.Connecting); + cancellationToken = newRequestTokenSource.Token; + + try + { + await ConnectToEventSourceAsync(cancellationToken); + + // ConnectToEventSourceAsync normally doesn't return, unless it detects that the request has been cancelled. + Close(ReadyState.Closed); + } + catch (Exception e) + { + CancelCurrentRequest(); + + // If the user called Close(), ReadyState = Shutdown, so errors are irrelevant. + if (ReadyState != ReadyState.Shutdown) + { + Exception realException = e; + if (realException is AggregateException ae && ae.InnerExceptions.Count == 1) + { + realException = ae.InnerException; + } + if (realException is OperationCanceledException oe) + { + // This exception could either be the result of us explicitly cancelling a request, in which case we don't + // need to do anything else, or it could be that the request timed out. + if (oe.CancellationToken.IsCancellationRequested) + { + realException = null; + } + else + { + realException = new TimeoutException(); + } + } + + if (realException != null) + { + OnError(new ExceptionEventArgs(realException)); + } + Close(ReadyState.Closed); + } + } + } + } + + private async Task MaybeWaitWithBackOff() + { + TimeSpan sleepTime = _backOff.GetNextBackOff(); + if (sleepTime > MaxSleepTime) + { + // Task.Delay throws for anything above the platform timer ceiling, and this method + // runs outside the reconnect loop's exception handling, so an unclamped value + // would fault StartAsync and stop the stream permanently. + sleepTime = MaxSleepTime; + } + if (sleepTime > TimeSpan.Zero) + { + _logger.Info(string.Format("Waiting {0} milliseconds before reconnecting...", sleepTime.TotalMilliseconds)); + BackOffDelay = sleepTime; + try + { + await Task.Delay(sleepTime, _shutdownTokenSource.Token); + } + catch (OperationCanceledException) + { + // Cancellation happened during the wait, likely intentional close + _logger.Debug("Backoff wait interrupted by shutdown"); + } + } + } + + /// + public void Restart(bool resetBackoffDelay) + { + lock (this) + { + if (_readyState != ReadyState.Open) + { + return; + } + if (resetBackoffDelay) + { + _backOff.ResetBackoffN(); + } + } + CancelCurrentRequest(); + } + + /// + public void SetTemporaryRetryDelayBounds(TimeSpan initialDelay, TimeSpan maxDelay) + { + _backOff.SetBounds(initialDelay, maxDelay); + } + + /// + public void ClearTemporaryRetryDelayBounds() + { + _backOff.SetBounds(_configuredInitialRetryDelay, _configuredMaxRetryDelay); + } + + /// + /// Closes the connection to the SSE server. The EventSource cannot be reopened after this. + /// + public void Close() + { + if (ReadyState != ReadyState.Shutdown) + { + Close(ReadyState.Shutdown); + } + CancelCurrentRequest(); + _shutdownTokenSource.Cancel(); + + // do not dispose httpClient if it is user provided + if (_configuration.HttpClient == null) + { + _httpClient.Dispose(); + } + } + + /// + /// Equivalent to calling . + /// + public void Dispose() + { + Dispose(true); + } + + private void Dispose(bool disposing) + { + if (disposing) + { + Close(); + } + } + + #endregion + + #region Private Methods + + private HttpClient CreateHttpClient() + { + var client = _configuration.HttpMessageHandler is null ? + new HttpClient() : + new HttpClient(_configuration.HttpMessageHandler, false); + client.Timeout = _configuration.ResponseStartTimeout; + return client; + } + + private void CancelCurrentRequest() + { + CancellationTokenSource requestTokenSource = null; + lock (this) + { + requestTokenSource = _currentRequestToken; + _currentRequestToken = null; + } + if (requestTokenSource != null) + { + _logger.Debug("Cancelling current request"); + requestTokenSource.Cancel(); + requestTokenSource.Dispose(); + } + } + + internal virtual EventSourceService GetEventSourceService(Configuration configuration) + { + return new EventSourceService(configuration, _httpClient, _logger); + } + + private async Task ConnectToEventSourceAsync(CancellationToken cancellationToken) + { + _eventDataStringBuffer = null; + _eventDataUtf8ByteBuffer = null; + + var svc = GetEventSourceService(_configuration); + + svc.ConnectionOpened += (o, e) => + { + _connectionTimer.Restart(); + SetReadyState(ReadyState.Open, OnOpened, e.Headers); + }; + svc.ConnectionClosed += (o, e) => { SetReadyState(ReadyState.Closed, OnClosed); }; + + await svc.GetDataAsync( + ProcessResponseLineString, + ProcessResponseLineUtf8, + _lastEventId, + cancellationToken + ); + } + + private void Close(ReadyState state) + { + _logger.Debug(string.Format("Close({0}) - state was {1}", state, ReadyState)); + SetReadyState(state, OnClosed); + } + + private void ProcessResponseLineString(string content) + { + if (content == null) + { + // StreamReader may emit a null if the stream has been closed; there's nothing to + // be done at this level in that case + return; + } + if (content.Length == 0) + { + DispatchEvent(); + } + else + { + HandleParsedLine(EventParser.ParseLineString(content)); + } + } + + private void ProcessResponseLineUtf8(Utf8ByteSpan content) + { + if (content.Length == 0) + { + DispatchEvent(); + } + else + { + HandleParsedLine(EventParser.ParseLineUtf8Bytes(content)); + } + } + + + private void SetReadyState(ReadyState state, Action action = null, + IEnumerable>> headers = null) + { + lock (this) + { + if (_readyState == state || _readyState == ReadyState.Shutdown) + { + return; + } + + _readyState = state; + } + + if (action != null) + { + action(new StateChangedEventArgs(state, headers)); + } + } + + private void HandleParsedLine(EventParser.Result result) + { + if (result.IsComment) + { + OnCommentReceived(new CommentReceivedEventArgs(result.GetValueAsString())); + } + else if (result.IsDataField) + { + if (result.ValueString != null) + { + if (_eventDataStringBuffer is null) + { + _eventDataStringBuffer = new List(2); + } + _eventDataStringBuffer.Add(result.ValueString); + _eventDataStringBuffer.Add("\n"); + } + else + { + if (_eventDataUtf8ByteBuffer is null) + { + _eventDataUtf8ByteBuffer = new MemoryStream(result.ValueBytes.Length + 1); + } + _eventDataUtf8ByteBuffer.Write(result.ValueBytes.Data, result.ValueBytes.Offset, result.ValueBytes.Length); + _eventDataUtf8ByteBuffer.WriteByte((byte)'\n'); + } + } + else if (result.IsEventField) + { + _eventName = result.GetValueAsString(); + } + else if (result.IsIdField) + { + _lastEventId = result.GetValueAsString(); + } + else if (result.IsRetryField) + { + if (long.TryParse(result.GetValueAsString(), out var retry)) + { + // Clamp while the value is still an integer. + var millis = Math.Max(0, + Math.Min(retry, Constants.MaxServerDirectedRetryDelayMillis)); + _backOff.SetServerDirectedMinDelay(TimeSpan.FromMilliseconds(millis)); + } + } + } + + private void DispatchEvent() + { + var name = _eventName ?? Constants.MessageField; + _eventName = null; + MessageEvent message; + + if (_eventDataStringBuffer != null) + { + if (_eventDataStringBuffer.Count == 0) + { + return; + } + // remove last item which is always a trailing newline + _eventDataStringBuffer.RemoveAt(_eventDataStringBuffer.Count - 1); + var dataString = string.Concat(_eventDataStringBuffer); + message = new MessageEvent(name, dataString, _lastEventId, _configuration.Uri); + + _eventDataStringBuffer.Clear(); + } + else + { + if (_eventDataUtf8ByteBuffer is null || _eventDataUtf8ByteBuffer.Length == 0) + { + return; + } + var dataSpan = new Utf8ByteSpan(_eventDataUtf8ByteBuffer.GetBuffer(), 0, + (int)_eventDataUtf8ByteBuffer.Length - 1); // remove trailing newline + message = new MessageEvent(name, dataSpan, _lastEventId, _configuration.Uri); + + // We've now taken ownership of the original buffer; null out the previous + // reference to it so a new one will be created next time + _eventDataUtf8ByteBuffer = null; + } + + _logger.Debug(string.Format("Received event \"{0}\"", name)); + OnMessageReceived(new MessageReceivedEventArgs(message)); + } + + private void OnOpened(StateChangedEventArgs e) => + Opened?.Invoke(this, e); + + private void OnClosed(StateChangedEventArgs e) => + Closed?.Invoke(this, e); + + private void OnMessageReceived(MessageReceivedEventArgs e) => + MessageReceived?.Invoke(this, e); + + private void OnCommentReceived(CommentReceivedEventArgs e) => + CommentReceived?.Invoke(this, e); + + private void OnError(ExceptionEventArgs e) => + Error?.Invoke(this, e); + + #endregion + } } diff --git a/OrderSupportTicket/LaunchDarkly.EventSource/EventSourceService.cs b/OrderSupportTicket/LaunchDarkly.EventSource/EventSourceService.cs index 4b21710..0bb5b94 100644 --- a/OrderSupportTicket/LaunchDarkly.EventSource/EventSourceService.cs +++ b/OrderSupportTicket/LaunchDarkly.EventSource/EventSourceService.cs @@ -7,6 +7,7 @@ using System.Net.Http.Headers; using System.Reflection; using System.Threading; using System.Threading.Tasks; +using WxOrder.Framework.Helpers; using static LaunchDarkly.EventSource.AsyncHelpers; @@ -23,6 +24,7 @@ namespace LaunchDarkly.EventSource private readonly Configuration _configuration; private readonly HttpClient _httpClient; + private readonly ILogger _logger; private const string UserAgentProduct = "DotNetClient"; internal static readonly string UserAgentVersion = ((AssemblyInformationalVersionAttribute)typeof(EventSource) @@ -48,10 +50,11 @@ namespace LaunchDarkly.EventSource #region Constructors - internal EventSourceService(Configuration configuration, HttpClient httpClient) + internal EventSourceService(Configuration configuration, HttpClient httpClient, ILogger logger) { _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } #endregion @@ -85,16 +88,20 @@ namespace LaunchDarkly.EventSource CancellationToken cancellationToken ) { + _logger.Debug(string.Format("Making {0} request to EventSource URI {1}", + _configuration.Method ?? HttpMethod.Get, + _configuration.Uri)); var request = CreateHttpRequestMessage(_configuration.Uri, lastEventId); this._configuration.HttpRequestModifier?.Invoke(request); - using (var response = await _httpClient.SendAsync(request, + using (var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false)) { - ValidateResponse(response); + _logger.Debug(string.Format("Response status: {0}", (int)response.StatusCode)); + ValidateResponse(response); using (var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false)) { @@ -109,10 +116,12 @@ namespace LaunchDarkly.EventSource if (_configuration.PreferDataAsUtf8Bytes) { + _logger.Debug("Reading UTF-8 stream without string conversion"); await ProcessResponseFromUtf8StreamAsync(processResponseLineUTF8, stream, cancellationToken); } else { + _logger.Debug("Reading stream with string conversion"); using (var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: ReadBufferSize)) { diff --git a/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs b/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs index 93c960a..6e4e04a 100644 --- a/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs +++ b/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs @@ -5,6 +5,8 @@ using OrderSupportTicket.Models; using OrderSupportTicket.Models.Api; using System; using System.Linq; +using System.Net; +using System.Net.Http; using System.Threading.Tasks; using WxOrder.Framework.Helpers; @@ -31,7 +33,7 @@ public sealed class EventSourceTicketRealtimeService /// public TicketRealtimeState State { get; private set; } = TicketRealtimeState.Disconnected; - public void Start(TicketClientOptions options) + public async Task Start(TicketClientOptions options) { if (_disposed) throw new ObjectDisposedException(nameof(EventSourceTicketRealtimeService)); @@ -40,9 +42,8 @@ public sealed class EventSourceTicketRealtimeService TicketApiService.ValidateOptions(options); StopCore(); - - var uri = BuildEventsUri(options); - var source = new EventSource(uri); + var config = BuildEventsUri(options); + var source = new EventSource(config); source.Opened += OnOpened; source.Closed += OnClosed; source.MessageReceived += OnMessageReceived; @@ -55,7 +56,7 @@ public sealed class EventSourceTicketRealtimeService } SetState(TicketRealtimeState.Connecting, "连接中…"); - _ = RunAsync(source); + await RunAsync(source); } public Task StopAsync() @@ -77,7 +78,6 @@ public sealed class EventSourceTicketRealtimeService { try { - NLogger.Instance.Info("工单服务连接成"); await source.StartAsync(); lock (_sync) { @@ -182,22 +182,53 @@ public sealed class EventSourceTicketRealtimeService new TicketRealtimeStateChangedEventArgs(state, message)); } - private static Uri BuildEventsUri(TicketClientOptions options) + //private static Uri BuildEventsUri(TicketClientOptions options) + //{ + // var appIds = options.AppIds + // .Where(a => !string.IsNullOrWhiteSpace(a)) + // .Distinct(StringComparer.Ordinal) + // .Select(a => "appId=" + Uri.EscapeDataString(a.Trim())); + // var query = new[] + // { + // "purchaseAccount=" + Uri.EscapeDataString(options.UserName) + // }.Concat(appIds); + + // return new Uri( + // TicketApiService.ServerUrl + // + EventsPath + // + "?" + // + string.Join("&", query), + // UriKind.Absolute); + //} + + private static Configuration BuildEventsUri(TicketClientOptions options) { var appIds = options.AppIds .Where(a => !string.IsNullOrWhiteSpace(a)) .Distinct(StringComparer.Ordinal) - .Select(a => "appId=" + Uri.EscapeDataString(a.Trim())); + .Select(a => Uri.EscapeDataString(a.Trim())); var query = new[] { "purchaseAccount=" + Uri.EscapeDataString(options.UserName) - }.Concat(appIds); - - return new Uri( + }; + var uri = new Uri( TicketApiService.ServerUrl + EventsPath + "?" + string.Join("&", query), UriKind.Absolute); + ConfigurationBuilder builder = Configuration.Builder(uri); + builder.RequestHeader("X-AppIds", $"[\"{string.Join("\",\"", appIds)}\"]"); + builder.Logger(NLogger.Instance); + //var proxy = new WebProxy("http://127.0.0.1:8081"); // + //var handler = new HttpClientHandler + //{ + // Proxy = proxy, + // UseProxy = true + //}; + //var client = new HttpClient(handler); + //builder.HttpClient(client); + builder._responseStartTimeout = TimeSpan.FromSeconds(30); + return builder.Build(); } } diff --git a/OrderSupportTicket/Services/TicketApiService.cs b/OrderSupportTicket/Services/TicketApiService.cs index fbee627..2740135 100644 --- a/OrderSupportTicket/Services/TicketApiService.cs +++ b/OrderSupportTicket/Services/TicketApiService.cs @@ -372,8 +372,8 @@ public sealed class TicketApiService .ToList(); if (normalized.Count == 0) throw new ArgumentException("appIds 至少需要一个 APPID。", nameof(appIds)); - if (normalized.Count > 500) - throw new ArgumentException("appIds 最多支持 50 个 APPID。", nameof(appIds)); + //if (normalized.Count > 5000) + // throw new ArgumentException("appIds 最多支持 50 个 APPID。", nameof(appIds)); foreach (var appId in normalized) ValidateAppId(appId); return normalized; diff --git a/OrderSupportTicket/Services/TicketRealtimeMonitor.cs b/OrderSupportTicket/Services/TicketRealtimeMonitor.cs index bf3691d..aac9ff8 100644 --- a/OrderSupportTicket/Services/TicketRealtimeMonitor.cs +++ b/OrderSupportTicket/Services/TicketRealtimeMonitor.cs @@ -91,7 +91,7 @@ public sealed class TicketRealtimeMonitor : IDisposable /// /// 启动或复用实时连接。相同账号和 APPID 重复调用不会重建连接。 /// - public void Start(TicketClientOptions options) + public async Task Start(TicketClientOptions options) { if (_disposed) throw new ObjectDisposedException(nameof(TicketRealtimeMonitor)); @@ -113,7 +113,7 @@ public sealed class TicketRealtimeMonitor : IDisposable _optionsFingerprint = fingerprint; } - RealtimeService.Start(options); + await RealtimeService.Start(options); } /// diff --git a/OrderSupportTicket/Services/TicketService.cs b/OrderSupportTicket/Services/TicketService.cs index e063320..504eddf 100644 --- a/OrderSupportTicket/Services/TicketService.cs +++ b/OrderSupportTicket/Services/TicketService.cs @@ -167,11 +167,11 @@ public sealed class TicketService : IDisposable /// /// 使用指定工单参数建立 SSE 连接。 /// - public void StartRealtime(TicketClientOptions options) + public async Task StartRealtime(TicketClientOptions options) { ThrowIfDisposed(); ValidateOptions(options); - _realtime.Start(options); + await _realtime.Start(options); } /// @@ -191,7 +191,7 @@ public sealed class TicketService : IDisposable ThrowIfDisposed(); await _realtime.StopAsync(); ThrowIfDisposed(); - StartRealtime(options); + await StartRealtime(options); } public void Dispose() diff --git a/OrderSupportTicket/ViewModels/OrderSupportTicketViewModel.cs b/OrderSupportTicket/ViewModels/OrderSupportTicketViewModel.cs index 5df20d1..0953b06 100644 --- a/OrderSupportTicket/ViewModels/OrderSupportTicketViewModel.cs +++ b/OrderSupportTicket/ViewModels/OrderSupportTicketViewModel.cs @@ -714,7 +714,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware, || (_ticketService.OwnsRealtime && !_initialized); if (shouldStartRealtime) { - _ticketService.StartRealtime(_options); + await _ticketService.StartRealtime(_options); started = true; } diff --git a/WxOrder.Framework/Helpers/ILogger.cs b/WxOrder.Framework/Helpers/ILogger.cs new file mode 100644 index 0000000..cb81701 --- /dev/null +++ b/WxOrder.Framework/Helpers/ILogger.cs @@ -0,0 +1,13 @@ +namespace WxOrder.Framework.Helpers; + +public interface ILogger +{ + void Debug(string message, Exception exception = null); + void Dispose(); + void Error(string message, Exception exception = null); + void Fatal(string message, Exception exception = null); + void Info(string message, Exception exception = null); + void Log(string message, LogMessageLevel level, Exception exception = null); + void Trace(string message, Exception exception = null); + void Warn(string message, Exception exception = null); +} \ No newline at end of file diff --git a/WxOrder.Framework/Helpers/NLogger.cs b/WxOrder.Framework/Helpers/NLogger.cs index b26ea2e..65759e2 100644 --- a/WxOrder.Framework/Helpers/NLogger.cs +++ b/WxOrder.Framework/Helpers/NLogger.cs @@ -32,7 +32,7 @@ namespace WxOrder.Framework.Helpers /// 日志文件按日期分目录存放,并在跨天时自动切换: /// {logDirectory}/yyyy-MM/yyyy-MM-dd.log,例如 log/2022-12/2022-12-12.log /// - public sealed class NLogger : IDisposable + public sealed class NLogger : IDisposable, ILogger { private const string DefaultName = "Default"; @@ -77,9 +77,9 @@ namespace WxOrder.Framework.Helpers /// 日志名称(写入每行日志中,用于区分来源,不再决定文件名)。 public string Name { get; } - public bool IsDebugEnabled { get; set; } = true; + public bool IsDebugEnabled { get; set; } = false; - public bool IsDebugWriteLineEnabled { get; set; } = true; + public bool IsDebugWriteLineEnabled { get; set; } = false; public LogMessageLevel MinimumLevel { get; set; } = LogMessageLevel.Trace;