配合使用 ASP.NET Core SignalR 和 TypeScript 以及 Webpack

作者:Sébastien SougnezScott Addie

开发人员可以通过 Webpack 捆绑和生成 Web 应用的客户端资源。 本教程介绍在 ASP.NET Core SignalR Web 应用中使用 Webpack,该应用的客户端是使用 TypeScript 编写的。

在本教程中,你将了解:

  • 为入门 ASP.NET Core SignalR 应用搭建基架
  • 配置 SignalR TypeScript 客户端
  • 使用 Webpack 配置生成管道
  • 配置 SignalR 服务器
  • 启用客户端和服务器之间的通信

查看或下载示例代码如何下载

先决条件

创建 ASP.NET Core Web 应用

配置 Webpack 和 TypeScript

以下步骤配置 TypeScript 到 JavaScript 的转换和客户端资源的捆绑。

  1. 在项目根目录中运行以下命令,创建 package.json 文件 :

    npm init -y
    
  2. 将突出显示的属性添加到 package.json 文件并保存文件更改 :

    {
      "name": "SignalRWebPack",
      "version": "1.0.0",
      "private": true,
      "description": "",
      "main": "index.js",
      "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1"
      },
      "keywords": [],
      "author": "",
      "license": "ISC"
    }
    

    private 属性设置为 true,防止下一步出现包安装警告。

  3. 安装所需的 npm 包。 从项目根目录运行以下命令:

    npm i -D -E clean-webpack-plugin@3.0.0 css-loader@3.4.2 html-webpack-plugin@3.2.0 mini-css-extract-plugin@0.9.0 ts-loader@6.2.1 typescript@3.7.5 webpack@4.41.5 webpack-cli@3.3.10
    

    需要注意的一些命令细节:

    • 每个包名称中 @ 符号后是版本号。 npm 安装这些特定的包版本。
    • -E 选项禁用 npm 将语义化版本控制范围运算符写到 package.json 的默认行为 。 例如,使用 "webpack": "4.41.5" 而不是 "webpack": "^4.41.5" 此选项防止意外升级到新的包版本。

    有关详细信息,请参阅 npm-install 文档。

  4. 将 package.json 文件的 scripts 属性替换为以下代码 :

    "scripts": {
      "build": "webpack --mode=development --watch",
      "release": "webpack --mode=production",
      "publish": "npm run release && dotnet publish -c Release"
    },
    

    脚本的一些解释:

    • build:在开发模式下捆绑客户端资源并观察文件更改。 文件观察程序使捆绑在每次项目文件发生更改时重新生成。 mode 选项可禁用生产优化,例如摇树优化和缩小优化。 仅在开发中使用 build
    • release:在生产模式下捆绑客户端资源。
    • publish:运行 release 脚本,在生产模式下捆绑客户端资源。 它调用 .NET Core CLI 的 publish 命令发布应用。
  5. 在项目根目录中创建名为 webpack.config.js 的文件,使其包含以下代码 :

    const path = require("path");
    const HtmlWebpackPlugin = require("html-webpack-plugin");
    const { CleanWebpackPlugin } = require("clean-webpack-plugin");
    const MiniCssExtractPlugin = require("mini-css-extract-plugin");
    module.exports = {
        entry: "./src/index.ts",
        output: {
            path: path.resolve(__dirname, "wwwroot"),
            filename: "[name].[chunkhash].js",
            publicPath: "/"
        },
        resolve: {
            extensions: [".js", ".ts"]
        },
        module: {
            rules: [
                {
                    test: /\.ts$/,
                    use: "ts-loader"
                },
                {
                    test: /\.css$/,
                    use: [MiniCssExtractPlugin.loader, "css-loader"]
                }
            ]
        },
        plugins: [
            new CleanWebpackPlugin(),
            new HtmlWebpackPlugin({
                template: "./src/index.html"
            }),
            new MiniCssExtractPlugin({
                filename: "css/[name].[chunkhash].css"
            })
        ]
    };
    

    前面的文件配置 Webpack 编译。 需要注意的一些配置细节:

    • output 属性替代 dist 的默认值 。 捆绑反而在 wwwroot 目录中发出 。
    • resolve.extensions 数组包含 .js,以便导入 SignalR 客户端 JavaScript 。
  6. 在项目根目录中创建新的 src 目录,以存储项目的客户端资产 。

  7. 创建包含以下标记的 src/index.html 。

    <!DOCTYPE html>
    <html>
    <head>
        <meta charset="utf-8" />
        <title>ASP.NET Core SignalR</title>
    </head>
    <body>
        <div id="divMessages" class="messages">
        </div>
        <div class="input-zone">
            <label id="lblMessage" for="tbMessage">Message:</label>
            <input id="tbMessage" class="input-zone-input" type="text" />
            <button id="btnSend">Send</button>
        </div>
    </body>
    </html>
    

    前面的 HTML 定义主页的样板标记。

  8. 创建新的 src/css 目录 。 目的是存储项目的 .css 文件 。

  9. 创建包含以下 CSS 的 src/css/main.css :

    *, *::before, *::after {
        box-sizing: border-box;
    }
    
    html, body {
        margin: 0;
        padding: 0;
    }
    
    .input-zone {
        align-items: center;
        display: flex;
        flex-direction: row;
        margin: 10px;
    }
    
    .input-zone-input {
        flex: 1;
        margin-right: 10px;
    }
    
    .message-author {
        font-weight: bold;
    }
    
    .messages {
        border: 1px solid #000;
        margin: 10px;
        max-height: 300px;
        min-height: 300px;
        overflow-y: auto;
        padding: 5px;
    }
    

    前面的 main.css 文件设计应用样式 。

  10. 创建包含以下 JSON 的 src/tsconfig.json :

    {
      "compilerOptions": {
        "target": "es5"
      }
    }
    

    前面的代码配置 TypeScript 编译器,生成与 ECMAScript 5 兼容的 JavaScript。

  11. 创建包含以下代码的 src/index.ts :

    import "./css/main.css";
    
    const divMessages: HTMLDivElement = document.querySelector("#divMessages");
    const tbMessage: HTMLInputElement = document.querySelector("#tbMessage");
    const btnSend: HTMLButtonElement = document.querySelector("#btnSend");
    const username = new Date().getTime();
    
    tbMessage.addEventListener("keyup", (e: KeyboardEvent) => {
        if (e.key === "Enter") {
            send();
        }
    });
    
    btnSend.addEventListener("click", send);
    
    function send() {
    }
    

    前面的 TypeScript 检索对 DOM 元素的引用并附加两个事件处理程序:

    • keyup:用户在 tbMessage 文本框中键入时触发此事件。 用户按 Enter 时调用 send 函数 。
    • click:用户单击“发送”按钮时触发此事件 。 调用 send 函数。

