Intellij IDEA 打包jar的多种方式-CSDN博客

mikel阅读(314)

来源: Intellij IDEA 打包jar的多种方式-CSDN博客

IDEA打包jar包的多种方式

用IDEA自带的打包形式
用Maven插件maven-shade-plugin打包
用Maven插件maven-assembly-plugin打包
用IDEA自带的打包形式
1.File->Project Structure->Artifacts->Add->Jar->From modules with dependencies

2.配置
第一步选择Main函数执行的类。
第二步选择如图的选项,目的是对第三方Jar包打包时做额外的配置,如果不做额外的配置可不选这个选项(但不保证打包成功)
第三步需要在src/main目录下,新建一个resources目录,将MANIFEST.MF文件保存在这里面,因为如果用默认缺省值的话,在IDEA12版本下会有bug。

 

点击ok

3.把第三方jar包放入lib目录

 

4.build

 

5.在out/artifacts目录下生成jar包

用maven-shade-plugin打包
上面的打包过程实在是过于的繁琐,而且也没有利用到maven管理项目的特色。为此,我们这里利用maven中的maven-shade-plugin插件。在pom.xml中,我们加入如下的信息来加入插件。

1.加入maven-shade-plugin插件
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>1.4</version>
<configuration>
<createDependencyReducedPom>true</createDependencyReducedPom>
</configuration>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<transformers>
<transformer
implementation=”org.apache.maven.plugins.shade.resource.ManifestResourceTransformer”>
<mainClass>Main.Main</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>

这里面配置了一个`configuration`标签内容,在此标签下面 有一个transformer标签,用来配置Main函数的入口( <mainClass>Main.Main</mainClass>),当然此标签内容很复杂,不是上面写的那么简单,上面之所以如此简单,是因为在所有类中(包括第三方Jar)只有一个Main方法。如果第三方jar中有Main方法,就要进行额外的配置,上面这么配置,不一定能执行成功。

2.使用maven命令打包
mvn clean compile //清除之前target编译文件并重新编译
mvn clean package //对项目进行打包(因为配置过插件,所以jar包是可执行的)
mvn clean install //安装项目,然后就可以使用了

可以通过自带的maven管理工具代替执行上面的命令

 

注意: 想要忽略测试用例

mvn package -DskipTests

或者

mvn package -Dmaven.test.skip=true

maven.test.skip同时控制maven-compiler-plugin和maven-surefire-plugin两个插件的行为,即跳过编译,又跳过测试

也可以使用插件

