Monday, November 22, 2021

Running a .NET 6 Service on a Specific Port

I often have web services for test and demo purposes. For these services, I want to hard-code a localhost port so that I can minimize conflicts if I have multiple projects and services running at the same time. What I found is that the way I was doing this with .NET 5 does not work with .NET 6 services.

What I want is for my service to run at http://localhost:9874.

Short Version

Previously, I used "UseUrls" to set the location (this is the the Program.cs file of a project that uses a Startup class -- here's a link to a full sample on GitHub: Program.cs):
    public static IHostBuilder CreateHostBuilder(string[] args) =>
        Host.CreateDefaultBuilder(args)
            .ConfigureWebHostDefaults(webBuilder =>
            {
                webBuilder.UseStartup<Startup>()
                    .UseUrls("http://localhost:9874");
            });
The translation of this code into a top-level program version that we get with .NET 6 would look something like this (calling "UseUrls" on the WebHost of the WebApplication builder):
    var builder = WebApplication.CreateBuilder(args);
    builder.WebHost.UseUrls("http://localhost:9874");
    // Add services to the container.
    builder.Services.AddSingleton<IPeopleProvider, HardCodedPeopleProvider>();
However, THIS DOES NOT WORK.

Fortunately, I found an option that does work:
    var builder = WebApplication.CreateBuilder(args);
    builder.WebHost.ConfigureKestrel(options => options.ListenLocalhost(9874));
    // Add services to the container.
    builder.Services.AddSingleton<IPeopleProvider, HardCodedPeopleProvider>();
If you are curious about why this might be, feel free to read on.

Note: If you want to look at the completed code, you can check the ".NET 6 Services (4 approaches)" repository on GitHub: https://github.com/jeremybytes/dotnet6-services-4-approaches. Specifically, you can look at the "Program.cs" file in any of the projects (here's one from the first project: Program.cs).

Different Ways of Setting URLs

There are several different ways to specify a URL for a service. This can be done with code, configuration, environment variables, or with a command-line argument. I'm not going to go through all of the options, just the ones that I tried. There is a pretty good article that shows these on the Microsoft Docs site: Configuring endpoints for the ASP.NET Core Kestrel web server.

At the time of writing for this post, there is a piece that is incorrect about .NET 6. The article mentions that the default endpoints are http://localhost:5000 and https://localhost:5001. This is true of .NET 5, however, the default endpoints where moved (randomized) for .NET 6. So the ports used by default will vary by project. (We'll see why this may be the cause of our problems a bit later.)

Configuration
You can put the settings into configuration (appsettings.json). Here's an example of how to set the URL:
  "Kestrel": {
    "Endpoints": {
      "Http": {
        "Url": "http://localhost:9874"
      }
    }
  }
With this configuration, the default location will be overridden by our desired location: http://localhost:9874.

Command-Line Arguments
Another way to specify the location is to use the "--urls" option on the command line. Here's an example of running the service and overriding the URL from the command line:
PS D:\controller-api> dotnet run --urls "http://localhost:9874"
Building...
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:9874
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: D:\controller-api\
The output shows the service running at http://localhost:9874.

Code with "UseUrls"
Another options is to use code to set the location. This is the approach that I want to use for my scenario.

As mentioned above, previously, I used the "UseUrls" extension method in the Program class with .NET 5.
    public static IHostBuilder CreateHostBuilder(string[] args) =>
        Host.CreateDefaultBuilder(args)
            .ConfigureWebHostDefaults(webBuilder =>
            {
                webBuilder.UseStartup<Startup>()
                    .UseUrls("http://localhost:9874");
            });
This code still works when using either .NET 5 or .NET 6 with a Startup class.

But the default template for .NET 6 web api projects now uses the top-level programs feature, so I wanted to try to incorporate it into that template.

I found that the "WebHost" on the "WebApplication" builder object lets us use the "UseUrls" method. It even shows up in code completion in both Visual Studio Code and Visual Studio 2022. So, I tried it. Here are the first few lines of a "Program.cs" file that uses the .NET 6 template with the UseUrls added:
    var builder = WebApplication.CreateBuilder(args);
    builder.WebHost.UseUrls("http://localhost:9874");
    // Add services to the container.
    builder.Services.AddSingleton<IPeopleProvider, HardCodedPeopleProvider>();
This looks really promising, and everything builds without errors. But it doesn't work.

A Clue
Why doesn't this work? I was struggling with this, and I was looking to see if maybe I missed a setting or if there's something new that I don't know about.

The answer came when I was pairing with Lynn Langit (she's awesome, check out her stuff on GitHub: Lynn Langit). In the article mentioned above, she noticed that there was a section called "Limitations" (direct link: "Limitations"). The clue was in the following sentence:
When both the Listen and UseUrls approaches are used simultaneously, the Listen endpoints override the UseUrls endpoints.
This made me suspect that there was a call to "Listen" that was overriding my values.

Well, two can play at this game. So I started looking at "Listen" options. There are a lot of different methods that take various types of parameters. After a bit of struggling with methods that didn't quite do what I wanted, I stumbled across the exact one that I needed: ListenLocalHost.

Code with ListenLocalHost
The ListenLocalHost method is part of the KestrelServerOptions that can be set pretty easily in code (once you know it exists). Here are the first few lines of the Program.cs file with the options set:
    var builder = WebApplication.CreateBuilder(args);
    builder.WebHost.ConfigureKestrel(options => options.ListenLocalhost(9874));
    // Add services to the container.
    builder.Services.AddSingleton<IPeopleProvider, HardCodedPeopleProvider>();
This uses the "ConfigureKestrel" method on the same WebHost property that is part of our builder. The parameter is a delegate that takes a KestrelServerOptions. Inside the lambda expression, we can call "ListenLocalHost(9874)" on the options. This will configure our service to run at http://localhost:9874.

And this works!

I was very happy to find a solution. But why do we have this problem?

Some Speculation
I have some suspicions on why "UseUrls" doesn't work, but I have not been able to confirm them. What I guess is happening is that the new "random port" functionality that is part of .NET 6 uses one of the "Listen" methods (this would be in a part of the code that we don't see). If this internal code uses "Listen", then that means that "UseUrls" would not work as mentioned in the Limitations section of the article mentioned above.

I may be able to confirm this by digging through IL code looking at what is generated by the compiler, but I haven't taken the time to do that yet.

At this point, I'm happy to have a solution that works for me, and I'm moving forward with .NET 6 migrations.

Wrap Up

With new versions of frameworks, there is often a "who moved my cheese" problem where things are no longer where they used to be or things work differently that they did before. I've run into several of those things with .NET 6. The default templates and top-level programs can be really useful in a lot of scenarios. I use them mainly for prototyping or fake services that I use for testing or demos.

There is a lot of magic that happens with the default templates and top-level programs. If it works for you, it's pretty great. But if you need to step outside of the box a little, it can get pretty frustrating. The documentation is just not out there yet. And when searching for issues, you often get answers to the problem in previous iterations of the framework.

I will keep moving forward on .NET 6 and trying to figure out how to get things working like I want them to. Another example is that debugging in Visual Studio 2022 gets a bit interesting when you change the ports in code. But I'll save that for another article.

Happy Coding!

No comments:

Post a Comment