65.9K
CodeProject 正在变化。 阅读更多。
Home

使用桌面控制台式 UI、Skia 绘图和多个类 REST node.js 托管服务进行轻松原型设计

2019 年 1 月 24 日

CPOL

27分钟阅读

viewsIcon

12646

使用桌面控制台式 UI、Skia 绘图和多个类 REST node.js 托管服务进行轻松原型设计

动机

当我开发一些软件解决方案时,我对用户界面有一个大致的构想。有时,它可能会发生巨大变化,有时,它可能与我的初始设计几乎保持不变。当处理服务(非 UI)组件或进行一些复杂的计算或统计计算时,通常需要指定多个参数并轻松使用它们。在控制台中输入是实现所有这些目标的自然方式。然而,对于显示复杂图形和良好的用户交互,控制台可能不是首选。此外,当使用线程和控制台时,检测 UI 阻塞或其他常见问题并不容易,因为每个线程都可以轻松更新控制台。因此,我开发了一个简单且可定制的解决方案来解决所有这些小问题。

如果您遵循完整的文章,您将能够完成什么

整个解决方案是从头开始创建的。它需要一些输入和一些工作。如果有一个地方让你放弃并转向其他阅读,那么这就是最好的。本文不会逐行解释代码,你需要自己阅读代码并弄清楚。当然,如果有问题,我将尽我所知回答它们。

现成的演示解决方案使用两个服务,一个监听 http 请求,另一个监听 web socket 消息,它们都在 node.js 服务器上运行,使用相同的“业务”逻辑。此逻辑是一个简单的 JavaScript 编写方法,它创建一个具有两个属性(`enum` 和 `string`)的对象,并将它们设置为随机值。客户端是一个 WPF 应用程序,使用定制的文本块控件,看起来像控制台,一个用于输入所谓“命令”的文本框,以及一个用于 Skia 图形库的 WPF 实现的控件。

这是 http 服务器(Windows 10 操作系统)控制台的样子

这是 Web Socket 服务器(Windows 10 操作系统)控制台的样子

这是 WPF 客户端应用程序的样子,在执行 help 命令以显示已实现的命令后,服务器之一生成了 2,000 个随机点并显示在 Skia UI 元素中。

您可以列出可用颜色并根据自己的喜好进行更改。您将在本文中看到几种颜色组合。此 GIF 将展示示例的实际效果。

阅读本文和制作所述解决方案的先决条件

此解决方案创建于 2018 年底左右,因此当时所有产品和相应的 NuGet 或 NPM 包的当前版本都已使用。对于桌面应用程序,使用了以下 IDE:

  • Microsoft Visual Studio Community 2017,可从 MSDN 免费下载

并安装了以下 NuGet 包及其依赖项:

  • MahApps.Metro
  • MahApps.Metro.IconPacks
  • Newtonsoft.Json
  • SkiaSharp
  • SkiaSharp.Views

对于服务,使用了以下 IDE:

  • Visual Studio Code,可从 MSDN 免费下载

作为托管服务的服务器应用程序

  • *node.js*,可从 nodejs.org 免费下载

以及以下 `npm` 包

  • express
  • body-parser
  • ws

为了测试 http 服务,安装 Postman、Fiddler 或类似应用程序可能会很有用。本次演示中使用了 Postman。
演示解决方案可以使用其他 IDE 完成,所以这是一种个人选择。在您决定使用哪个 IDE 并安装好它,以及正确安装 node.js 之后,就可以开始了。

客户端 - 基础

尽管我们的客户端很简单,但它必须正确模拟一个真实的应用程序。因此,它需要内容导航并使用除 UI 线程之外的其他线程。将使用 MVVM 模式,其中包含一个基本视图模型和 `ICommand` 接口的实现。实现此骨架后,我们将创建一个自定义文本块,它将继承默认文本块。我们将向此新控件添加一个 `DependencyProperty`,`CustomInlinesProperty`,以便我们可以从视图模型绑定到此属性并通过绑定对其进行更新。`TextBlock` 有一个名为 Inlines 的 `InlineCollection` 类型属性,但它不是依赖属性,因此我们无法使用它,我们必须实现自定义属性。

创建解决方案

我们启动 Visual Studio 并通过单击“文件”→“新建”→“项目...”创建一个新项目。此时会显示一个“**新建项目**”对话框,从中我们选择 WPF 应用程序(.NET Framework)(尽管 WPF for .NET Core 可用,但我们暂时仍使用旧方式),指定名称和位置,然后按“确定”。在撰写本文时,我在我的 D: 驱动器上创建了一个名为 *devdotnet* 的文件夹,用作位置,解决方案和项目的名称为 `LocalBrowser`。生成并运行解决方案以查看一切是否正常工作。

安装 MahApps

我们将安装一个 NuGet 包,以便为客户端应用程序提供漂亮的 UI。因此,我们右键单击项目 `LocalBrowser`,然后从下拉菜单中选择“管理 NuGet 包...”。显示 NuGet 窗口,我们单击“浏览”选项卡,然后在搜索框中键入 `MahApps.Metro`,然后执行搜索。如果输入没有拼写错误,第一个结果就是我们想要的,我们必须选择它,然后单击“安装”按钮。将出现一个名为“预览更改”的弹出窗口,我们单击“确定”进行确认。

安装完成后,我们将寻找 `MahApps.Metro.IconPacks`,并以相同的方式安装它。如果现在点击“已安装”选项卡,我们应该会看到类似这样的内容:

我们再次构建并运行客户端。它应该看起来与我们第一次构建时完全相同。但这不会持续很久。

使用 MahApps MetroWindow

我们打开解决方案资源管理器并展开 `LocalBrowser` 项目。然后,右键单击 *MainWind.xaml* 并选择“重命名...”。接着,我们将名称更改为 Shell。XAML 文件应该是 *Shell.xaml*,代码隐藏文件应该是 *Shell.xaml.cs*。然后,我们打开代码隐藏文件 *Shell.xaml.cs*。我们右键单击该文件并选择“删除和排序 Usings”。然后,我们右键单击第 8 行的 `MainWindow`,并选择“重命名”,将类名也重命名为 Shell。最后,`Shell` 类继承自 `Window` 类。我们删除此 `Window` 类并键入 `MetroWindow`,然后右键单击并选择“快速操作和重构...”,然后选择 `using MahApps.Metro.Controls`。

之后,`Shell initialize` 方法应该会显示红色下划线,因为我们还没有修复 XAML 文件。我们先保存它,暂时这样放着。我们打开 XAML 文件 *Shell.xaml*。让我们将其更改为这个。请记住,我们将在后续步骤中进一步更改此文件。

然后,我们打开 *App.xaml*。我们将 XAML 更改为这个。此后不再进行任何更改。

我们也可以打开 *app.xaml.cs* 并删除其中不必要的 `using`。然后,我们再次构建并运行客户端。这次,我们应该会看到一个大得多的 Metro 窗口。我们可以将宽度和高度更改为我们喜欢的值。

创建文件夹结构和几个基本类

