2024年8月

前言

前段时间我们完成了七天.NET 8 操作 SQLite 入门到实战的开发系列教程,有不少同学留言问如何将项目发布部署到IIS上面运行。本篇文章我们就一起来讲讲在IIS上部署ASP.NET Core Web API和Blazor Wasm。

前提条件

安装.NET Core SDK

IIS Web服务器安装配置

Internet Information Services (IIS) 是一种灵活、安全且可管理的 Web 服务器,用于托管 Web 应用(包括 ASP.NET Core)。

Windows10 IIS Web服务器安装配置详细教程:
https://mp.weixin.qq.com/s/oaqypmpHOTLA9_5sF6-W7Q

安装 .NET Core 托管捆绑包

安装 .NET Core 托管捆绑包(Hosting Bundle)在将 .NET Core 应用程序部署到 IIS 时是一个必要的步骤。托管捆绑包包含了多项关键组件,这些组件确保 .NET Core 应用程序可以在 IIS 上正确运行。

https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-aspnetcore-8.0.7-windows-hosting-bundle-installer

安装 URL 重写模块

重写 URL 必须使用 URL 重写模块。 此模块默认不安装,且不适用于安装为 Web 服务器 (IIS) 角色服务功能。 必须从 IIS 网站下载该模块。

如果没有安装 URL 重写模块,Blazor应用部署IIS会打不开页面:

URL 重写模块下载页:

https://www.iis.net/downloads/microsoft/url-rewrite#additionalDownloads

重新打开IIS,即可看到,安装成功:

七天.NET 8 操作 SQLite 入门到实战详细教程

EasySQLite 项目源码地址

ASP.NET Core Web API发布部署

使用VS2022发布
WebApi项目

WebApi项目部署IIS

IIS部署好会后访问地址提示找不到 localhost 的网页:

因为发布后运行的环境属于
Production
环境。

  • http://localhost:8899/swagger/index.html

在发布成功的项目路径中找到
web.config
文件,添加如下配置:

在生产环境中展示 Swagger 通常是不推荐的,因为它可能会暴露你的 API 文档,增加安全风险。

你也可以直接修改这个Development环境下的过滤:

<aspNetCore processPath=".\BrowserBookmarks.exe" stdoutLogEnabled="false" stdoutLogFile=".\logs\stdout" hostingModel="inprocess">
  <environmentVariables>
  <environmentVariable name="ASPNETCORE_ENVIRONMENT" value="Development" />
 </environmentVariables>
</aspNetCore>

重新启用IIS项目,访问成功:

Blazor Wasm发布部署

使用VS2022发布
WebUI项目

WebUI项目部署IIS

参考文章

对于新手来说,接口与自动化测试练习网站是提升技能的重要资源。以下是一些适合新手进行接口与自动化测试练习的推荐网站:

1、httpbin.org

httpbin.org
是一个在线提供HTTP服务的网站,它提供了丰富的HTTP接口,用于测试HTTP请求和响应的各种信息,如cookie、IP、headers和登录验证等,对web开发和测试非常有帮助。

网站地址:

https://www.httpbin.org/

特别说明:

  • 接口丰富
    :httpbin.org提供了不同请求方法、返回不同状态码、使用不同验证方法、返回请求的IP和UA、返回随机字符串数据、具备特定延迟时间、可以操作Cookie的HTTP接口。
  • 支持图形化界面
    :它给出了图形化的界面,可以在网页中进行接口的调用,并定制化输入和输出,大大提高了开发效率。
  • 使用场景
    :通过httpbin.org返回的请求头信息,可以了解HTTP请求的具体内容,包括请求方法、请求头、请求体等。在开发和测试阶段,可以利用httpbin.org提供的接口进行接口测试,验证接口的功能和性能。

2、GitHub API

GitHub提供了其REST API的详细文档,可以在这里学习如何使用GitHub的API,并进行自动化测试。GitHub API的广泛使用使得它成为学习接口测试的一个好选择。

