This article presents custom hosting of ASP.NET middleware by the unmanaged process. It allows developers to use managed C# code to handle HTTP(S) requests almost as in the case of normal managed Web API, call unmanaged functions from the managed controller and return the result of the unmanaged code execution back to managed code. It runs in Windows and Linux alike.
Table of Contents
Introduction
My CodeProject article, Hosting .NET Core Components in Unmanaged C/C++ Process in Windows and Linux, shows the technique to place .NET code to native Windows and Linux processes where their main part is written in C/C++. Lately, I feel increased interest in the development community to this topic (regular downloads of the article's code and questions from colleagues). This encouraged me to revisit this subject and add more elements to the software. This new article augments two major features to the application, namely,
- usage of ASP.NET middleware in the native process permitting easy handling of HTTP(S) requests, and
- ability to return the result of unmanaged callback to .NET code.
The general structure of the application (or rather the simple HTTP(S) server) is depicted in the figure above. The code itself is reduced w.r.t. the previous article to emphasize the new features (although currently omitted parts may be easily returned).
Managed / Unmanaged Code Collaboration
Similarly to previous work, a C++ gateway class GatewayToManaged
provides major functionality from the unmanaged side. The Trusted Platform Assemblies (TPA) list is augmented with ASP.NET DLLs (please see the implementation of GatewayToManaged::Init()
method). The header of this class is given below:
#pragma once
#include "Export.h"
#include "coreclrhost.h"
using namespace std;
EXPORTED typedef long (*unmanaged_callback_ptr)
(const char* funcName, const char* args, long long int lArrAddr);
EXPORTED typedef char* (*managed_direct_method_ptr)
(const char* funcName, const char* args, unmanaged_callback_ptr unmanagedCallback);
class EXPORTED GatewayToManaged
{
public:
GatewayToManaged();
~GatewayToManaged();
bool Init(int argc, char* argv[]);
char* Invoke(const char* funcName, const char* args,
unmanaged_callback_ptr unmanagedCallback);
bool Close();
private:
void* _hostHandle;
unsigned int _domainId;
managed_direct_method_ptr _managedDirectMethod;
void BuildTpaList(const char* directory, const char* extension, string& tpaList);
managed_direct_method_ptr CreateManagedDelegate();
#if WINDOWS
HMODULE _coreClr;
#elif LINUX
void* _coreClr;
#endif
};
Method GatewayToManaged::Init()
performs the most preparations. It
- loads components of
CoreCLR
- constructs Trusted Platform Assemblies (TPA) list including ASP.NET DLLs
- defines main
CoreCLR
properties - starts the .NET Core runtime and creates the default (and only)
AppDomain
, and finally - creates an
object managed_direct_method_ptr _managedDirectMethod
permitting a managed delegate call
Like in the previous work, the managed delegate is called with managed static
method Gateway.ManagedDirectMethod()
. Managed methods to call are placed in Worker.dll. For simplicity, only one such method Worker.StartWebApi()
is provided. The list of callable methods from class Worker
is obtained in method Gateway.ManagedDirectMethod()
via reflection.
File Host.cpp containing start
function of the process int main(int argc, char* argv[])
looks as follows:
#include <iostream>
#include "Host.h"
using namespace std;
#include "callback.h"
int main(int argc, char* argv[])
{
cout << "Host started" << endl;
cout << "To quit please insert any char and press <enter>" << endl << endl;
GatewayToManaged gateway;
gateway.Init(argc, argv);
string args = "";
string retStrQ = gateway.Invoke("StartWebApi",
args.c_str(), UnmanagedCallback);
cout << retStrQ.c_str() << endl;
char ch;
cin >> ch;
gateway.Close();
return 0;
}
As it may be seen, it includes file callback.h containing function static long UnmanagedCallback(const char* funcName, const char* args, long long int lArrAddr)
called from managed code. In our case, this function is called from the managed controller in WebApi.dll. Arguments of the function stand for the name of the actual unmanaged function to call, its arguments, and the memory address of the return value. The memory pointed to by lArrAddr
is shared by managed and unmanaged code for data exchange. The implementation of this mechanism will be described below.
Method UnmanagedCallback()
constitutes a simple example of an unmanaged callback. It performs some simple manipulation with char[]
strings. It is essential that the result for the managed code should be converted to Unicode, i.e., to wchar_t
. This should be done keeping in mind the different lengths of wchar_t
in Windows (2 bytes) and Linux (4 bytes). I did this in a rather primitive manner assuming only one meaningful byte. In real-life applications, this issue should be properly addressed.
Web API
The main goal of this work is to add ASP.NET middleware to unmanaged processes in order to facilitate the handling of HTTP(S) requests. Managed WebApi DLL contains ASP.NET middleware (class Web
) and controllers (in our sample only one class JobController
for the sake of simplicity). But it has been revealed that the standard for managed process way of controller routing based by default on the controller attributes reflected over by the host, doesn't work directly here. A similar reflection-based routing mechanism may be implemented relatively easily. But to keep things simple, I chose to hardcode the routing in the method Web.Run()
as follows:
app.UseEndpoints(endpoints =>
{
foreach (var key in new[] { "/api/job", "/api/job/{arg}" })
endpoints.MapGet(key, async context => await JobGetHandler(context));
endpoints.MapPost("/api/job",
async context => await JobPostHandler(context));
});
So, our controller is not equipped with attributes. Neither does it have a base class as it is common for a conventional ASP.NET controller. Methods JobGetHandler()
and JobPostHandler()
wrap calls to appropriate methods of the controller class and return their outputs with context.Response.WriteAsync(jsonString)
. These returned objects act as arguments in methods endpoints.MapGet()
and endpoints.MapPost()
in the code snippet above.
"Pinned" Memory
Our controller calls unmanaged code with UnmanagedCallbackDelegate callback
by running its extension method Exec()
provided below:
public static class CallbackExtension
{
public static string Exec(this UnmanagedCallbackDelegate callback,
ILogger logger, string funcName = null, string args = null)
{
const int PINNED_LEN = 200;
string ret = "Server error";
if (callback != null)
{
int len = -1;
var chs = new string(' ', PINNED_LEN).ToCharArray();
var handleChs = GCHandle.Alloc(chs, GCHandleType.Pinned);
var chsPtr = handleChs.AddrOfPinnedObject();
try
{
len = callback(funcName, args, (long)chsPtr);
chs = handleChs.Target as char[];
}
catch (Exception e)
{
logger.Error($"ERROR while executing callback.
{Environment.NewLine}{e}");
}
finally
{
handleChs.Free();
}
if (len > 0)
ret = new string(chs).Substring(0, len);
else
logger.Error($"ERROR while executing callback in
method {funcName}(), params: {args}");
}
else
logger.Error($"ERROR: callback is null in method {funcName}()");
return ret;
}
}
The most important part of the method is handling the memory shared by managed and unmanaged code. Allocated memory is "pinned", used by unmanaged part, and then freed in the finally
block. Such a technique makes the same memory fragment available from both managed and unmanaged code. It is not very performant but allows unmanaged code to return data to the managed part. And in general, unmanaged-managed hybrid is used only in special cases when the alternative is a huge and often unfeasible refactoring of unmanaged code. So, performance is not the first priority in such a situation.
How to Use
In order to run the code, we should specify CLR and ASP.NET directories. They are defined in the two leading command line arguments for the executable files (Host.exe for Windows and Host.out for Linux respectively).
The directories may be obtained with command:
dotnet --info
The output of this command provides (among other useful information) the .NET and ASP.NET runtimes directories.
Note: .NET and ASP.NET runtimes directories depend on .NET SDK and the runtime you use. So they may differ from those provided in the demo command files for Windows and Linux.
The third command line argument, being provided (it may be any string
), causes a "breakpoint" at the start of the host process allowing the developer to attach a debugger to the process.
Windows
Visual Studio was used to build the solution in Windows. In the project Host, to ensure build for Windows, we have to define WINDOWS in Configuration -> Configuration Properties -> C/C++ -> Preprocessor -> Preprocessor Definitions. In the same configuration path parameter _CRT_SECURE_NO_WARNINGS
should be defined to allow native code to use simple C functions to process char[]
string
s.
Configuration -> Configuration Properties -> Debugging -> Commands Arguments should contain the commands arguments described above, i.e., .NET and ASP.NET runtimes directories and [optionally] the breakpoint argument. It is important that the bitness (32- or 64-bit) of CoreCLR should match the bitness in which the host was built. In our case, C++ projects should be built in x64 mode. After the build, all executable and configuration files will be placed into the directory $(SolutionDir)x64. For convenience, command files Host.cmd and Host-with-breakpoint.cmd contain the executable file Host.exe with the required command line arguments.
Important: Please check your .NET and ASP.NET runtimes directories (see Note above) before running the command files.
Linux
To test the software in Linux environment, I used Ubuntu Windows Subsystem for Linux (WSL) with Ubuntu version 22.04. The following Linux command should be executed from directory $(SolutionDir)Host in order to build Linux executable file, Host.out.
g++ -o Host.out -D LINUX GatewayToManaged.cpp Host.cpp -ldl
It will produce an output executable file Host.out. This file should be copied to directory $(SolutionDir)x64. In the same directory Linux, the command shell script files Host.sh and Host-with-breakpoint.sh containing the command line to run the application are placed.
Important: Please check your .NET and ASP.NET runtimes directories (see Note above) before running the command shell script files.
To test application in Linux, a TLS certificate should be provided. This is attained by installing .NET SDK (not just runtimes) and executing the following command:
dotnet dev-certs https
Results
When Host executable starts, it writes in the console that it is listening HTTP requests on port 7000 and HTTS requests on port 7001. Now we can test it with HTTP(S) requests using a browser and Postman.
GET
request:
https://localhost:7001/api/job/arg0
produces:
{"Payload":"Echo from Unmanaged~ funcGet(arg0)","Os":"WINDOWS Server",
"Message":"Current Local Time","Time":"16/10/2022 9:26:16"}
POST
request:
https://localhost:7001/api/job
with Body -> raw -> JSON = { "arg0": "zero arg" }
results output:
"{\"Payload\":\"Echo from Unmanaged~ funcPost
({ \\"arg0\\": \\"zero arg\\" })\",\"Os\":\"LINUX Server\",
\"Message\":\"Current Local Time\",\"Time\":\"10/16/2022 09:19:17\"}"
Conclusion
This work extends my article, Hosting .NET Core Components in Unmanaged C/C++ Process in Windows and Linux, by incorporating ASP.NET middleware into the unmanaged process. It allows developers to use managed C# code to handle HTTP(S) requests almost as in the case of normal managed Web API, call unmanaged functions from the managed controller and return the result of the unmanaged code execution back to managed code. It runs in Windows and Linux alike.
History
- 17th October, 2022: Initial version