现在是时候为我们的解决方案创建合适的文件夹结构,并制作一些我们将进一步使用的基本类了。在解决方案资源管理器中,右键单击项目名称(在我的情况下是 `LocalBrowser`),然后从下拉菜单中选择“添加...”然后选择“新建文件夹”。将文件夹命名为 *Custom*。按照相同的方法,创建以下文件夹:*Models*、*ViewModels*、*Views* 和 *Infrastructure*。您的解决方案现在应该看起来像这样:

右键单击 *ViewModels* 文件夹,选择“**添加**”->“**类**”。将类命名为 `ViewModelBase`。将此代码添加到其中,使其具有以下内容:

好的,现在是下一个视图模型的时间。右键单击 *ViewModels* 文件夹,选择“**添加**”→“**类**”,这次将类命名为 `ShellViewModel`。我们稍后会回到它。现在,再添加两个视图模型,分别称为 `ConsoleViewModel` 和 `ConsoleSimplifiedViewModel`。现在,我们将在 *Views* 文件夹中添加两个用户控件。与 `ViewModels` 一样,我们将更改生成的代码,使它们属于 `main` `namespace`。因此,右键单击“**Views**”→“**添加**”,然后选择“用户控件...”。将用户控件命名为 `ConsoleSimplifiedView`。再添加一个用户控件,并将其命名为 `AnotherView`。转到 `ConsoleSimplifiedView` 的代码隐藏,并删除所有不必要的 `using`。然后,将 `namespace` 更改为 `LocalBrowser` 即可。`Initialize` 方法将显示红色下划线。我们稍后会修复这个问题。以相同的方式处理其他视图。

现在,我们进入 XAML 视图并删除其中的一些标记。**我们必须修复 x:Class,使其正确表示我们在代码隐藏中所做的更改。** 我们将在以下段落中更改简化的控制台视图,但对于其他视图,我们已经准备就绪——标记应该如下所示:

接下来是创建 `ICommand` 实现。因此,我们右键单击“Infrastructure”,选择“**添加**”→“**类**”,然后将类命名为 `SimpleCommand`。它的最终代码如下:

此时,我们再次构建并运行客户端。它应该运行并看起来与以前完全相同。如果不是,请再次阅读该部分并修复所有可能的问题,然后再继续。

创建自定义文本块实现

我们之前说过,我们将使用自定义文本块,以便通过绑定从视图模型添加内联对象。现在,是时候创建我们的自定义文本块了。它将继承自框架 `TextBlock`,并且只会添加一个 `ObservableCollection` 类型的依赖属性,并会引发 `CollectionChanged` 事件。因此,这意味着它不是线程安全的,我们必须稍后在相应的视图模型中处理这个问题。

我们从解决方案资源管理器中选择 *Custom* 文件夹,右键单击并选择“**添加**”→“**类**”。我们将类命名为 `CustomInlinesTextBlock`。这是它的代码:

自定义文本块的 ConsoleViewModel

我们需要一个视图模型,它将与自定义文本块一起工作并做一些基本的事情。我们希望有以下几点:

  • 拥有执行一些基本操作的方法,无论自定义控件放置在哪里,例如清除文本块中显示的所有内容、更改字符颜色、更改背景、显示帮助。此方法集应该在其他使用 `consoleviewmodel` 的类中可访问,以便它们也可以添加其他实现。
  • 某种方式来保存一些方法的有用用户描述并在需要时显示它。
  • 某种方式来处理用户输入的一组 `string`。第一个 `string` 始终是命令名,并且始终是必需的,如果后面有 `string`,它们将被视为参数。
  • 能够显示来自 UI 和其他线程的“系统消息”,因此当发生错误或可能需要用户注意的事情时,我们可以轻松显示它。

这是 `ConsoleViewModel` 的最终代码。由于它太长,而且屏幕截图会太高,所以我们必须使用 HTML 显示它。这不是最好的方法,但它有效。

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Reflection;
using System.Windows.Documents;
using System.Windows.Media;
using System.Windows.Shapes;

namespace LocalBrowser
{
    public class ConsoleViewModel : ViewModelBase
    {
        private ObservableCollection<Inline> inlines;
        private Brush foreground, background, paragraph;
        private const char whiteSpace = ' ';

