如何将ngrok集成到ASP.NET Core启动中并自动更新你的webhook URLs

277 阅读13分钟

当你在本地机器上开发Web应用程序时,你有时需要你的应用程序可以从互联网上到达。这样做的最常见原因之一是开发Webhooks

Webhooks是一种在事件发生时被外部服务通知的方式。不是你向该服务发送HTTP请求,而是该服务向你的公共网络服务发送HTTP请求。

为了在本地开发webooks,你可以使用像ngrok这样的隧道服务,它在你的本地网络和互联网之间建立一个隧道。然而,当你使用ngrok的免费计划时,ngrok会在你重新启动隧道的任何时候创建一个随机的公共URL。这意味着你需要随时用新的URL更新你的webhooks。如果更新你的webhook URL需要大量的点击和击键,这可能是一个相当麻烦的事情。

幸运的是,你可以通过自动化来避免这些重复性的工作在本教程中,你将学习如何在你的ASP.NET Core应用程序启动时自动启动ngrok。然后,你将学习如何抓取随机的ngrok URL,并使用该URL来自动配置Twilio的webhooks。

创建一个ASP.NET Core网络项目

打开你喜欢的外壳,使用下面的命令创建一个名为NgrokAspNet的新文件夹,并导航到它:

mkdir NgrokAspNet
cd NgrokAspNet

使用.NET CLI来创建一个新的空的Web项目:

dotnet new web

你的新项目包含一个C#文件,名为Program.cs

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.Run();

该程序创建了一个新的网络应用程序,有一个响应 "Hello World "的端点。回到你的外壳,使用.NET CLI启动该项目:

dotnet run

输出应该是这样的:

Building...
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: https://localhost:7121
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5033
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
      Content root path: /Users/nswimberghe/NgrokAspNet/

请注意这两个 localhost当你生成Web项目时,为你选择的 URL
选择其中一个URL,在浏览器中打开它。你应该看到浏览器中显示 "Hello World!"。

让.NET项目继续运行,并打开一个新的shell来运行下面的命令!

使用ngrok将你的本地Web服务器通过隧道连接到互联网上

你可以在你的机器上运行ngrok CLI工具,将本地的URL隧道到一个公共的URL,这个URL看起来类似于66a605a7ced5.ngrok.io,有一个不同的子域。每次你使用ngrok启动一个新的隧道,子域都是不同的。

在你的新外壳中,运行以下命令:

ngrok http [YOUR_HTTP_SERVER_URL]

将[YOUR_HTTP_SERVER_URL]替换为网络服务器的URL,开头为 http://localhost.

这将启动一个新的隧道到一个新的公共URL,你可以在显示的输出中找到:

Ngrok output showing the two public URLs forwarding the local web server URLs

切换回网络浏览器,导航到你的shell中列出的转发URL之一。一些浏览器可能会警告你这是一个欺骗性的网站,在这种情况下你可以不考虑。浏览器应该再次返回 "Hello World",但这次是通过公共URL。这意味着你可以与任何人分享这个URL,他们也将能够与你的本地Web服务器通信。这也意味着webhooks可以到达你的本地服务器。

你也可以用HTTPS URL做隧道,但你需要注册一个ngrok账户(免费)并认证你的ngrok CLI工具。一旦你完成了这些,你也可以用以". "开头的URL运行ngrok http 命令。

除了转发的URL之外,还有一个显示的Web界面URL。在这里你可以访问ngrok的本地仪表板和API。切换回你的浏览器并导航到ctrl + c停止ngrok进程,关闭这个shell实例。切换到你的另一个shell,按ctrl + c停止.NET项目。

在 ASP.NET Core 启动期间自动启动 ngrok

你能够通过运行你的ASP.NET项目,然后在一个单独的shell中运行ngrok来公开服务你的web应用程序。通过将ngrok集成到你的ASP.NET项目的启动过程中,可以优化这个工作流程。

要以编程方式启动ngrok隧道,你需要从代码中运行ngrok CLI命令。你可以使用Process.NET APIs,但有一个开源库可以使与CLI工具和进程的交互更容易。CliWrap

使用.NET CLI添加CliWrap NuGet包

dotnet add package CliWrap

在写这篇文章时,CliWrap NuGet包的版本是3.4.0。

NgrokAspNet 项目目录下创建一个新的C#文件,名为TunnelService.cs,并添加以下代码:

using System.Text.Json.Nodes;
using CliWrap;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;

namespace NgrokAspNet;