配置应用

  1. Startup.Configure 中,添加对 UseDefaultFilesUseStaticFiles 的调用。

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }
        
        app.UseRouting();
        app.UseDefaultFiles();
        app.UseStaticFiles();
        
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapHub<ChatHub>("/hub");
        });
            
    }
    

    上述代码允许服务器查找和处理 index.html 文件 。 无论用户输入完整 URL 还是 Web 应用的根 URL,都会提供该文件。

  2. Startup.Configure 的末尾,将 /hub 路由映射到 ChatHub 中心 。 将显示 Hello World! 的代码 替换为以下行:

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapHub<ChatHub>("/hub");
    });
    
  3. Startup.ConfigureServices中,调用 AddSignalR

    services.AddSignalR();
    
  4. 在项目根目录 SignalRWebPack/ 中创建名为 Hubs 的新目录,以存储 SignalR 中心 。

  5. 创建包含以下代码的中心 Hubs/ChatHub.cs :

    using Microsoft.AspNetCore.SignalR;
    using System.Threading.Tasks;
    
    namespace SignalRWebPack.Hubs
    {
        public class ChatHub : Hub
        {
        }
    }
    
  6. 在 Startup.cs 文件顶部添加以下 using 语句来解析 ChatHub 引用 :

    using SignalRWebPack.Hubs;
    

启用客户端和服务器通信