        public ConsoleViewModel()
        {
            Inlines = new ObservableCollection<Inline>();
            ImplementedConsoleCommands = new Dictionary<string, Action>();
            CommandDescriptions = new Dictionary<string, string>();

            ImplementedConsoleCommands.Add("clear", ClearInlines);
            CommandDescriptions.Add("clear", "Clear all text displayed in console");
            ImplementedConsoleCommands.Add("cls", ClearInlines);
            CommandDescriptions.Add("cls", "Short for clear");

            ImplementedConsoleCommands.Add("color", ChangeForeground);
            CommandDescriptions.Add("color", "Change the color, used to display letters in console. 
                                     Example usage: color red");
            ImplementedConsoleCommands.Add("paragraph", ChangeParagraphBackground);
            CommandDescriptions.Add("paragraph", "Change the color of paragraph. 
                                     Example usage: paragraph green");
            ImplementedConsoleCommands.Add("background", ChangeBackground);
            CommandDescriptions.Add("background", "Change the background color of entire console. 
                                     Example usage: background blue");

            ImplementedConsoleCommands.Add("?", DisplayHelp);
            CommandDescriptions.Add("?", "Short for help");
            ImplementedConsoleCommands.Add("help", DisplayHelp);
            CommandDescriptions.Add("help", "Display information about all available commands");
            ImplementedConsoleCommands.Add("listcolors", DisplayAvailableColors);
            CommandDescriptions.Add("listcolors", "Displays all supported colors and their names");
            ImplementedConsoleCommands.Add("resetcolors", SetDefaultColors);
            CommandDescriptions.Add("resetcolors", "Set all color to default ones");

            SetDefaultColors();
        }

        public Dictionary<string, Action> ImplementedConsoleCommands { get; private set; }
        public Dictionary<string, string> CommandDescriptions { get; private set; }
        public string KeyInUse { get; private set; }
        public string[] ArgumentsInUse { get; private set; }

        public ObservableCollection<Inline> Inlines
        {
            get { return inlines; }
            set
            {
                inlines = value;
                OnPropertyChanged("Inlines");
            }
        }

        public Brush Foreground
        {
            get { return foreground; }
            set
            {
                foreground = value;
                OnPropertyChanged("Foreground");
            }
        }

        public Brush Background
        {
            get { return background; }
            set
            {
                background = value;
                OnPropertyChanged("Background");
            }
        }

        public void ProceedConsoleMessage(string userInput)
        {
            KeyInUse = string.Empty;
            ArgumentsInUse = null;
            if (userInput.ToLowerInvariant().IndexOf(whiteSpace) > -1)
            {
                string[] userInputWords = userInput.ToLowerInvariant().Split
                    (new char[] { whiteSpace }, StringSplitOptions.RemoveEmptyEntries);
                KeyInUse = userInputWords[0];
                ArgumentsInUse = userInputWords.Skip(1).ToArray();
            }
            else
            {
                KeyInUse = userInput.ToLowerInvariant();
            }
            if (ImplementedConsoleCommands.ContainsKey(KeyInUse))
            {
                ImplementedConsoleCommands[KeyInUse]();
            }
            else
            {
                MakeRun(userInput);
            }
        }

        public void ProceedSystemMessage(string message, bool warning = true)
        {
            App.Current.Dispatcher.InvokeAsync(() =>
            {
                Run run;
                if (warning)
                {
                    run = new Run() { Text = message, Foreground = Brushes.Red, 
                                      Background = Brushes.White };
                }
                else
                {
                    run = new Run() { Text = message };
                }
                Inlines.Add(run);
                Inlines.Add(new LineBreak());
            });
        }

        public void ProceedRunOnly(string message, Brush foreground, Brush background)
        {
            App.Current.Dispatcher.InvokeAsync(() =>
            {
                Run run = new Run() 
                          { Text = message, Foreground = foreground, Background = background };
                inlines.Add(run);
            });
        }

        private void ClearInlines()
        {
            Inlines.Clear();
        }

        private void ChangeForeground()
        {
            ChangeColor(ConsolePropertyToColor.Foreground);
        }

        private void ChangeParagraphBackground()
        {
            ChangeColor(ConsolePropertyToColor.Paragraph);
        }

        private void ChangeBackground()
        {
            ChangeColor(ConsolePropertyToColor.Background);
        }

        private void ChangeColor(ConsolePropertyToColor consolePropertyToColor)
        {
            string color = string.Empty; // for the exception handling
            try
            {
                if (ArgumentsInUse == null)
                {
                    MakeRun("In order to specify letters color you need to type word color 
                       followed by space then followed by name of the color, like color Red");
                    return;
                }
                else
                {
                    color = ArgumentsInUse[0].Trim().ToLowerInvariant();
                }

                switch (consolePropertyToColor)
                {
                    case ConsolePropertyToColor.Paragraph:
                        paragraph = (SolidColorBrush)new BrushConverter().ConvertFromString(color);
                        break;
                    case ConsolePropertyToColor.Background:
                        Background = (SolidColorBrush)new BrushConverter().ConvertFromString(color);
                        break;
                    case ConsolePropertyToColor.Foreground:
                    default:
                        Foreground = (SolidColorBrush)new BrushConverter().ConvertFromString(color);
                        break;
                }
            }
            catch (NotSupportedException)
            {
                ProceedSystemMessage(string.Format("This type of expression: 
                   {0} resulting in desired color: {1} is not suppored. Try with another one, 
                   like Red, Green, Blue, Black...)", KeyInUse, color));
            }
            catch (FormatException)
            {
                ProceedSystemMessage(string.Format("This type of expression: {0} 
                   resulting in desired color: {1} is not suppored. Try with another one, 
                   like Red, Green, Blue, Black...)", KeyInUse, color));
            }
            catch (Exception)
            {
                throw;
            }
        }

        private void DisplayHelp()
        {
            foreach (string s in CommandDescriptions.Keys)
            {
                MakeRun(string.Format("{0,-20}\t{1}", s, CommandDescriptions[s]));
            }
        }

        private void DisplayAvailableColors()
        {
            Type brushesType = typeof(Brushes);
            PropertyInfo[] brushesProperties = brushesType.GetProperties
                       (BindingFlags.Static | BindingFlags.Public);
            InlineUIContainer uc;
            Run run;
            Rectangle rectangle;
            BrushConverter bc = new BrushConverter();
            SolidColorBrush brush;
            foreach (PropertyInfo pi in brushesProperties)
            {
                brush = (SolidColorBrush)bc.ConvertFromString(pi.Name);
                rectangle = new Rectangle() { Width = 100, Height = 20, Fill = brush };
                uc = new InlineUIContainer(rectangle);
                run = new Run() { Text = string.Format("  {0}  ", pi.Name) };
                Inlines.Add(uc);
                Inlines.Add(run);
            }
            Inlines.Add(new LineBreak());
        }

        private void MakeRun(string message)
        {
            Run run;
            if (paragraph == null)
            {
                run = new Run() { Text = message };
            }
            else
            {
                run = new Run() { Text = message, Background = paragraph };
            }

            Inlines.Add(run);
            Inlines.Add(new LineBreak());
        }

        private void SetDefaultColors()
        {
            Foreground = Brushes.White;
            Background = new SolidColorBrush(Colors.Black);
            Background.Opacity = 0.9;
            paragraph = null;
        }

        private enum ConsolePropertyToColor
        {
            Foreground,
            Paragraph,
            Background
        }
    }
}

ConsoleSimplified 和 Shell – 草稿

这是我们为这两个视图模型创建工作草稿并向其相应的视图添加一些标记的地方。请记住,我们将在本文的第三部分更改这两个视图模型。下面,您可以找到 `ConsoleSimplifiedViewModel` 的草稿。

这是相应的草稿标记视图

这是 shell 视图的标记。请注意旋转的 Font Awesome 图标。它可能看起来是故意的,但它会告诉我们我们的 UI 是否挂起或始终响应。此视图将保持不变,我们只会在本文的第三部分稍微更改相应的视图模型。

这是 `ShellViewModel`。让我们再说一遍——这是草稿,将在本文的第三部分略作修改。客户端再次

我们几乎到了文章第一部分的结尾。剩下的就是将 shell 的 `DataContext` 设置为其视图模型。我们可以在 XAML 中或在代码隐藏中完成此操作。或者以其他几种方式,超出本文的范围。这次我们无论如何都在代码隐藏中完成,就像这样。所以,这是 shell 视图的代码隐藏,在整个演示解决方案中它将保持不变。

这是第一部分结束时解决方案资源管理器的样子

我们构建并运行应用程序。我们应该看到旋转的徽标,并且能够通过单击图标从一个用户控件(视图)导航到另一个。如果您发现,如果您导航到控制台视图并开始输入,但没有显示任何字符,那么您注意到了正确。这是因为当控件加载时,`textbox` 不会自动获得焦点。您可以尝试在 XAML 中修复此问题,但可能不起奏效。此问题的正确修复尚未实现,它将在何时实现?——是的,在第三部分。我们将通过一个小的代码隐藏技巧来修复此问题,该技巧在另一位作者的单独 CodeProject 文章中解释得非常好。因此,所需的行为就像本文开头展示的演示 GIF 中所示的那样。然而,此时,我们仍然可以用我们的控制台做一些事情。让我们运行应用程序,导航到控制台视图并聚焦文本框。然后通过键入 **listcolors** 并按 Enter 键列出所有可用颜色。您可以选择一些颜色并更改字体颜色或背景颜色或段落颜色。这就是我为第一部分的最后一张截图更改控制台的方式。

Node.js 托管的类 REST 服务

在本文的这一部分,我们将讨论创建两个托管在 node.js 上的服务。这些服务非常类似于 REST,它们是无状态的,连接它们的客户端无法判断它们是直接连接还是通过中介连接,所以它们是分层系统,不可能获得过时数据,所以我们具有可缓存性等等。一个 Web 服务应该实现几个特性才能成为 REST,我没有检查整个列表,所以出于这个原因,我们称之为类 REST。

我们将实现两个服务,一个处理 http 请求,另一个处理 web socket 请求。我们将使用这两个特定的服务,因为桌面客户端存在两个 .NET 类,`HttpClient` 用于处理 http,`ClientWebSocket` 用于处理 web sockets。我曾考虑是否创建更多服务,一个使用消息队列(如 RabbitMQ 或 ZeroMQ 或一些类似的 Apache 组件),或者创建使用 gRPC 或类似技术的东西。大多数消息解决方案都需要单独安装消息服务器(顺便说一句,ZeroMQ 不需要),所以它们不适合这个演示。gRPC 需要 .NET Core 客户端,所以我们也跳过它,不用于这个演示解决方案。所以,事不宜迟,我们开始...

为服务解决方案创建文件夹并安装所需的 npm 包

此时,我们假设 node.js 已顺利安装。我在我的 D: 驱动器上创建了一个名为 *devnodejs* 的文件夹,并在该文件夹中又创建了一个名为 *moduleDemosSimplifed* 的文件夹。因此,在创建这些文件夹后,我们启动 **Node.js 命令提示符**,这是一个普通的命令提示符,设置了一些用于使用 node.js 的变量,然后我们导航到刚刚创建的文件夹。我们保持提示符打开,然后启动 Visual Studio Code 或 Atom。从现在起,我将只将 Visual Studio Code 编辑器称为编辑器,但您可以使用任何您喜欢的。我忘记了我在编辑器中安装了哪些插件才能很好地显示 http 和 JavaScript,所以您必须自己弄清楚。因此,在我们启动代码编辑器后,我们通过选择“文件”->“打开文件夹”来打开我们的文件夹。我们应该会看到类似这样的内容:

然后,我们回到 node.js 提示符并键入 `npm init`。呵呵,不,我们不这样做。我只是在开玩笑。我们只是键入 `npm install express`,然后键入 `npm install body-parser`。之后 node.js 提示符看起来像这样:

我们回到编辑器,创建我们的第一个文件,*httpServer.js*。我们将在以下章节中更改其内容,但首先,我们需要测试我们的系统。我们在文件中输入以下代码。请注意,我没有使用分号来结束行,因为这不是必需的。但最终,这取决于您。例如,当我编写客户端 JavaScript 时,我使用分号。在这里,我没有。

回到 node.js 控制台,我们键入 `node httpserver.js`,我们期望看到刚刚在编辑器中创建的确认控制台消息。现在,在不取消 node.js 脚本执行的情况下,我们去打开浏览器。或者,如果我们有多个浏览器,我们打开那些最近更新且运行良好的浏览器。然后,我们在地址栏中键入以下内容:`https://:3001` 并确认。

如果我们在 node.js 服务器文件中看到我们的消息,在我的例子中是 `Hello, server is working`,那么我们就可以进行下一步了。如果我们回头看 node.js 控制台,我们会看到长文本,这是解析后的传入消息。

模拟一些服务器逻辑 - node.js 模块的使用

我们将创建一个名为 *business.js* 的文件,其中包含一个名为 `getFigure` 的方法。此方法将创建一个对象,并随机分配一个 `enum` 和一个 `string` 属性。我们将在 *httpServer.js* 和稍后的 *wsServer.js* 中使用此方法,并且我们不会以其名称导出此方法,而是以另一个名称——`getInfo` 导出。所以这是 *business.js* 的代码。这里有趣的是,我们以两种方式创建随机数——一种是使用默认的 JavaScript `Random`,另一种是使用 *node.js* 加密库 `randomFillSync`。在生产环境中,我建议您使用 `randomFill`。这里不使用它是因为它需要回调,并且因为同步方法在即使我发出大量请求(五个客户端几乎同时发出 2,000 或 50,000 个请求)时也能表现良好。

httpServer - 实现 Post 请求处理并进行基本测试

我们选择 node.js 控制台并按 Ctrl+C 停止执行。然后我们键入 `cls`。我们将更改服务器 JavaScript 文件,因此在更改后,我们需要再次启动 node 执行。有一些 `npm` 包可以自动执行此操作并节省您的时间,如果您愿意,可以安装它们。在本教程中,我们不这样做。

我们修改现有的 *httpServer.js* 并添加一些 post 处理方法。这些方法将稍后由我们的客户端使用 - 它将调用它们并从 `httpServer` 接收信息。这是最终的 *httpServer.js* 的样子:

您可以注释/取消注释控制台日志以获取一些信息,这在您进一步开发时会非常有用。现在,是时候通过在 *node.js* 控制台中键入 `node httpServer.js` 并确认来再次启动服务器了。之后,我们应该再次看到控制台消息确认服务器正在运行。我们将使用 Postman 对 post 请求进行基本测试。还有其他工具可用,我想您也可以使用 Fiddler,也许还有更多我目前不记得的工具。当 Postman 窗口加载时,我们将发出 `GET` 请求以查看一切是否正常,就像我们之前使用浏览器所做的那样。因此,我们从 Postman 下拉菜单中选择 `GET`,键入 `https://:3001` 并确认。我们应该会看到相同的消息“`Hello, server is working`”返回。如果一切正常,我们将发出 `POST` 请求。因此,这次我们从下拉菜单中选择 `POST` 并在 Postman 文本框中指定 `https://:3001/ping`。然后,我们单击 `Body` 选项卡并输入 json 格式的文本 - 我们从 `Body` 选项卡正下方的另一个下拉菜单中指定 **JSON (application/json)**。请参阅屏幕截图:

然后我们点击发送按钮,它应该再次返回 json,这次值应该是“`Pong`”。然后,我们可以尝试另一件事,将正文中的值更改为其他内容,例如 *something else*,然后再次点击“**发送**”。结果如下:

这一切看起来很简单,但非常重要。尝试格式化错误的 json 消息,你会得到服务器异常。尝试在控制台中记录解析后的消息,看看 node.js 如何为被视为对象的 json 对象着色。如果你发送 json 对象,但它被 node.js(实际上是你的逻辑)视为字符串,它将以不同的方式显示。

wsServer 实现

当 *httpServer.js* 在其 *node.js* 控制台中运行时,我们启动另一个 *node.js* 控制台并导航到与之前相同的文件夹。我们将使用相同的业务逻辑,但这次,我们将创建一个服务器,它将监听 web socket 消息。为了使用 web sockets,我们将使用 `ws` 模块。这是我们安装它的方法:

这是 *wsServer.js* 代码,以及最终的服务器解决方案结构:

当我们对代码满意后,我们也可以开始执行它。我们在第二个 *node.js* 控制台(第一个运行 `httpServer` 的控制台)中键入 `node wsServer.js` 并确认。通知消息出现在控制台中。`wsServer` 已启动并正在运行。我们将让两个 *node.js* 控制台都运行,然后继续文章的第三部分。我们假设这两个服务器在文章的其余部分始终运行正常。如果在开发过程中发生异常,我们应该相应地重新启动它们。

关于这一部分的最后几句话——我试图让服务器部分变得简单。服务器代码通常更复杂,它考虑了很多事情——服务如何托管、集群、其他模块、身份验证、授权等等,这些都超出了我们文章的范围。好消息是 node.js 开发很自然,而且有大量的资源和示例代码,所以如果你走 node.js 的路,我认为你走对了。当我使用 node.js 时,一切都变得非常容易,我感到非常开心。相比之下,当我阅读一些大公司关于类似主题的文档时,我总是在某个时候感到愤怒。

回到客户端 - 是时候开始与服务通信并进行 Skia 绘图了

在这一部分,我们将使用 `HttpClient` 和 `ClientWebSocket` 类向我们在第二部分中创建的服务发出 http 和 web socket 请求。本章有几个目标,让我们总结一下:

  • 实现几个自定义控制台命令,以便我们能够执行更复杂的任务——调用 http 服务、连接到 ws 服务、调用它、关闭连接等等,更改服务 URL 的权限,以便当我们在网络或 Web 上从其他机器测试客户端时,我们可以轻松更改我们将设置的默认值。因为这是一个演示应用程序,所以我们不会使用任何配置文件。如果您想进一步开发,我建议您使用配置文件,而不是像我们这里这样硬编码地址。
  • WebSocket 的性质要求首先建立连接(*这是通过发送 http... 实际上你可以在网上搜索 WebSocket 工作原理的详细描述,这里我们跳过它*),当此连接建立后,双方之间使用 WebSocket 协议发送消息。因此,很自然地,我们希望有一个单独的线程专门处理 WebSocket。我们通过使用一个无限循环来实现它,只有当我们关闭连接或关闭应用程序时才打破循环。为了轻松处理所有这些,我们将创建一个额外的 `enum`,`ThreadedWebSocketState`。
  • 当处理 WebSocket 消息时,我们将使用一个名为 `WebSocketMessage` 的小类来处理消息。它将只有两个属性:一个名为 `Method` 的 `string` 属性和一个名为 `Data` 的对象。`Data` 实际上是一个 json `string`,我们稍后会将其转换为其他对象。
  • Skia 移植到 WPF 的方式不允许使用绑定。所以我们将使用代码隐藏方法和 `OnPaintSurface` 事件,我们将在这个事件辅助方法中绘制我们的东西。我们将使用 `ConcurrentBag` 来添加对象,然后基于它们在 Skia 画布上进行绘图。所以我们还将实现一个方法,用于调用 Skia 元素的 `InvalidateVisual`,并清除当前添加到 `bag` 中的所有对象。我们将需要稍微调整我们的导航和 `ConsoleSimplifiedViewModel` 来处理所有这些。
  • 我们将稍微修改 `ConsoleSimplifiedView` 的代码隐藏,以便当用户控件加载时,我们可以键入所有命令的文本框会被选中。这是因为一个技巧,这个技巧在另一位作者的一篇 CodeProject 文章中解释得非常好。文章链接作为注释放在代码本身中。
  • 我们希望数据能够以图形方式呈现,这些数据来自我们的“业务”逻辑。在那里,我们创建一个随机 `enum`,它将决定我们绘制什么图形,而服务逻辑提供的下一件事是一个 60(n)个字符长的随机 `string`。我们将取前 30(n/2)个字符并将其转换为 0 到 1 之间的一个数字,我们对剩余的 30(n/2)个字符也做同样的事情。这样我们就会得到归一化的 x 和 y 坐标,我们可以轻松地绘制一个点。

那么,让我们开始文章的第三部分吧。

安装额外的 NuGet 包并创建一些类和枚举

按照第一部分中安装 NuGet 包的说明,我们将再安装三个包,以便使用 **json** 和 **Skia**。这是我们安装所有这三个包后,已安装包选项卡的样子:

不用管 `ControlzEx`,它是 `MahApps` 所必需的。构建并运行客户端以确保一切正常工作。

我们右键单击 *Models* 文件夹并添加一个名为 `WebSocketMessage` 的新类。它看起来像这样:

然后,我们创建 `enum` `ThreadedWebSocketState`

然后,我们创建 `enum` `FigureKinds`

然后,我们创建 `Figure` 类。构建并运行以确保一切正常。

更新导航视图模型和简化控制台(视图、代码隐藏和视图模型)

好的,我们已经到了这一步,这很棒。很快,我们将开始调用服务并使用演示解决方案。下面的类对于示例解决方案很重要,因此请确保一切都对您有利,并且您对它们感到满意。如果需要进一步解释,请提问。所有呈现的类和标记文件都是最终版本。

这是 `ConsoleSimplifiedView` 的 XAML 的样子(带有一些图形解释)

现在这是相同类的代码隐藏,它看起来像这样:

using LocalBrowser.Models;
using SkiaSharp;
using SkiaSharp.Views.Desktop;
using System;
using System.Collections.Concurrent;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Threading;

namespace LocalBrowser
{
    public partial class ConsoleSimplifiedView : UserControl
    {
        public ConsoleSimplifiedView()
        {
            InitializeComponent();
            IsVisibleChanged += OnIsVisibleChanged;
            skiaElement.PaintSurface += OnPaintSurface;
            Figures = new ConcurrentBag<Figure>();
        }

        public ConcurrentBag<Figure> Figures { get; private set; }

        public void ScrollToEnd()
        {
            scroll.ScrollToEnd();
        }

        public void SkiaInvalidateVisual()
        {
            App.Current.Dispatcher.InvokeAsync(() =>
            {
                skiaElement.InvalidateVisual();
            });
        }

        public void ClearSkia()
        {
            Figures = new ConcurrentBag<Figure>();
            skiaElement.InvalidateVisual();
        }

        private void OnPaintSurface(object sender, SKPaintSurfaceEventArgs e)
        {
            SKCanvas canvas = e.Surface.Canvas;
            canvas.Clear();

            float width = (float)skiaElement.ActualWidth;
            float height = (float)skiaElement.ActualHeight;
            SKPoint cente = new SKPoint(width / 2, height / 2);

            SKPoint point;
            SKPaint circlePaint = new SKPaint() { Style = SKPaintStyle.StrokeAndFill, 
                                                  IsAntialias = true, Color = SKColors.OrangeRed };
            SKPaint rectanglePaint = new SKPaint() { Style = SKPaintStyle.StrokeAndFill, 
                                                     IsAntialias = true, Color = SKColors.BlueViolet };
            SKPaint squarePaint = new SKPaint() { Style = SKPaintStyle.StrokeAndFill, 
                                                  IsAntialias = true, Color = SKColors.DarkSeaGreen };

            foreach (Figure figure in Figures)
            {
                point = new SKPoint(figure.NormalX * width, figure.NormalY * height);
                if (figure.Kinds.HasFlag(FigureKinds.circle))
                {
                    canvas.DrawCircle(point, 4, circlePaint);
                }

                if (figure.Kinds.HasFlag(FigureKinds.rectangle))
                {
                    canvas.DrawRect(point.X, point.Y, 10, 5, rectanglePaint);
                }

                if (figure.Kinds.HasFlag(FigureKinds.square))
                {
                    canvas.DrawRect(point.X, point.Y, 7, 7, squarePaint);
                }
            }
        }

        private void OnIsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e)
        {
            // Only to focus the text box. 
            // Thanks to https://codeproject.org.cn/Tips/
            // 478376/%2FTips%2F478376%2FSetting-focus-to-a-control-inside-a-usercontrol-in
            if ((bool)e.NewValue == true)
            {
                Dispatcher.BeginInvoke(DispatcherPriority.ContextIdle, new Action(() =>
                {
                    txt.Focus();
                }));
            }
        }
    }
}

所以,我们这样更改 `ConsoleSimplifiedViewModel`。这是最终版本:

using LocalBrowser.Models;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.WebSockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Input;

namespace LocalBrowser
{
    public class ConsoleSimplifiedViewModel : ViewModelBase
    {
        private string userInput, httpAuthority, wsAuthority;
        private ConsoleViewModel consoleViewModel;
        private ConsoleSimplifiedView view;
        private Stack<string> lifo;

        private ThreadedWebSocketState state = ThreadedWebSocketState.None;
        private bool consoleLogging = false;

        private WebSocketMessage webSocketMessage;

        public ConsoleSimplifiedViewModel(ConsoleSimplifiedView view)
        {
            this.view = view; // skia user control does not support binding, 
                              // so we deal with it like this :)
            lifo = new Stack<string>();

            httpAuthority = "https://:3001";
            wsAuthority = "ws://:3004";

            ProceedUserInputCommand = new SimpleCommand(ProceedUserInput, CanProceedUserInput);
            ProceedPreviousCommand = new SimpleCommand(ProceedPrevious);
            ConsoleViewModel = new ConsoleViewModel();
            ConsoleViewModel.ImplementedConsoleCommands.Add("shutdown", ShutdownApp);
            ConsoleViewModel.CommandDescriptions.Add("shutdown", "Closes the entire application");

            ConsoleViewModel.ImplementedConsoleCommands.Add("toggledetails", ToggleLogging);
            consoleViewModel.CommandDescriptions.Add("toggledetails", 
                                    "Controls showing of additional information");
            ConsoleViewModel.ImplementedConsoleCommands.Add("clearskia", ClearSkia);
            ConsoleViewModel.CommandDescriptions.Add("clearskia", "Clear the skia surface");

            ConsoleViewModel.ImplementedConsoleCommands.Add("pinghttp", PingviaHttp);
            ConsoleViewModel.CommandDescriptions.Add("pinghttp", string.Format
                                 ("Ping the http server at {0}/ping", httpAuthority));
            ConsoleViewModel.ImplementedConsoleCommands.Add("callhttp", CallNodeViaHttp);
            ConsoleViewModel.CommandDescriptions.Add("callhttp", string.Format("Make http POST 
                request to {0}/figure. Typing callhttp 10 will make 10 requests", httpAuthority));

            ConsoleViewModel.ImplementedConsoleCommands.Add("startws", StartWebSocketMode);
            ConsoleViewModel.CommandDescriptions.Add("startws", string.Format
                                    ("Creates a connection to {0}", wsAuthority));
            ConsoleViewModel.ImplementedConsoleCommands.Add("stopws", StopWebSocketMode);
            ConsoleViewModel.CommandDescriptions.Add("stopws", string.Format
                                    ("Closes an existing connection to {0}", wsAuthority));
            ConsoleViewModel.ImplementedConsoleCommands.Add("callws", CallNodeViaWebSocket);
            ConsoleViewModel.CommandDescriptions.Add("callws", string.Format
                                    ("Send message requesting fugure to {0}", wsAuthority));
            ConsoleViewModel.ImplementedConsoleCommands.Add("pingws", PingNodeViaWebSocket);
            ConsoleViewModel.CommandDescriptions.Add("pingws", string.Format
                                    ("Send message requesting ping back to {0}", wsAuthority));

            ConsoleViewModel.ImplementedConsoleCommands.Add("changeauthority", ChangeAuthority);
            ConsoleViewModel.CommandDescriptions.Add("changeauthority", 
                           "Changes the authority. Arguments are schema host port. 
                            Example: changeauthority ws 192.168.0.1 1234");
            ConsoleViewModel.ImplementedConsoleCommands.Add("showauthorities", ShowAuthorities);
            ConsoleViewModel.CommandDescriptions.Add("showauthorities", "Shows current authorities");
        }

        public ICommand ProceedUserInputCommand { get; private set; }
        public ICommand ProceedPreviousCommand { get; private set; }

        public string UserInput
        {
            get { return userInput; }
            set
            {
                userInput = value;
                OnPropertyChanged("UserInput");
            }
        }

        public ConsoleViewModel ConsoleViewModel
        {
            get { return consoleViewModel; }
            set
            {
                consoleViewModel = value;
                OnPropertyChanged("ConsoleViewModel");
            }
        }

        private bool CanProceedUserInput(object parameter = null)
        {
            return (string.IsNullOrEmpty(UserInput.Trim()) == false);
        }

        private void ProceedUserInput(object parameter = null)
        {
            ConsoleViewModel.ProceedConsoleMessage(UserInput);
            view.ScrollToEnd();
            lifo.Push(UserInput);
            UserInput = string.Empty;
        }

        private void ProceedPrevious(object parameter = null)
        {
            if (lifo.Count > 0)
            {
                UserInput = lifo.Pop();
            }
            else
            {
                UserInput = string.Empty;
            }
        }

        private void ToggleLogging()
        {
            consoleLogging = !consoleLogging;
            ConsoleViewModel.ProceedConsoleMessage(string.Format
                   ("Console logging was set to: {0}", consoleLogging ? "On" : "Off"));
        }

        private void ClearSkia()
        {
            view.ClearSkia();
        }

        private void ChangeAuthority()
        {
            if (ConsoleViewModel.ArgumentsInUse == null || 
               String.IsNullOrEmpty(ConsoleViewModel.ArgumentsInUse[0]) || 
                ConsoleViewModel.ArgumentsInUse.Length != 3)
            {
                ConsoleViewModel.ProceedConsoleMessage
                       ("This commands requires three arguments in following succession: 
                             schema(ws or http) host port");
            }
            else
            {
                if (ConsoleViewModel.ArgumentsInUse[0].ToLowerInvariant().Equals("ws"))
                {
                    string wsAuthorityCandidate = string.Format("ws://{0}:{1}", 
                      ConsoleViewModel.ArgumentsInUse[1], ConsoleViewModel.ArgumentsInUse[2]);
                    if (Uri.IsWellFormedUriString(wsAuthorityCandidate, UriKind.Absolute))
                    {
                        wsAuthority = wsAuthorityCandidate;
                    }
                    else
                    {
                        ConsoleViewModel.ProceedRunOnly("The resulting Uri", 
                                              ConsoleViewModel.Foreground, null);
                        ConsoleViewModel.ProceedRunOnly(string.Format(" {0} ", 
                                     wsAuthorityCandidate), ConsoleViewModel.Background, 
                                     ConsoleViewModel.Foreground);
                        ConsoleViewModel.ProceedRunOnly(string.Format
                           (" does not appear to be valid. Please, enter proper arguments.{0}", 
                           Environment.NewLine), ConsoleViewModel.Foreground, null);
                    }
                }
                else if (ConsoleViewModel.ArgumentsInUse[0].ToLowerInvariant().Equals("http"))
                {
                    string httpAuthorityCandidate = string.Format("http://{0}:{1}", 
                       ConsoleViewModel.ArgumentsInUse[1], ConsoleViewModel.ArgumentsInUse[2]);
                    if (Uri.IsWellFormedUriString(httpAuthorityCandidate, UriKind.Absolute))
                    {
                        httpAuthority = httpAuthorityCandidate;
                    }
                    else
                    {
                        ConsoleViewModel.ProceedRunOnly
                                      ("The resulting Uri", ConsoleViewModel.Foreground, null);
                        ConsoleViewModel.ProceedRunOnly(string.Format(" {0} ", 
                              httpAuthorityCandidate), ConsoleViewModel.Background, 
                                    ConsoleViewModel.Foreground);
                        ConsoleViewModel.ProceedRunOnly(string.Format
                                (" does not appear to be valid. Please, 
                                   enter proper arguments.{0}", Environment.NewLine), 
                                   ConsoleViewModel.Foreground, null);
                    }
                }
            }
        }

        private void ShowAuthorities()
        {
            ConsoleViewModel.ProceedConsoleMessage(string.Format
               ("Current authorities: {0}{1}{0}{2}", Environment.NewLine, httpAuthority, wsAuthority));
        }

        private void ShutdownApp()
        {
            System.Windows.Application.Current.Shutdown();
        }

        private void CallNodeViaHttp()
        {
            string requestUri = string.Format("{0}/figure", httpAuthority);
            if (ConsoleViewModel.ArgumentsInUse == null || String.IsNullOrEmpty
                                    (ConsoleViewModel.ArgumentsInUse[0]))
            {
                ThreadPool.QueueUserWorkItem(o =>
                {
                    CallNodeViaHttp(requestUri, string.Empty);
                });
            }
            else
            {
                int loop;
                if (Int32.TryParse(ConsoleViewModel.ArgumentsInUse[0], out loop))
                {
                    loop = Math.Abs(loop);
                    Parallel.For(0, loop, o =>
                    {
                        ThreadPool.QueueUserWorkItem(bo =>
                        {
                            CallNodeViaHttp(requestUri, string.Empty);
                        });
                    });
                }
            }
        }

        private void PingviaHttp()
        {
            string json = JsonConvert.SerializeObject(new { ping = "Ping" });
            ThreadPool.QueueUserWorkItem(o =>
            {
                CallNodeViaHttp(string.Format("{0}/ping", httpAuthority), json);
            });
        }

        private async void CallNodeViaHttp(string requestUri, string json)
        {
            using (HttpClient httpClient = new HttpClient())
            {
                try
                {
                    HttpResponseMessage response = await httpClient.PostAsync
                          (requestUri, new StringContent(json, Encoding.UTF8, "application/json"));
                    if (response.StatusCode.Equals(HttpStatusCode.OK))
                    {
                        string fromServer = await response.Content.ReadAsStringAsync();
                        if (requestUri.Equals(string.Format("{0}/figure", httpAuthority)))
                        {
                            Figure httpFigure = JsonConvert.DeserializeObject<Figure>(fromServer);
                            httpFigure.Min = 1750;
                            httpFigure.Max = 2450;
                            httpFigure.CalculateNormals();
                            if (consoleLogging)
                            {
                                ConsoleViewModel.ProceedSystemMessage(string.Format
                                ("Received a figure: {1} Data: {2} Length: {3}{0}Normal 
                                    X:{4} Normal Y: {5} Max: {6} Min: {7}",
                                    Environment.NewLine, httpFigure.Kinds, httpFigure.Data, 
                                    httpFigure.Data.Length, httpFigure.NormalX, 
                                    httpFigure.NormalY, httpFigure.Max, httpFigure.Min), false);
                            }
                            view.Figures.Add(httpFigure);
                            view.SkiaInvalidateVisual();
                        }
                        else if (requestUri.Equals(string.Format("{0}/ping", httpAuthority)))
                        {
                            ConsoleViewModel.ProceedSystemMessage(string.Format
                               ("We asked server: Ping. Server answered: {0}", fromServer), false);
                        }
                    }
                }
                catch (HttpRequestException hre)
                {
                    ConsoleViewModel.ProceedSystemMessage(string.Format
                            ("There has been an underlying issue.{0} Details: {1}", 
                              Environment.NewLine, hre.Message));
                }
                catch (Exception)
                {
                    throw;
                }
            }
        }

        private void StartWebSocketMode()
        {
            ThreadPool.QueueUserWorkItem(omicron =>
            {
                TaskWebSocket();
            });
        }

        private void CallNodeViaWebSocket()
        {
            if (state.Equals(ThreadedWebSocketState.Created))
            {
                if (ConsoleViewModel.ArgumentsInUse == null || String.IsNullOrEmpty
                                  (ConsoleViewModel.ArgumentsInUse[0]))
                {
                    webSocketMessage = new WebSocketMessage() { Method = "figure", Data = 0 };
                }
                else
                {
                    int loop;
                    if (int.TryParse(ConsoleViewModel.ArgumentsInUse[0], out loop))
                    {
                        webSocketMessage = new WebSocketMessage() 
                               { Method = "figure", Data = Math.Abs(loop) };
                    }
                }
            }
            else
            {
                ConsoleViewModel.ProceedSystemMessage("Please connect to web socket server first.");
            }
        }

        private void PingNodeViaWebSocket()
        {
            if (state.Equals(ThreadedWebSocketState.Created))
            {
                webSocketMessage = new WebSocketMessage() { Method = "ping" };
            }
            else
            {
                ConsoleViewModel.ProceedSystemMessage("Please connect to web socket server first.");
            }
        }

        private void StopWebSocketMode()
        {
            if (state.Equals(ThreadedWebSocketState.Created))
            {
                webSocketMessage = new WebSocketMessage() { Method = "stop" };
            }
            else
            {
                ConsoleViewModel.ProceedSystemMessage("Please connect to web socket server first.");
            }
        }

        private async void TaskWebSocket()
        {
            using (ClientWebSocket ws = new ClientWebSocket())
            {
                try
                {
                    state = ThreadedWebSocketState.Created;
                    Uri wsUri = new Uri(wsAuthority);
                    await ws.ConnectAsync(wsUri, CancellationToken.None);
                    ConsoleViewModel.ProceedSystemMessage
                          (string.Format("Connected to {0}", wsAuthority), false);
                }
                catch (WebSocketException wse)
                {
                    ConsoleViewModel.ProceedSystemMessage(string.Format
                       ("There has been an underlying issue.{0} Details: {1}", 
                         Environment.NewLine, wse.Message));
                }
                catch (Exception)
                {
                    throw;
                }

                while (ws.State == WebSocketState.Open)
                {
                    if (string.IsNullOrEmpty(webSocketMessage?.Method) == false)
                    {
                        if (webSocketMessage.Method.Equals("stop"))
                        {
                            await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, 
                                             "drun drun", CancellationToken.None);
                            webSocketMessage = null;
                            state = ThreadedWebSocketState.Closed;
                            ConsoleViewModel.ProceedSystemMessage
                                ("Collection to websocket server was closed", false);
                            break;
                        }
                        else
                        {
                            byte[] message = Encoding.UTF8.GetBytes
                                          (JsonConvert.SerializeObject(webSocketMessage));
                            await ws.SendAsync(new ArraySegment<byte>(message), 
                                    WebSocketMessageType.Text, true, CancellationToken.None);
                            ArraySegment<byte> received = new ArraySegment<byte>(new byte[1024]);
                            WebSocketReceiveResult result = await ws.ReceiveAsync
                                                  (received, CancellationToken.None);
                            WebSocketMessage messageBack = 
                                     JsonConvert.DeserializeObject<WebSocketMessage>
                                             (Encoding.UTF8.GetString(received.Array));

                            if (webSocketMessage.Method.Equals("ping"))
                            {
                                ConsoleViewModel.ProceedSystemMessage(string.Format
                                ("App to node.js: ping: node.js to app: {0}", messageBack.Data), false);
                                webSocketMessage = null;
                            }
                            else if (webSocketMessage.Method.Equals("figure"))
                            {
                                ProcessFigure(messageBack);
                                webSocketMessage.Data = Math.Max(0, (int)webSocketMessage.Data - 1);
                                if ((int)webSocketMessage.Data == 0)
                                {
                                    view.SkiaInvalidateVisual();
                                    webSocketMessage = null;
                                }
                            }
                        }
                    }
                }
            }
        }

        private void ProcessFigure(WebSocketMessage messageBack)
        {
            Figure wsFigure = JsonConvert.DeserializeObject<Figure>(messageBack.Data.ToString());
            wsFigure.Min = 1750;
            wsFigure.Max = 2450;
            wsFigure.CalculateNormals();

            if (consoleLogging)
            {
                ConsoleViewModel.ProceedSystemMessage(string.Format("Received a figure: 
                  {1} Data: {2} Length: {3}{0}Normal X:{4} Normal Y: {5} Max: {6} Min: {7}",
                    Environment.NewLine, wsFigure.Kinds, wsFigure.Data, wsFigure.Data.Length, 
                    wsFigure.NormalX, wsFigure.NormalY, wsFigure.Max, wsFigure.Min), false);
            }
            view.Figures.Add(wsFigure);
        }
    }
}

这是导航视图模型,即 `ShellViewModel`。

此时,您构建并运行解决方案。如果一切顺利,我们就可以进行下一步了——调用服务并解释演示的功能。如果构建和运行解决方案时出现问题,您必须排除故障并最终修复它。从这一点开始,不再编写代码,我们将调用服务,查看 Skia 如何绘制结果,并讨论一些方法和动机。

使用演示 - 输入命令并观察小圆圈、矩形和正方形

我们已经构建了这样一个好的演示解决方案,现在是时候使用它了。所以我们启动客户端,导航到视图,**不点击任何地方就开始输入**,然后输入 **help**。我们将看到所有已实现命令的列表——一个单词和一组其他单词,描述如果我们输入该单词并确认会发生什么。我们从第一部分知道如何更改字体和背景颜色,因此我们也可以利用这些知识。然后,我们开始使用服务。

使用 httpServer.js 服务

我们输入 `pinghttp`,如果我们在第二部分中创建并仍在运行的服务仍在工作,我们应该会看到响应消息。然后,我们输入 `toggleDetails`,我们应该在我们的控制台样式的文本块中看到有关更改状态的信息。然后我们输入 `callhttp` 并确认。我们得到了这样的结果:

我们遇到了生成所有三种图形——`circle`、`rectangle` 和 `square` 的情况。正如你所看到的,随机 `string` 存在,通过我们的自定义归一化方法,我们从这个随机 `string` 中获得两个 `x` 和 `y` 归一化坐标。Skia 使用它们进行绘图。让我们再进行二十次调用。现在我们有了这种情况。我们可以检查图形集和生成的 `string` 每次都不同,因此在画布上的不同点有不同的图形。

使用 wsServer.js 服务

好的,现在我们输入 `clear` 来清除控制台屏幕,然后输入 `clearskia` 来清除 skia 画布。然后,我们输入 `startws` 来建立与 web socket 服务器的连接。然后,我们输入 `callws`。因为我们打开了控制台日志,所以我们会再次看到详细信息。好的,让我们再调用九次,总共十次。

达到演示解决方案的极限

好的,现在是时候多玩一会儿了。我们可以启动多个客户端,并从它们发出大量的 http 和 ws 服务器请求。在某些时候,根据我们的硬件,我们将达到极限并遇到一些异常。这里是 http 和 ws 调用之间的快速比较。您可能会问:`HttpClient` 进行的 http 调用和 `ClientWebSocket` 进行的 web socket 调用有什么区别?有很多区别,一个调用带有信息,另一个带有另一个信息,除了业务信息,它基本相同。或者您可以启动客户端的发布版本,并开始进行大量调用,直到您看到类似这样的内容:

我们遇到了 `HttpRequestException`,因为我们创建了太多的 `HttpClient`,导致客户端 UI 线程进行了太多的调用,以至于服务器无法处理它们并最终挂起。我正在使用一个双路 Xeon 服务器处理器,带有三十二个逻辑处理器,运行在 2.6GHz,内存足够大,48GB,但它是 DDR3,工作在 1333MHz。相比之下,我还有另一台机器,配备 Core i7 CPU,八个处理器,运行在 4.2GHz,内存较少,32GB,但它是 DDR5,工作在 2933MHz,所以在那里我进行了 50,000 次 http 调用,没有发生此类错误。当我增加数量时才会发生。

现在,让我们看看 web socket 服务器的性能。我们清除控制台和 Skia 画布,然后键入 `startws`。然后,我们进行 5000 次 ws 调用。没有异常,而且速度更快。因为在我们的代码中,所有请求完成后我们才更新画布,所以我们可能会看到一两秒的空白画布。好的,现在让我们进行 50,000 次调用。仍然没有异常。请看结果:

在我制作这个演示解决方案的时候,我未能达到进行 WebSocket 调用的极限,也许是因为我没有使用更多演示客户端实例并进行更多调用。我曾希望在某个时候,我能达到我在服务器逻辑中使用的 `randomFillSync` 所带来的限制,但我没有。

我希望你喜欢这篇文章,就像我制作它时一样。

它有一些设计上的妥协,但我想对于演示解决方案来说还是相当不错的。我希望有人会觉得这很有趣。下一步可能就是集群 `httpServer`,看看这如何改善在发生 `HttpRequestException` 之前处理的请求数量。

© . All rights reserved.