public class TunnelService : BackgroundService
{
    private readonly IServer server;
    private readonly IHostApplicationLifetime hostApplicationLifetime;
    private readonly IConfiguration config;
    private readonly ILogger<TunnelService> logger;

    public TunnelService(
        IServer server,
        IHostApplicationLifetime hostApplicationLifetime,
        IConfiguration config,
        ILogger<TunnelService> logger
    )
    {
        this.server = server;
        this.hostApplicationLifetime = hostApplicationLifetime;
        this.config = config;
        this.logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await WaitForApplicationStarted();

        var urls = server.Features.Get<IServerAddressesFeature>()!.Addresses;
        // Use https:// if you authenticated ngrok, otherwise, you can only use http://
        var localUrl = urls.Single(u => u.StartsWith("http://"));

        logger.LogInformation("Starting ngrok tunnel for {LocalUrl}", localUrl);
        var ngrokTask = StartNgrokTunnel(localUrl, stoppingToken);

        var publicUrl = await GetNgrokPublicUrl();
        logger.LogInformation("Public ngrok URL: {NgrokPublicUrl}", publicUrl);

        await ngrokTask;
        
        logger.LogInformation("Ngrok tunnel stopped");
    }

    private Task WaitForApplicationStarted()
    {
        var completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        hostApplicationLifetime.ApplicationStarted.Register(() => completionSource.TrySetResult());
        return completionSource.Task;
    }

    private CommandTask<CommandResult> StartNgrokTunnel(string localUrl, CancellationToken stoppingToken)
    {
        var ngrokTask = Cli.Wrap("ngrok")
            .WithArguments(args => args
                .Add("http")
                .Add(localUrl)
                .Add("--log")
                .Add("stdout"))
            .WithStandardOutputPipe(PipeTarget.ToDelegate(s => logger.LogDebug(s)))
            .WithStandardErrorPipe(PipeTarget.ToDelegate(s => logger.LogError(s)))
            .ExecuteAsync(stoppingToken);
        return ngrokTask;
    }

    private async Task<string> GetNgrokPublicUrl()
    {
        using var httpClient = new HttpClient();
        for (var ngrokRetryCount = 0; ngrokRetryCount < 10; ngrokRetryCount++)
        {
            logger.LogDebug("Get ngrok tunnels attempt: {RetryCount}", ngrokRetryCount + 1);

            try
            {
                var json = await httpClient.GetFromJsonAsync<JsonNode>("http://127.0.0.1:4040/api/tunnels");
                var publicUrl = json["tunnels"].AsArray()
                    .Select(e => e["public_url"].GetValue<string>())
                    .SingleOrDefault(u => u.StartsWith("https://"));
                if (!string.IsNullOrEmpty(publicUrl)) return publicUrl;
            }
            catch
            {
                // ignored
            }

            await Task.Delay(200);
        }

        throw new Exception("Ngrok dashboard did not start in 10 tries");
    }
}

TunnelService 类将负责使用ngrok CLI启动一个隧道,以后它还将配置Twilio webhooks。

这段代码很多,让我们一块一块地剖析一下:

    public TunnelService(
        IServer server,
        IHostApplicationLifetime hostApplicationLifetime,
        IConfiguration config,
        ILogger<TunnelService> logger
    )
    {
        this.server = server;
        this.hostApplicationLifetime = hostApplicationLifetime;
        this.config = config;
        this.logger = logger;
    }

构造函数接受多个参数,这些参数将由ASP.NET Core中内置的依赖注入容器提供。所有的参数都存储在私有字段中,所以它们可以在整个类中被访问:

  • server 参数包含当前正在启动的Web服务器的信息。一旦网络服务器启动,你可以从server 字段中获取本地的URLs。
  • hostApplicationLifetime 参数让你可以钩住不同的生命周期事件(开始/停止/停止)。
    config 参数将包含所有通过命令行参数、环境变量、JSON文件、用户秘密等传入.NET应用程序的配置。配置现在还没有使用,但它将在即将到来的章节中使用。
  • logger 参数将被用来记录任何与运行隧道有关的信息。
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await WaitForApplicationStarted();

        var urls = server.Features.Get<IServerAddressesFeature>()!.Addresses;
        // Use https:// if you authenticated ngrok, otherwise, you can only use http://
        var localUrl = urls.Single(u => u.StartsWith("http://"));

        logger.LogInformation("Starting ngrok tunnel for {LocalUrl}", localUrl);
        var ngrokTask = StartNgrokTunnel(localUrl, stoppingToken);

        var publicUrl = await GetNgrokPublicUrl();
        logger.LogInformation("Public ngrok URL: {NgrokPublicUrl}", publicUrl);

        await ngrokTask;
        
        logger.LogInformation("Ngrok tunnel stopped");
    }