应用目前显示用于发送消息的基本窗体,但尚不能正常工作。 服务器正在侦听特定的路由,但是不涉及发送消息。

  1. 在项目根目录运行以下命令:

    npm i @microsoft/signalr @types/node
    

    上述的代码会安装:

    • SignalR TypeScript 客户端,它允许客户端向服务器发送消息。
    • 用于 node.js 的 TypeScript 类型定义,支持 Node.js 类型的编译时检查。
  2. 将突出显示的代码添加到 src/index.ts 文件 :

    import "./css/main.css";
    import * as signalR from "@microsoft/signalr";
    
    const divMessages: HTMLDivElement = document.querySelector("#divMessages");
    const tbMessage: HTMLInputElement = document.querySelector("#tbMessage");
    const btnSend: HTMLButtonElement = document.querySelector("#btnSend");
    const username = new Date().getTime();
    
    const connection = new signalR.HubConnectionBuilder()
        .withUrl("/hub")
        .build();
    
    connection.on("messageReceived", (username: string, message: string) => {
        let m = document.createElement("div");
    
        m.innerHTML =
            `<div class="message-author">${username}</div><div>${message}</div>`;
    
        divMessages.appendChild(m);
        divMessages.scrollTop = divMessages.scrollHeight;
    });
    
    connection.start().catch(err => document.write(err));
    
    tbMessage.addEventListener("keyup", (e: KeyboardEvent) => {
        if (e.key === "Enter") {
            send();
        }
    });
    
    btnSend.addEventListener("click", send);
    
    function send() {
    }
    

    前面的代码支持从服务器接收消息。 HubConnectionBuilder 类创建新的生成器,用于配置服务器连接。 withUrl 函数配置中心 URL。

    SignalR 启用客户端和服务器之间的消息交换。 每个消息都有特定的名称。 例如,名为 messageReceived 的消息可以运行负责在消息区域显示新消息的逻辑。 可以通过 on 函数完成对特定消息的侦听。 可以侦听任意数量的消息名称。 还可以将参数传递到消息,例如所接收消息的作者姓名和内容。 客户端收到一条消息后,会创建一个新的 div 元素并在其 innerHTML 属性中显示作者姓名和消息内容。 它添加到显示消息的主要 div 元素。

  3. 客户端可以接收消息后,将它配置为发送消息。 将突出显示的代码添加到 src/index.ts 文件 :

    import "./css/main.css";
    import * as signalR from "@microsoft/signalr";
    
    const divMessages: HTMLDivElement = document.querySelector("#divMessages");
    const tbMessage: HTMLInputElement = document.querySelector("#tbMessage");
    const btnSend: HTMLButtonElement = document.querySelector("#btnSend");
    const username = new Date().getTime();
    
    const connection = new signalR.HubConnectionBuilder()
        .withUrl("/hub")
        .build();
    
    connection.on("messageReceived", (username: string, message: string) => {
        let messages = document.createElement("div");
    
        messages.innerHTML =
            `<div class="message-author">${username}</div><div>${message}</div>`;
    
        divMessages.appendChild(messages);
        divMessages.scrollTop = divMessages.scrollHeight;
    });
    
    connection.start().catch(err => document.write(err));
    
    tbMessage.addEventListener("keyup", (e: KeyboardEvent) => {
        if (e.key === "Enter") {
            send();
        }
    });
    
    btnSend.addEventListener("click", send);
    
    function send() {
        connection.send("newMessage", username, tbMessage.value)
            .then(() => tbMessage.value = "");
    }
    

    通过 WebSockets 连接发送消息需要调用 send 方法。 该方法的第一个参数是消息名称。 消息数据包含其他参数。 在此示例中,一条标识为 newMessage 的消息已发送到服务器。 该消息包含用户名和文本框中的用户输入。 如果发送成功,会清空文本框。

  4. NewMessage 方法添加到 ChatHub 类:

    using Microsoft.AspNetCore.SignalR;
    using System.Threading.Tasks;
    
    namespace SignalRWebPack.Hubs
    {
        public class ChatHub : Hub
        {
            public async Task NewMessage(long username, string message)
            {
                await Clients.All.SendAsync("messageReceived", username, message);
            }
        }
    }
    

    服务器收到消息后,前面的代码会将这些消息播发到所有连接的用户。 没有必要使用泛型 on 方法接收所有消息。 使用以消息名称命名的方法就可以了。

    在此示例中,TypeScript 客户端发送一条标识为 newMessage 的消息。 C# NewMessage 方法需要客户端发送的数据。 Clients.All 上对 SendAsync 进行调用。 接收的消息会发送到所有连接到中心的客户端。

