欢迎大家阅读 ASP.NET Core Razor Pages 打造查看真实 IP、IPv6 系列文章,本篇是系列的第二篇,详细介绍代码的编写。如果错过了《使用 Razor Pages 打造查看真实 IP、IPv6 检测网站 —— 思路篇》,可以点击书名号内的链接查看。
本篇文章对应的代码可以在 https://github.com/huhubun/BunIp/tree/simple_ver 查看到。
开始之前
在上一篇我们已经创建好了一个空的 Razor 页面项目。
但开始编码之前,我们修改一下 Visual Studio 的配置。在 Visual Studio 的调试按钮附近找到 IIS Express 的字样,点击旁边的下拉菜单,修改为和项目一致的选项,表示使用 ASP.NET Core 的 Kestrel (https://docs.microsoft.com/aspnet/core/fundamentals/servers/kestrel?view=aspnetcore-5.0)服务器进行调试。
当你运行项目时,弹出一个控制台窗口而非启动 IIS Express 时,说明配置成功。
获取真实 IP
在 Pages
文件夹下找到 Index.cshtml
,它就是我们的首页。Index.cshtml
是 Razor 页面,它还有一个以 .cs
后缀结尾的 IndexModel
类。在 Visual Studio 的“解决方案资源管理器”中,展开 Index.cshtml
文件前面的小三角,或者在 Index.cshtml
文件内容的空白处点击右键,选择 转到 PageModel
,都可以进入页面对应的 Page Model 类中。在 Razor 页面里可以使用 @Model.XXX
这样的方式访问到 Page Model 里的属性。
为了能让页面显示请求者的 IP 地址,按照我们的设计,只需要读取 X-Real-IP
头即可。不过为了兼顾通过 nginx 访问(上线后)和直接访问站点(开发调试时),我们需要对 X-Real-IP
的值进行判断。因为开发中访问的时候是没有这个头的,就需要显示 Kestrel 获取到的请求者的地址。我们添加一个帮助类完成这个操作,新建 Helpers
文件夹,并在其中新建 IpHelper.cs
文件:
using Microsoft.AspNetCore.Http;
using System;
using System.Net;
namespace IpTest.Helpers
{
public static class IpHelper
{
public static IPAddress GetRealIp(HttpContext httpContext)
{
// 如果存在 X-Real-IP header,并且值合法,就是用 X-Real-IP 的值
var headers = httpContext.Request.Headers;
var realIpHeader = headers["X-Real-IP"];
if (!String.IsNullOrEmpty(realIpHeader) && IPAddress.TryParse(realIpHeader, out var ipAddress))
{
return ipAddress;
}
return httpContext.Connection.RemoteIpAddress;
}
}
}
然后在 IndexModel
里增加一个只读属性 DisplayIp
,它的值来自于上面的帮助方法获取的 IP 地址:
public IPAddress DisplayIp => IpHelper.GetRealIp(HttpContext);
接着修改 Index.cshtml
,把原有的内容替换成这样:
@page
@model IndexModel
@{
ViewData["Title"] = "Home page";
}
class="text-center"><h1 class="display-4">@Model.DisplayIph1>div>
可以看到我们使用了 @Model.DisplayIp 获取 Model 的内容。按下 Ctrl + F5 运行项目,因为是通过 localhost 访问的,所以我们看见的地址是 IPv6 的 ::1(也有可能是 127.0.0.1,取决于操作系统是优先使用 IPv6 还是 IPv4):
向 appsettings.json 增加配置
根据设计,需要准备三个地址,我们把这三个地址添加到配置中,因为要想发起 ajax 请求需要知道请求的地址是什么。
打开 appsettings.Development.json 文件(如果没有可以手动创建一个),在原有内容同一层级增加 IpTest
{
// 原有的内容省略
// ...
"IpTest": {
// 部署站点信息,通过请求头中的 Host 进行识别
"DeploySite": {
// 混合部署,同时支持 IPv4 和 IPv6 访问
"Hybrid": {
// 域名(不需要填写 HTTP 协议,例如 ip.bun.plus)
"Domain": "localhost",
// 协议(http、https)
"Scheme": "http",
// 端口号
"Port": "5000"
},
// IPv4 Only
"IPv4": {
"Domain": "127.0.0.1",
"Scheme": "http",
"Port": "14444"
},
// IPv6 Only
"IPv6": {
"Domain": "[::1]",
"Scheme": "http",
"Port": "16666"
}
}
}
{
// 原有的内容省略
// ...
"IpTest": {
// 部署站点信息,通过请求头中的 Host 进行识别
"DeploySite": {
// 混合部署,同时支持 IPv4 和 IPv6 访问
"Hybrid": {
// 域名(不需要填写 HTTP 协议,例如 ip.bun.plus)
"Domain": "localhost",
// 协议(http、https)
"Scheme": "http",
// 端口号
"Port": "5000"
},
// IPv4 Only
"IPv4": {
"Domain": "127.0.0.1",
"Scheme": "http",
"Port": "14444"
},
// IPv6 Only
"IPv6": {
"Domain": "[::1]",
"Scheme": "http",
"Port": "16666"
}
}
}
可以看到,我们有了一个名为 DeploySite
接着为这个配置创建一个类,以便在程序中直接访问到它们的值。在项目根目录下创建文件夹 /Configs/DeploySites,并在其中创建 SiteInfo.cs:
namespace IpTest.Configs.DeploySites
{
public class SiteInfo
{
///
/// 域名
///
public string Domain { get; set; }
///
/// 访问协议(http、https)
///
public string Scheme { get; set; }
///
/// 端口号
///
public int? Port { get; set; }
public Uri Uri
{
get
{
if (Port.HasValue)
{
return (new UriBuilder(Scheme, Domain, Port.Value)).Uri;
}
return (new UriBuilder(Scheme, Domain)).Uri;
}
}
}
}
namespace IpTest.Configs.DeploySites
{
public class SiteInfo
{
///
/// 域名
///
public string Domain { get; set; }
///
/// 访问协议(http、https)
///
public string Scheme { get; set; }
///
/// 端口号
///
public int? Port { get; set; }
public Uri Uri
{
get
{
if (Port.HasValue)
{
return (new UriBuilder(Scheme, Domain, Port.Value)).Uri;
}
return (new UriBuilder(Scheme, Domain)).Uri;
}
}
}
}
最后的 Uri
然后到 Configs 文件夹下创建 DeploySite.cs:
using IpTest.Configs.DeploySites;
namespace IpTest.Configs
{
public class DeploySite
{
///
/// 混合部署,同时支持 IPv4 和 IPv6 访问
///
public SiteInfo Hybrid { get; set; }
///
/// IPv4 Only
///
public SiteInfo IPv4 { get; set; }
///
/// IPv6 Only
///
public SiteInfo IPv6 { get; set; }
}
}
using IpTest.Configs.DeploySites;
namespace IpTest.Configs
{
public class DeploySite
{
///
/// 混合部署,同时支持 IPv4 和 IPv6 访问
///
public SiteInfo Hybrid { get; set; }
///
/// IPv4 Only
///
public SiteInfo IPv4 { get; set; }
///
/// IPv6 Only
///
public SiteInfo IPv6 { get; set; }
}
}
为了方便的访问和标识站点的部署模式,再在 Configs 文件夹下创建一个枚举 DeployMode:
namespace IpTest.Configs
{
public enum DeployMode
{
Hybrid,
IPv4,
IPv6
}
}
namespace IpTest.Configs
{
public enum DeployMode
{
Hybrid,
IPv4,
IPv6
}
}
最后来到 Startup.cs,把配置的读取加入到 ConfigureServices()
public void ConfigureServices(IServiceCollection services)
{
services.AddRazorPages();
services.Configure(Configuration.GetSection("IpTest:DeploySite"));
}
public void ConfigureServices(IServiceCollection services)
{
services.AddRazorPages();
services.Configure(Configuration.GetSection("IpTest:DeploySite"));
}
注:IpTest:DeploySite 表示 IpTest 节点下的 DeploySite
显示当前站点的部署模式
接着在 Index.cshtml.cs
// using 部分略
public class IndexModel : PageModel
{
// 增加 IOptions 的注入
private readonly IOptions _deploySites;public IndexModel(IOptions deploySites)
{
_deploySites = deploySites;
}// 中间部分没有修改,略// 当前站点的部署模式,根据 host 头自动判断public DeployMode CurrentDeployMode
{get
{var host = Request.Headers["Host"];// 获取枚举的名称并用于循环foreach (var name in Enum.GetNames())
{// 使用枚举的名称,结合反射,获取配置的值// (如果这里不这样做,就需要写三个 if 分别判断 Hybrid、IPv4 和 IPv6 的配置)var siteInfo = typeof(DeploySite).GetProperty(name).GetValue(_deploySites.Value) as SiteInfo;// 拼接配置中的 domain 和 port,并和请求头中的 host 进行比较// 如果使用的是 80 和 443 端口,host 是没有端口号的,所以需要排除一下var domainAndPort = siteInfo.Domain;if (siteInfo.Port.HasValue && siteInfo.Port is not (80 or 443))
{
domainAndPort += $":{siteInfo.Port}";
}if (host == domainAndPort)
{// 如果 host 头与配置匹配,表示找到了当前站点的部署模式,返回相关枚举return Enum.Parse(name);
}
}throw new Exception($"Host 的值 {host} 与 DeploySite 的配置没有匹配项");
}
}
}
// using 部分略
public class IndexModel : PageModel
{
// 增加 IOptions 的注入
private readonly IOptions _deploySites;public IndexModel(IOptions deploySites)
{
_deploySites = deploySites;
}// 中间部分没有修改,略// 当前站点的部署模式,根据 host 头自动判断public DeployMode CurrentDeployMode
{get
{var host = Request.Headers["Host"];// 获取枚举的名称并用于循环foreach (var name in Enum.GetNames())
{// 使用枚举的名称,结合反射,获取配置的值// (如果这里不这样做,就需要写三个 if 分别判断 Hybrid、IPv4 和 IPv6 的配置)var siteInfo = typeof(DeploySite).GetProperty(name).GetValue(_deploySites.Value) as SiteInfo;// 拼接配置中的 domain 和 port,并和请求头中的 host 进行比较// 如果使用的是 80 和 443 端口,host 是没有端口号的,所以需要排除一下var domainAndPort = siteInfo.Domain;if (siteInfo.Port.HasValue && siteInfo.Port is not (80 or 443))
{
domainAndPort += $":{siteInfo.Port}";
}if (host == domainAndPort)
{// 如果 host 头与配置匹配,表示找到了当前站点的部署模式,返回相关枚举return Enum.Parse(name);
}
}throw new Exception($"Host 的值 {host} 与 DeploySite 的配置没有匹配项");
}
}
}
我们看看效果,看效果之前还要做两件事,首先在 Index.cshtml 中输出一下 CurrentDeployMode
@page
@model IndexModel
@{
ViewData["Title"] = "Home page";
}
@page
@model IndexModel
@{
ViewData["Title"] = "Home page";
}
class="text-center">
class="display-4">@Model.DisplayIp</h1>
当前请求的是 @Model.CurrentDeployMode 的站点
接着找到 /Properties/launchSettings.json 文件的 IpTest 节点中的 applicationUrl
"applicationUrl": "http://localhost:5000;http://127.0.0.1:14444;http://[::1]:16666",
"applicationUrl": "http://localhost:5000;http://127.0.0.1:14444;http://[::1]:16666",
注意,这三个地址的需要和 appsettings.Development.json 文件里配置的三个地址对应。这样才能在本地调试的时候一次绑定多个地址和端口,方便我们测试。修改好后 Ctrl + F5
哇,工作的非常好~注意观察服务器启动时绑定的端口以及浏览器的地址栏。
实现只返回 JSON 的 API
根据我们的设计,还需要有一个接收 ajax 请求并返回请求者 IP 地址的接口。但现在有个棘手的问题,Razor Page 不是 Page 吗,怎么返回一段 json 便于 ajax 之后处理呢?
这都不是事,我们在 Pages 文件夹下新建一个名为 IP.cshtml(IP两个字母必须大写)的“空 Razor 页面”。直接找到它的 IP.cshtml.cs 文件中的 OnGet() 方法。如果你有做过 MVC 或者 Web API 的开发,那么熟悉的东西来了,把 OnGet() 方法的 void 改为 JsonResult
using BunIp.Web.Helpers;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace BunIp.Web.Pages
{
public class IPModel : PageModel
{
public JsonResult OnGet()
{
var ipAddress = IpHelper.GetRealIp(HttpContext);
return new JsonResult(new { Ip = ipAddress.ToString() });
}
}
}
using BunIp.Web.Helpers;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace BunIp.Web.Pages
{
public class IPModel : PageModel
{
public JsonResult OnGet()
{
var ipAddress = IpHelper.GetRealIp(HttpContext);
return new JsonResult(new { Ip = ipAddress.ToString() });
}
}
}
启动项目,在地址后加上 /ip。铛铛,熟悉的配方熟悉的结果,接口搞定!
根据 ajax 请求的结果判断 IP 可用性
现在要准备发起 ajax 了!
通过之前增加的 CurrentDeployMode 属性,可以知道当前请求的站点是以哪种模式部署的,再结合请求者的 IP 地址(一开始增加的 DisplayIp
如果当前是混合模式,并且 DisplayIp
如果当前是混合模式,并且 DisplayIp
如果当前是 IPv4 模式,说明请求者访问了只能通过 IPv4 访问的站点,不需要发 ajax,把当前请求的地址展示出来即可
如果当前是 IPv6 模式,说明请求者访问了只能通过 IPv6 访问的站点,同上
更新 Model 和 Page
向 Index.cshtml.cs
public bool shouldTryIpv4 => CurrentDeployMode == DeployMode.Hybrid && DisplayIp.AddressFamily == AddressFamily.InterNetworkV6;
public bool shouldTryIpv6 => CurrentDeployMode == DeployMode.Hybrid && DisplayIp.AddressFamily == AddressFamily.InterNetwork;
public Uri ipv4Url => _deploySites.Value.IPv4.Uri;
public Uri ipv6Url => _deploySites.Value.IPv6.Uri;
public bool shouldTryIpv4 => CurrentDeployMode == DeployMode.Hybrid && DisplayIp.AddressFamily == AddressFamily.InterNetworkV6;
public bool shouldTryIpv6 => CurrentDeployMode == DeployMode.Hybrid && DisplayIp.AddressFamily == AddressFamily.InterNetwork;
public Uri ipv4Url => _deploySites.Value.IPv4.Uri;
public Uri ipv6Url => _deploySites.Value.IPv6.Uri;
接着修改 Index.cshtml,我们增加了两个 div 并增加了一段 js 代码。通过 jQuery ajax 向对应服务器发起请求,并将返回的 IP 地址呈现在新增加的 div 中:
@page
@model IndexModel
<div class="text-center">
<p class="lead mb-0">当前地址p>
<h1 class="display-4">@Model.DisplayIph1>
@if (Model.ShouldTryIpv4)
{
<div id="TryIpv4" class="d-none">
<p class="lead">
您也拥有 IPv4 地址 <span class="ip-address">span>
p>
div>
}
@if (Model.ShouldTryIpv6)
{
<div id="TryIpv6" class="d-none">
<p class="lead">
您也拥有 IPv6 地址 <span class="ip-address">span>
p>
div>
}
div>
@section Scripts
{
<script>
$(function () {var tryIpV4 = $('#TryIpv4')var tryIpV6 = $('#TryIpv6')if (tryIpV4.length > 0) {
$.get('@Model.Ipv4Url' + 'ip', showAnotherIp(tryIpV4))
}
if (tryIpV6.length > 0) {
$.get('@Model.Ipv6Url' + 'ip', showAnotherIp(tryIpV6))
}
function showAnotherIp(container) {return function (data) {
container.find('.ip-address').text(data.ip)
container.removeClass('d-none')
}
}
});script>
}
@page
@model IndexModel
<div class="text-center">
<p class="lead mb-0">当前地址p>
<h1 class="display-4">@Model.DisplayIph1>
@if (Model.ShouldTryIpv4)
{
<div id="TryIpv4" class="d-none">
<p class="lead">
您也拥有 IPv4 地址 <span class="ip-address">span>
p>
div>
}
@if (Model.ShouldTryIpv6)
{
<div id="TryIpv6" class="d-none">
<p class="lead">
您也拥有 IPv6 地址 <span class="ip-address">span>
p>
div>
}
div>
@section Scripts
{
<script>
$(function () {var tryIpV4 = $('#TryIpv4')var tryIpV6 = $('#TryIpv6')if (tryIpV4.length > 0) {
$.get('@Model.Ipv4Url' + 'ip', showAnotherIp(tryIpV4))
}
if (tryIpV6.length > 0) {
$.get('@Model.Ipv6Url' + 'ip', showAnotherIp(tryIpV6))
}
function showAnotherIp(container) {return function (data) {
container.find('.ip-address').text(data.ip)
container.removeClass('d-none')
}
}
});script>
}
添加跨域规则
因为 ajax 访问受到跨域的限制,我们还需要到 Startup.cs
// 前略
public class Startup
{
// 增加一个常量用作我们 CORS 规则的名称
private const string CORS_POLICY_NAME = "IP_TEST_CORS";
// 略
public void ConfigureServices(IServiceCollection services)
{
// 前略
// 新增 CORS 配置
services.AddCors(options =>
{
var deploySites = Configuration.GetSection("IpTest:DeploySite").Get();var origins = new Uri[]
{
deploySites.Hybrid.Uri,
deploySites.IPv4.Uri,
deploySites.IPv6.Uri
};
options.AddPolicy(CORS_POLICY_NAME, builder =>
builder.WithOrigins(origins.Select(o => o.ToString().TrimEnd('/')).ToArray())
.AllowAnyMethod()
.AllowAnyHeader()
);
});
}public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{// 前略// !!!注意,UseCors() 必须加在 UseEndpoints() 方法之前 !!!
app.UseCors(CORS_POLICY_NAME);
app.UseEndpoints(endpoints =>
{
endpoints.MapRazorPages();
});
}
// 前略
public class Startup
{
// 增加一个常量用作我们 CORS 规则的名称
private const string CORS_POLICY_NAME = "IP_TEST_CORS";
// 略
public void ConfigureServices(IServiceCollection services)
{
// 前略
// 新增 CORS 配置
services.AddCors(options =>
{
var deploySites = Configuration.GetSection("IpTest:DeploySite").Get();var origins = new Uri[]
{
deploySites.Hybrid.Uri,
deploySites.IPv4.Uri,
deploySites.IPv6.Uri
};
options.AddPolicy(CORS_POLICY_NAME, builder =>
builder.WithOrigins(origins.Select(o => o.ToString().TrimEnd('/')).ToArray())
.AllowAnyMethod()
.AllowAnyHeader()
);
});
}public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{// 前略// !!!注意,UseCors() 必须加在 UseEndpoints() 方法之前 !!!
app.UseCors(CORS_POLICY_NAME);
app.UseEndpoints(endpoints =>
{
endpoints.MapRazorPages();
});
}
在 ConfigureServices() 中,我们对配置的地址进行了一些处理,主要是因为 Origins (https://developer.mozilla.org/docs/Web/HTTP/Headers/Origin)的格式为 http://localhost:5000,而我们的 Uri 属性返回的 Uri 类型转换为 string 之后末尾有一个斜杠 http://localhost:5000/ 会导致浏览器校验 Origin 失败,所以我们需要去除 Uri 后的 /。
另外就是千万注意,UseCors() 方法必须加在 UseEndpoints()
修改完成后重新运行项目,可以看到当访问 localhost:5000
本地调试指南
首先发布我们的站点,在项目上点击右键 - 发布,目标选择“文件夹”即可。
在正式上云和 nginx 之前,我们肯定要在本地(或树莓派上)进行调试,有这么几个地方需要特别注意的:
可以直接通过 dotnet
通过添加 --urls 参数设置要绑定的 IP 地址和端口号,和在 launchSettings.json 里设置 applicationUrl 一样,例如:dotnet IpTest.dll --urls "http://10.0.0.20:15000;http://10.0.0.20:14444;http://[*]:16666"(注意:--urls 后面的地址要用 "
上面例子最后的 [*]
appsettings.json(或 appsettings.Development.json)中配置的三个站点信息是需要和跨域相对应的,不能写任何通配符在里面,必须写具体的 IP 地址
发布之后,程序使用的是 Production 模式,不会使用 appsettings.Development.json 里的配置,所以需要把 appsettings.Development.json 改名成 appsettings.Production.json 或者把相关配置放在 appsettings.json
我在我的树莓派上进行调试时,使用的是如下配置(这个配置对应上面的 --urls
{
// 前略
"IpTest": {
"DeploySite": {
"Hybrid": {
"Domain": "10.0.0.20",
"Scheme": "http",
"Port": "15000"
},
"IPv4": {
"Domain": "10.0.0.20",
"Scheme": "http",
"Port": "14444"
},
"IPv6": {
"Domain": "[fe80::30da:fd9b:c9e7:9ef9]",
"Scheme": "http",
"Port": "16666"
}
}
}
}
{
// 前略
"IpTest": {
"DeploySite": {
"Hybrid": {
"Domain": "10.0.0.20",
"Scheme": "http",
"Port": "15000"
},
"IPv4": {
"Domain": "10.0.0.20",
"Scheme": "http",
"Port": "14444"
},
"IPv6": {
"Domain": "[fe80::30da:fd9b:c9e7:9ef9]",
"Scheme": "http",
"Port": "16666"
}
}
}
}
树莓派内网 IP 是 10.0.0.20 和 fe80::30da:fd9b:c9e7:9ef9。
本地测试通过后,上云的话,将 Domain 全部换成对应域名即可,Scheme 和 Port
配置 nginx
部署的部分本文不详细展开,请参阅微软文档 使用 Nginx 在 Linux 上托管 ASP.NET Core (https://docs.microsoft.com/aspnet/core/host-and-deploy/linux-nginx?view=aspnetcore-5.0),这里主要说一下 nginx 的配置。
绑定 IPv6 的地址和端口
和在浏览器中访问 IPv6 地址一样,在 nginx 中配置端口号时 IPv6 地址也需要用 [] 包裹。注意 listen 部分 [::]:443
server {
listen [::]:443 http2;
server_name ipv6.bun.plus;
# 后略
server {
listen [::]:443 http2;
server_name ipv6.bun.plus;
# 后略
如果要给一个域名同时绑定 IPv4 和 IPv6,只需要写两次 listen
server {
listen [::]:443 http2;
listen 443 http2;
server_name ip.bun.plus;
server {
listen [::]:443 http2;
listen 443 http2;
server_name ip.bun.plus;
第一个 listen 用于绑定 IPv6 的 443 端口,第二个 listen
添加自定义 X-Real-IP 头
Nginx 必须知道他要把响应返回给谁,在 $remote_addr 中保存有请求者的真实地址,$
server {
listen [::]:443 http2;
server_name ipv6.bun.plus;
# 后略
location / {
proxy_pass http://localhost:5000;
proxy_http_version 1.1;
# 后略
proxy_set_header X-Real-IP $remote_addr;
}
}
server {
listen [::]:443 http2;
server_name ipv6.bun.plus;
# 后略
location / {
proxy_pass http://localhost:5000;
proxy_http_version 1.1;
# 后略
proxy_set_header X-Real-IP $remote_addr;
}
}
通过 proxy_set_header 我们可以设置自定义的 header X-Real-IP,并将它的值为 $remote_addr
当 nginx 将请求转发给 proxy_pass 的地址时,就会带上这个请求头,我们程序中就能通过 Request.Headers["X-Real-IP"]
到这里我们的网站就完成啦,部署好后可以发给朋友们试试,看看他们支不支持 IPv6 以及真实 IP 是多少了,enjoy~
下集预告
网站的核心功能已经开发完成,下面需要整点儿花活了~
因为我的云服务器没有 IPv6 地址(腾讯出来挨打),IPv6 的部分我将部署在树莓派上。为了彰显树莓派的独特气质,我将使用 C# 获取树莓派的型号并将其在网页上输出,以及借此机会介绍 ASP.NET Core 中我很喜欢的功能:视图组件 View Component。
敬请期待!