TunnelService BackgroundService ExecuteAsync ExecuteAsync 是这个类的主方法,将在网络应用程序启动时被调用。 将使用 等待网络应用程序启动,然后抓取本地URL。一个单一的URL将从本地URLs中抽取。如果你在前面认证了ngrok,你可以使用HTTPS URL而不是HTTP URL,方法是用 来代替 。ExecuteAsync WaitForApplicationStarted "https://" "http://"

接下来,ngrok隧道将被启动,然后公共ngrok URL将被检索,最后等待运行ngrok CLI的Task 。当Web应用程序停止时,ngrok进程也将停止,这将完成ngrokTask

    private Task WaitForApplicationStarted()
    {
        var completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        hostApplicationLifetime.ApplicationStarted.Register(() => completionSource.TrySetResult());
        return completionSource.Task;
    }

WaitForApplicationStarted 将创建一个可等待的 ,当 事件被触发时就会完成。奇怪的是, 上的生命周期事件没有使用委托或Task ApplicationStarted IHostApplicationLifetime C#事件,而是使用 's。CancellationToken

你可以向CancellationToken.Register 方法传递一个lambda或委托,当CancellationToken 被取消时,它将被调用。对于存储在IHostApplicationLifetime.ApplicationStarted 属性中的取消令牌来说,当令牌被取消时,这意味着应用程序已经开始。多么(不)直观,我说得对吗?

为了使其使用起来更直观,你可以创建一个TaskCompletionSource ,并在hostApplicationLifetime.ApplicationStarted.Register 回调中设置其结果。这将把Task 设置为完成,在这种情况下,这将是应用程序已经开始的时候。

如果这些都有点混乱,重要的是,await WaitForApplicationStarted() 将等待网络应用程序启动:

    private CommandTask<CommandResult> StartNgrokTunnel(string localUrl, CancellationToken stoppingToken)
    {
        var ngrokTask = Cli.Wrap("ngrok")
            .WithArguments(args => args
                .Add("http")
                .Add(localUrl)
                .Add("--log")
                .Add("stdout"))
            .WithStandardOutputPipe(PipeTarget.ToDelegate(s => logger.LogDebug(s)))
            .WithStandardErrorPipe(PipeTarget.ToDelegate(s => logger.LogError(s)))
            .ExecuteAsync(stoppingToken);
        return ngrokTask;
    }

StartNgrokTunnel 将使用CliWrap库来运行ngrok CLI。由此产生的命令将看起来像这样:

ngrok http [YOUR_LOCAL_SERVER_URL] --log stdout

这个命令将像以前一样启动ngrok隧道,但增加了--log stdout 参数。这个日志参数指示ngrok将日志记录到标准输出,然后可以通过WithStandardOutputPipe 。 标准输出和错误输出将被输送到logger

一个关键的细节是,stoppingToken 被传递到ExecuteAsync 。当应用程序被停止时,stoppingToken 将被取消。你可以使用这个令牌来优雅地处理应用程序即将被关闭的时候。通过将stoppingToken 传递给ExecuteAsync ,当应用程序被停止时,ngrok进程也将被停止。这样,你就不会有任何ngrok的子进程在周围徘徊了:

    private async Task<string> GetNgrokPublicUrl()
    {
        using var httpClient = new HttpClient();
        for (var ngrokRetryCount = 0; ngrokRetryCount < 10; ngrokRetryCount++)
        {
            logger.LogDebug("Get ngrok tunnels attempt: {RetryCount}", ngrokRetryCount + 1);

            try
            {
                var json = await httpClient.GetFromJsonAsync<JsonNode>("http://127.0.0.1:4040/api/tunnels");
                var publicUrl = json["tunnels"].AsArray()
                    .Select(e => e["public_url"].GetValue<string>())
                    .SingleOrDefault(u => u.StartsWith("https://"));
                if (!string.IsNullOrEmpty(publicUrl)) return publicUrl;
            }
            catch
            {
                // ignored
            }

            await Task.Delay(200);
        }

        throw new Exception("Ngrok dashboard did not start in 10 tries");
    }

GetNgrokPublicUrl 将获取公共HTTPS URL并返回。你可以通过向本地ngrok API请求获得公共隧道URL,网址是http://127.0.0.1:4040/api/tunnels。