测试应用

确认应用遵循以下步骤。

两个浏览器窗口都显示的消息

先决条件

创建 ASP.NET Core Web 应用

配置 Webpack 和 TypeScript

以下步骤配置 TypeScript 到 JavaScript 的转换和客户端资源的捆绑。

  1. 在项目根目录中运行以下命令,创建 package.json 文件 :

    npm init -y
    
  2. 将突出显示的属性添加到 package.json 文件 :

    {
      "name": "SignalRWebPack",
      "version": "1.0.0",
      "private": true,
      "description": "",
      "main": "index.js",
      "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1"
      },
      "keywords": [],
      "author": "",
      "license": "ISC"
    }
    

    private 属性设置为 true,防止下一步出现包安装警告。

  3. 安装所需的 npm 包。 从项目根目录运行以下命令:

    npm install -D -E clean-webpack-plugin@1.0.1 css-loader@2.1.0 html-webpack-plugin@4.0.0-beta.5 mini-css-extract-plugin@0.5.0 ts-loader@5.3.3 typescript@3.3.3 webpack@4.29.3 webpack-cli@3.2.3
    

    需要注意的一些命令细节:

    • 每个包名称中 @ 符号后是版本号。 npm 安装这些特定的包版本。
    • -E 选项禁用 npm 将语义化版本控制范围运算符写到 package.json 的默认行为 。 例如,使用 "webpack": "4.29.3" 而不是 "webpack": "^4.29.3" 此选项防止意外升级到新的包版本。

    有关详细信息,请参阅 npm-install 文档。

  4. 将 package.json 文件的 scripts 属性替换为以下代码 :

    "scripts": {
      "build": "webpack --mode=development --watch",
      "release": "webpack --mode=production",
      "publish": "npm run release && dotnet publish -c Release"
    },
    

    脚本的一些解释:

    • build:在开发模式下捆绑客户端资源并观察文件更改。 文件观察程序使捆绑在每次项目文件发生更改时重新生成。 mode 选项可禁用生产优化,例如摇树优化和缩小优化。 仅在开发中使用 build
    • release:在生产模式下捆绑客户端资源。
    • publish:运行 release 脚本,在生产模式下捆绑客户端资源。 它调用 .NET Core CLI 的 publish 命令发布应用。
  5. 在项目根目录中创建名为 webpack.config.js 的文件,使其包含以下代码 :

    const path = require("path");
    const HtmlWebpackPlugin = require("html-webpack-plugin");
    const CleanWebpackPlugin = require("clean-webpack-plugin");
    const MiniCssExtractPlugin = require("mini-css-extract-plugin");
    
    module.exports = {
        entry: "./src/index.ts",
        output: {
            path: path.resolve(__dirname, "wwwroot"),
            filename: "[name].[chunkhash].js",
            publicPath: "/"
        },
        resolve: {
            extensions: [".js", ".ts"]
        },
        module: {
            rules: [
                {
                    test: /\.ts$/,
                    use: "ts-loader"
                },
                {
                    test: /\.css$/,
                    use: [MiniCssExtractPlugin.loader, "css-loader"]
                }
            ]
        },
        plugins: [
            new CleanWebpackPlugin(["wwwroot/*"]),
            new HtmlWebpackPlugin({
                template: "./src/index.html"
            }),
            new MiniCssExtractPlugin({
                filename: "css/[name].[chunkhash].css"
            })
        ]
    };
    

    前面的文件配置 Webpack 编译。 需要注意的一些配置细节:

    • output 属性替代 dist 的默认值 。 捆绑反而在 wwwroot 目录中发出 。
    • resolve.extensions 数组包含 .js,以便导入 SignalR 客户端 JavaScript 。
  6. 在项目根目录中创建新的 src 目录,以存储项目的客户端资产 。

  7. 创建包含以下标记的 src/index.html 。

    <!DOCTYPE html>
    <html>
    <head>
        <meta charset="utf-8" />
        <title>ASP.NET Core SignalR</title>
    </head>
    <body>
        <div id="divMessages" class="messages">
        </div>
        <div class="input-zone">
            <label id="lblMessage" for="tbMessage">Message:</label>
            <input id="tbMessage" class="input-zone-input" type="text" />
            <button id="btnSend">Send</button>
        </div>
    </body>
    </html>
    

    前面的 HTML 定义主页的样板标记。

  8. 创建新的 src/css 目录 。 目的是存储项目的 .css 文件 。

  9. 创建包含以下标记的 src/css/main.css :

    *, *::before, *::after {
        box-sizing: border-box;
    }
    
    html, body {
        margin: 0;
        padding: 0;
    }
    
    .input-zone {
        align-items: center;
        display: flex;
        flex-direction: row;
        margin: 10px;
    }
    
    .input-zone-input {
        flex: 1;
        margin-right: 10px;
    }
    
    .message-author {
        font-weight: bold;
    }
    
    .messages {
        border: 1px solid #000;
        margin: 10px;
        max-height: 300px;
        min-height: 300px;
        overflow-y: auto;
        padding: 5px;
    }
    

    前面的 main.css 文件设计应用样式 。

  10. 创建包含以下 JSON 的 src/tsconfig.json :

    {
      "compilerOptions": {
        "target": "es5"
      }
    }
    

    前面的代码配置 TypeScript 编译器,生成与 ECMAScript 5 兼容的 JavaScript。

  11. 创建包含以下代码的 src/index.ts :

    import "./css/main.css";
    
    const divMessages: HTMLDivElement = document.querySelector("#divMessages");
    const tbMessage: HTMLInputElement = document.querySelector("#tbMessage");
    const btnSend: HTMLButtonElement = document.querySelector("#btnSend");
    const username = new Date().getTime();
    
    tbMessage.addEventListener("keyup", (e: KeyboardEvent) => {
        if (e.keyCode === 13) {
            send();
        }
    });
    
    btnSend.addEventListener("click", send);
    
    function send() {
    }
    

    前面的 TypeScript 检索对 DOM 元素的引用并附加两个事件处理程序:

    • keyup:用户在 tbMessage 文本框中键入时触发此事件。 用户按 Enter 时调用 send 函数 。
    • click:用户单击“发送”按钮时触发此事件 。 调用 send 函数。

