前面我们说了,Blazor WASM的编译产出其实是一个纯前端项目,在部署时我们需要把它托管在某种Web Server上。而现实是,很少有纯前端项目能脱离开API和数据库存在,90%的情况下,一个完整的应用至少包含三层:前端项目,后端API项目,以及一个数据库实例。
对于大型项目,一般的部署设计会更复杂,我也非常缺乏大型web项目的落地实践经验,这里就不瞎说了。对于中小型项目,最方便的部署选择是:单机部署,用Nginx托管前端项目产出,再用Nginx把API请求转发到后端进程里去,数据库也同时部署在本地。
这样做的好处是逻辑上依然前后端分离,等项目成长到一定规模的时候,也方便做分离部署和API层的扩容,到时候你再前后端单独部署,甚至服务端程序上K8S,前端上CDN,也没什么大的难度。
可即便是这样简单的部署,对有些项目来说依然太复杂了,很多web项目,甚至压根没有必要去上Nginx。这个时候 .NET Core就有一个非常好的选择:
dotnet new
原生就支持这样的解决方案模板,在新建Blazor WASM项目的时候给命令行加上--hosted
参数,就会生成一个包含三个csproj
项目的解决方案。比如dotnet new blazorwasm -o xxx --hosted
。但我们今天不用官方模板。。诶,就是玩。
今天我们通过手动攒一个Hosted Blazor WASM项目,来体会一下前后端是怎么联动的。我们今天的任务很简单:
前后端联调里最重要的一环就是API的定义,狭隘一点的说,就是JSON的定义。这里实际上有两个问题:
这是个非常简单,也非常容易解决的问题,但实际工作过程中,就这两个小小的问题,经常会困扰整个开发团队。Blazor里就没有这个问题,虽然WASM项目在运行期,前后端通信依然是依靠Ajax请求和JSON数据格式的,但Blazor框架可以向前端和后端同时屏蔽掉这些细节:ASP .NET Core WebAPI框架本身就有一套实现可以把C#对象自动转换成JSON对象,而Blazor的前端部分,又可以自动的把JSON对象再转回到C#对象中去。
前端和后端可以直接引用同一个C#定义的类库,至于运行的时候,转了多少弯弯绕,程序员是没必要关心的。
我们要做一个彩票预测网站,前后端通信要传递的数据就是彩票号码的定义。在这个例子中,我们就可以把这部分共用的数据结构,定义在一个C#类库中去。所以,我们先新建一个类库项目,如下:
> mkdir LotteryForecast
> cd LotteryForecast
LotteryForecast> mkdir Shared
LotteryForecast> cd Shared
LotteryForecast\Shared>
在Shared
目录中,新建一个项目文件LotteryForecast.Shared.csproj
,内容如下:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<SupportedPlatform Include="browser" />
</ItemGroup>
</Project>
上面的项目文件与普通的 .NET 类库项目不同的一点是,声明了SupportedPlatform
为browser
。这句魔法声明的具体细节不必太纠结,你可以简单这样理解:虽然Blazor WASM框架让 .NET Runtime运行在了浏览器上,但那并不是一个 .NET Runtime的完全体,只是一个阉割版的 .NET Runtime,所以也并不是所有的 .NET特性都支持。添加这句魔法声明后,工具链在编译的过程中就会去检查类库中的代码,如果你使用了一些无法在浏览器上使用的Feature,工具链就会提醒你。
然后在这个项目下,写下前后端需要共享的数据类型:彩票。还是在Shared
目录下,新建LotteryTicket.cs
,内容如下:
using System;
using System.Linq;
namespace LotteryForecast.Shared;
public class LotteryTicket
{
public int[] RedBalls { get; }
public int[] BlueBalls { get; }
public LotteryTicket()
{
Random rand = new Random();
this.RedBalls = Enumerable.Range(1, 6).Select(_ => rand.Next(1, 34)).OrderBy(n => n).ToArray();
this.BlueBalls = Enumerable.Range(1, 2).Select(_ => rand.Next(1, 17)).OrderBy(n => n).ToArray();
}
}
如此这样,我们对接口的定义就完成了一半。目前整个项目目录结构如下:
LotteryForecast
|
`-- Shared
|
`--> LotteryForecast.Shared.csproj
`--> LotteryTicket.cs
回到上级目录,再新建一个新目录来放置后端项目:
LotteryForecast\Shared> cd ..
LotteryForecast> mkdir Server
LotteryForecast> cd Server
LotteryForecast\Server>
新建项目文件LotteryForecast.Server.csproj
,内容如下:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="7.0.13" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Shared\LotteryForecast.Shared.csproj" />
<ProjectReference Include="..\Client\LotteryForecast.Client.csproj" />
</ItemGroup>
</Project>
一切都看着很正常:新建了一个项目,使用的Sdk是Microsoft.NET.Sdk.Web
,同时引用了我们刚才创建的类库项目。但有两点很奇怪:
M.A.Components.WebAssembly.Server
的NuGet包,这是为什么呢?先按下这两个疑惑,我们先来分析这个API项目都要做些什么:
首先,它至少要是个API项目
要包含一个接口,可以让前端访问后得到一个或者几个彩票预测结果。。直接点说,就是要返回一个LotteryTicket
的列表出去。
其次,我们在文章开头讲了,Blazor WASM是一个纯前端项目,它本身是需要寄宿在一个Web Server中运行的,而我们今天这个例子的意义就在于,把前端项目寄宿在这个API项目中,所以需要一些魔法来提示工具链两件事情:
在编译API项目的时候,请率先编译WASM项目
编译完Blazor WASM项目后,把Blazor WASM的编译产出作为静态资源,放在API项目的wwwroot目录下作为静态资源。再编译API项目本身
最后是一个可选项:在Blazor WASM寄宿在API项目之后,为了方便调试Blazor WASM的代码,需要有能力让我们在前端项目最打断点,理想的效果是:
在VS中,给WASM项目里的代码打个断点,这个在VS中打的断点,可以通过一种魔法,传递给浏览器的dev tool,这样浏览器就会在断点处停止运行,更进一步的,浏览器在断点处暂停时,可以将上下文信息传递回VS,我们在VS的调试窗口可以看到上下文的变量值,并控制步进、步入等功能。
最后一点干讲起来有点抽象,其实要达到的就是下图示意的效果:
我们上面的两个疑惑,就对应着我们要解决的两个问题:
M.A.Components.WebAssembly.Server
包里有很多魔法,这些魔法可以让WASM项目在编译期寄宿,也可以实现本地调试时VS与浏览器之间的特殊通信..\Client\LotteryForecast.Client.csproj
,其实就是将要创建的WASM项目。是通过在msbuild脚本中以引用的方式,告诉工具链:我们要引入的WASM项目是谁接下来就是创建Program.cs
,内容如下:
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace LotteryForecast.Server;
public static class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers(); // 1
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseWebAssemblyDebugging(); // 2
}
else
{
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseBlazorFrameworkFiles(); // 3
app.UseStaticFiles(); // 4
app.UseRouting();
app.MapControllers(); // 5
app.MapFallbackToFile("index.html"); // 6
app.Run();
}
}
我们来分析代码中的关键点:
Controller
来实现API的基础功能。UseWebAssemblyDebugging()
方法其实就是M.A.Components.WebAssembly.Server
包里实现的魔法,用了它后就能支持本地调试了UseBlazorFrameworkFiles()
方法也是M.A.Components.WebAssembly.Server
里的魔法,用了它之后,就可以把WASM项目寄宿在API后端项目中了// 3
的魔法你可以简单理解为把WASM的编译产出放在了wwwroot
目录下,并没有自动开启静态文件支持,所以这里需要手动开启一下MapControllers()
里能找到的任何API时,就会把请求重定向到静态文件index.html
上去。这个index.html
并不是当前API项目需要写的一个静态文件,而是WASM项目的编译产出现在我们开始实现我们的API,按照ASP .NET Core的约定俗成,我们把Controller放在名为Controllers
的子目录中
LotteryForecast\Server> mkdir Controllers
LotteryForecast\Server\Controllers>
然后新建一个名为LotteryForecastController.cs
的文件,内容如下:
using Microsoft.AspNetCore.Mvc;
using LotteryForecast.Shared;
using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System.Linq;
namespace LotteryForecast.Server.Controllers;
[ApiController]
[Route("[controller]")]
public class LotteryForecastController : ControllerBase
{
[HttpGet]
public IEnumerable<LotteryTicket> Get()
{
return Enumerable.Range(1, 5).Select(_ => new LotteryTicket());
}
}
显然,这个API的endpoint就是/LotteryForecast
,使用HTTP GET方法,无需参数,返回的是彩票列表
到此,我们的服务端也攒完了,现在整体项目目录结构如下:
LotteryForecast
|
`-- Server
| |-- Controllers
| | |
| | `--> LotteryForecastController.cs
| |
| `--> LotteryForecast.Server.csproj
| `--> Program.cs
|
`-- Shared
|
`--> LotteryForecast.Shared.csproj
`--> LotteryTicket.cs
现在唯一不足的是,我们在Server项目中引用了一个还未创建的WASM项目,导致Server现在还没法通过编译。接下来,我们就来创建这个WASM项目。
回到上级目录,再新建一个子目录来放置前端项目
LotteryForecast\Server\Controllers> cd ..
LotteryForecast\Server> cd ..
LotteryForecast> mkdir Client
LotteryForecast> cd Client
LotteryForecast\Client>
有了前两篇文章的介绍,这次就熟悉多了,新建文件LotteryForecast.Client.csproj
,内容如下:
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="7.0.13" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="7.0.13" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Shared\LotteryForecast.Shared.csproj" />
</ItemGroup>
</Project>
按道理讲,其实这里引用...DevServer
这个包多少是有点多余,但无伤大雅,不要有那么强的心理洁癖。
新建Program.cs
,内容如下:
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using System.Net.Http;
using System;
namespace LotteryForecast.Client;
public static class Program
{
public static async Task Main(string[] args)
{
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
await builder.Build().RunAsync();
}
}
这次出现的新玩意,是builder.Services.AddScoped(xxx)
这一行。简单来说,这是给DI容器里注了一个HttpClient
对象,用来方便Blazor Components内部调用后端API。
但这里有个知识点值得提一下:就是DI容器的Blazor框架中的对象生命周期。
我们都知道,ASP .NET Core框架中的依赖注入池,里面的对象,或者叫Service,有三种生命周期:
其中Transient
是每次取,都生成新对象。而Singleton
是全局唯一。这俩玩意是两个极端。
在没有接触Blazor WASM之前,Scoped
的含义也比较清晰:这个生命周期主要用在ASP .NET Core项目中,它指的是,与HTTP请求同寿。可以简单的理解为每当有HTTP请求来袭时,DI池就会为这个请求单独创建一个Scoped
Service。
但情况在Blazor WASM这里有一点点点点不同:简单来说,在Blazor WASM前端项目的DI池中,Scoped
和Singleton
是同一个意思。
但但但但是:这并不是说,在一个浏览器上,一定只有一个全局唯一的Scoped/Singleton
Service实例,不是的。
一定要搞清楚,对于浏览器来说,每一个Tab,每一个标签页,都是一个独立运行的应用:即同样的Blazor WASM页面,在用户的浏览器上,分两个Tab打开了两次,那么这两个Tab分别有它们独立的Scoped/Singleton
service实例。
并且!浏览器的刷新(真正的刷新,用户按F5触发的那种刷新),其实就相当于应用的强制重启:所有Scoped/Singleton
Service实例都会被销毁,重新创建。(其实叫重新创建也不准确,你可以把“刷新”理解为“关掉了原应用,然后重新打开”,新开的应用其实和原应用没什么关系,就相当于你新开了个tab,然后把老的tab关闭掉)
App.razor
与wwwroot/index.html
新建App.razor
文件,内容如下:
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" />
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<p>Sorry, there's nothing at this address.</p>
</NotFound>
</Router>
新建wwwroot/index.html
,内容如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<base href="/" />
<title>LotteryForecast</title>
</head>
<body>
<div id="app">Loading...</div>
<script src="_framework/blazor.webassembly.js"></script>
</body>
</html>
都是老知识点,没什么可说的
Pages/Index.razor
新建Pages/Index.razor
,来写真正的前端页面,内容如下:
@page "/"
@using Microsoft.AspNetCore.Components.Web
@using LotteryForecast.Shared
@using System.Net.Http;
@using System.Net.Http.Json;
@inject HttpClient httpClient
<style>
table, th, td {
border: 1px solid black;
border-collapse: collapse;
}
</style>
<PageTitle>LotteryForecast!!!</PageTitle>
<button @onclick=@(this.OnButtonClick)>Refresh</button>
<hr/>
@if (this.lotteries == null)
{
<p><em>Loading...</em></p>
}
else
{
<table>
<thead>
<tr>
<th colspan="6">Blue Balls</th>
<th colspan="2">Red Balls</th>
</tr>
</thead>
<tbody>
@foreach (var lottery in this.lotteries)
{
<tr>
<td>@lottery.RedBalls[0]</td>
<td>@lottery.RedBalls[1]</td>
<td>@lottery.RedBalls[2]</td>
<td>@lottery.RedBalls[3]</td>
<td>@lottery.RedBalls[4]</td>
<td>@lottery.RedBalls[5]</td>
<td>@lottery.BlueBalls[0]</td>
<td>@lottery.BlueBalls[1]</td>
</tr>
}
</tbody>
</table>
}
@code {
LotteryTicket[]? lotteries;
protected override async Task OnInitializedAsync()
{
this.lotteries = await this.httpClient.GetFromJsonAsync<LotteryTicket[]>("LotteryForecast");
}
async Task OnButtonClick()
{
this.lotteries = await this.httpClient.GetFromJsonAsync<LotteryTicket[]>("LotteryForecast");
}
}
虽然看着挺长,但其实也全是老知识点,唯一的新东西就是对API的调用了。在DI池中有HttpClient
的service后,调用API也非常简单:
@inject
将HttpClient
注入到当前组件中GetFromJsonAsync<>
方法向API发送了一个GET请求,并且断言返回的数据可以被转换成LotteryTicket[]
类型,然后在参数中写上API有endpoint就行了。到此,所有项目都创建完毕,并且可以编译运行了,现在整体项目目录结构如下:
LotteryForecast
|
`-- Client
| |
| |-- Pages
| | |
| | `--> Index.razor
| |
| |-- wwwroot
| | |
| | `--> index.html
| |
| `--> App.razor
| `--> Program.cs
|
`-- Server
| |-- Controllers
| | |
| | `--> LotteryForecastController.cs
| |
| `--> LotteryForecast.Server.csproj
| `--> Program.cs
|
`-- Shared
|
`--> LotteryForecast.Shared.csproj
`--> LotteryTicket.cs
.NET Core挺好的,Blazor挺好的,但现在 .NET已经7.0了,在一些犄角旮旯还是有一些让人无语的小问题,这里简单提几个。
现在我们项目写完了,不就应该运行了吗?如果你一步一步跟着我做到这里,那么理论上,我们就应该能通过如下手段把项目运行起来
然后如何运行整个项目呢?这里一定要注意,要谨记:我们的前端项目是寄宿在API项目中的,所以实际要运行的是API项目,所以要如下运行:
LotteryForecast\Client\Pages> cd ..
LotteryForecast\Client> cd ..\Server
LotteryForecast\Server> dotnet run
然后你就会发现,啥玩意都加载不出来,并且日志窗口里有如下报错:
这是因为我们这个手攒的项目,没有附带任何多余的配置文件,也没有指定监听端口,所以框架就按默认的5000
端口去开启程序。
但又因为我们在Server/Program.cs
中写了要自动重定向到Https协议上,但我们又没有指定默认的Https监听端口,而框架又没有为Https指定默认的监听端口,所以就404了。。。。吗?
其实不是的,我们404有另外的原因,实际https重定向component,在未指定监听端口时只会warning一下,并不会把请求砍掉。404的真实原因其实并不在这里
Production
模式下,不寄宿现在我们换个命令:
LotteryForecast\Server> dotnet run --urls="http://localhost:5000;https://localhost:5001"
这下应该能跑了吧?这回把Https端口补上了哦!
事实是。。。不行
从日志上看,我们已经成功配置了https端口,并且对于http://localhost:5000
的请求也能成功重定向到https://localhost:5001
上去。
但浏览器上还是显示404,而日志窗口提示我们:Server/Program.cs
中指定的app.MapFallbackToFile("index.html")
找不到!没找到index.html
这个文件!
而背后的原因竟然是:在非Development
模式下,WASM项目不会寄宿到API项目的wwwroot
下去。。解决办法也简单:把运行时的Host Environment改为Development
即可
通过命令行可以如下整改
LotteryForecast\Server> dotnet run --urls="http://localhost:5000;https://localhost:5001" --environment="Development"
经过整改后,终于可以成功运行了!!
launchSettings.json
上面的命令终究还是有些长了,我们也不想本地开发的时候每次都写那么长的命令,有一个办法就是添加一个仅在本地开发环境生效的配置文件:launchSettings.json
按照惯例,这个文件应当位于Properties
目录中,下面是示例文件内容:
{
"profiles":{
"Development": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "https://localhost:7116;http://localhost:5278",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
这个文件其实有两个功效:
dotnet run
或dotnet watch
时,launchSettings.json
中profiles
章节的第一个profile会被加载profiles
章节中的每个profile其实就是一个启动配置上面的配置文件除了指定了http与https的监听端口以及环境变量ASPNETCORE_ENVIRONMENT
(间接的指定Hosting Environment)之外,还有一个有意思的配置是inspectUri
:它里面其实写的是如何让调试器与浏览器进行通信,从而达成我们上面提到过的:在IDE里打断点到前端代码中进行调试的功效。它的值不需要去理解,复制粘贴就可以了。
现在我们有了三个项目,是时候整一个解决方案把它们整合起来了,按以下的命令进行操作就行,命令本身的名称足够简单明了,不需要过多解释:
LotteryForecast> dotnet new sln
LotteryForecast> dotnet sln add .\Server\LotteryForecast.Server.csproj
LotteryForecast> dotnet sln add .\Client\LotteryForecast.Client.csproj
LotteryForecast> dotnet sln add .\Shared\LotteryForecast.Shared.csproj
LotteryForecast>
这样,我们整个解决方案的目录结构就如下所示:
LotteryForecast
|
`-- Client
| |
| |-- Pages
| | |
| | `--> Index.razor
| |
| |-- wwwroot
| | |
| | `--> index.html
| |
| `--> App.razor
| `--> Program.cs
|
`-- Server
| |-- Controllers
| | |
| | `--> LotteryForecastController.cs
| |
| |-- Properties
| | |
| | `--> launchSettings.json
| |
| `--> LotteryForecast.Server.csproj
| `--> Program.cs
|
`-- Shared
| |
| `--> LotteryForecast.Shared.csproj
| `--> LotteryTicket.cs
|
`--> LotteryForecast.sln
这基本就挺完美的了。。现在你可以直接使用VS打开这个LotteryForecast.sln
解决方案文件,使用IDE进行开发了。注意请使用较新的VS版本,过老的版本不支持 .NET 7.0 Sdk。推荐使用2022及以上版本。
用Visual Studio打开项目后,可以发现LotteryForecast.Server
就是默认的启动项目,并且我们在launchSettings.json
中写的名为Development
的profile也被正确加载了。现在,试一试在前端项目中打一个断点,然后按F5开始调试吧!
发布与部署依然是以API项目为核心,按如下命令即可生成可直接部署在Linux和Windows上的二进制(目标机器不需要安装 .NET Runtime)
LotteryForecast\Server> dotnet publish --configuration Release --runtime linux-x64 --self-contained --output ../Dist/Linux_X64
LotteryForecast\Server> dotnet publish --configuration Release --runtime win-x64 --self-contained --output ../Dist/Win_X64
在实际部署后,只需要直接启动可执行文件即可。。前后端被打包在一起了。不过不要忘记指定https的端口号哦!
这样对于中小型项目,无需Nginx把简单问题复杂化,也无需分离部署前后端,就这样一把梭就可以直接上线。
其实本篇文章所搭建的这样一个项目, .NET 官方是有模板的,不需要我们每次开新坑都那么辛苦的搞。就是在新建blazorwasm
项目的时候,添加--hosted
参数,以说明我们要新建的是一个前后端一把梭、前端托管在后端项目中的这样的一个项目。
有兴趣的话可以看一看这个官方模板的生成内容和我们新建的这个彩票项目有什么不同。。如果你有兴趣去看的话,你会发现我们这个项目其实更简略,很多内容都规避掉了,比如前端的Layout组件啊什么的。
我这样做主要还是想把本文的重心放在展示全栈开发这个侧重点上来,而不过多的过早引入太多的Blazor细节知识。
dotnet new blazorwasm --hosted -o HelloHostedWASM