Besides an opportunity for my team to return to the comforts of dotnet from nodejs, performance improvements in asp.net core (as evident from the latest round i.e. 16 of Web Framework Benchmarks by TechEmpower) was another good reason to trial it for an internal microsite with limited users.
Creating from SPA template
Dotnet core SPA templates are quite a handy way to get started with react/angular apps with asp.net core on the server side. You can get started by just a simple dotnet command:
dotnet new reactredux
Dotnet offers templates for both react-js and angular.
This basic template is easy to work with. It uses “Create React App” for the client-side with asp.net core project on the server side. When you run the project in Visual Studio, asp.net core pipeline is configured to pass the requests to react development server (i.e. webpack-dev-server) which handles live reloading. This code from startup.cs will get the port assigned by “npm start” and map it to the dotnet world.
if (env.IsDevelopment())
{
spa.UseReactDevelopmentServer(npmScript: "start");
}
We needed this port to be the same across all the dev machines. With the following two changes, you can achieve that:
In startup.cs
if (env.IsDevelopment())
{
spa.UseProxyToSpaDevelopmentServer("http://localhost:3100");
}
in /ClientApp/package.json
"start": "rimraf ./build && set Port=3100 && react-scripts start"
As shown in the snippet below, project file instructs dotnet to build and include the JS application in the artifacts to publish:
<Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish"></Target>
<!-- As part of publishing, ensure the JS resources are freshly built in production mode -->
<Exec WorkingDirectory="$(SpaRoot)" Command="npm install" />
<Exec WorkingDirectory="$(SpaRoot)" Command="npm run build" />
<!-- Include the newly-built files in the publish output -->
<ItemGroup>
<DistFiles Include="$(SpaRoot)build\**; $(SpaRoot)build-ssr\**" />
<ResolvedFileToPublish Include="@(DistFiles->'%(FullPath)')" Exclude="@(ResolvedFileToPublish)">
<RelativePath>%(DistFiles.Identity)</RelativePath>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</ResolvedFileToPublish>
</ItemGroup>
</Target>
Moving to Docker
We needed a docker image to be able to deploy it on our AKS (Azure Kubernetes Service). We will imitate the current build process with minor changes to the dotnet project:
- Build server-side artifacts
- Build client-side artifacts
- Package all in one
Recent dotnet images do not have nodejs pre-installed. So we cannot have our dotnet project building client-side artifacts using npm. This leaves us with two options, either install npm as part of the docker build or use multistage builds. I am going to use the later option, because it is much cleaner, simpler and an efficient approach. Thanks to the asp.net announcement post for providing a very clear example of how to do this.
Prepare for the docker build
To make it buildable on the dotnet image without nodejs installed, we will need to make some changes to our project file so that it does not try to build client-side during publishing. Begin with removing the following lines from the csproj file. These will be inside “PublishRunWebpack” target.
<Exec WorkingDirectory="$(SpaRoot)" Command="npm install" />
<Exec WorkingDirectory="$(SpaRoot)" Command="npm run build" />
Docker build
In our multistage build, we will follow the same process as I have mentioned above. In our first stage, we will prepare a server-side build environment using the dotnet image:
FROM microsoft/dotnet:2.1-sdk as build-env
WORKDIR /app
COPY src/SampleSpa/*.csproj ./src/SampleSpa/
COPY *.sln ./
RUN dotnet restore SampleSpa.sln
This will give us an intermediate image that has the dotnet solution with all the NuGet packages restored. We named it build-env so that we can use it in the next steps.
Now we will prepare the client-side build using a nodejs based image:
FROM node:8.9.4 as clientBuild
WORKDIR /ClientApp
COPY src/SampleSpa/ClientApp/ .
RUN npm install
RUN npm run build
Here we get another intermediate image with client-side code built using npm commands. Now we can combine the output of both the images and publish final deployable artifacts.
FROM build-env as publish
COPY . ./
RUN dotnet publish -c Release -o out
COPY --from=clientBuild ./ClientApp/build ./src/SampleApp/out/ClientApp/build
This new intermediate image, that builds server-side artifacts and combines them with client-side artifacts. We can use this to build our final image.
FROM microsoft/dotnet:2.1-aspnetcore-runtime
WORKDIR /app
COPY --from=publish /app/src/SampleApp/out/ .
EXPOSE 80:80
ENTRYPOINT ["dotnet", "SampleApp.dll"]
Well, putting all these snippets together in one file will give us our final dockerfile:
# 1. Prepare server side build image
FROM microsoft/dotnet:2.1-sdk as build-env
WORKDIR /app
COPY src/SampleSpa/*.csproj ./src/SampleSpa/
COPY *.sln ./
RUN dotnet restore SampleSpa.sln
#2 Build client-side artifacts
FROM node:8.9.4 as clientBuild
WORKDIR /ClientApp
COPY src/SampleSpa/ClientApp/ .
RUN npm install
RUN npm run build
#3 Publish final output by merging server-side and client-side artifacts
FROM build-env as publish
COPY . ./
RUN dotnet publish -c Release -o out
COPY --from=clientBuild ./ClientApp/build ./src/SampleSpa/out/ClientApp/build
# Build runtime image
FROM microsoft/dotnet:2.1-aspnetcore-runtime
WORKDIR /app
COPY --from=publish /app/src/SampleSpa/out/ .
EXPOSE 80:80
ENTRYPOINT ["dotnet", "SampleSpa.dll"]