配置 ASP.NET Core 应用

  1. Startup.Configure 方法中提供的代码显示 Hello World! 。 app.Run 方法调用替换为对 UseDefaultFilesUseStaticFiles 的调用。

    app.UseDefaultFiles();
    app.UseStaticFiles();
    

    前面的代码允许服务器定位并提供 index.html 文件,无论用户输入完整 URL 还是 Web 应用的根 URL 。

  2. Startup.ConfigureServices 中,调用 AddSignalR 此操作会将 SignalR 服务添加到项目。

    services.AddSignalR();
    
  3. 将 /hub 路由映射到 ChatHub 中心 。 Startup.Configure 的末尾添加以下行:

    app.UseSignalR(options =>
    {
        options.MapHub<ChatHub>("/hub");
    });
    
  4. 在项目根中创建名为 Hubs 的新目录 。 目的是存储 SignalR 中心(在下一步中创建)。

  5. 创建包含以下代码的中心 Hubs/ChatHub.cs :

    using Microsoft.AspNetCore.SignalR;
    using System.Threading.Tasks;
    
    namespace SignalRWebPack.Hubs
    {
        public class ChatHub : Hub
        {
        }
    }
    
  6. 在 Startup.cs 文件顶部添加以下代码,解析 ChatHub 引用 :

    using SignalRWebPack.Hubs;
    

启用客户端和服务器通信