不幸的是,当ngrok CLI启动时,这并不意味着隧道已经准备好了。这就是为什么这段代码被包围在一个循环中,每隔200毫秒就会尝试获取公共URL,最多10次。请随意改变200毫秒的延迟和retryCount ,以适合你的需要。

TunnelService 类已经完成,但你仍然需要配置网络应用程序,使其在后台运行。更新Program.cs,基于下面突出显示的行:

var builder = WebApplication.CreateBuilder(args);
if (builder.Environment.IsDevelopment()) 
    builder.Services.AddHostedService<NgrokAspNet.TunnelService>();
var app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.Run();

TunnelService 将被配置为在后台运行,但只有当应用程序在开发环境中运行时才会如此。毕竟,你只想把ngrok隧道用于本地开发。

在暂存或生产环境中运行这个程序是没有意义的,而且如果它真的运行的话,可能会引起麻烦。而不是只在开发环境中运行这个,你也可以添加一个更明确的配置元素,但这取决于你自己

就这样吧!使用.NET CLI运行应用程序并观察输出:

dotnet run

公共ngrok URL将被记录到输出中,像这样。"公共ngrok URL: 6797-72-66-29-154.ngrok.io"。抓住公共ngrok URL并在浏览器中导航到它。你会再次看到,"Hello World!"。

用ngrok URLs自动更新Twilio Webhooks

开始使用Twilio

如果你还没有,你需要在Twilio上进行以下设置:

  • 从Twilio购买一个新的电话号码。电话号码的费用将被应用于你的免费促销信用。
    确保记下你的新Twilio电话号码。 你以后会需要它的!
  • 如果你使用的是Twilio的试用账户,你只能向经过验证的来电号码发送短信。如果你的电话号码或你想发短信的电话号码不在验证过的来电号码列表中,请验证一下。
  • 最后,你需要找到你的Twilio账户SID和Auth Token。导航到你的Twilio账户页面,注意你的Twilio 账户SID Auth Token位于页面的左下方。
    Account Info box holding 3 read-only fields: Account SID field, Auth Token field, and Twilio phone number field.

更新Twilio电话号码Webhooks

适用于C#和.NET的Twilio SDK将帮助你与Twilio的API进行交互,并对Webhooks进行响应。将Twilio NuGet包添加到你的项目中:

dotnet add package twilio

回到你的代码编辑器,打开TunnelService.cs文件。更新文件顶部的using 语句,以包括这三个新的Twilio引用:

using System.Text.Json.Nodes;
using CliWrap;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Twilio.Clients;
using Twilio.Rest.Api.V2010.Account;
using Twilio.Types;

更新ExecuteAsync 方法,在记录公共ngrok URL后调用异步ConfigureTwilioWebhook 方法:

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await WaitForApplicationStarted();

        var urls = server.Features.Get<IServerAddressesFeature>()!.Addresses;
        // Use https:// if you authenticated ngrok, otherwise, you can only use http://
        var localUrl = urls.Single(u => u.StartsWith("http://"));

        logger.LogInformation("Starting ngrok tunnel for {LocalUrl}", localUrl);
        var ngrokTask = StartNgrokTunnel(localUrl, stoppingToken);

        var publicUrl = await GetNgrokPublicUrl();
        logger.LogInformation("Public ngrok URL: {NgrokPublicUrl}", publicUrl);

        await ConfigureTwilioWebhook(publicUrl);
        
        await ngrokTask;
        
        logger.LogInformation("Ngrok tunnel stopped");
    }

GetNgrokPublicUrl 方法之后添加异步的ConfigureTwilioWebhook 方法:

    private async Task ConfigureTwilioWebhook(string publicUrl)
    {
        var twilioClient = new TwilioRestClient(config["TwilioAccountSid"], config["TwilioAuthToken"]);
        var phoneNumber = (await IncomingPhoneNumberResource.ReadAsync(
            phoneNumber: new PhoneNumber(config["TwilioPhoneNumber"]),
            limit: 1,
            client: twilioClient
        )).Single();
        phoneNumber = await IncomingPhoneNumberResource.UpdateAsync(
            phoneNumber.Sid,
            voiceUrl: new Uri($"{publicUrl}/voice"), voiceMethod: Twilio.Http.HttpMethod.Post,
            smsUrl: new Uri($"{publicUrl}/message"), smsMethod: Twilio.Http.HttpMethod.Post,
            client: twilioClient
        );
        logger.LogInformation(
            "Twilio Phone Number {TwilioPhoneNumber} Voice URL updated to {TwilioVoiceUrl}",
            phoneNumber.PhoneNumber,
            phoneNumber.VoiceUrl
        );
        logger.LogInformation(
            "Twilio Phone Number {TwilioPhoneNumber} Message URL updated to {TwilioMessageUrl}",
            phoneNumber.PhoneNumber,
            phoneNumber.SmsUrl
        );
    }