网站地址:

https://docs.github.com/en/rest

3、JSONPlaceholder

JSONPlaceholder是一个提供免费的在线RESTful API服务的网站,提供了用于测试的各种端点。专为开发者设计,用于实验、原型设计和测试网络请求。

网站地址:

https://jsonplaceholder.typicode.com/

特点:
无需注册,零配置即可开始使用,提供了一个简单、快速的测试环境。其数据遵循RESTful API规范,适用于前端开发和测试,特别适合新手进行接口测试练习。

4、 Swagger Petstore

Swagger Petstore是一个示例服务器项目,主要用于展示Swagger(现已更名为OpenAPI)的功能和用法。

网站地址:

https://petstore.swagger.io/

使用场景
:适用于开发者学习Swagger/OpenAPI、测试API文档生成工具、以及作为实际项目中API设计的参考。

5、Reqres

Reqres 是一个在线的RESTful API测试服务,它为开发者提供了一个模拟的API环境,用于测试和开发RESTful风格的网络应用。

网站地址:

https://reqres.in/

特点:Reqres 提供了一个免费的 API 用来练习接口测试,它模拟了真实的 RESTful 服务,你可以通过它来发送请求和接收响应。

6、Mocky

Mocky也是一个在线工具网站,主要用于模拟网络请求返回的数据信息,对于开发者来说是一个非常实用的工具。Mocky 允许你创建自定义的模拟 API 来练习接口测试。你可以定义自己的响应数据,然后通过 API 来测试。

网站地址

https://designer.mocky.io/

特点:
Mocky可以帮助开发者模拟各种网络请求返回的数据,包括JSON、XML等格式。在开发过程中,当后端接口尚未开发完成时,开发者可以利用Mocky来模拟返回数据,从而进行前端开发和调试。

7、Webdriveruniversity.com

Webdriveruniversity.com 是一个专注于自动化测试学习的网站,它提供了丰富的课程和资源,帮助用户提升自动化测试技能。

网站地址:

http://Webdriveruniversity.com

特点:
这是一个练习WebDriver UI自动化测试的网站,专注于自动化测试领域,特别是使用Selenium WebDriver等工具的测试技能提升。涵盖了Selenium WebDriver、Cypress、Java等多个工具和编程语言。

好了,今天先介绍到这,如果有补充,欢迎在下方留言。

一:背景

1. 讲故事

前些天有位朋友找到我,说他们的程序崩溃了,也自己分析了下初步结果,让我帮忙再确认下,既然让我确认,那就开始dump分析之旅吧。

二:WinDbg 分析

1. 为什么会崩溃

windbg 有一个强大之处就是带有一个自动化的分析命令
!analyze -v
可以帮助我们快速的分析,输出如下:


0:000> !analyze -v
*******************************************************************************
*                                                                             *
*                        Exception Analysis                                   *
*                                                                             *
*******************************************************************************

CONTEXT:  (.ecxr)
rax=00007ff95c5a9877 rbx=00007ff959d6d8e0 rcx=0000000000000000
rdx=0000000000000000 rsi=000000e394b98de0 rdi=000000e394b99530
rip=00007ff959c7b699 rsp=000000e394b99510 rbp=000000e394b99d00
 r8=0000000000000000  r9=0000000000000007 r10=0000000000000000