应用当前显示一个发送消息的简单窗体。 尝试执行此操作时没有任何反应。 服务器正在侦听特定的路由,但是不涉及发送消息。

  1. 在项目根目录运行以下命令:

    npm install @aspnet/signalr
    

    前面的命令安装 SignalR TypeScript 客户端,它允许客户端向服务器发送消息。

  2. 将突出显示的代码添加到 src/index.ts 文件 :

    import "./css/main.css";
    import * as signalR from "@aspnet/signalr";
    
    const divMessages: HTMLDivElement = document.querySelector("#divMessages");
    const tbMessage: HTMLInputElement = document.querySelector("#tbMessage");
    const btnSend: HTMLButtonElement = document.querySelector("#btnSend");
    const username = new Date().getTime();
    
    const connection = new signalR.HubConnectionBuilder()
        .withUrl("/hub")
        .build();
    
    connection.on("messageReceived", (username: string, message: string) => {
        let m = document.createElement("div");
    
        m.innerHTML =
            `<div class="message-author">${username}</div><div>${message}</div>`;
    
        divMessages.appendChild(m);
        divMessages.scrollTop = divMessages.scrollHeight;
    });
    
    connection.start().catch(err => document.write(err));
    
    tbMessage.addEventListener("keyup", (e: KeyboardEvent) => {
        if (e.keyCode === 13) {
            send();
        }
    });
    
    btnSend.addEventListener("click", send);
    
    function send() {
    }
    

    前面的代码支持从服务器接收消息。 HubConnectionBuilder 类创建新的生成器,用于配置服务器连接。 withUrl 函数配置中心 URL。

    SignalR 启用客户端和服务器之间的消息交换。 每个消息都有特定的名称。 例如,名为 messageReceived 的消息可以运行负责在消息区域显示新消息的逻辑。 可以通过 on 函数完成对特定消息的侦听。 可以侦听任意数量的消息名称。 还可以将参数传递到消息,例如所接收消息的作者姓名和内容。 客户端收到一条消息后,会创建一个新的 div 元素并在其 innerHTML 属性中显示作者姓名和消息内容。 新消息将添加到显示消息的主 div 元素中。

  3. 客户端可以接收消息后,将它配置为发送消息。 将突出显示的代码添加到 src/index.ts 文件 :

    import "./css/main.css";
    import * as signalR from "@aspnet/signalr";
    
    const divMessages: HTMLDivElement = document.querySelector("#divMessages");
    const tbMessage: HTMLInputElement = document.querySelector("#tbMessage");
    const btnSend: HTMLButtonElement = document.querySelector("#btnSend");
    const username = new Date().getTime();
    
    const connection = new signalR.HubConnectionBuilder()
        .withUrl("/hub")
        .build();
    
    connection.on("messageReceived", (username: string, message: string) => {
        let messageContainer = document.createElement("div");
    
        messageContainer.innerHTML =
            `<div class="message-author">${username}</div><div>${message}</div>`;
    
        divMessages.appendChild(messageContainer);
        divMessages.scrollTop = divMessages.scrollHeight;
    });
    
    connection.start().catch(err => document.write(err));
    
    tbMessage.addEventListener("keyup", (e: KeyboardEvent) => {
        if (e.keyCode === 13) {
            send();
        }
    });
    
    btnSend.addEventListener("click", send);
    
    function send() {
        connection.send("newMessage", username, tbMessage.value)
                  .then(() => tbMessage.value = "");
    }
    

    通过 WebSockets 连接发送消息需要调用 send 方法。 该方法的第一个参数是消息名称。 消息数据包含其他参数。 在此示例中,一条标识为 newMessage 的消息已发送到服务器。 该消息包含用户名和文本框中的用户输入。 如果发送成功,会清空文本框。

  4. NewMessage 方法添加到 ChatHub 类:

    using Microsoft.AspNetCore.SignalR;
    using System.Threading.Tasks;
    
    namespace SignalRWebPack.Hubs
    {
        public class ChatHub : Hub
        {
            public async Task NewMessage(long username, string message)
            {
                await Clients.All.SendAsync("messageReceived", username, message);
            }
        }
    }
    

    服务器收到消息后,前面的代码会将这些消息播发到所有连接的用户。 没有必要使用泛型 on 方法接收所有消息。 使用以消息名称命名的方法就可以了。

    在此示例中,TypeScript 客户端发送一条标识为 newMessage 的消息。 C# NewMessage 方法需要客户端发送的数据。 Clients.All 上对 SendAsync 进行调用。 接收的消息会发送到所有连接到中心的客户端。

测试应用

确认应用遵循以下步骤。

两个浏览器窗口都显示的消息

其他资源

上一篇:教程:ASP.NET Core SignalR 入门

下一篇:在 SignalR 中使用中心 ASP.NET Core

关注微信小程序
程序员编程王-随时随地学编程

扫描二维码
程序员编程王

扫一扫关注最新编程教程