​​ConfigureTwilioWebhook 方法接收公共ngrok URL作为参数。帐户SIDAuth Tokenconfig 字段中获取,并传入TwilioRestClient 的构造函数。

你可以使用API密钥来验证,而不是使用账户SID和Auth Token。API密钥的权限较少,而且更容易被撤销,这使它们成为更安全的选择。

Twilio电话号码的详细信息是使用TwilioRestClient ,然后电话号码的详细信息被用来更新语音webhook URL和短信webhook URL。语音和短信webhook URL将被设置为公共隧道URL,并分别附加上/voice/message

该项目现在依赖于TwilioAccountSid,TwilioAuthToken, 和TwilioPhoneNumber 配置元素,但它们还没有被配置。你可以使用.NET用户秘密来配置这些类型的敏感配置。

使用.NET CLI为你的项目初始化用户秘密:

dotnet user-secrets init

运行以下命令来配置秘密:

dotnet user-secrets set TwilioAccountSid [YOUR ACCOUNT SID]
dotnet user-secrets set TwilioAuthToken [YOUR AUTH TOKEN]
dotnet user-secrets set TwilioPhoneNumber [YOUR TWILIO PHONE NUMBER]

用你的Twilio账户SID替换[YOUR ACCOUNT SID] ,用你的TwilioAuth Token替换[YOUR AUTH TOKEN] ,用你的Twilio Phone Number替换 [YOUR TWILIO PHONE NUMBER]

通过运行应用程序来测试你到目前为止的工作:

dotnet run

你应该看到额外的输出,看起来像这样:

Twilio Phone Number +1234567890 Voice URL updated to https://5fb3-72-66-29-154.ngrok.io/voice", and "Twilio Phone Number +1234567890 Message URL updated to https://5fb3-72-66-29-154.ngrok.io/message

响应Twilio webhooks

一旦设置了webhook URLs,每当有电话或短信打到你的Twilio电话号码上,Twilio就会向你的公共URLs发送HTTP请求。
你需要接受这些HTTP请求,/voice/message 然后用TwiML指令来回应。根据下面代码中的高亮行更新Program.cs

using Twilio.TwiML;

var builder = WebApplication.CreateBuilder(args);
if (builder.Environment.IsDevelopment()) 
    builder.Services.AddHostedService<NgrokAspNet.TunnelService>();
var app = builder.Build();

app.MapGet("/", () => "Hello World!");
app.MapPost("/voice", () =>
{
    var response = new VoiceResponse();
    response.Say("Hello World!");
    return Results.Text(response.ToString(), "application/xml");
});
app.MapPost("/message", () =>
{
    var response = new MessagingResponse();
    response.Message("Hello World!");
    return Results.Text(response.ToString(), "application/xml");
});

app.Run();

当Twilio向/voice 发送HTTP POST请求时,端点将用以下TwiML进行响应:

<?xml version="1.0" encoding="utf-8"?>
<Response>
  <Say>Hello World!</Say>
</Response>

结果,Twilio将把 "Hello World!"转录为音频,并将其流式传输给呼叫者。

当Twilio向/message 发送HTTP POST请求时,端点将回应以下TwiML:

<?xml version="1.0" encoding="utf-8"?>
<Response>
  <Message>Hello World!</Message>
</Response>

结果,Twilio将以 "Hello World!"的文字信息作为回应。

测试Twilio的webhooks

如果一切顺利,你现在就可以通过运行一个命令来开发和测试webhooks了dotnet run 。使用.NET CLI启动应用程序:

dotnet run

等待webhook URLs被更新,然后打电话和/或发短信给你的Twilio电话号码。
如果你打电话,你应该听到 "Hello World!",如果你发短信,你应该收到一条短信 "Hello World!"。

如何将ngrok集成到ASP.NET并自动更新你的webhooks

在本教程中,你学会了如何通过将ngrok集成到你的ASP.NET Core启动中并自动更新你的webhooks来简化你的webhook开发过程,使用这些步骤:

  1. 获取你的本地ASP.NET URLs
  2. 使用BackgroundService 来运行 ngrok 隧道
  3. 从ngrok的本地API中获取ngrok转发的URL
  4. 使用ngrok转发的URL更新你的webhooks URLs