r11=0000000000000000 r12=0000022da11451d0 r13=0000000000000000
r14=000000e394b9a9e0 r15=0000000000040ae4
iopl=0         nv up ei pl nz na pe nc
cs=0033  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000200
KERNELBASE!RaiseException+0x69:
00007ff9`59c7b699 0f1f440000      nop     dword ptr [rax+rax]
Resetting default scope

EXCEPTION_RECORD:  (.exr -1)
ExceptionAddress: 00007ff959c7b699 (KERNELBASE!RaiseException+0x0000000000000069)
   ExceptionCode: c000041d
  ExceptionFlags: 00000001
NumberParameters: 0

PROCESS_NAME:  xxx.Desktop.dll

ERROR_CODE: (NTSTATUS) 0xc000041d - <Unable to get error code text>

EXCEPTION_CODE_STR:  c000041d
...

从卦中可以看到当前的崩溃码是
c000041d
,即
An unhandled exception was encountered during a user callback
,这个异常码是个统称异常,言外之意就是内部还藏有真实的异常码,那真实的异常码是多少呢?

2. 真实的异常码在哪里

要想知道这个答案,可以切到异常上下文找到 RaiseException 的父函数在图观察,输出如下:


0:000> k 5
 # Child-SP          RetAddr               Call Site
00 000000e3`94b99510 00007ff8`eb52cb19     KERNELBASE!RaiseException+0x69
01 000000e3`94b995f0 00007ff8`eb52cb4b     coreclr!NakedThrowHelper2+0x9
02 000000e3`94b99620 00007ff8`eb52cb55     coreclr!NakedThrowHelper_RspAligned+0x1e
03 000000e3`94b99b48 00007ff8`8da3caa3     coreclr!NakedThrowHelper_FixRsp+0x5
04 000000e3`94b99b50 00007ff8`8d5a5e23     Avalonia_Base!Avalonia.Rendering.Composition.Compositor.RequestCompositionUpdate+0x83

0:000> ub 00007ff8`eb52cb19
...
00007ff8`eb52cb14 e857910b00      call    coreclr!LinkFrameAndThrow (00007ff8`eb5e5c70)

0:000> uf coreclr!LinkFrameAndThrow
Flow analysis was incomplete, some code may be missing
coreclr!LinkFrameAndThrow [D:\a\_work\1\s\src\coreclr\vm\excep.cpp @ 6934]:
 6934 00007ff8`eb5e5c70 4053            push    rbx
 6934 00007ff8`eb5e5c72 4883ec20        sub     rsp,20h
 6937 00007ff8`eb5e5c76 488d05bb771f00  lea     rax,[coreclr!FaultingExceptionFrame::`vftable' (00007ff8`eb7dd438)]
 ...
 6949 00007ff8`eb5e5cea 448b05c7682800  mov     r8d,dword ptr [coreclr!g_SavedExceptionInfo+0x18 (00007ff8`eb86c5b8)]
 6949 00007ff8`eb5e5cf1 8b15ad682800    mov     edx,dword ptr [coreclr!g_SavedExceptionInfo+0x4 (00007ff8`eb86c5a4)]
 6949 00007ff8`eb5e5cf7 8b0da3682800    mov     ecx,dword ptr [coreclr!g_SavedExceptionInfo (00007ff8`eb86c5a0)]
 6950 00007ff8`eb5e5cfd 4883c420        add     rsp,20h
 6950 00007ff8`eb5e5d01 5b              pop     rbx
 6949 00007ff8`eb5e5d02 48ff2537581b00  jmp     qword ptr [coreclr!_imp_RaiseException (00007ff8`eb79b540)]  Branch
 ...

从卦中可以看到 RaiseException 的参数来自于异常信息全局变量
g_SavedExceptionInfo
,这个变量中存放着当前崩溃的真实上下文以及寄存器信息,在 CLR 中的数据结构如下:


struct SavedExceptionInfo
{
    EXCEPTION_RECORD m_ExceptionRecord;
    CONTEXT m_ExceptionContext;
    CrstStatic m_Crst;
}

有了这些之后接下来就可以用 dt 来挖了,输出如下:


0:000> dt coreclr!g_SavedExceptionInfo 00007ff8eb86c5a0
   +0x000 m_ExceptionRecord : _EXCEPTION_RECORD
   +0x0a0 m_ExceptionContext : _CONTEXT
   +0x570 m_Crst           : CrstStatic

