The time has finally come. You are probably eager to fix some of the issues we saw in chapters 3 and 4, and now we’ll get to do that. First things first, let’s come up with a game plan on how to tackle this refactoring. We already know a couple of things that we need to do differently:
In chapter 3 we were told to use .NET 5 instead of the .NET Framework for the new version of the Flying Dutchman Airlines service.
We need to rewrite the endpoints to be clean code (in particular, adhering to the DRY principle).
We need to fix the security vulnerability—a hardcoded connection string.
The object names do not match the database column names. We should fix that to ensure a perfect isomorphic relationship between the codebase and the database.
We need to adhere to the OpenAPI file discussed in chapter 3 and shown in appendix D.
Although not necessarily part of the requirements, we would like to include some additional deliverables to improve the quality of the job, thus ensuring a job well done:
We want to use test-driven development to write unit tests that back the codebase.
We want to use Entity Framework Core to revamp the database layer by reverse-engineering the deployed database.
We want to autogenerate an updated OpenAPI file on the launch of the service to compare against the provided OpenAPI from FlyTomorrow.
Of course, we will do much more than just these improvements, but it is good to have some general broad strokes in mind. We are also in a very interesting position: we are stuck somewhere in the middle of having to keep the old codebase alive and working and greenfield development.
DEFINITION Greenfield development means that we are working on a project that isn’t held back by any previous design decisions or old code. In practice, this usually means a brand-new project.
We have set requirements and an old codebase that we need to mimic (where appropriate and possible), but we also start with an empty project. In the real world, you will often encounter this scenario. You no doubt have had the experience of trying to create a new version of an existing product—a “next-gen” version, if you will. Figure 5.1 shows where we are in the scheme of the book.
Our first order of business is to create a new .NET 5 solution.
In this section, we’ll create a new .NET 5 solution and project. We’ll also look at what predefined solution and project templates exist for .NET 5. You have the following two ways to create a new .NET 5 solution:
You can use a command line, be it the Windows command line or a macOS/ Linux terminal.
You can use an IDE like Visual Studio. Using Visual Studio automates the process somewhat. Most things you can do in a command line or terminal with C# you can also do in Visual Studio with a couple of clicks.1
The outcome of using either route is the same: you end up with a new .NET 5 solution. We’ll be using the command line. Creating a new, empty .NET 5 solution or project is very simple, as shown here:
> dotnet new [TEMPLATE] [FLAGS]
NOTE Before you attempt to create a .NET 5 project, please make sure you have installed the latest .NET 5 SDK and runtime. Installation instructions are in appendix C.
We can use a variety of templates. Some of the more common ones are web
, webapp
, mvc
, and webapi
. For our purposes, we use perhaps two of the most popular of all: sln
and console
. The dotnet new sln
command creates a new solution, whereas dotnet new console
creates a new project and a “hello, world” source file. As discussed in section 3.3.2, C# uses solutions and projects to organize its codebases. A solution is the top-level entity and contains multiple projects. We write our logic within the projects. Projects can be thought of as different modules, packages, or libraries, depending on our language of preference.
We also pass the -n
flag along with the creation command. This allows us to specify a name for our solution and project. If we do not explicitly specify a name for our solution, the name of our project or solution defaults to the name of the folder in which we create the files.
To create our starting point, run the following command. Note that the command-line tool does not let you create a new solution folder when creating a new solution. If you want to do this, you can either use Visual Studio (which does allow for it) or create the folder first and then run the following command in the solution folder.
> dotnet new sln -n "FlyingDutchmanAirlinesNextGen"
The command creates only one thing: a solution file called FlyingDutchmanAirlinesNextGen.sln
, shown in figure 5.2. We could open this solution file in Visual Studio, but we cannot do much without a project.
Now that we have a solution file, we should create a project called FlyingDutchmanAirlines. To create a new project, we use the console
template, as shown next. This creates a .NET 5 console application, which we’ll then change to be a web service.
> dotnet new console -n "FlyingDutchmanAirlines"
After running the command, we are greeted by a message saying that “Restore succeeded.
” A restore is a process that the .NET CLI performs before the creation of a new project and before compiling after a “clean” operation (“clean” deletes all local executable files, including dependencies) or first compilation, to gather required dependencies. We can also run this command on its own by saying
> dotnet restore
A restore can come in handy when dealing with dependency troubles. The restore
command also creates a new folder next to our solution file called FlyingDutchmanAirlines (the same as the project name we passed in), as shown in figure 5.3. When we enter the folder, we see another folder called obj. The obj folder contains configuration files for NuGet and its packages. Back in the root folder for the project, we have a project file and a C# source file.
Our project is created, but we still need to add it to the solution. When you create a project, dotnet
does not scan any subdirectories looking for a contained solution. To add a project to a solution, use the “solution add” command as follows:
> dotnet sln [SOLUTION PATH] add [PROJECT PATH]
The [SOLUTION PATH]
points to the path of the solution file to which you want to add a project. The [PROJECT PATH]
, similarly, points to a csproj file to be added to the solution. You can add multiple projects at the same time by adding multiple [PROJECT PATH]
arguments to the command.
In our situation, running from the root FlyingDutchmanAirlinesNextGen folder, the command takes just the one csproj into account, as shown here:
> dotnet sln FlyingDutchmanAirlinesNextGen.sln add ➥ .FlyingDutchmanAirlinesFlyingDutchmanAirlines.csproj
The terminal lets us know with a message—Project `FlyingDutchmanAirlines FlyingDutchmanAirlines.csproj` added to the solution
.—that we were successful in our effort. If we open up the FlyingDutchmanAirlinesNextGen.sln file in a text editor, we see a reference to the FlyingDutchmanAirlines.csproj file as follows:
Project("{...}") = ➥ "FlyingDutchmanAirlines", ➥ "FlyingDutchmanAirlinesFlyingDutchmanAirlines.csproj", "{...}" EndProject
This is the reference added by the solution add
command. The reference tells an IDE and the compiler that there is a project with the name FlyingDutchmanAirlines as part of this solution.
In section 5.1 we created a new solution and project to use for the next-gen version of the Flying Dutchman Airlines service. In this section, we’ll look at the source code generated as a result of the actions we took in section 5.1 and configure the console application to function as a web service.
The only source file in the solution (and project) at this point is Program.cs, shown in the next listing. This file is automatically generated through the console
template we used in section 5.1 to create a new project. It contains the entry point for the program—a static
method called Main
—which returns nothing. Here, it also accepts a string array called args
. This array contains any command-line arguments passed in on launch.
using System; namespace FlyingDutchmanAirlines { class Program { static void Main(string[] args) { ❶ Console.WriteLine("Hello World!"); ❶ } ❶ } }
❶ A static void Main is the default entry point for a C# console application.
Using the command line to run the FlyingDutchmanAirlinesNextGen project, it outputs “Hello World!” to the console. Let’s remove the "Hello World!"
string from the code. This puts us in a good spot to change the console application to something more functional: a web service.
We need to configure our brand-new .NET 5 app to accept HTTP requests and route them to the endpoints we’ll implement. To do this, we also need to set up Host
, which is the underlying process that runs the web service and interacts with the CLR. Our application lives inside the Host
, which in turn lives inside the CLR.
NOTE We can draw similarities between web containers (such as IIS) and Tomcat. To put it in Java terms, .NET 5 is your JVM and Spring, whereas Host
is your Tomcat.
We configure Host
to launch a “host process” that is responsible for app startup and lifetime management. We also tell Host
that we want to use WebHostDefaults
. This allows us to use Host
for a web service, as shown in figure 5.4. At a minimum, the host configures the server and request-processing pipeline.
My preferred way of configuring the Host
in .NET 5. is to follow these three steps:
Use the CreateDefaultBuilder
method on the static Host
class (part of the Microsoft.Extensions.Hosting
namespace) to create a builder.
Configure the Host
builder by telling it we want to use WebHostDefaults
and set a startup class and a startup URL with a port specified.
When we try to configure a startup class for our builder’s returned Host
instance, we have to use the UseStartup
class. This comes as part of ASP.NET, which is not installed through .NET 5 by default. To access this functionality (and anything in ASP.NET), we need to add the ASP.NET
package to the FlyingDutchmanAirlines project. We can do this through the NuGet package manager in Visual Studio or through our trusty command line when we are inside the project folder, as follows:
> dotnet add package Microsoft.AspNetCore.App
After executing the command, the command line lets you know that the package was successfully added to the project.
NOTE The command also executes a restore
action. For more details on restore
, see section 5.1.
If we try to build the project now, we get a warning saying that we should be using a framework reference instead of a package reference. This is due to some shuffling that went on with .NET namespaces in the last couple of years. This warning doesn’t prohibit us from using the code as it is now, but we can get rid of it pretty easily. In a text editor such as Notepad++ or (for the brave) Vim, open the FlyingDutchmanAirlines.csproj file. In that file, add the boldface code and remove the package reference to ASP.NET:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net5.0</TargetFramework> </PropertyGroup> <ItemGroup> <FrameworkReference Include="Microsoft.AspNetCore.App" /> </ItemGroup> <ItemGroup> <PackageReference Include="Microsoft.AspNetCore.App" Version="2.2.8" /> ... </ItemGroup> </Project>
Now that the Microsoft.AspNetCore
package is installed (as a framework reference), and we got rid of the compiler warning, we can use ASP.NET functionality. The first thing we want to do is tell the compiler we want to use the AspNetCore.Hosting
namespace, as shown in the next listing. In this book, the namespace imports are often omitted from code listings. This is done because they take up precious space and can be autopopulated in most IDEs.
using System; using Microsoft.AspNetCore.Hosting; ❶ namespace FlyingDutchmanAirlines { class Program { static void Main(string[] args) { ❷ } } }
❶ We use the Microsoft.AspNetCore.Hosting namespace.
❷ We no longer output “Hello, World!” to the console.
Set the startup URL to be http://0.0.0.0:8080.
In the program’s Main
method, we add a call to Host.CreateDefaultBuilder
. This call returns a HostBuilder,
with some defaults already. We then tell the resulting builder we want to use a specific URL and port by calling UseUrls
. Then we call Build
to build and return the actual Host
. We assign the output to a variable of type IHost
. We assign our new Host
to an explicitly typed variable of type IHost
. Finally, the code starts the Host
by calling host.Run()
, as shown next:
using System; using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; namespace FlyingDutchmanAirlines { class Program { static void Main(string[] args) { IHost host = ➥ Host.CreateDefaultBuilder().ConfigureWebHostDefaults(builder => { builder.UseUrls("http:/ /0.0.0.0:8080"); }).Build(); host.Run(); } } }
If you try to compile and run the service in this state, the service launches but then terminates with an InvalidOperationException
. This exception tells us we do not have a Startup
class configured and tied to the Host
. But before we create this Startup
class, let’s leave the Program
class in the best shape possible. We have our Host
creation and call to Run
in the Main
method, but should it really be in there?
In section 1.4, we discussed the importance of writing methods that read like a narrative. If I am a new developer, looking at a public method (in this case Main
), I probably do not care about implementation details. Instead, I want to get an understanding of the broad strokes of what the method does. To that end, we can extract the initialization and assignment of host
and the call to host.Run
into a separate, private method as follows:
private static void InitalizeHost() { IHost host = Host.CreateDefaultBuilder() .ConfigureWebHostDefaults(builder => { builder.UseUrls("http:/ /0.0.0.0:8080"); }).Build(); host.Run(); }
Having extracted the Host
creation logic into a separate method is a good step, but we can do just a bit more. We should consider two other things. First, we don’t need to store the result of the HostBuilder
in a variable, because we use it only to call Run
. Why don’t we just call Run
directly after Build
and avoid the unnecessary memory assignment, as shown next:
IHost host = Host.CreateDefaultBuilder() .ConfigureWebHostDefaults(builder => { builder.UseUrls("http:/ /0.0.0.0:8080"); }).Build().Run();
private static void InitalizeHost() => Host.CreateDefaultBuilder() .ConfigureWebHostDefaults(builder => { builder.UseUrls("http:/ /0.0.0.0:8080"); }).Build().Run();
How does this impact our Main
method? Not much. All we have to do is call the InitializeHost
method as follows:
namespace FlyingDutchmanAirlines { class Program { static void Main(string[] args) { InitializeHost(); } private static void InitalizeHost() => Host.CreateDefaultBuilder() .ConfigureWebHostDefaults(builder => { builder.UseUrls("http:/ /0.0.0.0:8080"); }).Build().Run(); } }
Our code is clean and readable, but we still have that runtime exception to deal with. Clean code is nice, but if it doesn’t have the required functionality, it isn’t good enough. The exception said that we need to register a Startup
class with the HostBuilder
before we build and run the resulting IHost
. I guess we have no choice but to make that our next item of work.
We do not have a Startup
class yet, but we can remedy that by creating a file called Startup.cs (in the project’s root folder is fine for this purpose) as follows:
namespace FlyingDutchmanAirlines { class Startup { } }
To configure our Startup
class, create a Configure
method in the Startup
class. This method is called by the HostBuilder
and contains a crucial configuration option, shown in the next listing, which allows us to use controllers and endpoints.
public void Configure(IApplicationBuilder app) { app.UseRouting(); ❶ app.UseEndpoints(endpoints => endpoints.MapControllers()); ❷ }
❶ Uses routing and makes routing decisions for the service in this class
❷ Uses an endpoint pattern for routing web requests. MapControllers scans and maps all the controllers in our service.
The small method in listing 5.3 is the core of our configuration code. When UseRouting
is called, we tell the runtime that certain routing decisions for the service are made in this class. If we did not have the call to UseRouting
, we would not be able to hit any endpoint. UseEndpoints
does what it says it does: it allows us to use and specify endpoints. It takes in an argument of a type we have not encountered before: Action
. This is an instance of a delegate.
We pass in a lambda expression, which when executed will configure the app’s endpoints by calling MapControllers
. A handy method, MapControllers
scans our codebase for any controllers and generates the appropriate routes to the endpoints in our controllers.
The only thing remaining to do before registering our Startup
class with the Host
is to create a ConfigureServices
method and call AddControllers
on the passed-in IServiceCollection
, as shown in the next code sample. The IServiceCollection
interface allows us to add functionalities to the service, such as support for controllers or dependency-injected types. These functionalities get added to an internal service collection.
public void ConfigureServices(IServiceCollection services) { services.AddControllers(); }
Why do we need to add controller support to the service collection? Didn’t we just scan for the controllers and add routes to the RouteTable
? At runtime, Host
first calls ConfigureServices
, giving us a chance to register any services we want to use in our app (in this case, our controllers). If we skipped this step, MapControllers
would not find any controllers.
To use IServiceCollection
, we need to use the Microsoft.Extensions .DependencyInjection
namespace, shown in the next code snippet. Dependency injection is used by the runtime to provide the current, up-to-date ServiceCollection
. You can find more information about dependency injection in section 6.2.9.
namespace FlyingDutchmanAirlines { class Startup { public void Configure(IApplicationBuilder app){ app.UseRouting(); app.UseEndpoints(endpoints => endpoints.MapControllers(); }); } public void ConfigureServices (IServiceCollection services) { services.AddControllers(); } } }
We are done with the Startup
class. Now, let’s configure it to be used by the HostBuilder
. We do this by going back to Program.cs and adding a call to UseStartup <Startup>()
to the HostBuilder
:
namespace FlyingDutchmanAirlines { class Program { static void Main(string[] args) { InitializeHost(); } private static void InitalizeHost() => Host.CreateDefaultBuilder() .ConfigureWebHostDefaults(builder => { builder.UseStartup<Startup>(); builder.UseUrls("http:/ /0.0.0.0:8080"); }).Build().Run(); } }
Now when we launch the application, we get a console window telling us that the service is running and listening on http://0.0.0.0:8080. This code looks slightly different from what the autogenerated template would give us. The functionality remains the same, and both are good jumping-off points.
Now that we have the prerequisites out of the way, we can start adding some logic to our service.
The architectural paradigm we plan to use for the Flying Dutchman Airlines next-gen service is the repository/service pattern. With this pattern, we use an upside-down development strategy, where we work from the bottom up: first implement the low-level database calls, then work our way up to creating the endpoints.
Our service architecture comprises the following four layers:
The benefit we get by working from the bottom up is that the code complexity grows organically. Typically, that would be a very bad thing. But in this case, we have the tools to control this growth and keep it in check.
We can examine the data flow of our architecture (figure 5.5) by taking any endpoint and walking through the required steps to satisfy the requirements. For example, let’s take POST /Booking/{flightNumber}
. First, an HTTP request enters the Booking
controller. That would have an instance of a BookingService
(every entity will have its own service and repository), which would call the BookingRepository
and any other services it needs for any entity it may need to interact with. Then the BookingRepository
calls any appropriate database methods. At that point, the flow is reversed, and we go back up the chain to return the result value to the user.
As mentioned before and shown in figure 5.6, all entities have their own set of service and repository classes. If there is a need for an operation on another entity, the initial service makes the call to that entity’s service to request the operation to be performed.
If we look back at chapter 4, we are reminded of the curious way that database access was handled in the previous version of the application. The connection string was hardcoded into the class itself and no ORM was used. To refresh our minds: an object-relational mapping tool is used to map code against a database, ensuring a good match (or isomorphic relationship). Our two major goals in this section are to
Set up Entity Framework Core and “reverse-engineer” the deployed database.
Store the connection string securely through the use of an environment variable.
One of the most powerful features of Entity Framework Core is the ability to “reverse-engineer” a deployed database. Reverse-engineering means that Entity Framework Core autogenerates all the models in your codebase from a deployed database, saving you a lot of time. Reverse-engineering also guarantees that your models work with the database and are mapped correctly to the schema. In chapter 3, we discussed the need for a correct isomorphic relationship between model and schema, and using an ORM tool to reverse-engineer models is a way to achieve that.
In this section, we’ll learn how to use Entity Framework Core to reverse-engineer the deployed database and automatically create models to match the database’s tables. Because we reverse-engineer the database, we can be assured that we are working with compatible code to query the database.
To reverse-engineer our database, we first need to install Entity Framework Core by running the dotnet install
command, as shown next. Entity Framework Core (EF Core) does not come automatically installed with .NET 5 as it is a separate project.
> dotnet tool install -–global dotnet-ef
On success, the command line lets you know that you can invoke the tool by using the dotnet-ef
command and which version you just installed. Entity Framework Core can connect to many different types of databases. Most databases (SQL, NoSQL, Redis) have packages (also called database drivers) that allow Entity Framework Core to connect to them. Because our database is a SQL Server, we install the respective driver. We also need to add the Entity Framework Core Design package. These packages contain the functionality we need to connect to a SQL Server database (the SqlServer
namespace) and reverse-engineer the models (the Design
namespace).
Make sure you run the following commands from your project’s root folder (FlyingDutchmanAirlines, not the solution’s root folder, FlyingDutchmanAirlinesNextGen):
> dotnet add package Microsoft.EntityFrameworkCore.SqlServer > dotnet add package Microsoft.EntityFrameworkCore.Design
The commands install all required packages and dependencies for connecting to a SQL Server with the help of Entity Framework Core.
We can now reverse-engineer the database by using the next command:
> dotnet ef dbcontext scaffold [CONNECTION STRING] [DATABASE DRIVER] [FLAGS]
The command contains two unfamiliar terms—dbcontext
and scaffold
:
dbcontext
refers to the creation of a class of type DbContext
. A dbcontext
is the main class we use to set up our database connection in the code.
scaffold
instructs Entity Framework Core to create models for all database entities in the database we are connected to. Much like real-life scaffolding, it creates a sort of wrap around the original item (a house or a building) that we can use to modify the said item. In our case, it puts a scaffold around the deployed SQL database.
We can use flags to specify the folder of the generated models and dbContext
. We’ll save these into a dedicated folder as follows, to avoid having a bunch of model files in our project root folder:
> dotnet ef dbcontext scaffold ➥ "Server=tcp:codelikeacsharppro.database.windows.net,1433;Initial ➥ Catalog=FlyingDutchmanAirlines;Persist Security Info=False;User ➥ Id=dev;Password=FlyingDutchmanAirlines1972!; ➥ MultipleActiveResultSets=False;Encrypt=True; ➥ TrustServerCertificate=False;Connection Timeout=30;" ➥ Microsoft.EntityFrameworkCore.SqlServer -–context-dir DatabaseLayer ➥ –-output-dir DatabaseLayer/Models
If you run into issues running the command, please double-check all spaces, line breaks (there should be none), and flags. The command starts by building the current project. Then, it tries to connect to the database with the given connection string. Finally, it generates the dbContext
class (FlyingDutchmanAirlinesContext.cs) and the appropriate models. Let’s examine the created FlyingDutchmanAirlinesContext
class. A generated DatabaseContext
has the following four major pieces:
But before we look at these items, there is something peculiar in the class declaration:
public partial class FlyingDutchmanAirlinesContext : DbContext
What is this partial
business?
|
If you look at the generated class, you’ll notice it has two different constructors. By default, in C#, if you do not provide a constructor, the compiler generates a parameterless constructor for you under the hood. This constructor is called the default constructor, or the implicit constructor. C# creates the default constructor whenever there is no explicit constructor so you can instantiate a new instance of the said class.
As seen in listing 5.4, both constructors can create an instance of FlyingDutchmanAirlinesContext
. In the case of FlyingDutchmanAirlines
, you can create a new instance with or without passing in an instance of type DbContextOptions
. If you do pass that instance into the constructor, it invokes the constructor of its base class (DbContext
in this case).
public FlyingDutchmanAirlinesContext() { } ❶ public FlyingDutchmanAirlinesContext(DbContextOptions ➥ <FlyingDutchmanAirlinesContext> options) : base(options) { } ❷
❶ An explicit default constructor
❷ An overloaded constructor with a parameter that calls the base constructor
For more information on method and constructor overloading, see chapter 4.
In this section, we’ll discuss the DbSet
type as well as the general workflow when using Entity Framework Core. Looking past the constructors, we see four collections of type DbSet
, each holding one of our database models. The DbSet
types are collections that we consider part of the internals of EF Core. Entity Framework Core uses the DbSet<Entity>
collections to store and maintain an accurate copy of the database tables and their contents.
We also see a familiar concept: auto-properties. The collections are public
, but they are also virtual, as shown next:
public virtual DbSet<Airport> Airport { get; set; } public virtual DbSet<Booking> Booking { get; set; } public virtual DbSet<Customer> Customer { get; set;} public virtual DbSet<Flight> Flight { get; set; }
When you declare something virtual
, you tell the compiler that you allow the property or method to be overridden in a derived class. If you do not declare something as virtual
, you cannot override it.
|
Like many other ORM tools, Entity Framework Core often behaves unintuitively at first. All operations you would normally make directly against the database are done against an in-memory model before they are saved to the database. To do this, Entity Framework Core stores most available database records in the DbSet
. This means that if you have added a Flight
entity with a primary key of 192
in the database, you also have that particular entity loaded into memory during runtime. Having access to the database contents from memory at runtime allows you to easily manipulate objects and abstract away that you are using a database at all. The drawback is performance. Keeping lots of records in memory can become quite the resource hog, depending on how large your database is (or becomes). As shown in figure 5.7, the normal workflow for operating on an entity through Entity Framework Core follows:
Query the appropriate DbSet
for the object you want to manipulate (not needed for INSERT
/ADD
operations).
Change the DbSet
appropriately (not needed for READ
operations).
It is good to keep in mind that just because changes have been made in a DbSet
, they are not necessarily made in the database yet. Entity Framework Core still needs to commit these changes to the database, and we’ll explore how to do that further in this chapter.
The third building block of the FlyingDutchmanAirlinesContext class
comprises two configuration methods: OnConfiguring
and OnModelCreating
, shown in the next code. OnConfiguring
is called on the configuration of the DbContext
, which is done automatically at launch, whereas OnModelCreating
is called during model creation (at runtime, during launch).
protected override void OnConfiguring(DbContextOptionsBuilder ➥ optionsBuilder) { if (!optionsBuilder.IsConfigured) { optionsBuilder.UseSqlServer( ➥ "Server=tcp:codelikeacsharppro.database.windows.net,1433;Initial ➥ Catalog=FlyingDutchmanAirlines;Persist Security Info=False;User ➥ ID=dev;Password=FlyingDutchmanAirlines1972!; ➥ MultipleActiveResultSets=False; ➥ Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;"); } }
The OnConfiguring
method takes in an argument of type DbContextOptionsBuilder
. The OnConfiguring
method is called by the runtime automatically on the configuration of the DbContext
and uses dependency injection to provide the DbContextOptionsBuilder
. Here, we should configure any settings related to how we connect to the database. Therefore, we need to provide a connection string.
But, unfortunately, the hardcoded connection string rears its ugly head once more. Surely there must be a better way to do this. I propose we use environment variables for this. An environment variable is a key-value pair, {K, V }, which we set at the operating system level. We can retrieve environment variables at run time, making them excellent for providing variables that either change per system or deployment or values that we do not want hardcoded in our codebase.
NOTE Environment variables are often used for web services deployed through containerized orchestration systems such as Kubernetes. If you do not want to (or cannot) set an environment variable on the operating system level, you can instead use cloud solutions such as Azure Key Vault and Amazon AWS Key Management Service. For more information on Kubernetes, see Ashley David’s Bootstrapping Microservices with Docker, Kubernetes, and Terraform (Manning, 2021) or Marko Lukša’s Kubernetes in Action (2nd edition; Manning, 2021).
Every operating system does environment variables slightly differently—we’ll discuss the practical differences between Windows and macOS in a moment. The way we retrieve an environment variable in C# does not change based on the operating system, however. In the System.IO
namespace is a method called GetEnvironmentVariable
that we can use for that exact purpose, as shown here:
Environment.GetEnvironmentVariable([ENVIRONMENT VARIABLE KEY]);
You just pass it in the key of the environment variable you want to retrieve (ENVIRONMENT VARIABLE KEY
), and the method does so for you. If the environment variable does not exist, it returns a null value without throwing an exception, so you need to do some validation based on that null value. What would your environment variable look like? Because it is a key-value pair, and because environment variables cannot contain any spaces, you can do something like {FlyingDutchmanAirlines_Database_Connection_ String, [Connection String]}
.
TIP Because environment variables are system wide, you cannot have environment variables with duplicate keys. Keep this in mind when choosing a value for the key.
The process of setting environment variables differs slightly from operating system to operating system. In Windows, you set an environment variable through the Windows command line, using the setx
command, followed by the desired key-value pair, as follows:
> setx [KEY] [VALUE] > setx FlyingDutchmanAirlines_Database_Connection_String ➥ "Server=tcp:codelikeacsharppro.database.windows.net,1433;Initial ➥ Catalog=FlyingDutchmanAirlines;Persist Security Info=False;User ➥ ID=dev;Password=FlyingDutchmanAirlines1972!; ➥ MultipleActiveResultSets=False;Encrypt=True; ➥ TrustServerCertificate=False;Connection Timeout=30;"
If successful, the command line reports that the value was saved successfully (SUCCESS: Specified value was saved.
). To verify that the environment variable was saved, launch a new command line (newly set environment variables do not show up in active command-line sessions), and run the echo
command for the environment variable. If you do not see the environment variable show up, as shown next, you may have to reboot your machine:
> echo %FlyingDutchmanAirlines_Database_Connection_String%
If everything went all right, the echo
command should return the value of the environment variable (in this case, our connection string). We can now use this environment variable in our service!
Like Windows, we use a command-line environment to set environment variables on macOS: the macOS terminal. Setting an environment variable is just as easy on macOS as it is on Windows, as shown here:
> export [KEY] [VALUE] > export FlyingDutchmanAirlines_Database_Connection_String ➥ "Server=tcp:codelikeacsharppro.database.windows.net,1433;Initial ➥ Catalog=FlyingDutchmanAirlines;Persist Security Info=False;User ➥ ID=dev;Password=FlyingDutchmanAirlines1972!; ➥ MultipleActiveResultSets=False;Encrypt=True; ➥ TrustServerCertificate=False;Connection Timeout=30;"
And you can verify by using echo
on macOS as well, like so:
> echo $FlyingDutchmanAirlines_Database_Connection_String
On macOS, things are somewhat trickier when we run the service and try to grab the environment variables when debugging a codebase through Visual Studio. In macOS, environment variables defined through the command line do not automatically become available to GUI applications such as Visual Studio. The workaround is to launch Visual Studio through the macOS terminal or to add the environment variables in Visual Studio as part of the runtime configurations.
Having set the environment variable, we can now grab it in our code. We want to grab it in the OnConfigure
method instead of hardcoding the connection string. We can use the Environment.GetEnvironmentVariable
method for this. Because the Environment.GetEnvironmentVariable
returns a null value if it cannot find the environment variable, we use the null coalescing operator (??
) to set it to an empty string in that case, as follows:
protected override void OnConfiguring(DbContextOptionsBuilder ➥ optionsBuilder) { if(!optionsBuilder.IsConfigured) { string connectionString = Environment.GetEnvironmentVariable( ➥ "FlyingDutchmanAirlines_Database_Connection_String") ➥ ?? string.Empty; optionsBuilder.UseSqlServer(connectionString); } }
We could have handled the null
case in a couple of different ways (most notably by using either a conditional or by inlining the GetEnvironmentVariable
call along with the null coalescing operator into the UseSqlServer
method), but this is my preferred way. It is readable yet succinct. By doing this little trick, we increased the security of our application tenfold, especially when you consider the problems caused by a hard-coded connection string committed to a source control management system.
The remaining code we have not touched on yet in the FlyingDutchmanAirlinesContext
are the OnModelCreating
methods, shown in the next listing.
protected override void OnModelCreating(ModelBuilder modelBuilder) { ❶ modelBuilder.Entity<Airport>(entity => { ❷ entity.Property(e => e.AirportId) ❷ .HasColumnName("AirportID") ❷ .ValueGeneratedNever(); ❷ entity.Property(e => e.City) ❷ .IsRequired() ❷ .HasMaxLength(50) ❷ .IsUnicode(false) ❷ entity.Property(e => e.Iata) ❷ .IsRequired() ❷ .HasColumnName("IATA") ❷ .HasMaxLength(3) ❷ .IsUnicode(false) ❷ }); ❷ modelBuilder.Entity<Booking>(entity => { ❸ entity.Property(e => e.BookingId).HasColumnName("BookingID"); ❸ entity.Property(e => e.CustomerId).HasColumnName("CustomerID"); ❸ entity.HasOne(d => d.Customer) ❸ .WithMany(p => p.Booking) ❸ .HasForeignKey(d => d.CustomerId) ❸ .HasConstraingName("FK__Booking__Custome_71D1E811"); ❸ entity.HasOne(d => d.FlightNumberNavigation) ❸ .WithMany(p => p.Booking) ❸ .HasForeignKey(d => d.FlightNumber) ❸ .OnDelete(DeleteBehavior.ClientSetNull) ❸ .HasConstraintName(“FK__Booking__FlightN__4F7CD00D”); ❸ }); ❸ modelBuilder.Entity<Customer>(entity => { ❹ entity.Property(e => e.CustomerId) ❹ .HasColumnName("CustomerID") ❹ entity.Property(e => e.Name) ❹ .IsRequired() ❹ .HasMaxLength(50) ❹ .IsUnicode(false) ❹ }); ❹ modelBuilder.Entity<Flight>(entity => { ❺ entity.HasKey(e => e.FlightNumber); ❺ entity.Property(e => e.FlightNumber).ValueGeneratedNever(); ❺ entity.HasOne(d => d.DestinationNavigation) ❺ .WithMany(p => p.FlightDestinationNavigation) ❺ .HasForeignKey(d => d.Destination) ❺ .OnDelete(DeleteBehavior.ClientSetNull) ❺ .HasConstraintName("FK_Flight_AirportDestination"); ❺ entity.HasOne(d => d.OriginNavigation) ❺ .WithMany(p => p.FlightOriginNavigation) ❺ .HasForeignKey(d => d.Origin) ❺ .OnDelete(DeleteBehavior.ClientSetNull); ❺ }); ❺ OnModelCreatingPartial(modelBuilder); ❻ } partial void OnModelCreatingPartial(ModelBuilder modelBuilder); ❼
❶ Overrides the base’s OnModelCreating method
❷ Prepares the EF Core to use the Airport model
❸ Prepares the EF Core to use the Booking model
❹ Prepares the EF Core to use the Customer model
❺ Prepares the EF Core to use the Flight model
❻ Calls the partial OnModelCreatingPartial method
❼ Defines the partial OnModelCreatingPartial method
Note that the exact constraint names may differ on your system, because they are autogenerated. The OnModelCreating
method sets up the entities internally for Entity Framework Core, along with the key constraints defined in the database schema. This allows us to operate on the entities without directly messing with the database (which is the whole idea of Entity Framework Core). A generated method (and a call to it) is also called OnModelCreatingPartial
. The Entity Framework Core console toolset generated the OnModelCreatingPartial
method, so you can execute additional logic as part of the model-creation process. We are not going to do that, so we can remove the OnModelCreatingPartial
method and the call to it. Do be aware that if you have to rerun the reverse-engineering process (or any other code-generator tool), your changes will be overwritten again.
If we want to prevent somebody from deriving from a class, what keyword do we need to attach to the class?
If we want to allow somebody to override a property or method, what keyword do we attach?
Fill in the blanks: “A __________ is the underlying process that runs the web service. It, in turn, lives inside the __________.
True or false? When using a Startup
class, you need to register it with the Host
.
Try it yourself: Write an expression-body-style method that accepts two integers and returns their product. This should be a one-line solution. Hint: Think about lambda.
Within the context of a repository/service pattern, how many controller, service, and repository layers should there be?
We can create .NET 5 solutions and projects by using predefined templates in the command line such as console
and mvc
. Templates are ways to easily create common flavors of solutions and projects.
A restore
is an operation that gets all necessary dependencies for a project to compile.
We can add a project to a solution by using the dotnet sln [SOLUTION] add [PROJECT]
command.
A Host
is a process living inside the CLR that runs a web application, providing an interface between the CLR and the user.
Methods that just return the value of an expression can be written succinctly using a syntax similar to lambda expressions. This is called an expression-bodied method and can make our code more readable.
In a Startup
class, we can set up routes and allow for the use of controllers and endpoints. This is important for MVC web applications because it allows us to call endpoints and use the concept of controllers.
The repository/service pattern comprises multiple repositories, services, and controllers (one per entity). This easy-to-follow paradigm helps us control the flow of data.
Entity Framework Core is a powerful object-relational mapping tool that can reverse-engineer deployed databases by scaffolding them. This saves the developer a lot of time and allows for a near-perfect isomorphic relationship between the database and the codebase.
Use the partial
keyword to define classes and methods that have their implementation spread across multiple fields. The partial
keyword is often used by automatic code generators.
When declaring something as virtual
, you say that this property, field, or method can be overridden safely. This is useful when balancing the needs for the extensibility and the sanctity of your codebase.
You can “hide” nonvirtual properties and methods by adding the new
keyword to a method or property signature.
When a class is sealed, you cannot inherit from it. In effect, sealing a class stops any class from deriving from it. Sealing classes becomes useful when you know for a fact that your class is the lowest level of inheritance there is and you want to prevent tampering with your code.
Environment variables are key-value pairs that can be set in an operating system. They can store sensitive data such as connection strings or passwords.
1.Installation instructions for Visual Studio can be found in appendix C. If you want to learn more about Visual Studio, see Bruce Johnson’s Professional Visual Studio 2017 (Wrox, 2017) and Johnson’s Essential Visual Studio 2019 (Apress, 2020). Disclaimer: The author was the technical reviewer for Essential Visual Studio 2019: Boosting Development Productivity with Containers, Git, and Azure Tools.
3.19.29.89