<plugin>
<groupId>org.apahce.maven.plugins<groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.5</version>
<configuration>
<includes>
<include>**/*Tests.java</include>
</includes>
</configuration>
</plugin>

使用* / Test.Java 来匹配所有以Tests结尾的Java类。两个星号*用来匹配任意路径,一个星号用来获取除路径风格符外的0个或多个字符。还可使用excludes来排除Test类

在target/目录下生成jar包

 

使用java -jar xxx.jar运行即可

用maven-assembly-plugin打包
上面的方法,我们还需要点击很多命令去打包。这次利用一个新的插件,可以打包更简单。同样,在pom.xml中加入如下代码。上文的maven-shade-plugin插件代码可以删除。最好不要写2个插件代码。

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<version>2.2-beta-5</version>
<configuration>
<descriptors>
<descriptor>src/main/resources/assembly.xml</descriptor>
</descriptors>
<archive>
<manifest>
<mainClass>Main.Main</mainClass>
</manifest>
</archive>
</configuration>
</plugin>

<assembly>
<id>j2se-assembly</id>
<formats>
<format>jar</format>
</formats>
<dependencySets>
<dependencySet>
<outputDirectory>lib</outputDirectory>
</dependencySet>
</dependencySets>
</assembly>

这里同样配置了一个manifest标签来配置Main函数的入口。然后通过如下指令来实现打包。

mvn assembly:assembly
或者

========使用mvn assembly:assembly运行会出现Error reading assemblies: No assembly descriptors found 异常,但是使用mvn package命令打包没问题 不知道为什么(꒪⌓꒪)=======
————————————————

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。

原文链接:https://blog.csdn.net/Thousa_Ho/article/details/72799871

【JAVA】使用intellij IDEA将项目打包为jar包_idea打包jar文件-CSDN博客

mikel阅读(287)

来源: 【JAVA】使用intellij IDEA将项目打包为jar包_idea打包jar文件-CSDN博客

当你有一个能正常编译的项目,以springboot为例,有两步步骤

打包配置
打包
一、打包配置
1.点击右上角快捷按钮/文件–>项目结构,打开项目结构设置

 

2.项目结构–>Artifacts,如图所示选择

3.在Create JAR from Modules配置,

5.配置jar输出相关设置

二、打包
1.构建–>Build Artifacts

2.选择Build即可

然后可以在设置的输出路径查看打的jar包

我设置的输出路径为 C:\Users\admin\Desktop\kantools\target

 

使用java -jar 运行看看

!注意:这个方式是jar内不包含第三方的依赖的,如果想做成和第三方依赖一起打包的,建议使用maven工具
————————————————

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。

原文链接:https://blog.csdn.net/ET1131429439/article/details/119907638

一步到位——Node版本管理神器nvm安装教程(2024最新)-CSDN博客

mikel阅读(606)

来源: 一步到位——Node版本管理神器nvm安装教程(2024最新)-CSDN博客

前言
Node的安装是许多学习前端的小伙伴的必经之路,我们可能会遇到需要切换node版本的情况,卸载node再安装另一个显然不够优雅,因此nvm的出现极大提高我们切换node的效率。
然而,在nvm的安装过程中有很多坑,许多新手非常容易踩雷,那么接下来让我带领你们,一次到位安装nvm,包能用,包提醒哪里有坑,只要你按我说的一步一步做即可,记得点赞收藏一波哈!

为保证一次包过,所有cmd操作请用❗管理员身份❗运行

 

一、下载nvm
1. 发行版本地址
https://github.com/coreybutler/nvm-windows/releases
在这里可以查看到所有发行版本

 

2. 如何选择版本,为什么❓
现在是2024年,已经更新到1.1.12版本了,那么我们该选择哪个版本呢?这里我的建议是使用最新版本即可

3. 值得点赞的贴心下载链接
我们选择zip版本的压缩包下载即可,在压缩包里面是一个exe文件,在这里我也把链接放上,方便小伙伴一步到位下载,Github下载缓慢可以使用迅雷等P2P下载工具(直接复制链接打开迅雷即可弹出下载弹窗,没有就点击新建)

经典版(❗暂不推荐)
https://github.com/coreybutler/nvm-windows/releases/download/1.1.7/nvm-setup.zip

最新版(推荐,截止2024.07)
https://github.com/coreybutler/nvm-windows/releases/download/1.1.12/nvm-setup.zip

 

二、删除已有的Node.js
⭕Tips:如果你的电脑还没有安装Node.js,则这一步可以跳过

1. 为什么要卸载呢❓
这是因为如果不卸载的话,有可能安装不成功,如下图2所示,在安装的版本过程中,如果不事先卸载已安装的版本的话,则nvm安装程序也会提示在安装nvm之前必须先卸载已安装的Node.js,所以呢,如果我们所以呢我们就不要心疼了,直接大大方方卸载就行~

 

那么在哪里卸载呢?

2. 在控制面板卸载Node.js
按下Win键,直接键盘输入控制面板的拼音即可,或者Win + R打开运行输入control回车也行

 

然后点击卸载程序

 

然后我们找到Node.js

 

双击,然后选是,卸载即可

 

好的,这一步我们就卸载完了,打开cmd,输入node,这时候也提示

‘node’ 不是内部或外部命令,也不是可运行的程序或批处理文件。

说明我们已经卸载完毕

 

三、安装nvm
1. 同意用户协议
双击exe文件,选择I accept the agreement,然后next

 

接下来是选择安装目录,这一步我们一般默认即可

2. 选择nvm安装目录
但是要注意了❗如果要修改的话,安装路径中,文件夹不能含有中文和空格,推荐选择一个没有中文或者空格的安装目录,这一点特别注意,路径必须全英文,不含有任何中文字符,这一点在初学者中很容易犯错,比如D:\软件\nvm、C:\Users\坤坤\nvm ,都是不推荐的,后续使用过程中一般会报错

 

报错示例
nvm安装目录包含中文的话,执行后续指令的时候,会报这个错

 

 

如果实在不小心安装在中文目录的话,则卸载重来即可

3. 选择nodejs的安装目录
下一步是选择Node.js的安装目录,这里直接默认即可,但如果你有HarmonyOS APP开发需要的话,路径不能带空格,需要选择新的目录

 

报错示例
Node.js安装目录包含中文的话,执行后续指令的时候,会报这个错

 

 

4. Install
然后直接install即可

 

5. 安装完成
点击Finish完成安装

 

四、配置nvm镜像
1. 寻找安装目录
我们打开cmd,输入 nvm ,可以看到打印出了nvm的指令列表,说明nvm安装成功

 

我们输入 nvm root 查看nvm的安装目录

 

默认情况在这个位置,如果你是旧版cmd的话,直接选择这段路径,回车即可复制或者 Ctrl + Insert(Ins),新版终端则使用 Ctrl + C

C:\Users\你的用户名\AppData\Roaming\nvm

然后我们按下Win键直接Ctrl + V粘贴即可

 

2. 修改配置
打开安装目录后,直接编辑settings.txt即可

 

 

加入以下配置,Ctrl + S保存即可

 

不再推荐使用旧域名
node_mirror: https://npm.taobao.org/mirrors/node/
npm_mirror: https://npm.taobao.org/mirrors/npm/

推荐新的淘宝镜像域名👍

node_mirror: https://npmmirror.com/mirrors/node/
npm_mirror: https://npmmirror.com/mirrors/npm/
1
2
或者使用命令行设置
nvm npm_mirror https://npmmirror.com/mirrors/npm/
nvm node_mirror https://npmmirror.com/mirrors/node/
1
2
五、使用nvm
注意❗,我们先关闭cmd,然后重新打开一个

1. 查看可用版本
输入以下格式的指令

nvm list available
1

可以查看到最近的版本和LTS长期支持版,如果要查看全部版本则可以到以下网址查看所有发行版本

https://nodejs.org/download/release

 

2. 安装Node.js
输入以下格式的指令

nvm install 版本号
1
版本号可以是主版本,也可以是指定某个版本
例如 nvm install 20,nvm install 20.11.0

一般来说,在配置了淘宝镜像之后,下载速度很快,如果下载很慢的话,有可能是没有配置镜像或者网络波动

 

3. 使用对应的版本
nvm use 版本号
1
例如 nvm use 20

 

现在就切换到了Node.js 20版本

4. 查看npm版本
npm -v
1

5. npm升级降级安装
npm install npm@版本号
1
例如 npm i npm@6

 

6. 多版本管理
查看下载的Node.js版本列表
nvm ls
1

Currently using 64-bit executable 表示当前使用的版本

卸载某个版本
nvm uninstall 版本号
1
7. 附录:常用指令
nvm-arch #显示节点是以32位还是64位模式运行。

nvm install<version>[arch]
#版本可以是node.js版本,也可以是“最新”的最新稳定版本。
#(可选)指定是安装32位版本还是64位版本(默认为system arch)。
#将[arch]设置为“all”以安装32 AND 64位版本。
#在该命令的末尾添加–unsecurity,以绕过远程下载服务器的SSL验证。

nvm list[可用] # 列出node.js的安装。在末尾键入“available”(可用),查看可以安装的内容。别名为ls。

nvm on #启用node.js版本管理。

nvm off #禁用node.js版本管理。

nvm proxy [url]
#设置用于下载的代理。将[url]留空以查看当前代理。
#将[url]设置为“none”以删除代理。

nvm node_mirror [url] #设置节点镜像。默认为https://nodejs.org/dist/.将[url]留空以使用默认url。

nvm npm_mirror [url] #设置npm镜像。默认为https://github.com/npm/cli/archive/.将[url]留空为默认url。

nvm uninstall <version> #版本必须是特定的版本。

nvm use [version] [arch]
#切换到使用指定的版本。可选择指定32/64位体系结构。
#nvm使用<arch>将继续使用所选版本,但切换到32/64位模式。
#nvm-root[path]:设置nvm应存储不同版本node.js的目录。
#如果未设置<path>,则会显示当前根目录。

nvm version #显示用于Windows的nvm的当前运行版本。别名为v。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
文档:https://github.com/coreybutler/nvm-windows/blob/master/README.md

拓展阅读
1.使用nrm管理npm镜像源,加速下载
npm i -g nrm
1

使用淘宝镜像

nrm use taoabo
1
查看镜像源列表

nrm ls
1

2.安装pnpm
nodejs版本需要16.14以上,推荐18或者20等最新LTS版本

npm i -g pnpm
1
更新pnpm

npm i -g pnpm
1
推荐阅读
必看!VSCode字体界面美化教程
————————————————

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。

原文链接:https://blog.csdn.net/m0_46491549/article/details/129750694

IntelliJ IDEA 安装、配置和使用Lombok插件 - gdjlc - 博客园

mikel阅读(244)

来源: IntelliJ IDEA 安装、配置和使用Lombok插件 – gdjlc – 博客园

Lombok 可用来帮助开发人员消除 Java 的重复代码,尤其是对于简单的 Java 对象(POJO),比如说getter/setter/toString等方法的编写。它通过注解实现这一目的。
官网:https://projectlombok.org
下面是IntelliJ IDEA安装、配置和使用Lombok插件的过程。

一、安装Lombok插件

菜单栏File -> Settings -> Plugins,在中间Marketplace下面输入Lombok搜索后进行安装,安装后会提示重启IDEA。下面是已经安装后的截图。

二、配置注解处理器

菜单栏File -> Settings -> Plugins -> Build,Execution,Deployment -> Compiler -> Annotation Processors,勾选Enable annotation processing并保存。

三、使用Lombok插件

1、pom.xml加入依赖,当前最新版本是1.18.10。

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.10</version>
            <scope>provided</scope>
        </dependency>

2、缩写一个简单的实体类,在类名上加上注解@Data,在.class文件生成类中所有属性的get/set方法、equals、canEqual、hashCode、toString方法等

import lombok.Data;

@Data
public class User {
    private Integer userId;
}

菜单栏点击View -> Tool Windows -> Structure 就可以看到类中所有方法。

使用离线部署32B模型实现OpenDeepWiki项目代码自动分析与文档生成 - 239573049 - 博客园

mikel阅读(802)

来源: 使用离线部署32B模型实现OpenDeepWiki项目代码自动分析与文档生成 – 239573049 – 博客园

背景介绍

在企业环境中,我们经常需要对公司项目代码进行分析和文档生成。然而,考虑到代码的保密性,将代码上传至公共AI平台存在安全隐患。为解决这一问题,我们可以在公司内部GPU服务器上部署强大的大语言模型(如qwen2.5:32b-instruct-fp16),并结合OpenDeepWiki工具,实现安全、高效的代码仓库分析与文档自动生成。

环境需求

  • 硬件: 支持qwen2.5:32b-instruct-fp16模型运行的GPU服务器(推荐配置:4*RTX 3090)
  • 软件: Ollama(用于部署模型)、Docker和Docker Compose环境
  • 网络: 内部网络环境,确保安全性

部署步骤

1. 部署OpenDeepWiki

在服务器上创建并配置必要文件:

docker-compose.yml:

services:
koalawiki:
image: crpi-j9ha7sxwhatgtvj4.cn-shenzhen.personal.cr.aliyuncs.com/koala-ai/koala-wiki
environment:
KOALAWIKI_REPOSITORIES=/repositories
TASK_MAX_SIZE_PER_USER=5 # 每个用户AI处理文档生成的最大数量
REPAIR_MERMAID=1 # 是否进行Mermaid修复,1修复,其余不修复
CHAT_MODEL=qwen2.5:32b-instruct-fp16 # 必须要支持function的模型
ANALYSIS_MODEL=qwen2.5:32b-instruct-fp16 # 分析模型,用于生成仓库目录结构,这个很重要,模型越强,生成的目录结构越好,为空则使用ChatModel
CHAT_API_KEY=sk- #您的APIkey
LANGUAGE=简体中文 # 设置生成语言默认为”中文”
ENDPOINT=http://您的Ollamaip:11434/v1
DB_TYPE=SQLite
DB_CONNECTION_STRING=Data Source=/data/KoalaWiki.db
UPDATE_INTERVAL=5 # 仓库增量更新间隔,单位天
EnableSmartFilter=true # 是否启用智能过滤,这可能影响AI得到仓库的文件目录
PARALLEL_COUNT=1 # The warehouse processes the quantity in parallel
volumes:
./repositories:/app/repositories
./data:/data
koalawiki-web:
image: crpi-j9ha7sxwhatgtvj4.cn-shenzhen.personal.cr.aliyuncs.com/koala-ai/koala-wiki-web
environment:
NEXT_PUBLIC_API_URL=http://koalawiki:8080 # 用于提供给server的地址
nginx: # 需要nginx将前端和后端代理到一个端口
image: crpi-j9ha7sxwhatgtvj4.cn-shenzhen.personal.cr.aliyuncs.com/koala-ai/nginx:alpine
ports:
8090:80
volumes:
./nginx.conf:/etc/nginx/conf.d/default.conf
depends_on:
koalawiki
koalawiki-web

nginx.conf:

server {
listen 80;
server_name localhost;
# 设置上传文件大小限制为 100MB
client_max_body_size 100M;
# 日志配置
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
# 代理所有 /api/ 请求到后端服务
location /api/ {
proxy_pass http://koalawiki:8080/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection ‘upgrade’;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# 其他所有请求转发到前端服务
location / {
proxy_pass http://koalawiki-web:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection ‘upgrade’;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}

2. 启动服务

创建好上述文件后,在同级目录下执行以下命令:

  1. 拉取必要的镜像:
    docker-compose pull
  2. 启动容器:
    docker-compose up -d
  3. 等待服务初始化完成(通常需要几分钟)

3. 访问OpenDeepWiki平台

在浏览器中访问 http://[服务器IP]:8090,即可看到OpenDeepWiki的界面:

使用指南

添加代码仓库进行分析

  1. 从以下地址获取OpenDeepWiki源码(推荐国内用户使用Gitee):
  2. 下载源码的ZIP压缩包
  3. 在OpenDeepWiki平台点击”添加新仓库”:

  1. 选择”上传压缩包”,填写组织名称和仓库名称(这些字段必填,将影响前端路由显示),然后提交:

  1. 上传完成后,系统将开始处理仓库(处理时间约为3-5分钟)。处理中的仓库会显示在列表中:

  1. 处理完成后,点击仓库名称即可查看由qwen2.5:32b-instruct-fp16模型自动生成的文档:

系统优势

  • 安全可控:所有代码分析和文档生成过程都在内部环境完成,确保代码安全
  • 高质量文档:借助强大的qwen2.5:32b-instruct-fp16模型,生成的文档结构清晰、内容全面
  • 一键操作:简单的上传流程,无需复杂配置
  • 可扩展性:支持多种代码仓库格式,适用于不同项目需求

结语

通过部署OpenDeepWiki与qwen2.5:32b-instruct-fp16模型,我们可以安全、高效地为公司代码仓库生成完整文档,大幅提升项目理解和开发效率。

如果您对OpenDeepWiki感兴趣,欢迎访问以下地址并给项目点个Star:

在线体验地址https://opendeep.wiki/
目前已有500+仓库加入!您也可以将您的开源仓库添加进来。

Unity+MediaPipe虚拟试衣间技术实现全攻略 - TechSynapse - 博客园

mikel阅读(255)

来源: Unity+MediaPipe虚拟试衣间技术实现全攻略 – TechSynapse – 博客园

引言:数字时尚革命的序章

在元宇宙概念席卷全球的今天,虚拟试衣技术正成为连接物理世界与数字孪生的关键桥梁。本文将深入解析基于Unity引擎结合MediaPipe姿态估计框架的虚拟试衣系统实现,涵盖从环境搭建到完整AR试穿界面开发的全流程,最终实现支持实时人体追踪、多服装物理模拟及用户反馈的完整解决方案。

一、技术选型与架构设计

1.1 技术栈组合逻辑

  • Unity 3D引擎:跨平台渲染核心,提供物理引擎(PhysX)和AR Foundation框架。
  • MediaPipe:Google开源的跨平台ML解决方案,提供实时人体姿态估计。
  • TensorFlow.js:浏览器端轻量化ML推理(可选)。
  • Python后端:模型训练与数据处理。
  • C#:Unity主逻辑开发语言。

1.2 系统架构图

[摄像头输入][MediaPipe姿态估计][骨骼数据标准化][Unity场景][服装资源管理][物理模拟引擎][AR试穿界面][用户反馈系统]

二、开发环境配置

2.1 MediaPipe环境搭建(Python端)

# 创建Python虚拟环境
python -m venv venv_mediapipe
source venv_mediapipe/bin/activate  # Linux/Mac
# venv_mediapipe\Scripts\activate  # Windows
 
# 安装依赖包
pip install mediapipe==0.10.5 opencv-python==4.8.1.78

2.2 Unity项目配置

  1. 创建新3D项目(推荐使用URP渲染管线)。
  2. 导入必备包:
    • AR Foundation (4.3.0+);
    • ARCore XR Plugin (5.2.0+);
    • ARKit XR Plugin (5.2.0+);
  3. 安装NuGet for Unity(用于C#与Python交互)。

三、核心模块实现

3.1 MediaPipe姿态估计集成

3.1.1 Python姿态检测服务端

# server.py
import cv2
import mediapipe as mp
import socket
import json
import numpy as np
 
mp_pose = mp.solutions.pose
pose = mp_pose.Pose(static_image_mode=False,
                   model_complexity=2,
                   enable_segmentation=True,
                   min_detection_confidence=0.5)
 
def process_frame(frame):
    results = pose.process(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
    if results.pose_landmarks:
        landmarks = []
        for lm in results.pose_landmarks.landmark:
            landmarks.append({
                "x": lm.x,
                "y": lm.y,
                "z": lm.z,
                "visibility": lm.visibility
            })
        return json.dumps({"landmarks": landmarks})
    return None
 
# 启动TCP服务器
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.bind(('localhost', 65432))
    s.listen()
    conn, addr = s.accept()
    with conn:
        cap = cv2.VideoCapture(0)
        while cap.isOpened():
            ret, frame = cap.read()
            if not ret:
                break
            data = process_frame(frame)
            if data:
                conn.sendall(data.encode())

3.1.2 Unity客户端接收

// PoseReceiver.cs
using System.Net.Sockets;
using System.Text;
using UnityEngine;
 
public class PoseReceiver : MonoBehaviour
{
    private TcpClient client;
    private NetworkStream stream;
    
    void Start()
    {
        client = new TcpClient("localhost", 65432);
        stream = client.GetStream();
    }
 
    void Update()
    {
        if (stream.DataAvailable)
        {
            byte[] data = new byte[1024];
            int bytesRead = stream.Read(data, 0, data.Length);
            string json = Encoding.UTF8.GetString(data, 0, bytesRead);
            ProcessLandmarks(json);
        }
    }
 
    private void ProcessLandmarks(string json)
    {
        // 解析JSON并更新Avatar
    }
}

3.2 3D服装物理模拟

3.2.1 服装资源准备规范

  1. 使用Marvelous Designer制作基础版型。
  2. 导出为FBX格式,包含以下要求:
    • 网格面数控制在5000-8000三角面;
    • 包含Cloth约束标签;
    • 骨骼绑定采用Heatmap权重。

3.2.2 Unity物理材质配置

// ClothController.cs
using UnityEngine;
 
[RequireComponent(typeof(Cloth))]
public class ClothController : MonoBehaviour
{
    public Transform[] attachmentPoints;
    private Cloth cloth;
 
    void Start()
    {
        cloth = GetComponent<Cloth>();
        ConfigureClothPhysics();
    }
 
    void ConfigureClothPhysics()
    {
        // 基础物理参数
        cloth.bendingStiffness = 0.5f;
        cloth.stretchingStiffness = 0.8f;
        cloth.damping = 0.1f;
        
        // 碰撞设置
        cloth.selfCollision.enabled = true;
        cloth.selfCollision.stiffness = 0.2f;
    }
 
    public void AttachToPoints(Transform[] points)
    {
        // 动态绑定到人体骨骼点
    }
}

3.3 AR试穿界面开发

3.3.1 空间映射实现

// ARSessionManager.cs
using UnityEngine.XR.ARFoundation;
using UnityEngine.XR.ARSubsystems;
 
public class ARSessionManager : MonoBehaviour
{
    [SerializeField]
    private ARSession arSession;
 
    void Start()
    {
        ARSessionManager.sessionStateChanged += OnSessionStateChanged;
        arSession.Reset();
    }
 
    private void OnSessionStateChanged(ARSessionStateChangedEventArgs args)
    {
        if (args.state == ARSessionState.SessionTracking)
        {
            EnablePlaneDetection();
        }
    }
 
    private void EnablePlaneDetection()
    {
        ARPlaneManager planeManager = FindObjectOfType<ARPlaneManager>();
        planeManager.enabled = true;
    }
}

3.3.2 交互界面设计

<!-- CanvasSetup.uxml (Unity UI Builder) -->
<VerticalLayout>
    <Button id="switchModelBtn" text="切换服装"/>
    <Slider id="fitSlider" min="0" max="100" value="50"/>
    <Toggle id="physicsToggle" text="物理模拟"/>
</VerticalLayout>

3.4 用户反馈系统集成

3.4.1 本地反馈收集

// FeedbackSystem.cs
using UnityEngine;
using System.IO;
 
public class FeedbackSystem : MonoBehaviour
{
    public void SubmitFeedback(string comment, int rating)
    {
        string logEntry = $"{System.DateTime.Now}: Rating {rating} - {comment}\n";
        File.AppendAllText("feedback.log", logEntry);
    }
 
    public void AnalyzeFeedback()
    {
        // 简单情感分析示例
        string[] lines = File.ReadAllLines("feedback.log");
        int positiveCount = 0;
        foreach (string line in lines)
        {
            if (line.Contains("good") || line.Contains("great"))
                positiveCount++;
        }
        Debug.Log($"Positive Feedback Ratio: {positiveCount / lines.Length}");
    }
}

四、完整系统整合

4.1 主控逻辑流程

// VirtualFittingRoom.cs
using UnityEngine;
 
public class VirtualFittingRoom : MonoBehaviour
{
    [SerializeField] private GameObject[] clothingItems;
    private int currentClothingIndex = 0;
 
    void Start()
    {
        InitializeSubsystems();
        LoadInitialClothing();
    }
 
    void Update()
    {
        HandleInput();
        UpdateClothingPhysics();
    }
 
    private void InitializeSubsystems()
    {
        // 初始化AR、姿态接收、UI等
    }
 
    private void LoadInitialClothing()
    {
        Instantiate(clothingItems[currentClothingIndex], transform);
    }
 
    private void HandleInput()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            SwitchClothing();
        }
    }
 
    private void SwitchClothing()
    {
        Destroy(clothingItems[currentClothingIndex]);
        currentClothingIndex = (currentClothingIndex + 1) % clothingItems.Length;
        LoadInitialClothing();
    }
}

4.2 性能优化策略

  1. 姿态数据降频:每秒处理15帧而非30帧。
  2. LOD系统:根据距离动态调整服装网格精度。
  3. 异步加载:使用Addressables进行资源管理。
  4. 遮挡剔除:启用Unity的Occlusion Culling。

五、部署与测试

5.1 构建配置要点

  • 移动端适配:
    • 设置目标分辨率为1920×1080 ;
    • 启用Multithreaded Rendering ;
    • 设置Graphics API为Vulkan(Android)/Metal(iOS)。
  • Web部署:
    • 使用Unity WebGL构建;
    • 配置WASM内存为512MB;
    • 启用Code Striping。

5.2 测试用例设计

测试类型 测试场景 预期结果
姿态追踪 快速肢体运动 服装跟随延迟 < 200ms
物理模拟 坐下/起身动作 服装褶皱自然无穿透
AR稳定性 不同光照条件 空间锚点持续稳定
多设备兼容性 iOS/Android旗舰机型 帧率稳定在30+ FPS

六、扩展方向与行业应用

6.1 技术升级路径

  1. AI驱动:
    • 集成Stable Diffusion实现服装自动生成;
    • 使用ONNX Runtime优化ML推理。
  2. 交互升级:
    • 添加手势控制(通过MediaPipe Hand模块);
    • 实现语音交互(集成Azure Speech SDK)。

6.2 商业应用场景

  • 电商领域:AR试衣间提升转化率;
  • 影视制作:实时动作捕捉预览;
  • 医疗康复:姿势矫正训练系统。

七、完整项目代码结构

VirtualFittingRoom/
├── Assets/
│   ├── Scripts/          # 所有C#脚本
│   ├── Materials/        # 物理材质配置
│   ├── Models/           # 服装FBX资源
│   ├── Prefabs/          # 预制件集合
│   └── StreamAssets/     # AR配置文件
├── Python/
│   └── pose_server.py    # 姿态检测服务端
└── Docs/
    └── API_Reference.md  # 开发文档

八、总结与展望

本文详细阐述了从人体姿态捕捉到服装物理模拟的完整技术链路,通过MediaPipe+Unity的协同工作实现了具有商业价值的虚拟试衣解决方案。未来随着5G+AI技术的发展,该系统可拓展至:

  • 跨平台数字分身系统;
  • 大规模虚拟时装秀平台;
  • 个性化服装推荐引擎。

开发者可通过优化物理引擎参数、增加布料类型支持、完善用户反馈机制等方式持续提升系统实用性。

理解 C# 中的各类指针 - 黑洞视界 - 博客园

mikel阅读(235)

来源: 理解 C# 中的各类指针 – 黑洞视界 – 博客园

 

前言

变量可以理解成是一块内存位置的别名,访问变量也就是访问对应内存中的数据。

指针是一种特殊的变量,它存储了一个内存地址,这个内存地址代表了另一块内存的位置。

指针指向的可以是一个变量、一个数组元素、一个对象实例、一块非托管内存、一个函数等。

截止到发文为止,.NET 最新正式版本为 .NET 9,C# 最新正式版本为 C# 13。文中提及的 IL 代码可能会随编译器版本的不同而有所差异,仅供参考。

本文将介绍到发文为止 C# 中的各类指针,并对比差异:

  • 对象引用(Object Reference)
  • 指针(Pointer,一些资料中称为非托管指针)
  • IntPtr(表示指针或句柄的值,用于管理非托管资源或非托管代码交互)
  • 函数指针(Function Pointer)
  • 托管指针(Managed Pointer)

本文旨在为读者建立对各类指针的概念认知,不会每个细节都展开,读者可以参考 C# 的官方文档,了解更多用法。

涉及的知识点较多,如果存在纰漏和错误,还请谅解。

对象引用(Object Reference)

对象引用,也就是我们常说的引用类型变量,是一个类型安全的指针,指向引用类型实例的 MethodTable 指针,通过偏移和计算可以访问对象头和字段。

对象实例被分配在托管堆上,引用类型变量存储了一个指向该对象实例的引用。对象引用可以被赋值为 null,表示没有指向任何对象实例。通过 null 的对象引用访问不存在的对象会导致 NullReferenceException

对象引用可以存在栈或者堆上,作为局部变量时,存储在栈上;作为值类型字段时,跟随值类型的位置存储;作为引用类型字段时,存储在堆上。

指针(Pointer)

指针的声明和使用#

指针允许用户直接操作内存地址,提供了更高的性能和灵活性,但也带来了更高的风险。因此,C# 只允许在用 unsafe 关键字标记的代码块中使用指针,并且需要在项目中启用 <AllowUnsafeBlocks>true</AllowUnsafeBlocks>

unsafe 关键字可以用于方法、代码块、字段、类、结构体等。

一些资料中将这边的指针(Pointer)称为非托管指针(Unmanaged Pointer),因为它们不受 GC 的管理。

我们需要使用 <type>* ptr 的语法来声明指针类型的变量。

通过 & 运算符获取变量的地址,通过 * 运算符访问指针指向的数据。

& 通常被称为寻址运算符,* 通常被称为解引用运算符或间接寻址运算符。

unsafe class Program
{
    static void Main()
    {
        int* p = null; // 声明一个指向 int 的指针
        int a = 10;
        p = &a; // 获取 a 的地址并赋值给指针 p
        Console.WriteLine(*p); // 输出 10
    }
}

指针可以指向的位置#

指针可以指向以下几种位置:

  • 值类型变量:也就是指向值类型的数据本体。
  • 引用类型变量:因为引用类型变量存储的是对象实例的引用,所以这边相当于一个二级指针。
  • 值类型或者引用类型的实例字段:readonly 也可以修改。
  • 值类型或者引用类型的静态字段:readonly 也可以修改。
  • 数组元素:数组在内存中是连续存储的,所以可以通过指针和指针算法来访问数组元素。
  • 非托管内存:使用 Marshal 分配非托管内存。
  • 另一个指针(Pointer):可以实现多级指针。
  • null:表示没有指向任何有效的内存地址,通过 null 指针访问不存在的数据会导致 NullReferenceException

注意:在声明指向实例字段,静态字段以及数组元素的指针时,需要使用 fixed 关键字。

可以声明指针的位置#

指针可以在以下位置声明:

  • 局部变量:可以在方法中声明指针变量。
  • 方法参数:可以将指针作为方法参数传递。
  • 方法返回值:可以将指针作为方法的返回值。
  • 实例字段:可以在类或结构体中声明指针类型的字段。
  • 静态字段:可以在类或者结构体中声明指针类型的静态字段。
  • 只读属性:包含只读索引(indexer),但不支持自动属性(Automatically implemented properties)。

指向值类型变量的指针#

指针可以指向值类型变量,直接访问值类型的数据本体,并且可以修改值类型变量的值。

unsafe class Program
{
    static void Main()
    {
        int a = 10;
        int* p = &a; // 获取 a 的地址并赋值给指针 p
        Console.WriteLine(*p); // 输出 10

        *p = 20; // 修改指针 p 指向的值
        Console.WriteLine(a); // 输出 20
    }
}

指向对象引用的指针#

指针可以指向对象引用,相当于一个二级指针。

在下面的示例代码中,关键的部分标注了编译后的 IL 代码。

class Program
{
    static void Main()
    {
        var foo = new Foo
        {
            Bar = 1
        };

        unsafe
        {
            // ldloca.s     foo   // 加载 foo 的地址
            // conv.u             // 将 foo 的地址转换为 unsigned native int
            // stloc.1            // 将转换后的 int 存储到 fooPtr
            Foo* fooPtr = &foo;

            // ldloc.1            // 加载 fooPtr
            // ldind.ref          // 将 fooPtr 指向的对象引用加载到栈上
            // callvirt     instance int32 Foo::get_Bar()
            // call         void [System.Console]System.Console::WriteLine(int32)
            Console.WriteLine(fooPtr->Bar); // 输出 1

            // ldloc.1            // 加载 fooPtr
            // newobj       instance void Foo::.ctor()
            // dup
            // ldc.i4.2
            // callvirt     instance void Foo::set_Bar(int32)
            // nop
            // stind.ref          // 新的 Foo 对象的地址保存通过 fooPtr 保存到 foo
            *fooPtr = new Foo
            {
                Bar = 2
            };

            // ldloc.0      // 和指针相比,少了一个 ldind.ref,对象引用可以直接使用
            // callvirt     instance int32 Foo::get_Bar()
            // call         void [System.Console]System.Console::WriteLine(int32)
            Console.WriteLine(foo.Bar); // 输出 2
            
            // ldloc.1      // 加载 fooPtr
            // ldind.ref    // 将 fooPtr 指向的对象引用加载到栈上
            // ldc.i4.3     // 将 3 压入栈上
            // callvirt     instance void Foo::set_Bar(int32)
            fooPtr->Bar = 3;
            Console.WriteLine(foo.Bar); // 输出 3
        }
    }
}

class Foo
{
    public int Bar { get; set; }
}

关键的三个IL 指令:

  • conv.u:将对象引用(foo)的地址转换为 unsigned native int,并存储到指针(fooPtr)中。
  • ldind.ref:将指针(fooPtr)指向的对象引用(foo)加载到栈上。
  • stind.ref:将栈上的对象引用(新的foo实例的引用)存储到指针指向的地址(foo)上。

指向 GC Heap 的指针#

如果指针指向 GC Heap 上的数据,例如指向数组元素或者引用类型实例字段,指针需要通过 fixed 关键字固定对象的地址,防止 GC 移动对象的位置。

class Program
{
    static void Main()
    {
        Foo foo = new Foo
        {
            Bar = 1
        };

        unsafe
        {
            fixed (int* p = &foo.Bar) // 固定 foo.Bar 的地址
            {
                Console.WriteLine(*p); // 输出 1

                *p = 2; // 修改指针 p 指向的值
            }
        }

        Console.WriteLine(foo.Bar); // 输出 2
    }
}

class Foo
{
    public int Bar;
}

注意:不应在 fixed 语句块结束后,继续使用指针变量,因为 GC 可能会移动对象的位置,导致指针指向无效的内存地址。

class Program
{
    static void Main()
    {
        Foo foo = new Foo
        {
            Bar = 1
        };

        var weakReference = new WeakReference(foo);

        unsafe
        {
            int* p2;
            fixed (int* p1 = &foo.Bar) // 固定 foo.Bar 的地址
            {
                Console.WriteLine(*p1); // 输出 1

                p2 = p1; // 将指针 p1 存放的地址复制给 指针p2

                *p1 = 2; // 修改指针 p1 指向的值
            }

            Console.WriteLine(*p2); // 输出 2,此时 p1 已经被释放了,但 p2 仍然可以访问到 foo.Bar 的值

            // 往托管堆上分配一些数据,并触发 GC
            for (int i = 0; i < 1_000_000; i++)
            {
                var arr = new int[1000];
            }

            GC.Collect();

            Console.WriteLine(weakReference.IsAlive); // 输出 true,证明 foo 仍然存活
            Console.WriteLine(*p2); // 输出 0, 因为 foo 的位置已经被 GC 移动了
        }
    }
}

class Foo
{
    public int Bar;
}

指向数组元素的指针#

当指针指向数组元素时,可以通过指针算法遍历数组元素,指针的单次偏移量为元素类型的大小。

指针算法支持的操作有:

对指针进行加法和减法运算时,p + n 是将指针 p 向后移动 n 个元素的大小,p – n 是将指针 p 向前移动 n 个元素的大小。

本文会讨论三种数组类型:

  • 在栈上分配的数组
  • 在托管堆上分配的数组
  • 在非托管堆上分配的数组

本小节先讨论前两种,指向非托管堆上分配的数组的指针会在后面讨论。

栈上和非托管堆上分配的数组时,指针可以直接访问数组元素。在托管堆上分配的数组时,指针需要通过 fixed 关键字固定数组元素的地址,防止 GC 移动数组元素的位置。

在栈上分配的数组的示例代码:

unsafe class Program
{
    static void Main()
    {

        int* arr = stackalloc int[5] { 0, 1, 2, 3, 4 }; // 在栈上分配一个 int 数组并初始化
        // 下面是等效代码
        // int* arr = stackalloc int[5]; // 在栈上分配一个 int 数组
        // for (int i = 0; i < 5; i++)
        // {
        //     *(arr + i) = i; // 通过指针访问数组元素,赋值
        // }
        for (int i = 0; i < 5; i++)
        {
            Console.WriteLine(*(arr + i)); // 输出 0 1 2 3 4
        }
        // 也可以直接通过下标访问
        for (int i = 0; i < 5; i++)
        {
            Console.WriteLine(arr[i]); // 输出 0 1 2 3 4
        }
    }
}

在托管堆上分配的数组的示例代码:

unsafe class Program
{
    static void Main()
    {
        int[] arr = new int[5] { 0, 1, 2, 3, 4 }; // 在堆上分配一个 int 数组并初始化
        fixed (int* p = arr) // 固定数组元素的地址
        {
            for (int i = 0; i < 5; i++)
            {
                Console.WriteLine(*(p + i)); // 输出 0 1 2 3 4
            }
        }

        fixed (int* p = &arr[0]) // 固定数组元素的地址
        {
            for (int i = 0; i < 5; i++)
            {
                *(p + i) = i * 10; // 修改数组元素的值
            }
        }

        foreach (var item in arr)
        {
            Console.WriteLine(item); // 输出 0 10 20 30 40
        }
    }
}

在 fixed 语句块结束后,数组元素的地址会被释放,指针变量将不再有效。

在 fixed 语句块中,指针变量可以直接访问数组元素的地址,并且可以修改数组元素的值。

int* p = arr 和 int* p = &arr[0] 是等效的,都是获取数组第一个元素的地址。

注意: int[]* p = &arr 是创建一个指向数组变量的指针,并不是指向数组元素的指针。

指向静态字段的指针#

静态字段位于托管堆上,但非 GC 管理的内存区域,理论上内存地址应该是固定的,但不排除某些平台实现或某些情况下会被移动。

在.NET的规范以及C#语言规范中,编译器并不能完全确定某个字段是否可移动,必须通过 fixed 修饰保证安全。

统一使用 fixed 也可以避免特例导致的复杂性或bug。如果静态保存的是值类型还好。但如果静态字段保存的是一个对象引用,那就和方法的局部变量一样,指针必定需要通过 fixed 关键字固定对象的地址,防止 GC 移动对象的位置。静态字段如果存的是数组的引用,也是必须使用 fixed 关键字固定对象的地址才能访问数组元素。

unsafe class Program
{
    static void Main()
    {
        // 值类型的静态字段
        Foo.ValueTypeField = 1;

        // 获取指针
        fixed (int* valueTypeFieldPtr = &Foo.ValueTypeField)
        {
            *valueTypeFieldPtr = 2; // 修改值类型字段的值
        }

        Console.WriteLine(Foo.ValueTypeField); // 输出 2

        // 引用类型的静态字段
        Foo.ReferenceTypeField = new Bar { Baz = 1 };

        // 获取指针
        fixed (Bar* referenceTypeFieldPtr = &Foo.ReferenceTypeField)
        {
            *referenceTypeFieldPtr = new Bar { Baz = 2 }; // 修改引用类型字段的值
        }

        Console.WriteLine(Foo.ReferenceTypeField.Baz); // 输出 2

        // 数组的静态字段
        Foo.ArrayField = [1, 2, 3];

        // 获取指针
        fixed (int* arrayFieldPtr = Foo.ArrayField)
        {
            arrayFieldPtr[0] = 4; // 修改数组的值
        }

        Console.WriteLine(Foo.ArrayField[0]); // 输出 4
    }
}

class Foo
{
    public static int ValueTypeField;

    public static Bar ReferenceTypeField;

    public static int[] ArrayField;
}

class Bar
{
    public int Baz;
}

指向非托管内存的指针#

使用 Marshal.AllocHGlobal 分配非托管内存,返回一个指向非托管内存的指针,最后使用 Marshal.FreeHGlobal 释放非托管内存。

Marshal 提供的方法的参数和返回值都是 IntPtr 类型,但可以和指针互换转换。

public static class Marshal
{
    public static IntPtr AllocHGlobal(int cb);
    public static void FreeHGlobal(IntPtr hglobal);
}
using System.Runtime.InteropServices;

unsafe class Program
{
    static void Main()
    {
        // 在非托管内存中分配一块内存用于存储整数数组
        int size = 10;
        var ptr = (int*)Marshal.AllocHGlobal(size * sizeof(int));

        // 将数据写入非托管内存
        for (int i = 0; i < size; i++)
        {
            ptr[i] = i;
        }

        // 读取非托管内存的数据
        for (int i = 0; i < size; i++)
        {
            Console.WriteLine(ptr[i]);
        }

        // 也可以使用指针算法访问非托管内存存储的数组
        // int* p = ptr;
        // for (int i = 0; i < size; i++)
        // {
        //     Console.WriteLine(*p);
        //     p++;
        // }

        // 释放非托管内存
        Marshal.FreeHGlobal((IntPtr)ptr);
    }
}

作为方法参数的指针#

指针可以作为方法参数传递,允许在方法中修改指针指向的数据,但指针本身的传递是值传递,无法在传入的方法中修改指针的值,也就是无法修改指针指向的地址。

unsafe class Program
{
    static void Main()
    {
        int a = 10;
        int b = 20;
        
        int* p1 = &a; // 获取 a 的地址并赋值给指针 p1
        int* p2 = &b; // 获取 b 的地址并赋值给指针 p2
        Console.WriteLine(*p1); // 输出 10
        Console.WriteLine(*p2); // 输出 20

        ModifyPointer(p1, p2); // 传递指针 p1 和 p2
        Console.WriteLine(*p1); // 输出 11
    }

    static void ModifyPointer(int* p1, int* p2)
    {
        *p1 = 11; // 修改指针 p1 指向的值
        
        p1 = p2; // 无效代码,不会影响外部的 p1
    }
}

作为方法返回值的指针#

当指针作为方法的返回值时,需要注意不能返回局部变量的指针,因为局部变量在方法结束后会被销毁,指针将指向无效的内存地址。

unsafe class Program
{
    static void Main()
    {
        Foo* p = GetPointer(); // 获取指针

        Console.WriteLine(p->Bar); // 输出 10
        Console.WriteLine(p->Bar); // 输出 随机值
    }

    static Foo* GetPointer()
    {
        Foo a = new Foo
        {
            Bar = 10
        };
        return &a;
    }
}

struct Foo
{
    public int Bar;
}

上述代码中,GetPointer 方法返回了一个指向局部变量 a 的指针,但 a 在方法结束后会被销毁,所以返回的指针将指向无效的内存地址。

之所以第一次输出 10,是因为 a 的内存数据没有被覆盖,第二次输出随机值是因为 a 的内存数据已经被覆盖。

在打印 p->Bar 之前,将一些别的数据载入到栈上,就会覆盖 a 的内存数据。下面的代码只打印了一次 p->Bar,但在打印之前,已经将 20 到过栈上(被 Console.WriteLine 消费了),所以 a 的内存数据被覆盖了。

unsafe class Program
{
    static void Main()
    {
        Foo* p = GetPointer(); // 获取指针
        Console.WriteLine(20); // 输出 20
        Console.WriteLine(p->Bar); // 输出 随机值
    }

    static Foo* GetPointer()
    {
        Foo a = new Foo
        {
            Bar = 10
        };
        return &a;
    }
}

struct Foo
{
    public int Bar;
}

改为返回字段的指针也是一样的结果

unsafe class Program
{
    static void Main()
    {
        int* p = GetPointer(); // 获取指针

        Console.WriteLine(*p); // 输出 10
        Console.WriteLine(*p); // 输出 随机值
    }

    static int* GetPointer()
    {
        Foo a = new Foo
        {
            Bar = 10
        };
        return &a.Bar;
    }
}

struct Foo
{
    public int Bar;
}

多级指针#

下面是一个三级指针的例子

{
    int x = 1;
    int* p1 = &x;         // 一级指针
    int** p2 = &p1;       // 二级指针
    int*** p3 = &p2;      // 三级指针
    
    ***p3 = 2;            // 三次寻址

    Console.WriteLine(x); // 输出 2
}

进一步理解 fixed 关键字#

fixed 关键字用于固定对象的地址,防止 GC 移动对象的位置。

查看下面代码编译成的 IL 代码。

unsafe class Program
{
    static void Main()
    {
        // 引用类型的静态字段
        Foo.ReferenceTypeField = new Bar { Baz = 1 };

        // 获取指针
        fixed (Bar* referenceTypeFieldPtr = &Foo.ReferenceTypeField)
        {
            *referenceTypeFieldPtr = new Bar { Baz = 2 }; // 修改引用类型字段的值
        }

        Console.WriteLine(Foo.ReferenceTypeField.Baz); // 输出 2

        // 数组的静态字段
        Foo.ArrayField = [1, 2, 3];

        // 获取指针
        fixed (int* arrayFieldPtr = Foo.ArrayField)
        {
            arrayFieldPtr[0] = 4; // 修改数组的值
        }

        Console.WriteLine(Foo.ArrayField[0]); // 输出 4
    }
}

class Foo
{
    public static Bar ReferenceTypeField;

    public static int[] ArrayField;
}

class Bar
{
    public int Baz;
}
.class private auto ansi beforefieldinit
  Program
    extends [System.Runtime]System.Object
{

  .method private hidebysig static void
    Main() cil managed
  {
    .entrypoint
    .maxstack 4
    .locals init (
      [0] class Bar* referenceTypeFieldPtr,
      [1] class Bar& pinned V_1,
      [2] int32* arrayFieldPtr,
      [3] int32[] pinned V_3
    )
    // ... 省略方法体
  }
}

在 IL 代码中,Bar& pinned V_1 和 int32[] pinned V_3 表示固定的指向对象引用的托管指针和固定的数组的对象引用。

pinned 表示这个对象引用是固定的,GC 会识别到这个标记,并不会移动其指向的对象的位置。

在 fixed 语句块内,对 Bar* referenceTypeFieldPtr 的读写将转换为 Bar& pinned V_1 的读写。对 int32* arrayFieldPtr 的读写将转换为 int32[] pinned V_3 的读写。

IntPtr

基本概念#

IntPtr 是一个结构体,表示指针或句柄的值,用于管理非托管资源或非托管代码交互。

在部分场景,可以和指针互换使用,但 IntPtr 不能直接进行指针运算。

IntPtr 是一个平台相关的类型,在 32 位平台上是 4 字节,在 64 位平台上是 8 字节。

在使用 IntPtr 时,不需要使用 unsafe 关键字,也不需要启用 <AllowUnsafeBlocks>true</AllowUnsafeBlocks>(如果使用 P/Invoke 调用非托管函数时,仍然需要启用)。

指向非托管内存的 IntPtr#

在使用 IntPtr 管理非托管内存时,不能直接读取和写入内存,需要使用 Marshal 提供的ReadXXX 和 WriteXXX 方法。

using System.Runtime.InteropServices;

class Program
{
    static void Main()
    {
        // 在非托管内存中分配一块内存用于存储整数数组
        int size = 10;
        IntPtr ptr = Marshal.AllocHGlobal(size * sizeof(int));
        
        // 将数据写入非托管内存
        for (int i = 0; i < size; i++)
        {
            Marshal.WriteInt32(ptr + i * sizeof(int), i);
        }
        
        // 读取非托管内存的数据
        for (int i = 0; i < size; i++)
        {
            Console.WriteLine(Marshal.ReadInt32(ptr + i * sizeof(int)));
        }
        
        // 释放非托管内存
        Marshal.FreeHGlobal(ptr);
    }
}

保存句柄的 IntPtr#

IntPtr 也可以用于存储句柄,例如文件句柄、窗口句柄等。

句柄可以理解为一个指向资源的引用,通常是一个整数值,用于唯一标识和访问由操作系统管理的资源。本质上它是一个资源标识符,而不是资源在内存中的实际地址。

下面是一个 windows 平台的例子

using System.Runtime.InteropServices;

public static partial class Program
{
    // Define a delegate that corresponds to the unmanaged function.
    private delegate bool EnumWC(IntPtr hwnd, IntPtr lParam);

    // Import user32.dll (containing the function we need) and define
    // the method corresponding to the native function.
    [LibraryImport("user32.dll")]
    private static partial int EnumWindows(EnumWC lpEnumFunc, IntPtr lParam);

    // Define the implementation of the delegate; here, we simply output the window handle.
    private static bool OutputWindow(IntPtr hwnd, IntPtr lParam)
    {
        Console.WriteLine(hwnd.ToInt64());
        return true;
    }

    public static void Main(string[] args)
    {
        // Invoke the method; note the delegate as a first parameter.
        EnumWindows(OutputWindow, IntPtr.Zero);
    }
}

上面的代码使用了 LibraryImport 特性来导入 user32.dll 中的 EnumWindows 函数,并定义了一个委托 EnumWC 来对应这个函数的回调函数。EnumWindows 函数会枚举所有顶级窗口,并调用 OutputWindow 函数来输出每个窗口的句柄。

OutputWindow 函数的参数 hwnd 是一个 IntPtr 类型的句柄,表示窗口的句柄。可以使用 hwnd.ToInt64() 将其转换为长整型值进行输出。

函数指针(Function Pointer)

基本概念#

函数指针是一个指向函数的指针,分为托管函数指针和非托管函数指针。

这是一个 C# 9 新增的特性,建议读者阅读官方文档地址加深理解:
https://learn.microsoft.com/zh-cn/dotnet/csharp/language-reference/proposals/csharp-9.0/function-pointers

在 IL 层面,调用方法的指令分为三种:

  • call:直接调用静态方法或非虚方法。
    • 常用于静态方法、私有实例方法、构造函数、基类方法等。
    • 不会进行虚方法表查找,故不能用于虚方法调用。
  • callvirt:用于调用虚方法(virtual)、接口方法,或者有时也用来调用非虚实例方法。
    • 会进行虚方法表(vtable)查找,确保调用最终派生类的实现(多态)。
    • 调用前自动检测 this 是否为 null,如果是则抛出 NullReferenceException。所以 C# 编译器的常见做法是对非虚方法也使用 callvirt,以保证 null 检查。
  • calli:间接调用,通过函数指针进行调用。
    • 性能开销更低,但安全性、类型检查弱。
    • 通常只有在编写 IL 代码,或者使用 Emit 动态生成代码时才会使用。
    • 新增的函数指针语法允许在 C# 中使用 calli 指令,提供了更好的类型安全性。

早期 C# 为我们提供了委托(Delegate)来封装方法的引用,委托可以看作是一个类型安全的函数指针。所有的委托类型都继承自 System.Delegate 类。我们在调用委托时,实际上是调用了委托的 Invoke 这个虚方法,IL 指令是 callvirt

在后期新增的函数指针语法中,编译器使用 calli 指令来调用函数,而不是实例化委托对象并调用 Invoke 方法。

函数指针的声明和使用#

和指针一样,函数指针也需要在 unsafe 代码块中使用,并且需要启用 <AllowUnsafeBlocks>true</AllowUnsafeBlocks>

声明函数指针的语法如下:

delegate*<[parameter type list], return type> variableName

delegate* 是一个关键字,表示函数指针类型。

<parameter type list> 是参数类型列表,可以是空的,也可以是一个或多个参数类型,用逗号分隔。
return type 是返回值类型,可以是 void 或者其他类型。

下面是几个例子:

  • delegate*<void> ptr:表示一个不带参数和返回值的函数指针。
  • delegate*<int> ptr:表示一个不带参数,返回值为 int 的函数指针。
  • delegate*<int, int, int> ptr:表示一个带两个 int 参数,返回值为 int 的函数指针。
  • delegate*<int, int, void> ptr:表示一个带两个 int 参数,无返回值的函数指针。

函数指针的声明和使用示例:

unsafe class Program
{
    static void Main()
    {
        // 声明一个函数指针,指向一个返回 int 的函数,参数为两个 int
        delegate*<int, int, int> addPtr = &Add;

        // 调用函数指针
        int result = addPtr(1, 2);
        Console.WriteLine(result); // 输出 3
    }

    static int Add(int a, int b)
    {
        return a + b;
    }
}

使用 & 运算符获取函数的地址,并赋值给函数指针变量。

函数指针只能指向静态方法,不能指向实例方法或者委托。

可以指向静态的本地函数(local function),也就是说这个本地函数不是闭包。

下面对比函数指针和委托,用 BenchmarkDotNet 做个简单的性能测试

public class Program
{
    public static void Main(string[] args)
    {
        BenchmarkRunner.Run<Benchmark>();
    }
}

[MemoryDiagnoser]
public class Benchmark
{
    private delegate int AddDelegate(int a, int b);
    private static AddDelegate addDelegate = Add;

    private unsafe delegate*<int, int, int> addPtr = &Add;

    [Benchmark]
    public void Delegate()
    {
        for (int i = 0; i < 1000000; i++)
        {
            var result = addDelegate(1, 2);
        }
    }

    [Benchmark]
    public unsafe void FunctionPointer()
    {
        for (int i = 0; i < 1000000; i++)
        {
            var result = addPtr(1, 2);
        }
    }

    private static int Add(int a, int b)
    {
        return a + b;
    }
}

运行结果如下:

| Method          | Mean     | Error     | StdDev    | Allocated |
|---------------- |---------:|----------:|----------:|----------:|
| Delegate        | 1.530 ms | 0.0054 ms | 0.0048 ms |       1 B |
| FunctionPointer | 1.409 ms | 0.0042 ms | 0.0039 ms |       1 B |

虽然此处例子差距不是很明显,但还是能看到函数指针的性能更好一些。

托管函数指针和非托管函数指针#

在声明函数指针时,可以在 delegate* 后面加上 managed 或 unmanaged 关键字,表示托管函数指针或非托管函数指针。

不加关键字时,默认是托管函数指针。

下面是一个可以在 macOS 上运行的例子:

unsafe class Program
{
    // 声明C函数指针类型(C的 getpid:int getpid(void);)
    private delegate* unmanaged[Cdecl]<int> GetPidDelegate;

    static void Main()
    {
        var prog = new Program();
        prog.Run();
    }

    public void Run()
    {
        // 加载libc(macOS下通常路径就是 /usr/lib/libc.dylib)
        IntPtr lib = NativeLibrary.Load("/usr/lib/libc.dylib");

        // 获取getpid符号
        IntPtr pidFuncPtr = NativeLibrary.GetExport(lib, "getpid");

        // 转为函数指针(需要unsafe上下文)
        GetPidDelegate = (delegate* unmanaged[Cdecl]<int>)pidFuncPtr;

        // 用C#的函数指针调用 (unsafe 上下文中)
        int pid = GetPidDelegate();

        Console.WriteLine($"Current PID from libc.getpid(): {pid}");

        // 释放库
        NativeLibrary.Free(lib);
    }
}

上面的代码中,delegate* unmanaged[Cdecl]<int> 声明了一个非托管函数指针类型,指向一个返回 int 的函数。

Cdecl 是调用约定,表示使用 C 语言的调用约定。

通过获取 getpid 函数的地址,并将其转换为函数指针类型,最后调用该函数获取当前进程的 PID。

NativeLibrary 是一个用于加载和调用非托管库的类,提供了 Load 和 GetExport 方法来加载库和获取函数地址。

使用完后,使用 NativeLibrary.Free 方法释放库。

托管指针(Managed Pointer)

托管指针的声明和使用#

托管指针并非一个新的特性,在早期的 C# 版本中,我们在方法参数上使用的 ref 和 out 就是声明了托管指针。

在 IL 中,用 <type>* 来表示前面说的指针(pointer,有些资料中称为 非托管指针)。

而 ref 和 out 在 IL 中对应的是 <type>&,也就是托管指针(managed pointer)。

out 相当于 ref 的一种特殊情况,表示参数是一个输出参数,方法内部必须对其赋值。

另外还有一个 in 可以把方法参数声明为只读的托管指针,方法内部不能对其赋值。

使用托管指针时,我们不需要使用 unsafe 关键字,也不需要启用 <AllowUnsafeBlocks>true</AllowUnsafeBlocks>

注意:托管指针相关的语法会在几个位置用到 ref 关键字,但作用和意义是不同的。

  • 我们使用 ref <type> ptr 来声明一个托管指针。
  • 同时也用 ref 关键字来获取变量的地址,ref <type> ptr = ref a
  • 访问托管指针指向的数据时,语法上只需直接访问不带 ref 的指针变量名 ptr 即可。
  • 复制托管指针的值时,需要在指针变量前面加上 ref 关键字。ref <type> ptr2 = ref ptr
  • 修改托管指针指向的数据时,语法上只需直接访问不带 ref 的指针变量名 ptr 即可,ptr = ref b
class Program
{
    static void Main()
    {
        int a = 10;
        ref int p1 = ref a; // 声明一个托管指针,指向变量 a 的地址
        Console.WriteLine(p1); // 输出 10,访问托管指针 p1 指向的值,即 a 的值

        p1 = 20; // 修改托管指针 p1 指向的值,即修改 a 的值
        Console.WriteLine(a); // 输出 20

        ref int p2 = ref p1; // 将托管指针 p1 的值复制给 p2,即 p2 也指向 a 的地址

        p2 = 30; // 修改托管指针 p2 指向的值,即修改 a 的值
        Console.WriteLine(a); // 输出 30

        int b = 40;
        p1 = ref b; // 将 p1 重新指向 b
        Console.WriteLine(p1); // 输出 40,访问托管指针 p1 指向的值,即 b 的值
        
        p1 = 50; // 修改托管指针 p1 指向的值,即修改 b 的值
        Console.WriteLine(b); // 输出 50
        Console.WriteLine(p2); // 输出 30,p2 仍然指向 a 的地址
    }
}

托管指针可以指向的位置#

  • 值类型变量:也就是指向值类型的数据本体。
  • 引用类型变量:和上文指向对象引用的指针(Pointer)一样,相当于一个二级指针,但不支持指向另一个托管指针。
  • 值类型或者引用类型的实例字段。
  • 值类型或者引用类型的静态字段
  • 数组元素:但不支持指针算法。
  • null:表示没有指向任何有效的内存地址,尝试访问 null 指针会导致 NullReferenceException。目前只有作为 ref struct 的 ref 字段时,可能出现这个情况,需使用 Unsafe.IsNullRef<T>(T) 方法确定 ref 字段是否为 null。

可以声明托管指针的位置#

  • 局部变量:可以在方法中声明托管指针变量。
  • 方法参数:可以将托管指针作为方法参数传递。
  • 方法返回值:可以将托管指针作为方法的返回值。
  • ref struct 的实例字段:ref struct 的 ref 不代表这种 struct 是按引用传递的,是指其具有类似托管指针的限制。
  • 只读属性:包含只读索引(indexer),但不支持自动属性(Automatically implemented properties)。

托管指针的限制#

出于安全的设计目的,相较于指针(Pointer),托管指针只允许存在于栈上,不允许在存在于堆上。主要的限制如下:

  • 不能作为类或者非 ref struct 的结构体的字段。
  • 不能作为静态字段,因为静态字段在保存在托管堆上(非 GC Heap)。
  • 不能作为 async方法 或 迭代器方法 的参数,因为参数会被状态机捕获,并保存在堆上。
  • 不能在 await 和 yield 语句中使用,因为相关的变量会被状态机捕获,并保存在堆上。
  • 不能被闭包捕获,因为编译器会将闭包转换为一个类,并将捕获的变量作为类的字段。

作为能保存托管指针的的 ref struct,也只允许在栈上分配内存。C# 对 ref struct 的限制主要如下:

  • 不能作为类或者非 ref struct 的结构体的字段。
  • 不能作为静态字段。
  • 不能装箱。无法将 ref struct 装箱为 object 或者接口类型。也无法将 ref struct 作为数组元素。
  • 不能作为 async方法 的参数,因为参数会被状态机捕获,并保存在堆上。但可以作为迭代器方法的参数。
  • 不能在 await 和 yield 语句中使用,因为相关的变量会被状态机捕获,并保存在堆上。
  • 不能被闭包捕获,因为编译器会将闭包转换为一个类,并将捕获的变量作为类的字段。

指向对象引用的托管指针#

托管指针指向对象引用时,和指针(Pointer)一样,都类似于一个二级指针。

下面是一个简单的例子,演示了如何使用托管指针指向对象引用:

class Program
{
    static void Main()
    {
        Foo foo = new Foo
        {
            Bar = 1
        };

        // 声明一个托管指针,指向 foo 的地址
        // ldloca.s     foo   // 加载 foo 的地址
        // stloc.1            // 将转换后的 int 存储到 fooPtr
        ref Foo fooPtr = ref foo;

        // 访问托管指针指向的对象引用
        // ldloc.1            // 加载 fooPtr
        // ldind.ref          // 将 fooPtr 指向的对象引用加载到栈上
        // callvirt     instance int32 Foo::get_Bar()
        // call         void [System.Console]System.Console::WriteLine(int32)
        Console.WriteLine(fooPtr.Bar); // 输出 1

        // 修改托管指针指向的对象引用
        // ldloc.1            // 加载 fooPtr
        // newobj       instance void Foo::.ctor()
        // dup
        // ldc.i4.2
        // callvirt     instance void Foo::set_Bar(int32)
        // nop
        // stind.ref          // 新的 Foo 对象的地址保存通过 fooPtr 保存到 foo
        fooPtr = new Foo
        {
            Bar = 2
        };

        // 访问托管指针指向的对象引用
        Console.WriteLine(foo.Bar); // 输出 2

        // 通过托管指针修改原对象的属性
        // ldloc.1      // 加载 fooPtr
        // ldind.ref    // 将 fooPtr 指向的对象引用加载到栈上
        // ldc.i4.3     // 将 3 压入栈上
        // callvirt     instance void Foo::set_Bar(int32)
        // nop
        fooPtr.Bar = 3;
        Console.WriteLine(foo.Bar); // 输出 3
    }
}

public struct Foo
{
    public int Bar { get; set; }
}

上面的代码中,ref Foo fooPtr = ref foo; 声明了一个托管指针 fooPtr,指向 foo 的地址。

fooPtr 是一个托管指针,指向 foo 的地址,虽然语法可以直接访问 fooPtr.Bar 的属性,但其过程是先将 fooPtr 指向的对象引用加载到栈上,然后调用 get_Bar() 方法获取属性值。

fooPtr = new Foo { Bar = 2 }; 修改了 fooPtr 指向的对象引用,也就是修改了 foo 的值。

和指针(Pointer)那一章节生成的 IL 代码进行对比,你会发现,唯一的区别是将变量地址保存到指针时,指针比托管指针多了一个 conv.u 指令。

class Program
{
    static unsafe void Main()
    {
        Foo foo = new Foo
        {
            Bar = 1
        };

        // ldloca.s     foo
        // conv.u       // 将 foo 的地址转换为unsigned native int
        // stloc.1      // fooPtr1
        Foo* fooPtr1 = &foo;

        // ldloca.s     foo
        // stloc.2      // fooPtr2
        ref Foo fooPtr2 = ref foo;
    }
}

public struct Foo
{
    public int Bar { get; set; }
}

可以看出唯一的区别就是 指针(Pointer)和托管指针(Managed Pointer)在保存变量地址时,指针(Pointer)需要转换为 unsigned native int,而托管指针(Managed Pointer)不需要转换。

在获取对象引用时 ldind.ref 同时支持两种指针格式。

指向 GC Heap 的托管指针#

托管指针受 GC 管理,不用关注指向的数据是否在 GC 过程中被移动。在 GC 过程中,托管指针会被自动更新为新的地址。

下面是一个简单的例子,演示了如何使用托管指针指向引用类型的实例字段:

class Program
{
    static void Main()
    {
        Foo foo = new Foo
        {
            Bar = 1
        };

        ref int p = ref foo.Bar; // 声明一个托管指针,指向 foo 的 Bar 字段

        Console.WriteLine(p); // 输出 1

        p = 2; // 修改托管指针 p 指向的值,即修改 foo 的 Bar 字段

        Console.WriteLine(foo.Bar); // 输出 2
    }
}

public class Foo
{
    public int Bar;
}

指向数组元素的托管指针#

托管指针可以指向数组元素,但不支持指针算法。

class Program
{
    static void Main()
    {
        int[] arr = new int[5] { 0, 1, 2, 3, 4 };

        // 声明一个托管指针,指向数组的第一个元素
        ref int p = ref arr[0];

        Console.WriteLine(p); // 输出 0

        p = 10; // 修改托管指针 p 指向的值,即修改数组的第一个元素

        Console.WriteLine(arr[0]); // 输出 10
    }
}

指向静态字段的托管指针#

class Program
{
    static void Main()
    {
        // 声明一个托管指针,指向静态字段 Foo.StaticField 的地址
        ref int p = ref Foo.StaticField;

        Console.WriteLine(p); // 输出 0

        p = 20; // 修改托管指针 p 指向的值,即修改 Foo.StaticField 的值

        Console.WriteLine(Foo.StaticField); // 输出 20
    }
}

public class Foo
{
    public static int StaticField;
}

作为方法参数的托管指针#

目前,我们有下面几种方法可以声明托管指针作为方法参数:

注意:托管指针本身是值传递,无法在方法内修改外部的托管指针的指向

  1. ref 关键字:表示参数是一个引用类型的托管指针,方法内部可以修改托管指针指向的外部变量。
    class Program
    {
        static void Main()
        {
            int a = 10;
            int b = 20;
            
            ref int p1 = ref a; // 声明一个托管指针,指向变量 a 的地址
            ref int p2 = ref b; // 声明一个托管指针,指向变量 b 的地址
    
            Modify(ref p1, ref p2); // 传递托管指针作为参数
    
            Console.WriteLine(a); // 输出 11
            Console.WriteLine(b); // 输出 22
        }
    
        static void Modify(ref int p1, ref int p2)
        {
            p1 = 11; // 修改托管指针 p1 指向的变量 a 的值
    
            p1 = ref p2; // 将托管指针 p1 指向变量 b 的地址,但托管指针本身是值传递的,不会影响原变量 a 的值,这边修改的只是作为参数的 p1 的值
            
            p1 = 22; // 修改托管指针 p1 指向的变量 b 的值
        }
        }
    
  2. in 关键字:表示参数是一个只读的托管指针,方法内部不能修改托管指针指向的外部变量。
    class Program
    {
        static void Main()
        {
            int a = 10;
            int b = 20;
            
            ref int p1 = ref a; // 声明一个托管指针,指向变量 a 的地址
            ref int p2 = ref b; // 声明一个托管指针,指向变量 b 的地址
    
            Modify(ref p1, ref p2); // 传递托管指针作为参数
    
            Console.WriteLine(a); // 输出 10
            Console.WriteLine(b); // 输出 20
        }
    
        static void Modify(in int p1, in int p2)
        {
            // p1 = 11; // 错误:不能修改 in 托管指针指向的变量 a 的值
            p1 = ref p2; // 无效:不能修改 in 托管指针 ref int p1 的指向
        }
    }
    
  3. out 关键字:表示参数是一个输出参数,方法内部必须通过托管指针对其指向的外部变量赋值。
    class Program
    {
        static void Main()
        {
            int a = 10;
            int b = 20;
    
            Modify(out a, out b); // 传递托管指针作为参数
    
            Console.WriteLine(a); 
            Console.WriteLine(b);
        }
    
        static void Modify(out int p1, out int p2)
        {
            p1 = 11; // 修改 p1 指向的变量 a 的值,不赋值会报错
            p2 = 22; // 修改 p2 指向的变量 b 的值,不赋值会报错
    
            p1 = ref p2; // 无效:不能修改 out 托管指针 ref int p1 的指向
        }
    }
    
  4. readonly ref 关键字:按目前的标准,作为参数时和 in 关键字的效果是一样的。
    class Program
    {
        static void Main()
        {
            int a = 10;
    
            ref int p = ref a; // 声明一个托管指针,指向变量 a 的地址
    
            ModifyRef(ref p);
            ModifyRefReadonly(ref p);
            ModifyInt(in p);
        }
    
        static void ModifyRef(ref int p)
        {
            Console.WriteLine(p); // 可以读取托管指针指向的变量的值
            p = 11; // 修改托管指针指向的变量的值
        }
    
        static void ModifyInt(in int p)
        {
            Console.WriteLine(p); // 可以读取 in 托管指针指向的变量的值
            p = 11; // 错误:不能修改 in 托管指针指向的变量的值
        }
    
        static void ModifyRefReadonly(ref readonly int p)
        {
            Console.WriteLine(p); // 可以读取 ref readonly 托管指针指向的变量的值
            p = 11; // 错误:不能修改 in 托管指针指向的变量的值
        }
    }
    

ref readonly 托管指针#

在声明作为局部变量的托管指针时,可以使用 ref readonly 关键字,表示无法通过这个托管指针修改其指向的数据,但是可以修改托管指针的指向。

class Program
{
    static void Main()
    {
        int a = 10;

        // 声明一个 ref readonly 托管指针,指向变量 a 的地址
        ref readonly int p1 = ref a;

        // p1 = 20; // 错误:无法修改指向的变量的值

        int b = 20;

        p1 = ref b; // 可以指向其他变量

        Console.WriteLine(p1); // 输出 20
        Console.WriteLine(a); // 输出 10,a 的值没有改变
    }
}

作为 ref struct 的字段的托管指针#

ref struct 表示一个引用类型的结构体,具有类似于托管指针的限制。

在 ref struct 可以声明托管指针作为字段。

注意:只能在 ref struct 的构造函数中对 ref 字段 进行初始化,不支持初始化器初始化或者实例化完成之后的初始化,否则将触发 NullReferenceException

using System.Runtime.CompilerServices;

var foo = new Foo();

// 不能用 == null 来判断,会触发 NullReferenceException
// Console.WriteLine(foo.Value == null);

// 只能用 Unsafe.IsNullRef 来判断
Console.WriteLine(Unsafe.IsNullRef(foo.Value));

// 不能在 ref struct 实例化完成之后对 ref 字段进行初始化,会触发 NullReferenceException
// foo.Value = 1;

// 只能在 ref struct 的构造函数中对 ref 字段进行初始化
int value = 1;
var bar = new Bar(ref value);

Console.WriteLine(bar.Value);

ref struct Foo
{
    public ref int Value;
}

ref struct Bar
{
    public Bar(ref int value)
    {
        Value = ref value;
    }

    public ref int Value;
}

有几种方式可以声明 ref struct 的字段:

  1. ref 关键字:表示字段是一个引用类型的托管指针,可以修改指针指向的数据以及修改指针的指向。
    var a = 1;
    var foo = new Foo(ref a);
    
    Console.WriteLine(foo.Value); // 输出 1
    
    // 修改指针指向的数据
    foo.Value = 11;
    
    Console.WriteLine(a); // 输出 11
    
    // 修改指针的指向
    var b = 2;
    
    // 将指针重新指向 b
    foo.Value = ref b;
    
    Console.WriteLine(foo.Value); // 输出 2
    
    ref struct Foo
    {
        // 声明一个托管指针,指向 int 类型的值
        public ref int Value;
    
        public Foo(ref int value)
        {
            // 在构造函数中初始化托管指针
            Value = ref value;
        }
    }
    
  2. ref readonly 关键字:表示字段是一个指向只读数据的托管指针,不能修改指针指向的数据,但可以修改指针的指向。
    var a = 1;
    var foo = new Foo(ref a);
    
    Console.WriteLine(foo.Value); // 输出 1
    
    // foo.Value = 11; // 编译错误:不能修改只读数据
    
    // 修改指针的指向
    var b = 2;
    // 将指针重新指向 b
    foo.Value = ref b;
    Console.WriteLine(foo.Value); // 输出 2
    
    ref struct Foo
    {
        // 声明一个指向只读数据的托管指针,指向 int 类型的值
        public ref readonly int Value;
    
        public Foo(ref int value)
        {
            // 在构造函数中初始化托管指针
            Value = ref value;
        }
    }
    
  3. readonly ref 关键字:表示字段是一个只读的托管指针,不能修改指针的指向,但可以修改指针指向的数据。
    var a = 1;
    var foo = new Foo(ref a);
    
    Console.WriteLine(foo.Value); // 输出 1
    
    // 修改指针指向的数据
    foo.Value = 11;
    Console.WriteLine(a); // 输出 11
    
    // 修改指针的指向
    var b = 2;
    // 将指针重新指向 b
    // foo.Value = ref b; // 编译错误:不能修改只读指针的指向
    
    ref struct Foo
    {
        // 声明一个只读的托管指针,指向 int 类型的值
        public readonly ref int Value;
    
        public Foo(ref int value)
        {
            // 在构造函数中初始化托管指针
            Value = ref value;
        }
    }
    
  4. readonly ref readonly 关键字:表示字段是一个指向只读数据的只读托管指针,不能修改指针的指向,也不能修改指针指向的数据。
    var a = 1;
    var foo = new Foo(ref a);
    
    Console.WriteLine(foo.Value); // 输出 1
    
    // foo.Value = 11; // 编译错误:不能修改只读数据
    
    int b = 2;
    // 将指针重新指向 b
    // foo.Value = ref b; // 编译错误:不能修改只读指针的指向
    
    ref struct Foo
    {
        // 声明一个指向只读数据的只读托管指针,指向 int 类型的值
        public readonly ref readonly int Value;
    
        public Foo(ref int value)
        {
            // 在构造函数中初始化托管指针
            Value = ref value;
        }
    }
    

托管指针受 GC 管理#

托管指针受 GC 管理,不用关注指向的数据是否在 GC 过程中被移动。在 GC 过程中,托管指针会被自动更新为新的地址。

下面的例子中演示了用 指针(Pointer)和 托管指针(Managed Pointer)分别指向数组元素的情况。

GetArrayElementPointer 方法中的数组对象在方法结束后失去了根引用,GC 会在下一次回收时将其回收。

GetArrayElementManagedPointer 方法中的数组对象在方法结束后仍然有托管指针作为根引用,GC 不会回收它。

unsafe class Program
{
    static void Main()
    {
        Console.WriteLine("before GC");

        // 获取指针
        int* p1 = GetArrayElementPointer(out var wr1);

        // 输出 true,表示数组对象仍然存在
        Console.WriteLine($"wr1.IsAlive: {wr1.IsAlive}");
        // 输出 1
        Console.WriteLine($"*p1: {*p1}");

        // 获取托管指针
        ref int p2 = ref GetArrayElementManagedPointer(out var wr2);

        // 输出 true,表示数组对象仍然存在
        Console.WriteLine($"wr2.IsAlive: {wr2.IsAlive}");
        // 输出 2
        Console.WriteLine($"p2: {p2}");

        GC.Collect();

        Console.WriteLine();
        Console.WriteLine("after GC");

        // 输出 false,表示数组对象已被回收
        Console.WriteLine($"wr1.IsAlive: {wr1.IsAlive}");
        // 输出 随机值,有可能是 0,也有可能是其他值
        Console.WriteLine($"*p1: {*p1}");

        // 输出 true,表示数组对象仍然存在
        Console.WriteLine($"wr2.IsAlive: {wr2.IsAlive}");
        // 输出 2
        Console.WriteLine($"p2: {p2}");
    }

    static int* GetArrayElementPointer(out WeakReference wr)
    {
        int[] arr = [1];

        wr = new WeakReference(arr);

        fixed (int* p = &arr[0])
        {
            return p;
        }
    }

    static ref int GetArrayElementManagedPointer(out WeakReference wr)
    {
        int[] arr = [2];

        wr = new WeakReference(arr);

        return ref arr[0];
    }
}

Unsafe.AsRef 方法#

Unsafe.AsRef<T> 有两个重载:

  1. AsRef<T>(Void*): 将非托管指针转换为指向 类型的 T值的托管指针。
    using System.Runtime.CompilerServices;
    
    unsafe class Program
    {
        static void Main()
        {
            int a = 10;
            int* p = &a;
    
            // 将非托管指针转换为指向 int 的托管指针
            ref int p1 = ref Unsafe.AsRef<int>(p);
    
            Console.WriteLine(p1); // 输出 10
    
            p1 = 20; // 修改托管指针 p1 指向的值,即修改 a 的值
    
            Console.WriteLine(a); // 输出 20
        }
    }
    
  2. AsRef<T>(T): 将给定的 ref readonly 托管指针重新解释为可以修改指向的值的托管指针。

    可以修改 ref readonly 托管指针指向的值。

    using System.Runtime.CompilerServices;
    
    class Program
    {
        static void Main()
        {
            int a = 10;
    
            // 声明一个 ref readonly 托管指针,指向变量 a 的地址
            ref readonly int p1 = ref a;
    
            // 将 ref readonly 托管指针转换为普通的托管指针
            ref int p2 = ref Unsafe.AsRef<int>(p1);
    
            Console.WriteLine(p2); // 输出 10
    
            p2 = 20; // 修改托管指针 p2 指向的值,即修改 a 的值
    
            Console.WriteLine(a); // 输出 20
            Console.WriteLine(p1); // 输出 20,p1 仍然指向 a 的地址
        }
    }
    

    也可以修改 ref struct 的 ref readonly 或 readonly ref readonly 字段的值。

    using System.Runtime.CompilerServices;
    
    var a = 1;
    var foo = new Foo(ref a);
    
    Console.WriteLine(foo.Value); // 输出 1
    
    ref int p = ref  Unsafe.AsRef(foo.Value); // 获取指向 foo.Value 的指针
    
    p = 11; // 修改指针指向的值
    
    Console.WriteLine(a); // 输出 11
    
    ref struct Foo
    {
        // 声明一个指向只读数据的只读托管指针,指向 int 类型的值
        public readonly ref readonly int Value;
    
        public Foo(ref int value)
        {
            // 在构造函数中初始化托管指针
            Value = ref value;
        }
    }

修复HTTPS升级后出现 Mixed Content: The page at 'https://xxx' was loaded over HTTPS, but requested an insecure frame 'http://xxx'. This request has been blocked; the content must be served over HTTPS. 的问题 - 脆皮鸡 - 博客园

mikel阅读(360)

来源: 修复HTTPS升级后出现 Mixed Content: The page at ‘https://xxx’ was loaded over HTTPS, but requested an insecure frame ‘http://xxx’. This request has been blocked; the content must be served over HTTPS. 的问题 – 脆皮鸡 – 博客园

背景

image

  • 由于需要使用摄像头拍照,需要将原来的http升级到https,通过一顿捣鼓,升级成功。
  • 不过页面加载出现了问题,具体的提示是说:你的页面是在https环境,但是你访问了一个资源(我这里是iframe,也可能是stylesheet等其他资源),而这个资源是在http环境下的,浏览器不给你这样玩。
  • https只能访问https的资源,也因为此修改了接口的baseURL。

解决办法

在nginx 配置中加上 add_header Content-Security-Policy "upgrade-insecure-requests"; 这一条配置即可。

# 让 http 能够自动转发到 https
server {
listen 80;
server_name yourdomain.com;
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name yourdomain.com;
ssl_certificate yourcrt.pem;
ssl_certificate_key yourkey.pem;
ssl_session_timeout 5m;
location / {
proxy_pass http://127.0.0.1:1234;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# 加上这条即可 👇
add_header Content-Security-Policy “upgrade-insecure-requests”;
}
}

为什么HTTPS和HTTP不能混用?

  1. 混合内容问题:当一个安全的 HTTPS 页面试图加载非安全的 HTTP 内容时,这种情况被称为“混合内容”。浏览器通常会阻止这种行为,因为它降低了整个页面的安全性。
  2. 安全风险:HTTP 内容没有加密,易受中间人攻击。如果在 HTTPS 页面中加载 HTTP 内容,攻击者可能利用这个未加密的内容来攻击整个页面,比如通过注入恶意脚本。
  3. 隐私和完整性:HTTPS 旨在保护用户数据的隐私和完整性。混合内容使得 HTTPS 页面的这些保障部分失效,因为嵌入的 HTTP 内容不受同样的保护。
  4. 用户信任:用户可能信任一个安全的 HTTPS 页面,如果这个页面包含不安全的内容,这可能误导用户,使他们对整个页面的安全性有错误的理解。

如果实在要混用怎么办?

参考这篇文章:如何在 https 的 iframe 里访问 http 页面? | nginx应用实战-3
原理就是在服务器访问http拿到页面,然后包装成https再返回来。
其实最好就是原http页面也升级成https,上面的方法是针对原http不是自己写的没法改的情况下。

.net core 3 web api jwt 一直 401 - VAllen - 博客园

mikel阅读(204)

来源: .net core 3 web api jwt 一直 401 – VAllen – 博客园

最近在给客户开发 Azure DevOps Exension, 该扩展中某个功能需要调用使用 .NET Core 3 写的 Web Api。
在拜读了 Authenticating requests to your service 之后,我给 Web Api 增加了 JWT 认证。

PS: 我没有照抄代码,问题出现了…..问题出现了…..问题出现了…..

Postman 请求该 Web Api, 一直报 401 Unauthorized, 无论我换何种姿势请求, 都是 401 Unauthorized.
心中哪个老火啊, 上 jwt.io 校验了一下,是合法通过的,就是不知道为毛 Postman 不通过.
百思不得其解,度娘了一下, 有个标题引起了我的注意——.net core 3 web api jwt 一直 401
进去看了一眼,恍然大悟!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

答案竟然是:

在 app.UseAuthorization(); 之前加上 app.UseAuthentication(); 就可以了。

心中十万个草泥马在奔腾,因为这两个方法签名的单词太接近了,而且我英语很烂所以对相似的单词都是一眼略过,以为是一样的就没有照搬添加 app.UseAuthentication();
结果乌龙就发生了…

以下内容转载自:https://www.iteye.com/blog/lucky16-2020198

认证 (authentication) 和授权 (authorization) 的区别

以前一直分不清 authentication 和 authorization,其实很简单,举个例子来说:

你要登机,你需要出示你的身份证和机票,身份证是为了证明你张三确实是你张三,这就是 authentication;
而机票是为了证明你张三确实买了票可以上飞机,这就是 authorization。

在 computer science 领域再举个例子:

你要登陆论坛,输入用户名张三,密码1234,密码正确,证明你张三确实是张三,这就是 authentication;
再一check用户张三是个版主,所以有权限加精删别人帖,这就是 authorization。

如何把ASP.NET Core WebApi打造成Mcp Server - yi念之间 - 博客园

mikel阅读(282)

来源: 如何把ASP.NET Core WebApi打造成Mcp Server – yi念之间 – 博客园

前言#

MCP (Model Context Protocol)即模型上下文协议目前不要太火爆了,关于它是什么相信大家已经很熟悉了。目前主流的AI开发框架和AI工具都支持集成MCP,这也正是它的意义所在。毕竟作为一个标准的协议,当然是更多的生态接入进来才会有意义。使用MCP我们可以把Tools调用标准化,这意味着我们可以忽略语言、框架快速把工具融合到不同的模型中去。现在,如何把现有的业务逻辑快速的接入到模型中,成为模型落地很关键的一步,今天我们就借助微软的Semantic KernelMicrosoft.Extensions.AI框架,通过简单的示例展示,如何把现有的ASP NET Core WebApi转换成MCP Server

概念相关#

接下来我们大致介绍一下本文设计到的相关的概念以及涉及到的相关类库

MCP#

MCP是一个开放协议,它为应用程序向 LLM 提供上下文的方式进行了标准化。它的重点是标准化,而不是取代谁。它涉及到几个核心的概念

  • MCP Hosts: 如Claude DesktopIDEAI工具、或者是你开发的AI程序等
  • MCP Clients: 维护与MCP Servers一对一连接的协议客户端
  • MCP Servers: 轻量级程序,通过标准的Model Context Protocol提供特定能力

简单来说就是你写的AI应用就是MCP Hosts,因为MCP是一个协议,所以你需要通过MCP Clients访问MCP ServersMCP Servers提供的就是工具或者一些其他能力。需要说明的是,如果想在AI应用中使用MCP,模型需要支持Function Calling,当然如果你能通过提示词的方式调试出来也是可以的,但是效果肯定不如本身就支持Function Calling

因为MCP是一个开放协议,所以我们可以把原来固定在AI应用里的工具代码单独抽离出来,使用不同的开发语言形成独立的应用,这样这个Tools应用就可以和AI应用隔离,他们可以不是同一种语言,甚至可以在不同的机器上。所以现在很多开源的组件和平台都可以提供自己的MCP Server了。就和没有微服务概念之前我们代码都写到一个项目里,有了微服务之后我们可以把不同的模块形成单独的项目,甚至可以使用不同的开发语言。可以通过HTTP、RPC等多种方式进行通信。

框架#

简单介绍一下本文涉及到的相关框架及地址:

  • Microsoft.Extensions.AI:微软提供的通过.NET实现AIGC操作的开发基础框架,提供了基础对话Function Calling等基础操作,使用简单扩展性强,支持OTEL要测协议监控模型调用情况。目前已适配Ollama、OpenAI、Azure OpenAI等。项目地址https://github.com/dotnet/extensions/tree/main/src/Libraries/Microsoft.Extensions.AI
  • Semantic Kernel:以Microsoft.Extensions.AI为基础(低版本的不是)打造的更强大的AI开发框架,提供了基础对话Function Calling功能的同时,还提供了多模态、RAG、智能体、流程处理等强大的应用级功能,有.NET、Python、Java三种语言版本。项目地址https://github.com/microsoft/semantic-kernel
  • mcpdotnet(modelcontextprotocol/csharp-sdk):原名为mcpdotnet,现在是.NET构建MCP的官方项目,可以使的Microsoft.Extensions.AI和Semantic Kernel快速的适配到MCP。项目地址https://github.com/modelcontextprotocol/csharp-sdk

实现#

整体来说实现的思路也很简单,因为Semantic Kernel支持加载OpenAPI格式的数据加载成它的Plugins,我们可以把Plugins转换成Microsoft.Extensions.AI提供的标准的AIFunction类型,通过mcpdotnet可以把AIFunction标准类型转换成mcpdotnetTools

WebApi#

我们需要新建一个ASP.NET Core WebAPI项目,用来完成查询天气的功能。首先,添加Swagger支持。当然你使用别的库也可以,这里的重点就是可以得到该项目接口的OpenAPI数据信息。

<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.1" />

其次,添加根据IP查询地址信息的功能

<PackageReference Include="IPTools.China" Version="1.6.0" />

因为IPTools使用的是SQLite数据库,所以需要把db加载到项目里。具体使用细节可以查看该库的具体地址https://github.com/stulzq/IPTools

<ItemGroup>
 <None Update="ip2region.db">
    <CopyToOutputDirectory>Always</CopyToOutputDirectory>
  </None>
</ItemGroup>

接下来实现具体功能的Controller代码

 /// <summary>
 /// 获取城市天气
 /// </summary>
 [ApiController]
 [Route("api/[controller]/[action]")]
 public class WeatherController(IHttpClientFactory _httpClientFactory) : ControllerBase
 {
     /// <summary>
     /// 获取当前时间
     /// </summary>
     /// <returns>当前时间</returns>
     [HttpGet]
     public string GetCurrentDate()
     {
         return DateTime.Now.ToString("MM/dd");
     }

     /// <summary>
     /// 获取当前城市信息
     /// </summary>
     /// <returns>当前城市信息</returns>
     [HttpGet]
     public async Task<IpInfo> GetLocation()
     {
         var httpClient = _httpClientFactory.CreateClient();
         IpData ipInfo = await httpClient.GetFromJsonAsync<IpData>("https://ipinfo.io/json");
         var ipinfo = IpTool.Search(ipInfo!.ip);
         return ipinfo;
     }

     /// <summary>
     /// 获取天气信息
     /// </summary>
     /// <param name="region">省份</param>
     /// <param name="city">城市</param>
     /// <param name="currentDate">日期(格式:月份/日期)</param>
     /// <returns>天气信息</returns>
     [HttpGet]
     public async Task<string> GetCurrentWeather(string region, string city, string currentDate)
     {
         var httpClient = _httpClientFactory.CreateClient();
         WeatherRoot weatherRoot = await httpClient.GetFromJsonAsync<WeatherRoot>($"https://cn.apihz.cn/api/tianqi/tqybmoji15.php?id=88888888&key=88888888&sheng={region!}&place={city!}")!;
         DataItem today = weatherRoot!.data!.FirstOrDefault(i => i.week2 == currentDate)!;
         return $"{today!.week2} {today.week1},天气{today.wea1}{today.wea2}。最高气温{today.wendu1}摄氏度,最低气温{today.wendu2}摄氏度。";
     }
 }

public class IpData
{
    public string ip { get; set; }
    public string city { get; set; }
    public string region { get; set; }
    public string country { get; set; }
    public string loc { get; set; }
    public string org { get; set; }
    public string postal { get; set; }
    public string timezone { get; set; }
    public string readme { get; set; }
}

public class DataItem
{
    public string week1 { get; set; }
    public string week2 { get; set; }
    public string wea1 { get; set; }
    public string wea2 { get; set; }
    public string wendu1 { get; set; }
    public string wendu2 { get; set; }
    public string img1 { get; set; }
    public string img2 { get; set; }
}

public class WeatherRoot
{
    public List<DataItem> data { get; set; }
    public int code { get; set; }
    public string place { get; set; }
}

代码里实现了三个action,分别是获取城市天气、获取当前城市信息、获取天气信息接口。接下来添加项目配置

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
    options.SwaggerDoc("v1", new OpenApiInfo
    {
        Version = "v1",
        Title = "",
        Description = "",
    });

    var xmlFilename = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
    options.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFilename));
});
builder.Services.AddHttpClient();

var app = builder.Build();

//使用OpenApi的版本信息
app.UseSwagger(options =>
{
    options.OpenApiVersion = Microsoft.OpenApi.OpenApiSpecVersion.OpenApi3_0;
});
app.UseSwaggerUI(options =>
{
    options.SwaggerEndpoint("/swagger/v1/swagger.json", "v1");
});

app.UseAuthorization();

app.MapControllers();

app.Run();

完成上面的代码之后,可以运行起来该项目。通过http://项目地址:端口/swagger/v1/swagger.json获取WebApi接口的OpenAPI的数据格式。

MCP Server#

接下来搭建MCP Server项目,来把上面的WebApi项目转换成MCP Server。首先添加MCPSemanticKernel OpenApi涉及到的类库,因为我们需要使用SemanticKernel来把swagger.json加载成Plugins

<ItemGroup>
  <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
  <PackageReference Include="Microsoft.SemanticKernel.Plugins.OpenApi" Version="1.47.0" />
  <PackageReference Include="ModelContextProtocol" Version="0.1.0-preview.11" />
</ItemGroup>

接下来我们来编写具体的代码实现

IKernelBuilder kernelBuilder = Kernel.CreateBuilder();;
Kernel kernel = kernelBuilder.Build();

#pragma warning disable SKEXP0040

//把swagger.json加载成Plugin
//这里也可以是本地路径或者是文件流
await kernel.ImportPluginFromOpenApiAsync(
   pluginName: "city_date_weather",
   uri: new Uri("http://localhost:5021/swagger/v1/swagger.json"),
   executionParameters: new OpenApiFunctionExecutionParameters 
   { 
       EnablePayloadNamespacing = true
   }
 );

#pragma warning restore SKEXP0040

var builder = Host.CreateEmptyApplicationBuilder(settings: null);
builder.Services
    //添加MCP Server
    .AddMcpServer()
    //使用Stdio模式
    .WithStdioServerTransport()
    //把Plugins转换成McpServerTool
    .WithTools(kernel.Plugins);

await builder.Build().RunAsync();


public static class McpServerBuilderExtensions
{
    /// <summary>
    /// 把Plugins转换成McpServerTool
    /// </summary>
    public static IMcpServerBuilder WithTools(this IMcpServerBuilder builder, KernelPluginCollection plugins)
    {
        foreach (var plugin in plugins)
        {
            foreach (var function in plugin)
            {
                builder.Services.AddSingleton(services => McpServerTool.Create(function.AsAIFunction()));
            }
        }

        return builder;
    }
}

MCP的传输层协议可以使用stdio(既标准输入输出)sse或者是streamable,甚至是自定义的方式进行通信。其中stdio可以本机进程间通信,sse或者是streamable进行远程通信。它的消息格式,或者理解为数据传输的格式是JSON-RPC 2.0

其中ImportPluginFromOpenApiAsync方法是其中比较关键的点,它是把OpenApi接口信息转换成Kernel Plugins。它通过读取swagger.json里的接口信息的元数据构建成KernelFunction实例,而具体的触发操作则转换成Http调用。具体的实现方式可以通过阅读CreateRestApiFunction方法源码的实现。

再次AsAIFunction方法则是把KernelFunctionFromMethod转换成KernelAIFunction,因为KernelFunctionFromMethod是继承了KernelFunction类,KernelAIFunction则是继承了AIFunction类,所以这个操作是把KernelFunction转换成AIFunction。可以把KernelAIFunction理解成KernelFunction的外观类,它只是包装了KernelFunction的操作,所以触发的时候还是KernelFunctionFromMethod里的操作。具体的实现可以查看 KernelAIFunction类的实现。

几句简单的代码既可以实现一个Mcp Server,虽然上面我们使用的是Uri的方式加载的OpenAPI文档地址,但是它也支持本地文件地址或者文件流的方式。不得不说微软体系下的框架在具体的落地方面做得确实够实用,因为具体的逻辑都是WebApi实现的,Mcp Server只是一个媒介。

MCP Client#

最后实现的是MCP Client是为了验证Mcp Server效果用的,这里可以使用任何框架来实现,需要引入ModelContextProtocol和具体的AI框架,AI框架可以是Microsoft.Extensions.AI,也可以是Semantic Kernel。这里我们使用Microsoft.Extensions.AI,因为它足够简单也足够简洁,引入相关的类库

<ItemGroup>
    <PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="9.4.3-preview.1.25230.7" />
    <PackageReference Include="ModelContextProtocol" Version="0.1.0-preview.12" />
</ItemGroup>

其中ModelContextProtocol提供了McpClient功能,Microsoft.Extensions.AI提供具体的AI功能集成。具体实现如下所示

//加载McpServer,以为我们构建的是使用Stdio的方式,所以这里直接使用McpServer路径即可
await using IMcpClient mcpClient = await McpClientFactory.CreateAsync(new StdioClientTransport(new()
{
    Name = "city_date_weather",
    Command = "..\\..\\..\\..\\McpServerDemo\\bin\\Debug\\net9.0\\McpServerDemo.exe"
}));

//加载MCP Tools
var tools = await mcpClient.ListToolsAsync();
foreach (AIFunction tool in tools)
{
    Console.WriteLine($"Tool Name: {tool.Name}");
    Console.WriteLine($"Tool Description: {tool.Description}");
    Console.WriteLine();
}

//中文的function calling,国内使用qwen-max系列效果最好
string apiKey = "sk-****";
var chatClient = new ChatClient("qwen-max-2025-01-25", new ApiKeyCredential(apiKey), new OpenAIClientOptions
{
    Endpoint = new Uri("https://dashscope.aliyuncs.com/compatible-mode/v1")
}).AsIChatClient();

IChatClient client = new ChatClientBuilder(chatClient)
    //开启function calling支持
    .UseFunctionInvocation()
    .Build();

//构建Tools
ChatOptions chatOptions = new()
{
    Tools = [.. tools],
};

//创建对话代码
List<Microsoft.Extensions.AI.ChatMessage> chatList = [];

string question = "";
do
{
    Console.Write($"User:");
    question = Console.ReadLine();

    if (string.IsNullOrWhiteSpace(question) || question == "exists")
    {
        break;
    }

    chatList.Add(new Microsoft.Extensions.AI.ChatMessage(ChatRole.User, question));

    Console.Write($"Assistant:");
    StringBuilder sb = new StringBuilder();
    await foreach (var update in client.GetStreamingResponseAsync(chatList, chatOptions))
    {
        if (string.IsNullOrWhiteSpace(update.Text))
        {
            continue;
        }
        sb.Append(update.Text);

        Console.Write(update.Text);
    }

    chatList.Add(new Microsoft.Extensions.AI.ChatMessage(ChatRole.Assistant, sb.ToString()));

    Console.WriteLine();

} while (true);

Console.ReadLine();

上面的代码实现了McpClient接入AI应用

  • 首先,通过McpClient加载McpServer里的工具
  • 其次,把MCP Tools加载到Microsoft.Extensions.AI
  • 最后,在和AI模型对话的时候把Tools转换成function calling。中文的function calling,个人体验下来国内使用qwen-max系列效果最好

其中mcpClient.ListToolsAsync()获取到的是McpClientTool集合,而McpClientTool继承自AIFunction类,具体可查看McpClientTool实现源码。由此可以看出微软封装Microsoft.Extensions.AI基座的重要性,以后更多的框架都可以围绕Microsoft.Extensions.AI进行封装统一操作,这样大大提升了扩展的便捷性。

当然,你也可以使用Semantic Kernel框架进行上面的操作,这里就不过多赘述了,直接上代码

//加载McpServer,以为我们构建的是使用Stdio的方式,所以这里直接使用McpServer路径即可
await using IMcpClient mcpClient = await McpClientFactory.CreateAsync(new StdioClientTransport(new()
{
    Name = "city_date_weather",
    Command = "..\\..\\..\\..\\McpServerDemo\\bin\\Debug\\net9.0\\McpServerDemo.exe"
}));

//加载MCP Tools
var tools = await mcpClient.ListToolsAsync();

using HttpClientHandler handler = new HttpClientHandler
{
    ClientCertificateOptions = ClientCertificateOption.Automatic
};

using HttpClient httpClient = new(handler)
{
    BaseAddress = new Uri("https://dashscope.aliyuncs.com/compatible-mode/v1")
};

#pragma warning disable SKEXP0070
IKernelBuilder kernelBuilder = Kernel.CreateBuilder();
kernelBuilder.AddOpenAIChatCompletion("qwen-max-2025-01-25", "sk-***", httpClient: httpClient);
//把Tools加载成sk的Plugins
kernelBuilder.Plugins.AddFromFunctions("weather", tools.Select(aiFunction => aiFunction.AsKernelFunction()));

Kernel kernel = kernelBuilder.Build();
var chatCompletionService = kernel.GetRequiredService<IChatCompletionService>();

PromptExecutionSettings promptExecutionSettings = new()
{
    FunctionChoiceBehavior = FunctionChoiceBehavior.Auto()
};

var history = new ChatHistory();

while (true)
{
    Console.Write($"User:");
    string input = Console.ReadLine();

    if (string.IsNullOrWhiteSpace(input) || input == "exists")
    {
        break;
    }

    history.AddUserMessage(input);
    var chatMessage = await chatCompletionService.GetChatMessageContentAsync(
    history,
    executionSettings: promptExecutionSettings,
    kernel: kernel);

    Console.WriteLine("Assistant:" + chatMessage.Content);

    history.AddAssistantMessage(chatMessage.Content);
}

Console.ReadLine();

因为MCP是一个协议标准,所以MCP Server可以做到一次构建,到处使用。

运行效果#

运行的时候需要先运行起来WebApi项目,然后把McpServer编译成exe文件,然后运行McpClient项目,我们打印出来了可用的Tools列表。在Client项目进行对话,询问当前天气效果如下

感兴趣的如果想运行具体的代码示例,可以查看我上传的代码示例https://github.com/softlgl/McpDemo

总结#

本文演示了如何把ASP.NET Core WebApi打造成Mcp Server,通过讲解基本概念,介绍使用的框架,以及简单的示例展示了这一过程,整体来说是比较简单的。MCP的重点是标准化,而不是取代。如果想在AI应用中使用MCP,模型需要支持Function Calling.我们可以把原来固定在AI应用里的工具代码单独抽离出来,形成独立的应用,这样这个Tools应用就可以和AI应用隔离,形成独立可复用的工具。

现在AI大部分时候确实很好用,但是它也不是银弹。至于它的边界在哪里,只有不断地使用实践。你身边的事情都可以先用AI尝试去做,不断地试探它的能力。AI帮你做完的事情,如果能达到你的预期,你可以看它的实现方式方法,让自己学习到更好的思路。如果是完全依赖AI,而自己不去思考,那真的可能会被AI取代掉。只有你自己不断的进步,才能进一步的探索AI,让它成为你的好工具。