0:000> dx -r1 (*((coreclr!_EXCEPTION_RECORD *)0x7ff8eb86c5a0))
(*((coreclr!_EXCEPTION_RECORD *)0x7ff8eb86c5a0))                 [Type: _EXCEPTION_RECORD]
    [+0x000] ExceptionCode    : 0xc0000005 [Type: unsigned long]
    [+0x004] ExceptionFlags   : 0x0 [Type: unsigned long]
    [+0x008] ExceptionRecord  : 0x0 [Type: _EXCEPTION_RECORD *]
    [+0x010] ExceptionAddress : 0x7ff88da3caa3 [Type: void *]
    [+0x018] NumberParameters : 0x2 [Type: unsigned long]
    [+0x020] ExceptionInformation [Type: unsigned __int64 [15]]

从卦中信息来看当前崩溃的真正原因是
0xc0000005
,即
访问违例
,同时还记录了崩溃的那个点
RIP=0x7ff88da3caa3

3. 什么逻辑导致的崩溃

这个比较简单,用
!U

uf
都可以试下,输出如下:


0:000> !U 0x7ff88da3caa3
Normal JIT generated code
Avalonia.Rendering.Composition.Compositor.RequestCompositionUpdate(System.Action)
ilAddr is 0000022DC65AE2D4 pImport is 00000238EE6FECA0
Begin 00007FF88DA3CA20, size 96
...
00007ff8`8da3ca9b 488bce          mov     rcx,rsi
00007ff8`8da3ca9e e8cdeaa5fe      call    00007ff8`8c49b570 (Avalonia.Rendering.Composition.Compositor.RequestCompositionBatchCommitAsync(), mdToken: 00000000060009D9)
>>> 00007ff8`8da3caa3 488b4008        mov     rax,qword ptr [rax+8]
00007ff8`8da3caa7 8b4008          mov     eax,dword ptr [rax+8]
...

0:000> dt coreclr!g_SavedExceptionInfo 00007ff8eb86c5a0
   +0x000 m_ExceptionRecord : _EXCEPTION_RECORD
   +0x0a0 m_ExceptionContext : _CONTEXT
   +0x570 m_Crst           : CrstStatic

0:000> dx -r1 (*((coreclr!_CONTEXT *)0x7ff8eb86c640))
...
    [+0x078] Rax              : 0x0 [Type: unsigned __int64]
...

从卦中的汇编代码看,崩溃的原因是
Avalonia
框架的
RequestCompositionBatchCommitAsync
返回 null 导致的,即 rax=0,这个 Avalonia 不就是那个跨平台的WPF吗,有点意思了,接下来到源码中确认下到底是什么变量。

从代码逻辑上看 _nextCommit 是一个类变量而不是方法局部变量,在并发较高的情况下如果有其他方法将
_nextCommit=null
的话确实存在这种情况,为了验证想法在类中搜索,真的有方法会设置 null,截图如下:

到这里基本就搞清楚了,这是 Avalonia 的一个bug,最后我们看下 Avalonia 的版本,发现这个版本是非常新的,输出如下:


0:000> lmvm Avalonia_Base
    ...
    Timestamp:        A0BE2821 (This is a reproducible build file hash, not a timestamp)
    CheckSum:         001CDA05
    ImageSize:        001D4000
    File version:     11.1.0.0
    Product version:  11.1.0.0
    File flags:       0 (Mask 3F)
    File OS:          4 Unknown Win32
    File type:        2.0 Dll
    File date:        00000000.00000000
    Translations:     0000.04b0
    Information from resource tables:
        CompanyName:      Avalonia Team
        ProductName:      Avalonia
        InternalName:     Avalonia.Base.dll
        OriginalFilename: Avalonia.Base.dll
        ProductVersion:   11.1.0+2a8ea17985fd739234fa0d93c3437948535d35c4
        FileVersion:      11.1.0.0
        FileDescription:  Avalonia.Base
        LegalCopyright:   Copyright 2013-2024 © The AvaloniaUI Project

4. 如何解决呢

知道了这是 Avalonia 的bug,并且 Avalonia 也是非常新的版本,升级这条路就堵死了,只能提交个issue 给官方:
https://github.com/AvaloniaUI/Avalonia
来解决吧。

三:总结

这次生产事故挖了点新东西,有点好奇的是现在工控行业也开始用 Avalonia 替代 WPF 了吗? 不过现阶段稳定性和 WPF 是没法比的,期待未来更健壮的版本吧。

图片名称

软件构成

  • 后端
    后端是一个
    asp.netcore webapi
    项目,使用jwt进行身份验证和鉴权。
  • 前端
    前端是一个基于http协议的
    asp.netcore RezorPage
    项目,但实际上完全使用的
    wwwwroot
    目录下的静态文件。没有使用
    RazorPage

目前只有后端接口鉴权,前端页面任意访问

身份验证需求

这两天安全要求下来了,要求给前端页面加上身份验证。只开放
login
登录页面
redirect
第三方系统跳转此系统页面。初始办法是后端登录生成token后,放到
redis
中。

  • login.html
    访问前端
    login
    登录页面,点击登录后,后端接口返回token,
    login
    页面将token放在
    本地cookie
    中。之后打开新页面时都会带上这个cookie。前端中间件中取出cookie中的token,和redis中的token比较。如果一致,则放行到下一个中间件。如果不一致,则重定向到
    login
    录页面,并短路请求处理管道。

image

  • redirect.html
    对于
    redirect
    第三方系统跳转此系统页面,则是在url中传入了一个token,前端项目验证token有效性之后放入到redis中。同时
    redirect
    页面也把这个token放入本地cookie。之后新标签页都会带上这个cookie。在前端项目的中间件中进行验证。

image

问题

对于在新标签页打开页面的形式,这种身份验证方式运行良好,没有任何问题。

但是使用cookie进行身份验证有一个问题。就是
iframe
框架限制了跨域的cookie提交。当
redirect
被嵌入其它系统时,首先,这个本地cookie被禁止写入。其次,cookie不会被放到请求中,因此一直往登录页面跳转。

我之后试了下
会话cookie
,也就是
session
。但是被查看响应信息,提示
会话cookie
同样被浏览器禁止写入。这下子就麻烦了,好多其它系统页面都使用了
iframe
嵌入这个项目的页面。如果加上前端身份验证的话,那些地方的
iframe
都会跳转到登录页面。

是否只有https才能在iframe中携带cookie,解决这个跨域问题?

最终方案

经过
@the boy、图样图森破
的提醒。我最终决定两种方案都使用。但是通过url传递,而不是header,没法给html请求设置header啊。

我这里存在一种情况,不同域名的第三方系统,
iframe
打开的页面还会有弹窗子页面,这个弹窗也是一个iframe。这种情况使用第一种方案,同时还要改造原来的iframe中的页面,把token添加到其弹窗的子页面url中。这种页面不知道有多少,多了就寄了。

我这里还有域名不同,顶级域名不同的系统,第二种方案就省心多了。

vue前端自适应布局,一步到位所有自适应

页面展示


实现内容

1,左右布局

  • 左侧固定宽带,右侧自适应剩余的宽度。
  • 中间一条分割线,可以拖拉,自适应调整左右侧的宽度。
  • 左侧的高度超长自动出现横向滚动条,左侧宽度超长,自动出现竖向滚动条。

2,上中下布局

  • 最上面的 搜索条件 div 固定占用 100 px 高度,下面的 查询条件 div 固定占用 30 px 高度,最下面的分页固定占用高度,页面剩下的高度自动分配给中间的表格内容。
  • 表格内容高度超过后自动出现竖向滚动条,宽度超出后自动出现横向滚动条。
  • 点击按钮,可以 隐藏/显示 搜索条件 div 里面的内容。
  • 当隐藏 搜索条件 div 里面的内容时,中间表格的高度为:整个页面的高度—操作按钮div的高度—分页div的高度。
  • 当搜索条件 div 里面的内容时,中间表格的高度为:整个页面的高度—搜索条件div的高度—操作按钮div的高度—分页div的高度。

3,分辨率自适应

  • 加载即动态实时计算高度,宽度

实现代码

<template>
  <div class="app-container">
    <div class="left" :style="{ width: leftWidth + 'px' }">
      <div class="right-center-left">
        左边的内容,可以很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长<br />
        1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />
        1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />
        1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />
        1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />
        1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />
        1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />
      </div>
    </div>
    <div class="divider" @mousedown="startDragging"></div>
    <div class="right">
      <div v-if="showDiv1" class="div1">查询条件</div>
      <div class="div2">
        <button @click="toggleDiv1">操作按钮 div1</button>
      </div>
      <div class="div3" :style="{ height: div3Height + 'px' }">
        1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />
        1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />
        1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />
        1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />
        1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />
        1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />1<br />
      </div>
      <div class="div4">分页</div>
    </div>
  </div>
</template>

<script>
export default {
  name: "AppContainer",
  data() {
    return {
      isDragging: false,
      leftWidth: 200,
      showDiv1: true
    };
  },
  computed: {
    div3Height() {
      const totalHeight = window.innerHeight;
      const div2Height = 30;
      const div4Height = 30;
      const div1Height = this.showDiv1 ? 100 : 0;

      // 计算 div3 的高度
      return totalHeight - div2Height - div4Height - div1Height;
    }
  },
  methods: {
    startDragging(e) {
      this.isDragging = true;
      document.addEventListener("mousemove", this.onDrag);
      document.addEventListener("mouseup", this.stopDragging);
    },
    onDrag(e) {
      if (this.isDragging) {
        const minWidth = 50;
        const maxWidth = window.innerWidth - 50;
        const newLeftWidth = e.clientX;

        if (newLeftWidth > minWidth && newLeftWidth < maxWidth) {
          this.leftWidth = newLeftWidth;
        }
      }
    },
    stopDragging() {
      this.isDragging = false;
      document.removeEventListener("mousemove", this.onDrag);
      document.removeEventListener("mouseup", this.stopDragging);
    },
    toggleDiv1() {
      this.showDiv1 = !this.showDiv1;
    }
  }
};
</script>

<style scoped>
.app-container {
  display: flex;
  height: 100vh;
  overflow: hidden;
}

.left {
  overflow-x: auto;
  overflow-y: auto;
  white-space: nowrap;
  min-width: 90px;
}

.divider {
  width: 5px;
  cursor: ew-resize;
  background-color: #ccc;
}

.right {
  display: flex;
  flex-direction: column;
  height: 100%;
  flex: 1; /* 自动填满剩余宽度 */
}

.div1 {
  height: 100px;
  background-color: #f0f0f0;
}

.div2 {
  height: 30px;
  background-color: #ddd;
}

.div3 {
  overflow-x: auto; /* 添加横向滚动条 */
  overflow-y: auto; /* 添加纵向滚动条 */
  background-color: #f5f5f5;
}

.div4 {
  height: 200px;
  background-color: #ccc;
}
</style>

实现感想

这个功能,从毕业就开始思索,直到八年后的今天成熟完善,真是艰辛也是很不容易。目前市面上没有见过有人实现,很多人都是只言片语的,基本复制下来,无法达到效果。我这个一键复制到自己的项目,就能实现了,中间的坎坷不平,到了完全实现的这一刻,才觉得激动不已。

无任何坑,也没有任何额外的引入,一个普普通通,最简单的vue页面,布局建好,里面的内容就可以自己随意发挥了。

未觉池塘春草梦,阶前梧叶已秋声。记录激动时刻,也造福后来人。