ASP.NET MVC 中的HttpContextWrapper怎么获取POST请求的所有参数内容

mikel阅读(481)

ASP.NET MVC中,可以通过HttpContextWrapper获取POST请求的所有参数内容。以下是具体方法和步骤:

1. 获取表单数据(Form Data)

适用于application/x-www-form-urlencodedmultipart/form-data类型的POST请求:

csharp

// 创建HttpContextWrapper实例(通常在控制器中可直接使用Request属性)
var httpContextWrapper = new HttpContextWrapper(System.Web.HttpContext.Current);

// 获取POST表单数据
NameValueCollection formData = httpContextWrapper.Request.Form;

// 遍历所有参数
foreach (string key in formData.AllKeys)
{
    string value = formData[key];
    // 处理键值对(例如:key="username", value="John")
}

2. 获取JSON/RAW请求体

适用于application/json或其他原始数据类型的POST请求:

csharp

using (var reader = new StreamReader(httpContextWrapper.Request.InputStream))
{
    // 重置流位置(重要!)
    httpContextWrapper.Request.InputStream.Seek(0, SeekOrigin.Begin);
    
    // 读取原始内容
    string rawBody = reader.ReadToEnd();
    
    // 示例:解析JSON
    // dynamic jsonData = JsonConvert.DeserializeObject(rawBody);
}

3. 获取查询字符串参数(Query String)

如果POST请求同时包含URL查询参数:

csharp

NameValueCollection queryParams = httpContextWrapper.Request.QueryString;
// 遍历方式同Form

4. 统一获取所有参数(Form + QueryString)

csharp

NameValueCollection allParams = httpContextWrapper.Request.Params; // 包含Form和QueryString

关键注意事项:

  1. 流的位置重置
    读取InputStream前必须重置流位置,因为ASP.NET默认不会重置流:csharphttpContextWrapper.Request.InputStream.Seek(0, SeekOrigin.Begin);
  2. 内容类型检查
    根据Content-Type选择处理方式:csharpif (httpContextWrapper.Request.ContentType.Contains(“application/json”)) { // 处理JSON } else if (httpContextWrapper.Request.ContentType.Contains(“application/x-www-form-urlencoded”)) { // 处理表单 }
  3. 在控制器中的简化写法
    在Controller中可直接用基类属性(无需手动创建Wrapper):csharppublic ActionResult YourAction() { var formData = Request.Form; // 表单数据 var jsonData = new StreamReader(Request.InputStream).ReadToEnd(); // 原始Body return View(); }

完整示例(获取所有POST参数并转为字典):

csharp

        /// <summary>
/// 获取所有请求参数
/// </summary>
/// <param name="context">http内容</param>
/// <returns></returns>
public Dictionary<string, string> GetAllPostParameters(HttpContextBase context)
{
var parameters = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

// 处理表单数据
foreach (string key in context.Request.Form.AllKeys)
{
parameters[key] = context.Request.Form[key];
}

// 处理 JSON 数据
if (context.Request.ContentType?.StartsWith("application/json", StringComparison.OrdinalIgnoreCase) == true)
{
string json = new StreamReader(context.Request.InputStream, Encoding.UTF8).ReadToEnd();
try
{
// 重置并读取流
context.Request.InputStream.Seek(0, SeekOrigin.Begin);


if (string.IsNullOrWhiteSpace(json))
{
// 记录空 JSON 警告
log.Debug("收到空的 JSON 请求体");
return parameters;
}

// 尝试解析 JSON
var jsonData = JObject.Parse(json);

foreach (var prop in jsonData.Properties())
{
parameters[prop.Name] = prop.Value?.ToString() ?? string.Empty;
}
}
catch (JsonReaderException jex)
{
// 处理 JSON 解析错误
log.Error($"JSON 解析错误: {jex.Message}");

// 尝试解析为查询字符串格式
try
{
var keyValues = HttpUtility.ParseQueryString(json);
foreach (string key in keyValues.AllKeys)
{
parameters[key] = keyValues[key];
}
}
catch
{
// 如果两种方式都失败,保存原始内容
parameters["__raw_body"] = json;
}
}
catch (Exception ex)
{
log.Error($"处理 JSON 请求时出错: {ex.Message}");
}
}

return parameters;
}

// 使用示例
var wrapper = new HttpContextWrapper(HttpContext.Current);
var allParams = GetAllPostParameters(wrapper);

推荐做法:

  • 优先使用模型绑定ASP.NET MVC的模型绑定机制更安全高效。csharp[HttpPost] public ActionResult Submit(YourViewModel model) // 自动绑定POST数据 { // 直接使用model.Property }
  • 直接操作Request对象仅适用于动态数据或特殊情况。

MySQL误删数据了,如何快速恢复? - 苏三说技术 - 博客园

mikel阅读(233)

来源: MySQL误删数据了,如何快速恢复? – 苏三说技术 – 博客园

前言

最近星球中有位小伙伴说:他不小心把测试环境MySQL表中所有数据都误删了,问我要如何快速恢复?

幸好他误删的是测试环境,非生产环境。

我遇到过,之前有同事把生产环境会员表中的数据误删除的情况。

这篇文章跟大家一起聊聊MySQL如果误删数据了,要如何快速恢复。

希望对你会有所帮助。

1.为什么数据恢复如此重要?

2023年某电商平台误删20万用户数据,导致直接损失800万

某金融机构DBA误执行DROP TABLE,系统停摆6小时

这些事故背后,暴露的是误删数据之后恢复方案的缺失。

数据丢失的三大元凶

  1. 人为误操作(占75%):DELETE忘加WHERE、DROP TABLE手滑
  2. 程序BUG(占20%):循环逻辑错误、事务未回滚
  3. 硬件故障(占5%):磁盘损坏、机房断电

下面是数据丢失的主要原因:
image

那么,如果MySQL如果误删数据了,快速恢复数据的方案有哪些呢?

2.常见的数据恢复方案

方案1:Binlog日志恢复

该方案最常用。

适用场景:误执行DELETE、UPDATE

恢复流程

image

操作步骤

  1. 定位误操作位置
mysqlbinlog --start-datetime="2023-08-01 14:00:00" \
           --stop-datetime="2023-08-01 14:05:00" \
           mysql-bin.000001 > /tmp/err.sql
  1. 提取回滚SQL(使用python工具)
# parse_binlog.py
import pymysql
from pymysqlreplication import BinLogStreamReader

stream = BinLogStreamReader(
   connection_settings = {
       "host": "127.0.0.1",
       "port": 3306,
       "user": "root",
       "passwd": "root"},
   server_id=100,
   blocking=True,
   resume_stream=True,
   only_events=[DeleteRowsEvent, UpdateRowsEvent])

for binlogevent in stream:
   for row in binlogevent.rows:
       if isinstance(binlogevent, DeleteRowsEvent):
           # 生成INSERT语句
           print(f"INSERT INTO {binlogevent.table} VALUES {row['values']}")
       elif isinstance(binlogevent, UpdateRowsEvent):
           # 生成反向UPDATE
           print(f"UPDATE {binlogevent.table} SET {row['before_values']} WHERE {row['after_values']}")
  1. 执行恢复
python parse_binlog.py | mysql -u root -p db_name

方案2:延迟复制从库

该方案是金融级的方案。

适用场景:大规模误删数据

架构原理
image

配置步骤

  1. 设置延迟复制
STOP SLAVE;
CHANGE MASTER TO MASTER_DELAY = 1800; -- 延迟30分钟(1800秒)
START SLAVE;
  1. 误删后立即停止同步
STOP SLAVE;
  1. 将延迟从库提升为主库
RESET SLAVE ALL;
SHOW MASTER STATUS; -- 记录binlog位置

方案3:全量备份+增量恢复

适用场景:整表或整库误删

恢复流程

image

操作步骤

  1. 恢复全量备份
mysql -u root -p db_name < full_backup_20230801.sql
  1. 应用增量日志(跳过误操作点)
mysqlbinlog --start-position=100 --stop-position=500 \
          mysql-bin.000001 | mysql -u root -p

方案4:Undo日志恢复

该方案是InnoDB特有的。

适用场景:刚提交的误操作(事务未关闭)

核心原理
image

操作步骤

  1. 查询事务信息
SELECT * FROM information_schema.INNODB_TRX;
  1. 定位Undo页
SHOW ENGINE INNODB STATUS;
  1. 使用undrop-for-innodb工具
./undrop-for-innodb/system_parser -t user_data /var/lib/mysql/ibdata1

方案5:文件恢复

从物理备份中恢复,需要提前做备份。

适用场景:DROP TABLE误操作

恢复流程

image

操作步骤

  1. 安装恢复工具
yum install testdisk -y
  1. 扫描磁盘
photorec /dev/sdb1
  1. 重建表结构
CREATE TABLE user_data (...) ENGINE=InnoDB;
  1. 导入表空间
ALTER TABLE user_data DISCARD TABLESPACE;
cp recovered.ibd /var/lib/mysql/db_name/user_data.ibd
ALTER TABLE user_data IMPORT TABLESPACE;

方案6:云数据库快照恢复

适用场景:阿里云RDS、AWS RDS等云服务

操作流程(以阿里云为例)

image

最佳实践

  1. 设置策略:
    • 保留7天快照
    • 每4小时增量备份
  2. 误删后操作:
# 通过SDK创建临时实例
aliyun rds CloneInstance --DBInstanceId rm-xxxx \
                       --BackupId 111111111 \
                       --PayType Postpaid

3、恢复方案对比选型

方案 恢复粒度 时间窗口 复杂度 适用场景
Binlog日志恢复 行级 分钟级 小范围误删
延迟复制从库 库级 小时级 核心业务数据
全量+增量恢复 库级 小时级 整库丢失
Undo日志恢复 行级 秒级 极高 事务未提交
文件恢复 表级 不确定 极高 DROP TABLE操作
云数据库快照 实例级 分钟级 云环境

4.如何预防误删数据的情况?

4.1 权限控制(事前预防)

核心原则:最小权限分配

-- 禁止开发直接操作生产库
REVOKE ALL PRIVILEGES ON *.* FROM 'dev_user'@'%';

-- 只读账号配置
GRANT SELECT ON app_db.* TO 'read_user'@'%';

-- DML权限分离
CREATE ROLE dml_role;
GRANT INSERT, UPDATE, DELETE ON app_db.* TO dml_role;

4.2 操作规范(事中拦截)

  1. SQL审核:所有DDL必须走工单
  2. 高危操作确认:执行DROP前二次确认
-- 危险操作示例
DROP TABLE IF EXISTS user_data; -- 必须添加IF EXISTS
  1. WHERE条件检查:DELETE前先SELECT验证

4.3 备份策略(事后保障)

黄金备份法则:321原则

  • 3份备份(本地+异地+离线)
  • 2种介质(SSD+磁带)
  • 1份离线存储

总结

下面给大家总了数据恢复的三要三不要。

三要

  1. 立即冻结现场:发现误删马上锁定数据库。
  2. 优先使用Binlog:90%场景可通过日志恢复。
  3. 定期演练恢复:每季度做恢复测试。

三不要

  1. 不要心存侥幸:认为误删不会发生在自己身上。
  2. 不要盲目操作:恢复前先备份当前状态。
  3. 不要忽视监控:设置删除操作实时告警。

设计系统时,永远假设明天就会发生数据误删。

当灾难真正降临时,你会发现所有的预防措施都是值得的。

最后说一句(求关注,别白嫖我)

如果这篇文章对您有所帮助,或者有所启发的话,帮忙关注一下我的同名公众号:苏三说技术,您的支持是我坚持写作最大的动力。

求一键三连:点赞、转发、在看。

关注公众号:【苏三说技术】,在公众号中回复:进大厂,可以免费获取我最近整理的10万字的面试宝典,好多小伙伴靠这个宝典拿到了多家大厂的offer。

本文收录于我的技术网站http://www.susan.net.cn

最强ORM让你开发效率提升百倍 - 薛家明 - 博客园

mikel阅读(273)

来源: 最强ORM让你开发效率提升百倍 – 薛家明 – 博客园

最强ORM让你开发效率提升百倍

easy-query在经过2年的迭代目前已经在查询领域可以说是无敌的存在,任何orm都不是对手,这几年的功能点简单罗列一下

[x] 动态join:查询涉及到对应的关系表就会自动添加join反之则不会讲join加入到SQL中(2025年了感觉也不是什么新鲜特性了)
[x] 结构化DTO,自动根据DTO和表关系的路径自动筛选出需要的结构化属性(开发效率杀手)
[x] 结构化DTO额外配置,支持结构化DTO的返回下还能额外添加查询条件和筛选条件
[x] 隐式PARTITION BY
[X] 隐式子查询
[x] 子查询转GroupJoin全世界应该是独一份的功能,解决多对多深层关系在ORM中的子查询过多导致的性能问题真正解决了ORM在复杂查询下开发效率和性能的兼顾

框架地址 https://github.com/dromara/easy-query
文档地址 https://www.easy-query.com/easy-query-doc/
该文章demo地址 https://github.com/xuejmnet/eq-doc

刚好前几天我看到公众号有篇关于efcore的性能文章,我看了其实我一眼就知道了他的问题就是eq的子查询转GroupJoin但是正如强大的efcore也是没有实现该功能,话不多说本章节我们将入门通过公众号的demo实现大部分帖子相关的查询和功能

建模

实体关系如下:

  • 用户User:每个用户有多篇帖子和多条评论和多个点赞
  • 分类Category:帖子所属分类类目支持多个分类一个帖子或者多个帖子公用同一个分类
  • 帖子Post:每篇帖子有多个分类并可获得多个赞
  • 评论Comment:每条评论属于一个用户并关联一篇帖子 且评论支持楼中楼
  • 点赞Like:每个赞关联一篇帖子,多个点赞可以关联同一篇帖子
  • 分类帖子关联CategoryPost:帖子和分类的关联关系表

entity-relation

点击查看实体代码

帖子相关查询

帖子分页

对Post表进行分页按publishAt倒序进行排序按title进行搜索

首先我们定一个公用类


@Data
public class PageRequest {
    private Integer pageIndex=1;
    private Integer pageSize=5;
}

定义请求参数

@Data
public class PostPageRequest extends PageRequest {
    private String title;
}

分页动态条件

    @PostMapping("/page")
    public EasyPageResult<Post> page(@RequestBody PostPageRequest request) {
        return easyEntityQuery.queryable(Post.class)
                .where(t_post -> {
//                    if(EasyStringUtil.isNotBlank(request.getTitle())){
//                        t_post.title().contains(request.getTitle());
//                    }
                    t_post.title().contains(EasyStringUtil.isNotBlank(request.getTitle()),request.getTitle());
                })
                .orderBy(t_post -> t_post.publishAt().desc())
                .toPageResult(request.getPageIndex(),request.getPageSize());
    }

这边提供了两种方式实现动态查询,当title不为空的时候加入表达式筛选,执行我们来看看实际情况

  • 使用if函数包裹表达式断言,支持任意java表达式
  • 使用断言函数第一个参数重载,默认第一个参数为true才会执行断言操作
  • 使用where重载第一个参数为true执行当前where

请求参数

{"pageIndex":1,"pageSize":5,"title":"电影"}
==> Preparing: SELECT COUNT(*) FROM `t_post` WHERE `title` LIKE CONCAT('%',?,'%')
==> Parameters: 电影(String)


==> Preparing: SELECT `id`,`title`,`content`,`user_id`,`publish_at` FROM `t_post` WHERE `title` LIKE CONCAT('%',?,'%') ORDER BY `publish_at` DESC LIMIT 3
==> Parameters: 电影(String)

container还是like!!!
> 细心地朋友会发现我们使用了contains函数而不是like函数,因为当传入的查询条件本身带有%时那么like会让%变成通配符,而contains会将%视为被查询的一部分,这是需要用户注意的,具体使用contains还是like应该有用户自行决断

推荐写法🔥: 可能由用户会问如果我添加的条件有很多怎么办难道每一个都要这么写一遍吗?eq贴心的提供了多种方式来实现动态查询比如filterConfigure


    easyEntityQuery.queryable(Post.class)
          .filterConfigure(NotNullOrEmptyValueFilter.DEFAULT_PROPAGATION_SUPPORTS)
          .where(t_post -> {
              t_post.title().contains(request.getTitle());
          })
          .orderBy(t_post -> t_post.publishAt().desc())
          .toPageResult(pageIndex,pageSize);

通过添加filterConfigure支持让参数为null不参与业务,如果是字符串则必须保证isNotBlank,当然用户也可以通过自己的自定义来实现

更多的动态条件设置请参考文档

> 正常我们推荐使用filterConfigure或者使用if函数包裹条件而不是使用方法参数的第一个boolean类型来控制,因为参数boolean类型重载相对会让表达式不够直观且难以阅读所以我们极力推荐另外几种方式

我们学会了如何在单表查询分页下使用动态参数控制SQL,那么接下来我们将学习如何使用参数外部控制动态排序

分页动态排序

首先我们对请求的条件进行修改

@Data
public class PostPage3Request extends PageRequest {
    private String title;

    private List<InternalOrder> orders;

    @Data
     public static class InternalOrder{
         private String property;//这个是查询Post内的属性字段
         private boolean asc;//表示是否需要正序排序
     }
}

@PostMapping("/page3")
public EasyPageResult<Post> page3(@RequestBody PostPage3Request request) {
    return easyEntityQuery.queryable(Post.class)
            .filterConfigure(NotNullOrEmptyValueFilter.DEFAULT_PROPAGATION_SUPPORTS)
            .where(t_post -> {
                t_post.title().contains(request.getTitle());
            })
            //这个request.getOrders()!=null为true才会执行后续的方法也可以使用if包裹
            //当然如果你能确保request.getOrders()肯定不等于null的那么不需要加这个判断
            .orderBy(request.getOrders()!=null,t_post -> {
                for (PostPage3Request.InternalOrder order : request.getOrders()) {
                    //anyColumn表示需要排序的字段,orderBy表示使用正序还是倒序
                    t_post.anyColumn(order.getProperty()).orderBy(order.isAsc());
                }
            })
            .toPageResult(request.getPageIndex(),request.getPageSize());
}

请求参数

{"pageIndex":1,"pageSize":5,"title":"","orders":[{"property":"publishAt","asc":false},{"property":"title","asc":true}]}

执行请求后生成的SQL

==> Preparing: SELECT COUNT(*) FROM `t_post`
<== Time Elapsed: 13(ms)

==> Preparing: SELECT `id`,`title`,`content`,`user_id`,`publish_at` FROM `t_post` ORDER BY `publish_at` DESC,`title` ASC LIMIT 5
<== Time Elapsed: 17(ms)

支持我们已经支持了分页的动态排序,当然动态排序功能远不止此,更多动态排序请点击链接

分页join筛选

当然对于大部分业务而言实体对象不是一个孤单对象,当前的Post对象也是如此,我们经常会有连表或者子查询等操作,那么eq是如何快速便捷的实现join的呢

下面这种通过关联关系实现join的操作我们称之为隐式join

  • 查询帖子要求查询条件是某个用户的

首先因为涉及到join那么eq提供了关联关系将原先的Post单表和用户表进行多对一的关联

通过插件生成关联关系

  • 第一步呼出ui界面

navigate2

  • 第二步设置关联关系

navigate-ui

选择好对应的关联键后点击确认插件会帮你自动生成强类型属性|lombok属性或字符串

当然你也可以手写关联关系

@Data
@Table("t_post")
@EntityProxy
@EasyAlias("t_post")
@EasyAssertMessage("未找到对应的帖子信息")
public class Post implements ProxyEntityAvailable<Post, PostProxy> {
    @Column(primaryKey = true, comment = "帖子id")
    private String id;
    @Column(comment = "帖子标题")
    private String title;
    @Column(comment = "帖子内容")
    private String content;
    @Column(comment = "用户id")
    private String userId;
    @Column(comment = "发布时间")
    private LocalDateTime publishAt;

    /**
     * 发帖人
     **/
    @Navigate(value = RelationTypeEnum.ManyToOne, selfProperty = {PostProxy.Fields.userId}, targetProperty = {UserProxy.Fields.id})
    private User user;
}

修改完实体对象后我们做了一个post.userId=user.id的关系接下来我们创建查询对象


@Data
public class PostPage4Request extends PageRequest {
    private String title;

    private String userName; ①

    private List<InternalOrder> orders;

    @Data
     public static class InternalOrder{
         private String property;
         private boolean asc;
     }
}

    @PostMapping("/page4")
    public EasyPageResult<Post> page4(@RequestBody PostPage4Request request) {
        return easyEntityQuery.queryable(Post.class)
                .filterConfigure(NotNullOrEmptyValueFilter.DEFAULT_PROPAGATION_SUPPORTS)
                .where(t_post -> {
                    t_post.title().contains(request.getTitle());
                    t_post.user().name().contains(request.getUserName());
                })
                .orderBy(request.getOrders()!=null,t_post -> {
                    for (PostPage4Request.InternalOrder order : request.getOrders()) {
                        t_post.anyColumn(order.getProperty()).orderBy(order.isAsc());
                    }
                })
                .toPageResult(request.getPageIndex(),request.getPageSize());
    }

  • ①是我们新添加的查询属性userName

接下来我们发送请求

{
	"pageIndex": 1,
	"pageSize": 5,
	"title": "",
	"userName": "用户A","orders": [{
		"property": "user.createAt","asc": false
	}, {
		"property": "title",
		"asc": true
	}]
}
==> Preparing: SELECT COUNT(*) FROM `t_post` t LEFT JOIN `t_user` t1 ON t1.`id` = t.`user_id` WHERE t1.`name` LIKE CONCAT('%',?,'%')
==> Parameters: 用户A(String)

==> Preparing: SELECT t.`id`,t.`title`,t.`content`,t.`user_id`,t.`publish_at` FROM `t_post` t LEFT JOIN `t_user` t1 ON t1.`id` = t.`user_id` WHERE t1.`name` LIKE CONCAT('%',?,'%') ORDER BY t1.`create_at` DESC,t.`title` ASC LIMIT 3
==> Parameters: 用户A(String)

  • ①我们使用了用户名称作为筛选条件
  • ②我们使用了用户下的创建时间作为排序时间,user.createAtuser是关联导航属性就是我们之前定义的多对一,createAt是这个导航属性的字段名

当我们传递userName那么看下sql会是怎么样的

==> Preparing: SELECT COUNT(*) FROM `t_post` t

==> Preparing: SELECT t.`id`,t.`title`,t.`content`,t.`user_id`,t.`publish_at` FROM `t_post` t LEFT JOIN `t_user` t1 ON t1.`id` = t.`user_id` ORDER BY t1.`create_at` DESC,t.`title` ASC LIMIT 5

我们惊讶的发现eq非常智能的将分页中的total查询的所有join都去掉了,并且返回集合的那个sql任然保留,如果我们将orderBy也去掉会发现eq居然整个sql都不会添加join选项

==> Preparing: SELECT COUNT(*) FROM `t_post` t
<== Time Elapsed: 21(ms)
<== Total: 1
==> Preparing: SELECT t.`id`,t.`title`,t.`content`,t.`user_id`,t.`publish_at` FROM `t_post` t ORDER BY t.`title` ASC LIMIT 5
<== Time Elapsed: 18(ms)
<== Total: 5

你没有看错动态join就是这么简单,这就是真正的只能orm框架

回顾一下

  • 首先我们添加了动态查询筛选器配置filterConfigure(NotNullOrEmptyValueFilter.DEFAULT_PROPAGATION_SUPPORTS)让所有条件参数非null非空的值支持加入条件,这样就做到了动态查询的特性
  • 第二点因为我们传递userName参数,所以表达式的t_post.user().name().contains(request.getUserName());会生效并且会自动根据对应的关系使用leftJoin将post和user关联起来并且查询post下的user下的姓名
  • 第三点因为我们没有传递userName参数,所以表达式的t_post.user().name().contains(request.getUserName());不会生效,但是orderByuser.createAt还是会生效,所以page的时候total的哪一次查询因为没有使用user表所以不会join,但是toList的那一次因为orderBy用到了所以任然会进行leftJoin

扩展篇

为什么使用leftJoin

因为任何两张表的关系在没有明确指定一定存在的情况下那么leftJoin的操作是不会影响主表的结果集,假如每个Post并不是都会有一个user的情况下我如果使用user.createAt进行排序那么inner join会让主表的结果集变少,但这是完全不被允许的这种做法会大大增加用户使用的心智负担

那么如果我希望使用innerJoin而不是leftJoin呢,我们可以再配置@Navigate的时候通过属性required=true来告知框架Post必定有user

//.....省略其它代码
public class Post{

    /**
     * 发帖人
     **/
    @Navigate(value = RelationTypeEnum.ManyToOne,
            selfProperty = {PostProxy.Fields.userId},
            targetProperty = {UserProxy.Fields.id},
            required = true)private User user;
}

添加①属性required = true这样查询我们就能够发现框架会智能的使用innerJoin而不是leftJoin

==> Preparing: SELECT COUNT(*) FROM `t_post` t INNER JOIN `t_user` t1 ON t1.`id` = t.`user_id` WHERE t1.`name` LIKE CONCAT('%',?,'%')
==> Parameters: 用户A(String)


==> Preparing: SELECT t.`id`,t.`title`,t.`content`,t.`user_id`,t.`publish_at` FROM `t_post` t INNER JOIN `t_user` t1 ON t1.`id` = t.`user_id` WHERE t1.`name` LIKE CONCAT('%',?,'%') ORDER BY t1.`create_at` DESC,t.`title` ASC LIMIT 3
==> Parameters: 用户A(String)

隐式join怎么添加on条件

很多细心的盆友可能希望我们在关联用户的时候添加额外的查询条件那么应该如何实现呢

请求json为如下不查询userName,不进行user的属性排序

{
    "pageIndex":1,
    "pageSize":5,
    "title":"",
    "userName":"",
    "orders":[
        {
            "property":"title",
            "asc":true
        }
    ]
}

    @PostMapping("/page4")
    public EasyPageResult<Post> page4(@RequestBody PostPage4Request request) {
        return easyEntityQuery.queryable(Post.class)
                .filterConfigure(NotNullOrEmptyValueFilter.DEFAULT_PROPAGATION_SUPPORTS)
                .where(t_post -> {
                    t_post.user().filter(u -> { ①
                        u.phone().ne("123");
                    });
                    t_post.title().contains(request.getTitle());
                    t_post.user().name().contains(request.getUserName());
                })
                .orderBy(request.getOrders() != null, t_post -> {
                    for (PostPage4Request.InternalOrder order : request.getOrders()) {
                        t_post.anyColumn(order.getProperty()).orderBy(order.isAsc());
                    }
                })
                .toPageResult(request.getPageIndex(), request.getPageSize());
    }
==> Preparing: SELECT COUNT(*) FROM `t_post` t INNER JOIN `t_user` t1 ON t1.`id` = t.`user_id` AND t1.`phone` <> ?
==> Parameters: 123(String)

==> Preparing: SELECT t.`id`,t.`title`,t.`content`,t.`user_id`,t.`publish_at` FROM `t_post` t INNER JOIN `t_user` t1 ON t1.`id` = t.`user_id` AND t1.`phone` <> ? ORDER BY t.`title` ASC LIMIT 5
==> Parameters: 123(String)

  • ①会将条件添加到join的on上面实现关联关系的定义筛选

奇怪的事情发生了为什么这次我们没有传递user相关的数据依然给我们把inner join加上了,其实本质是inner joinon条件是会影响主表数量,本质和写到where里面是一样的,所以虽然你没有where的条件但是inner joinon条件依然会让整个表达式的join无法动态优化,

filter!!!
> 关联关系的filter会以join on的形式出现在sql中,相当于是额外对关联关系的筛选,缩小关系表,又因为post和user的关系为post必定有user:required=true所以会使用inner join代替left join

帖子内容返回用户名

我们之前使用关联让帖子筛选支持用户姓名,那么如果我们需要返回帖子和对应的发帖人姓名应该怎么处理呢

创建响应dto

/**
 * create time 2025/8/6 22:45
 * {@link com.eq.doc.domain.Post} ①
 *
 * @author xuejiaming
 */
@Data
@EntityProxy@SuppressWarnings("EasyQueryFieldMissMatch")public class PostPage4Response {
    private String id;
    private String title;
    private String content;
    private String userId;
    private LocalDateTime publishAt;
    
    private String userName; ④
}

  • ①在dto上标记当前表来自于哪张表,插件可以提示相关错误
  • ②自定义dto对象代理实现表达式内赋值
  • ③因为①的存在所以④会有插件提示不存在这个字段的警告,通过添加③来让插件不进行提示
  • ④额外增加一个字段接受用户姓名

    @PostMapping("/page5")
    public EasyPageResult<PostPage4Response> page5(@RequestBody PostPage4Request request) {
        return easyEntityQuery.queryable(Post.class)
                .filterConfigure(NotNullOrEmptyValueFilter.DEFAULT_PROPAGATION_SUPPORTS)
                .where(t_post -> {
                    t_post.title().contains(request.getTitle());
                    t_post.user().name().contains(request.getUserName());
                })
                .orderBy(request.getOrders() != null, t_post -> {
                    for (PostPage4Request.InternalOrder order : request.getOrders()) {
                        t_post.anyColumn(order.getProperty()).orderBy(order.isAsc());
                    }
                })
                .select(t_post -> new PostPage4ResponseProxy() ①
                        .id().set(t_post.id())
                        .title().set(t_post.title())
                        .content().set(t_post.content())
                        .userId().set(t_post.userId())
                        .publishAt().set(t_post.publishAt())
                        .userName().set(t_post.user().name()) ②
                )
                .toPageResult(request.getPageIndex(), request.getPageSize());
    }
  • ①通过@EntityProxy注解eq框架会生成代理对象,改对象支持dsl表达式赋值
  • ②通过使用隐式join的方式赋值到dto中
==> Preparing: SELECT COUNT(*) FROM `t_post` t

==> Preparing: SELECT t.`id` AS `id`,t.`title` AS `title`,t.`content` AS `content`,t.`user_id` AS `user_id`,t.`publish_at` AS `publish_at`,t1.`name` AS `user_name` FROM `t_post` t INNER JOIN `t_user` t1 ON t1.`id` = t.`user_id` ORDER BY t.`title` ASC LIMIT 5

我们可以看到生成的sql将joinuser表的name赋值给了dto的userName属性

那么如果属性很多又一样我们是否可以有建议方便的做法呢

.select(t_post -> new PostPage4ResponseProxy()
        .selectAll(t_post) ①
        .userName().set(t_post.user().name())
)
  • ①将原先的属性赋值使用selectAll进行复制如果存在不需要的字段则可通过selectIgnores进行排除如下

        return easyEntityQuery.queryable(Post.class)
                .filterConfigure(NotNullOrEmptyValueFilter.DEFAULT_PROPAGATION_SUPPORTS)
                .where(t_post -> {
                    t_post.title().contains(request.getTitle());
                    t_post.user().name().contains(request.getUserName());
                })
                .orderBy(request.getOrders() != null, t_post -> {
                    for (PostPage4Request.InternalOrder order : request.getOrders()) {
                        t_post.anyColumn(order.getProperty()).orderBy(order.isAsc());
                    }
                })
                .select(t_post -> new PostPage4ResponseProxy()
                        .selectAll(t_post)//查询post的全字段
                        .selectIgnores(t_post.title())//排除title
                        .userName().set(t_post.user().name())
                )
                .toPageResult(request.getPageIndex(), request.getPageSize());
==> Preparing: SELECT COUNT(*) FROM `t_post` t

==> Preparing: SELECT t.`id`,t.`content`,t.`user_id`,t.`publish_at`,t1.`name` AS `user_name` FROM `t_post` t INNER JOIN `t_user` t1 ON t1.`id` = t.`user_id` ORDER BY t.`title` ASC LIMIT 5

那么是否有不使用@EntityProxy的方式来返回呢

include查询

有时候我们希望返回的数据内容包含用户相关信息那么我们应该如何操作才能将返回的post信息里面包含user信息呢


    @PostMapping("/page7")
    public EasyPageResult<Post> page7(@RequestBody PostPage7Request request) {
        return easyEntityQuery.queryable(Post.class)
                .filterConfigure(NotNullOrEmptyValueFilter.DEFAULT_PROPAGATION_SUPPORTS)
                .include(t_post -> t_post.user())
                .where(t_post -> {
                    t_post.title().contains(request.getTitle());
                    t_post.user().name().contains(request.getUserName());
                })
                .toPageResult(request.getPageIndex(), request.getPageSize());
    }

这次我们选择返回post本体对象,并且不定义dto结构返回

==> Preparing: SELECT COUNT(*) FROM `t_post` t


==> Preparing: SELECT t.`id`,t.`title`,t.`content`,t.`user_id`,t.`publish_at` FROM `t_post` t LIMIT 5


==> Preparing: SELECT t.`id`,t.`name`,t.`phone`,t.`create_at` FROM `t_user` t WHERE t.`id` IN (?,?,?,?)
==> Parameters: c529b9ba-a90d-490e-9bad-15ef7c4f33cc(String),8510a91a-274e-494f-9325-f55c004706e5(String),1b59fa07-1824-4e01-a491-c780d167cf44(String),23376c96-a315-4a3f-aeb8-2e29c02f330b(String)

框架通过多次分批返回将整个数据返回(注意数据二次查询没有N+1问题完全放心使用),且返回的数据是以结构化对象的形式来返回到前端的

返回的响应数据


        {
            "id": "0c7fd05f-f999-4fcc-8c98-c0509b22b7f1",
            "title": "健身计划分享",
            "content": "# 这是用户用户D的帖子内容\n包含丰富的文本内容...",
            "userId": "c529b9ba-a90d-490e-9bad-15ef7c4f33cc",
            "publishAt": "2025-08-03T21:24:00.577",
            "user": {
                "id": "c529b9ba-a90d-490e-9bad-15ef7c4f33cc",
                "name": "用户D",
                "phone": "18806982998",
                "createAt": "2025-07-10T13:24:00.576"
            }
        }

那么如果我们希望返回的时候只返回user的id和name应该如何实现

  • 第一种返回数据库对象但是只查询id和name
  • 第二种定义dto使用selectAutoInclude

include部分列

easyEntityQuery.queryable(Post.class)
                .filterConfigure(NotNullOrEmptyValueFilter.DEFAULT_PROPAGATION_SUPPORTS)
                .include(t_post -> t_post.user(),uq->{
                    uq.select(u->u.FETCHER.id().name());
                })
                .where(t_post -> {
                    t_post.title().contains(request.getTitle());
                    t_post.user().name().contains(request.getUserName());
                })
                .toList()
==> Preparing: SELECT COUNT(*) FROM `t_post` t


==> Preparing: SELECT t.`id`,t.`title`,t.`content`,t.`user_id`,t.`publish_at` FROM `t_post` t LIMIT 5


==> Preparing: SELECT t.`id`,t.`name` FROM `t_user` t WHERE t.`id` IN (?,?,?,?)
==> Parameters: c529b9ba-a90d-490e-9bad-15ef7c4f33cc(String),8510a91a-274e-494f-9325-f55c004706e5(String),1b59fa07-1824-4e01-a491-c780d167cf44(String),23376c96-a315-4a3f-aeb8-2e29c02f330b(String)

返回的响应数据


        {
            "id": "0c7fd05f-f999-4fcc-8c98-c0509b22b7f1",
            "title": "健身计划分享",
            "content": "# 这是用户用户D的帖子内容\n包含丰富的文本内容...",
            "userId": "c529b9ba-a90d-490e-9bad-15ef7c4f33cc",
            "publishAt": "2025-08-03T21:24:00.577",
            "user": {
                "id": "c529b9ba-a90d-490e-9bad-15ef7c4f33cc",
                "name": "用户D",
                "phone": null,
                "createAt": null
            }
        }

include函数存在多个重载其中第二参数用于描述前一个include和对应的额外操作这边设置为只返回id和name

我们看到查询的时候仅查询id和name

这种查询返回的任然是数据库对象所以无法再返回的形状上移除phonecreateAt,那么是否有一种办法可以做到形状确定呢

答案是有的时候dto来代替数据库对象在使用selectAutoIncludeapi

结构化dto

结构化dto用来返回dto且形状确定适合生成文档和下游数据交互那么可以通过安装插件后进行如下操作

第一步我们使用插件创建结构化dto

在dto的package处右键选择CreateStructDTO

csdto1

第二步选择要返回的对象

csdto2

第三步勾选要返回的字段

csdto3

确定dto名称后框架会帮我们直接生成dto对象


/**
 * this file automatically generated by easy-query struct dto mapping
 * 当前文件是easy-query自动生成的 结构化dto 映射
 * {@link com.eq.doc.domain.Post }
 *
 * @author xuejiaming
 * @easy-query-dto schema: normal
 */
@Data
public class PostDTO {


    @Column(comment = "帖子id")
    private String id;
    @Column(comment = "帖子标题")
    private String title;
    @Column(comment = "帖子内容")
    private String content;
    @Column(comment = "发布时间")
    private LocalDateTime publishAt;
    /**
     * 发帖人
     **/
    @Navigate(value = RelationTypeEnum.ManyToOne)
    private InternalUser user;


    /**
     * {@link com.eq.doc.domain.User }
     */
    @Data
    public static class InternalUser {
        @Column(comment = "用户id")
        private String id;
        @Column(comment = "用户姓名")
        private String name;


    }

}


@PostMapping("/selectAutoInclude")
public List<PostDTO> selectAutoInclude(@RequestBody PostPage7Request request) {
    return easyEntityQuery.queryable(Post.class)
            .filterConfigure(NotNullOrEmptyValueFilter.DEFAULT_PROPAGATION_SUPPORTS)
            .include(t_post -> t_post.user(),uq->{
                uq.select(u->u.FETCHER.id().name());
            })
            .where(t_post -> {
                t_post.title().contains(request.getTitle());
                t_post.user().name().contains(request.getUserName());
            })
            .selectAutoInclude(PostDTO.class) ①
            .toList();
}
==> Preparing: SELECT t.`id`,t.`title`,t.`content`,t.`publish_at`,t.`user_id` AS `__relation__userId` FROM `t_post` t


==> Preparing: SELECT t.`id`,t.`name` FROM `t_user` t WHERE t.`id` IN (?,?,?,?,?)
==> Parameters: c529b9ba-a90d-490e-9bad-15ef7c4f33cc(String),8510a91a-274e-494f-9325-f55c004706e5(String),1b59fa07-1824-4e01-a491-c780d167cf44(String),23376c96-a315-4a3f-aeb8-2e29c02f330b(String),947ee5fd-5fd0-4889-94e3-03c5efff2c3a(String)

    {
        "id": "0c7fd05f-f999-4fcc-8c98-c0509b22b7f1",
        "title": "健身计划分享",
        "content": "# 这是用户用户D的帖子内容\n包含丰富的文本内容...",
        "publishAt": "2025-08-03T21:24:00.577",
        "user": {
            "id": "c529b9ba-a90d-490e-9bad-15ef7c4f33cc",
            "name": "用户D"
        }
    }

框架依然通过in来解决n+1的问题实现结构化的对象返回,框架支持任意结构化对象返回包括结构化对象扁平化

  • selectAutoIncludeselectapi和include的结合,会自动安装dto的要求将数据结构进行组装返回

说明!!!
> 注意千万不要再selectAutoInclude中传入数据库对象,因为数据库对象的传入会导致selectAutoInclude将整个关系树连根拔起都查询出来
> 注意千万不要再selectAutoInclude中传入数据库对象,因为数据库对象的传入会导致selectAutoInclude将整个关系树连根拔起都查询出来
> 注意千万不要再selectAutoInclude中传入数据库对象,因为数据库对象的传入会导致selectAutoInclude将整个关系树连根拔起都查询出来

selectAutoInclude!!!
selectAutoInclude这个api是eq的核心数据查询api之一用户必须完全掌握可以提高1000%的效率,并且没有n+1问题支持后续一对一 一对多的任意数据穿透查询

返回数据的时候我们如果不希望以结构化对象的形式返回,希望将user对象平铺到整个post中,又不希望使用set手动复制那么可以通过@NavigateFlat来实现额外属性的获取


/**
 * create time 2025/8/6 22:45
 * {@link com.eq.doc.domain.Post} ①
 *
 * @author xuejiaming
 */
@Data
public class PostPage6Response {
    private String id;
    private String title;
    private String content;
    private String userId;
    private LocalDateTime publishAt;

    @NavigateFlat(pathAlias = "user.id")private String userName;
}

注意我们必须要将①的link表示添加上,这样我们在写②的pathAlias时插件会自动给出相应的提示,查询是我们将使用selectAutoInclude来实现万能查询


    @PostMapping("/page6")
    public EasyPageResult<PostPage6Response> page6(@RequestBody PostPage4Request request) {
        return easyEntityQuery.queryable(Post.class)
                .filterConfigure(NotNullOrEmptyValueFilter.DEFAULT_PROPAGATION_SUPPORTS)
                .where(t_post -> {
                    t_post.title().contains(request.getTitle());
                    t_post.user().name().contains(request.getUserName());
                })
                .orderBy(request.getOrders() != null, t_post -> {
                    for (PostPage4Request.InternalOrder order : request.getOrders()) {
                        t_post.anyColumn(order.getProperty()).orderBy(order.isAsc());
                    }
                })
                .selectAutoInclude(PostPage6Response.class)
                .toPageResult(request.getPageIndex(), request.getPageSize());
    }
==> Preparing: SELECT COUNT(*) FROM `t_post` t


==> Preparing: SELECT t.`id`,t.`title`,t.`content`,t.`user_id`,t.`publish_at` FROM `t_post` t ORDER BY t.`title` ASC LIMIT 5


==> Preparing: SELECT `id` FROM `t_user` WHERE `id` IN (?,?,?)
==> Parameters: 8510a91a-274e-494f-9325-f55c004706e5(String),23376c96-a315-4a3f-aeb8-2e29c02f330b(String),c529b9ba-a90d-490e-9bad-15ef7c4f33cc(String)

> 注意千万不要再selectAutoInclude中传入数据库对象,因为数据库对象的传入会导致selectAutoInclude将整个关系树连根拔起都查询出来
> 注意千万不要再selectAutoInclude中传入数据库对象,因为数据库对象的传入会导致selectAutoInclude将整个关系树连根拔起都查询出来
> 注意千万不要再selectAutoInclude中传入数据库对象,因为数据库对象的传入会导致selectAutoInclude将整个关系树连根拔起都查询出来

@NavigateFlat支持任意级别对象关系获取,如果对象关系获取中间存在toMany无论是OneToMany还是ManyToMany那么最终都会变成List<?>集合

帖子内容带评论

简单的额外对象获取后我们希望实现返回给前端帖子内容并且携带上前三条相关评论,那么eq有几种方式呢

  • NaviagteFlat+limit+union
  • NaviagteFlat+limit+partition by
  • subquery+limit+joining

评论关系添加


@Data
@Table("t_post")
@EntityProxy
@EasyAlias("t_post")
@EasyAssertMessage("未找到对应的帖子信息")
public class Post implements ProxyEntityAvailable<Post, PostProxy> {
    //....业务字段

    /**
     * 发帖人
     **/
    @Navigate(value = RelationTypeEnum.ManyToOne,
            selfProperty = {PostProxy.Fields.userId},
            targetProperty = {UserProxy.Fields.id},
            required = true)
    private User user;


    /**
     * 评论信息
     **/
    @Navigate(value = RelationTypeEnum.OneToMany,
            selfProperty = {PostProxy.Fields.id},
            targetProperty = {CommentProxy.Fields.postId})
    private List<Comment> commentList;
}

因为帖子和评论的关系是一对多所以我们在帖子里面通过插件或者手动添加关联关系

limit+union

首先我们定义好需要返回的对象


/**
 * create time 2025/8/6 22:45
 * {@link com.eq.doc.domain.Post}
 *
 * @author xuejiaming
 */
@Data
public class PostPage8Response {
    private String id;
    private String title;
    private String content;
    private String userId;
    private LocalDateTime publishAt;

    @NavigateFlat(pathAlias = "user.id")
    private String userName;

    /**
     * 评论信息
     **/
    @Navigate(value = RelationTypeEnum.OneToMany,orderByProps = {
            @OrderByProperty(property = "createAt",asc = true)
    },limit = 3)
    private List<InternalComment> commentList;

    /**
     * {@link Comment}
     **/
    @Data
    public static class InternalComment {
        private String id;
        private String parentId;
        private String content;
        private LocalDateTime createAt;
    }

}

这样我们就设置好了要返回的数据并且支持额外返回3条评论


    @PostMapping("/postWithCommentPage")
    public List<PostPage8Response> postWithCommentPage(@RequestBody PostPage7Request request) {
        return easyEntityQuery.queryable(Post.class)
                .filterConfigure(NotNullOrEmptyValueFilter.DEFAULT_PROPAGATION_SUPPORTS)
                .where(t_post -> {
                    t_post.title().contains(request.getTitle());
                    t_post.user().name().contains(request.getUserName());
                })
                .selectAutoInclude(PostPage8Response.class).toList();
    }

    {
        "id": "0c7fd05f-f999-4fcc-8c98-c0509b22b7f1",
        "title": "健身计划分享",
        "content": "# 这是用户用户D的帖子内容\n包含丰富的文本内容...",
        "userId": "c529b9ba-a90d-490e-9bad-15ef7c4f33cc",
        "publishAt": "2025-08-03T21:24:00.577",
        "userName": "c529b9ba-a90d-490e-9bad-15ef7c4f33cc",
        "commentList": [
            {
                "id": "67c9ceb0-3eef-44ba-8bbc-c0d1f15f00ad",
                "parentId": "0",
                "content": "期待更多这样的内容",
                "createAt": "2025-08-05T17:24:00.579"
            },
            {
                "id": "d7753586-4bb9-448b-bedb-b178df897bca",
                "parentId": "fa80aaa0-9742-4a02-9209-a08d1bd979df",
                "content": "@用户B 我也这么认为",
                "createAt": "2025-08-06T00:24:00.579"
            },
            {
                "id": "2b40e873-5c0d-41c4-bf10-a38461017300",
                "parentId": "67c9ceb0-3eef-44ba-8bbc-c0d1f15f00ad",
                "content": "@用户C 具体是指哪方面?",
                "createAt": "2025-08-06T03:24:00.579"
            }
        ]
    }

我们看到真的和编写的dto如出一辙的返回了查询结果

-- 第1条sql数据
 SELECT
        t.`id`,
        t.`title`,
        t.`content`,
        t.`user_id`,
        t.`publish_at` 
    FROM
        `t_post` t

-- 第2条sql数据
SELECT t1.`id`, t1.`parent_id`, t1.`content`, t1.`create_at`, t1.`post_id` AS `__relation__postId`
FROM (
	(SELECT t.`id`, t.`parent_id`, t.`content`, t.`user_id`, t.`post_id`
		, t.`create_at`
	FROM `t_comment` t
	WHERE t.`post_id` = 'c529b9ba-a90d-490e-9bad-15ef7c4f33cc'
	ORDER BY t.`create_at` ASC
	LIMIT 3)
	UNION ALL
	(SELECT t.`id`, t.`parent_id`, t.`content`, t.`user_id`, t.`post_id`
		, t.`create_at`
	FROM `t_comment` t
	WHERE t.`post_id` = '8510a91a-274e-494f-9325-f55c004706e5'
	ORDER BY t.`create_at` ASC
	LIMIT 3)
	UNION ALL
	(SELECT t.`id`, t.`parent_id`, t.`content`, t.`user_id`, t.`post_id`
		, t.`create_at`
	FROM `t_comment` t
	WHERE t.`post_id` = '1b59fa07-1824-4e01-a491-c780d167cf44'
	ORDER BY t.`create_at` ASC
	LIMIT 3)
	UNION ALL
	(SELECT t.`id`, t.`parent_id`, t.`content`, t.`user_id`, t.`post_id`
		, t.`create_at`
	FROM `t_comment` t
	WHERE t.`post_id` = '23376c96-a315-4a3f-aeb8-2e29c02f330b'
	ORDER BY t.`create_at` ASC
	LIMIT 3)
	UNION ALL
	(SELECT t.`id`, t.`parent_id`, t.`content`, t.`user_id`, t.`post_id`
		, t.`create_at`
	FROM `t_comment` t
	WHERE t.`post_id` = '947ee5fd-5fd0-4889-94e3-03c5efff2c3a'
	ORDER BY t.`create_at` ASC
	LIMIT 3)
	UNION ALL
	(SELECT t.`id`, t.`parent_id`, t.`content`, t.`user_id`, t.`post_id`
		, t.`create_at`
	FROM `t_comment` t
	WHERE t.`post_id` = ?
	ORDER BY t.`create_at` ASC
	LIMIT 3)
	UNION ALL
	(SELECT t.`id`, t.`parent_id`, t.`content`, t.`user_id`, t.`post_id`
		, t.`create_at`
	FROM `t_comment` t
	WHERE t.`post_id` = ?
	ORDER BY t.`create_at` ASC
	LIMIT 3)
	UNION ALL
	(SELECT t.`id`, t.`parent_id`, t.`content`, t.`user_id`, t.`post_id`
		, t.`create_at`
	FROM `t_comment` t
	WHERE t.`post_id` = ?
	ORDER BY t.`create_at` ASC
	LIMIT 3)
	UNION ALL
	(SELECT t.`id`, t.`parent_id`, t.`content`, t.`user_id`, t.`post_id`
		, t.`create_at`
	FROM `t_comment` t
	WHERE t.`post_id` = ?
	ORDER BY t.`create_at` ASC
	LIMIT 3)
	UNION ALL
	(SELECT t.`id`, t.`parent_id`, t.`content`, t.`user_id`, t.`post_id`
		, t.`create_at`
	FROM `t_comment` t
	WHERE t.`post_id` = ?
	ORDER BY t.`create_at` ASC
	LIMIT 3)
	UNION ALL
	(SELECT t.`id`, t.`parent_id`, t.`content`, t.`user_id`, t.`post_id`
		, t.`create_at`
	FROM `t_comment` t
	WHERE t.`post_id` = ?
	ORDER BY t.`create_at` ASC
	LIMIT 3)
	UNION ALL
	(SELECT t.`id`, t.`parent_id`, t.`content`, t.`user_id`, t.`post_id`
		, t.`create_at`
	FROM `t_comment` t
	WHERE t.`post_id` = ?
	ORDER BY t.`create_at` ASC
	LIMIT 3)
) t1
-- 第3条sql数据
   SELECT
        `id` 
    FROM
        `t_user` 
    WHERE
        `id` IN (?, ?, ?, ?, ?)

一个生成了三条sql,中limit+union是第二条sql,但是union相对sql会变得复杂并且冗余所以我们尝试eq提供的第二种方式

limit+partition by

springboot的application.yml增加配置项

easy-query:
  #支持的数据库
  database: mysql
  #对象属性和数据库列名的转换器
  name-conversion: underlined
  default-track: true
  include-limit-mode: partition
  • include-limit-mode: partition这句话让原先的union all变成partition(后续partition可能会变成默认)

接下来我们继续请求


-- 第1条sql数据

    SELECT
        t.`id`,
        t.`title`,
        t.`content`,
        t.`user_id`,
        t.`publish_at` 
    FROM
        `t_post` t


-- 第2条sql数据

    SELECT
        t2.`id` AS `id`,
        t2.`parent_id` AS `parent_id`,
        t2.`content` AS `content`,
        t2.`create_at` AS `create_at`,
        t2.`post_id` AS `__relation__postId` 
    FROM
        (SELECT
            t1.`id` AS `id`,
            t1.`parent_id` AS `parent_id`,
            t1.`content` AS `content`,
            t1.`user_id` AS `user_id`,
            t1.`post_id` AS `post_id`,
            t1.`create_at` AS `create_at` 
        FROM
            (SELECT
                t.`id`,
                t.`parent_id`,
                t.`content`,
                t.`user_id`,
                t.`post_id`,
                t.`create_at`,
                (ROW_NUMBER() OVER (PARTITION 
            BY
                t.`post_id` 
            ORDER BY
                t.`create_at` ASC)) AS `__row__` 
            FROM
                `t_comment` t 
            WHERE
                t.`post_id` IN ('09e8395e-b7f7-48b4-8227-fcbf96c35d1e', '5d40f560-af15-4566-93cd-9359e0a27501', '76cdba56-b1f8-4432-bc0e-764d491c6cd5', '81eb5fb7-ec57-45d3-b9b9-5e6217ec4d31', '8a6f16a6-b51e-4a39-9ea9-fda57502bb29', 'a6982186-afc5-4f49-977d-97ff8c25cd9f', 'b1eb997d-9cb0-40ca-9495-a9d41da21125', 'b4f74aeb-3868-4810-9845-cab9e882229b', 'bf7e62ee-d833-4f5a-9a0a-07b9634ba26a', 'c6d0631f-160a-4a8c-8401-62db614f87c8', 'd9629994-d9fa-46a3-bd7c-5982f0900a3d', 'ed01ea8a-4162-42ba-a632-dd6d67bf9d45', 'f27edcf7-0fd8-44e3-b3cc-cbba41427dfe')) t1 
        WHERE
            t1.`__row__` >= 1 
            AND t1.`__row__` <= 3) t2



-- 第3条sql数据

    SELECT
        `id` 
    FROM
        `t_user` 
    WHERE
        `id` IN ('15b6a7c1-3f27-4d21-b67c-9c05cd9bf4b6', '2ae21dfa-9330-4d8c-bbfa-6b4618c56c45', '6e50464d-17a7-4f12-8458-c896d55dd276', '1d2a9d56-63df-4413-bd83-ff9a0c0a2166', '2ede4599-8f1a-4d0c-a0af-0dd50d903b87')
{
        "id": "5d40f560-af15-4566-93cd-9359e0a27501",
        "title": "健身计划分享",
        "content": "# 这是用户用户E的帖子内容\n包含丰富的文本内容...",
        "userId": "2ae21dfa-9330-4d8c-bbfa-6b4618c56c45",
        "publishAt": "2025-08-04T08:09:30.301",
        "userName": "2ae21dfa-9330-4d8c-bbfa-6b4618c56c45",
        "commentList": [
            {
                "id": "de5337b2-e13c-49f3-9b15-ac393784fc6f",
                "parentId": "46da0914-b046-45ad-8847-1c65c82ac71c",
                "content": "@用户C 有不同看法:",
                "createAt": "2025-08-07T06:09:30.304"
            },
            {
                "id": "daf5102c-b4dd-4f65-bee6-9b3df4f1b5d9",
                "parentId": "0",
                "content": "写得真详细",
                "createAt": "2025-08-07T09:09:30.304"
            },
            {
                "id": "46da0914-b046-45ad-8847-1c65c82ac71c",
                "parentId": "0",
                "content": "期待更多这样的内容",
                "createAt": "2025-08-07T10:09:30.304"
            }
        ]
    }

我们看到通过简单的配置我们将一对多返回前n条变动轻松简单并且可以快速实现支持分页,但是细心的朋友肯定发现了一个问题,我们需要的评论并不是平铺到整个post贴子的,帖子和评论虽然是一对多但是评论自己也是自关联,评论设计也是楼中楼为支持的那么我们应该如何设置让我们返回的评论支持返回第一层级呢

EXTRA_AUTO_INCLUDE_CONFIGURE

使用eq的EXTRA_AUTO_INCLUDE_CONFIGURE可以对selectAutoInclude的查询添加额外字段或额外搜索排序等处理

关于EXTRA_AUTO_INCLUDE_CONFIGURE的更多信息请查看文档

第一步对原始的dto对象进行插件快速提示插入EXTRA_AUTO_INCLUDE_CONFIGURE

extra-include-tip

我们移除select操作因为我们不需要

最终我们的返回dto如下


/**
 * create time 2025/8/6 22:45
 * {@link com.eq.doc.domain.Post}
 *
 * @author xuejiaming
 */
@Data
public class PostPage9Response {
    private String id;
    private String title;
    private String content;
    private String userId;
    private LocalDateTime publishAt;

    @NavigateFlat(pathAlias = "user.id")
    private String userName;

    /**
     * 评论信息
     **/
    @Navigate(value = RelationTypeEnum.OneToMany,orderByProps = {
            @OrderByProperty(property = "createAt",asc = true)
    },limit = 3)
    private List<InternalComment> commentList;

    /**
     * {@link Comment}
     **/
    @Data
    public static class InternalComment {


        private static final ExtraAutoIncludeConfigure EXTRA_AUTO_INCLUDE_CONFIGURE = CommentProxy.TABLE.EXTRA_AUTO_INCLUDE_CONFIGURE()
                .where(t_comment -> {
                    t_comment.parentId().eq("0");
                });

        private String id;
        private String parentId;
        private String content;
        private LocalDateTime createAt;
    }

}

我们看中间sql如下


    SELECT
        t2.`id` AS `id`,
        t2.`parent_id` AS `parent_id`,
        t2.`content` AS `content`,
        t2.`create_at` AS `create_at`,
        t2.`post_id` AS `__relation__postId` 
    FROM
        (SELECT
            t1.`id` AS `id`,
            t1.`parent_id` AS `parent_id`,
            t1.`content` AS `content`,
            t1.`user_id` AS `user_id`,
            t1.`post_id` AS `post_id`,
            t1.`create_at` AS `create_at` 
        FROM
            (SELECT
                t.`id`,
                t.`parent_id`,
                t.`content`,
                t.`user_id`,
                t.`post_id`,
                t.`create_at`,
                (ROW_NUMBER() OVER (PARTITION 
            BY
                t.`post_id` 
            ORDER BY
                t.`create_at` ASC)) AS `__row__` 
            FROM
                `t_comment` t 
            WHERE
                t.`parent_id` = '0'AND t.`post_id` IN ('09e8395e-b7f7-48b4-8227-fcbf96c35d1e', '5d40f560-af15-4566-93cd-9359e0a27501', '76cdba56-b1f8-4432-bc0e-764d491c6cd5', '81eb5fb7-ec57-45d3-b9b9-5e6217ec4d31', '8a6f16a6-b51e-4a39-9ea9-fda57502bb29', 'a6982186-afc5-4f49-977d-97ff8c25cd9f', 'b1eb997d-9cb0-40ca-9495-a9d41da21125', 'b4f74aeb-3868-4810-9845-cab9e882229b', 'bf7e62ee-d833-4f5a-9a0a-07b9634ba26a', 'c6d0631f-160a-4a8c-8401-62db614f87c8', 'd9629994-d9fa-46a3-bd7c-5982f0900a3d', 'ed01ea8a-4162-42ba-a632-dd6d67bf9d45', 'f27edcf7-0fd8-44e3-b3cc-cbba41427dfe')) t1 
        WHERE
            t1.`__row__` >= 1 
            AND t1.`__row__` <= 3) t2
  • ①是我们通过额外配置添加上去的

返回的json如下

{
        "id": "b4f74aeb-3868-4810-9845-cab9e882229b",
        "title": "初探人工智能",
        "content": "# 这是用户用户E的帖子内容\n包含丰富的文本内容...",
        "userId": "2ae21dfa-9330-4d8c-bbfa-6b4618c56c45",
        "publishAt": "2025-08-07T02:09:30.301",
        "userName": "2ae21dfa-9330-4d8c-bbfa-6b4618c56c45",
        "commentList": [
            {
                "id": "238fea11-c5d1-4485-977d-a0126cb74402",
                "parentId": "0",
                "content": "期待更多这样的内容",
                "createAt": "2025-08-07T09:09:30.304"
            },
            {
                "id": "e216eaf8-bf15-4eeb-aa4c-6489be83c355",
                "parentId": "0",
                "content": "内容很实用",
                "createAt": "2025-08-07T16:09:30.304"
            },
            {
                "id": "830bd1d9-1600-43a2-94b7-f6426a8a78c9",
                "parentId": "0",
                "content": "写得真详细",
                "createAt": "2025-08-07T17:09:30.304"
            }
        ]
    }

我们返回的post节点完美的符合我们内容

但是有时候我们可能需要返回的是post信息和前三条内容并且将前三条内容合并到一个字段上去那么应该怎么做

joining逗号分割

一如既往我们还是定义对应的dto


@Data
@EntityProxy
public class PostPage10Response {
    private String id;
    private String title;
    private String content;
    private String userId;
    private LocalDateTime publishAt;

    private String userName;
    
    private String commentContent;

}



    @PostMapping("/postWithCommentPage3")
    public List<PostPage10Response> postWithCommentPage3(@RequestBody PostPage7Request request) {
        return easyEntityQuery.queryable(Post.class)
                .filterConfigure(NotNullOrEmptyValueFilter.DEFAULT_PROPAGATION_SUPPORTS)
                .where(t_post -> {
                    t_post.title().contains(request.getTitle());
                    t_post.user().name().contains(request.getUserName());
                })
                .select(t_post -> new PostPage10ResponseProxy()
                        .selectAll(t_post)
                        .userName().set(t_post.user().name())
                        .commentContent().set(t_post.commentList().where(c->c.parentId().eq("0")).elements(0,2).joining(c->c.content()))
                ).toList();
    }

我们来看下表达式t_post.commentList().where(c->c.parentId().eq("0")).joining(c->c.content())这个表达式是将post下方的评论集合commentList通过where筛选取前三个的content内容合并

{
        "id": "09e8395e-b7f7-48b4-8227-fcbf96c35d1e",
        "title": "夏日旅行攻略",
        "content": "# 这是用户用户D的帖子内容\n包含丰富的文本内容...",
        "userId": "15b6a7c1-3f27-4d21-b67c-9c05cd9bf4b6",
        "publishAt": "2025-08-05T03:09:30.301",
        "userName": "用户D",
        "commentContent": "非常好的分享!,期待更多这样的内容,非常好的分享!"
    },
    {
        "id": "5d40f560-af15-4566-93cd-9359e0a27501",
        "title": "健身计划分享",
        "content": "# 这是用户用户E的帖子内容\n包含丰富的文本内容...",
        "userId": "2ae21dfa-9330-4d8c-bbfa-6b4618c56c45",
        "publishAt": "2025-08-04T08:09:30.301",
        "userName": "用户E",
        "commentContent": "完全同意你的观点,期待更多这样的内容,写得真详细"
    }

通过结果我们可以清晰地看到commentContentjoining函数通过逗号分割组合在一起了
我们再来看对应的sql


    SELECT
        t.`id`,
        t.`title`,
        t.`content`,
        t.`user_id`,
        t.`publish_at`,
        t1.`name` AS `user_name`,
        (SELECT
            GROUP_CONCAT(t2.`content` SEPARATOR ',') 
        FROM
            `t_comment` t2 
        WHERE
            t2.`post_id` = t.`id` 
            AND t2.`parent_id` = '0' 
        LIMIT
            3) AS `comment_content` 
    FROM
        `t_post` t 
    INNER JOIN
        `t_user` t1 
            ON t1.`id` = t.`user_id`

框架通过select子查询将结果清晰的将结果集通过group_concat函数组装到了comment_content列上

::: warning 性能!!!

如果由用户嫌弃select子查询性能低下eq贴心的提供了子查询转groupJoin助力用户实现更高效的sql
:::

当然这边为了演示使用了内容逗号分割,其实本质而言应该是将类目逗号分割更加合适

接下来我们创建帖子的类目关系表

帖子和类目关系是多对多通过CategoryPost表进行关联


@Data
@Table("t_post")
@EntityProxy
@EasyAlias("t_post")
@EasyAssertMessage("未找到对应的帖子信息")
public class Post implements ProxyEntityAvailable<Post, PostProxy> {
    //....其他业务字段和导航属性


    /**
     * 帖子类目信息
     **/
    @Navigate(value = RelationTypeEnum.ManyToMany,
            selfProperty = {PostProxy.Fields.id},
            selfMappingProperty = {CategoryPostProxy.Fields.postId},
            mappingClass = CategoryPost.class, targetProperty = {CategoryProxy.Fields.id},
            targetMappingProperty = {CategoryPostProxy.Fields.categoryId}, subQueryToGroupJoin = true)private List<Category> categoryList;
}
  • 其中我们看到①subQueryToGroupJoin = true该配置项让原本的多对多子查询可以直接在使用的时候使用groupJoin来代替可以让生成的sql性能更加高效

返回帖子内容+用户+评论前三个+所属类目逗号分割

设置返回dto


/**
 * create time 2025/8/6 22:45
 * {@link com.eq.doc.domain.Post}
 *
 * @author xuejiaming
 */
@Data
public class PostPage11Response {

    private static final ExtraAutoIncludeConfigure EXTRA_AUTO_INCLUDE_CONFIGURE = PostProxy.TABLE.EXTRA_AUTO_INCLUDE_CONFIGURE()
            .select(t_post -> Select.of(
                    t_post.categoryList().joining(cate->cate.name()).as("categoryNames")
            ));
        
    private String id;
    private String title;
    private String content;
    private String userId;
    private LocalDateTime publishAt;

    @NavigateFlat(pathAlias = "user.id")
    private String userName;

    @SuppressWarnings("EasyQueryFieldMissMatch")
    private String categoryNames;

    /**
     * 评论信息
     **/
    @Navigate(value = RelationTypeEnum.OneToMany, orderByProps = {
            @OrderByProperty(property = "createAt", asc = true)
    }, limit = 3)
    private List<InternalComment> commentList;

    /**
     * {@link Comment}
     **/
    @Data
    public static class InternalComment {


        private static final ExtraAutoIncludeConfigure EXTRA_AUTO_INCLUDE_CONFIGURE = CommentProxy.TABLE.EXTRA_AUTO_INCLUDE_CONFIGURE()
                .where(t_comment -> {
                    t_comment.parentId().eq("0");
                });

        private String id;
        private String parentId;
        private String content;
        private LocalDateTime createAt;
    }

}




@PostMapping("/postList4")
public List<PostPage11Response> postList4(@RequestBody PostPage7Request request) {
    return easyEntityQuery.queryable(Post.class)
            .filterConfigure(NotNullOrEmptyValueFilter.DEFAULT_PROPAGATION_SUPPORTS)
            .where(t_post -> {
                t_post.title().contains(request.getTitle());
                t_post.user().name().contains(request.getUserName());
            })
            .selectAutoInclude(PostPage11Response.class).toList();
}

我们通过对主表进行额外字段的添加让其直接支持额外字段返回

  • @SuppressWarnings("EasyQueryFieldMissMatch")这个注解主要是用来抑制插件警告,您如果觉得警告无所谓那么可以不加该注解对结果没有影响

    {
        "id": "0c6ab3ab-29a4-4320-a08e-195bdac27095",
        "title": "JVM调优实战",
        "content": "# 这是用户用户C的帖子内容\n包含丰富的文本内容...",
        "userId": "2e509ef4-0282-448f-ace0-43501d46ccf4",
        "publishAt": "2025-08-04T23:42:43.525",
        "userName": "2e509ef4-0282-448f-ace0-43501d46ccf4",
        "categoryNames": "娱乐,教育",
        "commentList": [
            {
                "id": "2d3643e6-8fb5-4a2b-a0bc-1c92030bfa34",
                "parentId": "0",
                "content": "完全同意你的观点",
                "createAt": "2025-08-07T00:42:43.526"
            },
            {
                "id": "5f7b2333-5578-40cd-940e-28e97d1b0aa1",
                "parentId": "0",
                "content": "完全同意你的观点",
                "createAt": "2025-08-07T11:42:43.526"
            },
            {
                "id": "0b1d0cbd-62a7-4922-b5fe-0ef4780e4c24",
                "parentId": "0",
                "content": "内容很实用",
                "createAt": "2025-08-07T15:42:43.526"
            }
        ]
    },
    {
        "id": "1a0e5854-c748-4c6b-a11d-d5bbb58326a1",
        "title": "电影推荐合集",
        "content": "# 这是用户用户B的帖子内容\n包含丰富的文本内容...",
        "userId": "70ec5f9f-7e9b-4f57-b2a4-9a35a163bd3e",
        "publishAt": "2025-08-03T02:42:43.525",
        "userName": "70ec5f9f-7e9b-4f57-b2a4-9a35a163bd3e",
        "categoryNames": "教育,科技",
        "commentList": [
            {
                "id": "723a588c-0d95-4db7-be6b-1745bfcfc540",
                "parentId": "0",
                "content": "内容很实用",
                "createAt": "2025-08-07T00:42:43.526"
            },
            {
                "id": "116ab46b-9b61-4644-ac10-73e65f5a01b9",
                "parentId": "0",
                "content": "内容很实用",
                "createAt": "2025-08-07T18:42:43.526"
            },
            {
                "id": "65cb0f86-7076-46a6-b333-c9c50e9336ae",
                "parentId": "0",
                "content": "写得真详细",
                "createAt": "2025-08-07T18:42:43.526"
            }
        ]
    }

完全完美符合我们需要的结果


-- 第1条sql数据

    SELECT
        t5.`__joining2__` AS `category_names`,
        t.`id`,
        t.`title`,
        t.`content`,
        t.`user_id`,
        t.`publish_at` 
    FROM
        `t_post` t 
    LEFT JOIN
        (SELECT
            t3.`post_id` AS `post_id`, GROUP_CONCAT(t2.`name` SEPARATOR ',') AS `__joining2__` FROM `t_category` t2 
        INNER JOIN
            `t_category_post` t3 
                ON t2.`id` = t3.`category_id` 
        GROUP BY
            t3.`post_id`) t5 
            ON t5.`post_id` = t.`id`
-- 第2条sql数据

    SELECT
        t2.`id` AS `id`,
        t2.`parent_id` AS `parent_id`,
        t2.`content` AS `content`,
        t2.`create_at` AS `create_at`,
        t2.`post_id` AS `__relation__postId` 
    FROM
        (SELECT
            t1.`id` AS `id`,
            t1.`parent_id` AS `parent_id`,
            t1.`content` AS `content`,
            t1.`user_id` AS `user_id`,
            t1.`post_id` AS `post_id`,
            t1.`create_at` AS `create_at` 
        FROM
            (SELECT
                t.`id`,
                t.`parent_id`,
                t.`content`,
                t.`user_id`,
                t.`post_id`,
                t.`create_at`,
                (ROW_NUMBER() OVER (PARTITION 
            BY
                t.`post_id` 
            ORDER BY
                t.`create_at` ASC)) AS `__row__` 
            FROM
                `t_comment` t 
            WHERE
                t.`parent_id` = '0' 
                AND t.`post_id` IN ('015c8538-0eaa-4afb-a1c7-4cca00dd6638', '0c6ab3ab-29a4-4320-a08e-195bdac27095', '1a0e5854-c748-4c6b-a11d-d5bbb58326a1', '31a955ba-04ec-4d07-a6d4-fac6c408ab7d', '36eba6b0-5dd4-41b3-a4af-d9c522a86b3a', '573ca56a-4575-458e-8258-7b76c2cfe959', '5f72b5bf-3ae6-4bd6-9df9-cf0c43abc37c', '63d5b82f-64e6-4985-ad4b-acf71d8368fc', '669ce2a5-abaf-49e8-bb7e-e498f7377b15', '73f5d341-c6df-43a1-afcd-e246c4d1fcc9', '89bf6652-0ae0-451a-8a16-d9b543898f81', '8dbcfcfe-44a7-45c2-9db9-d0302c5a9a94')) t1 
        WHERE
            t1.`__row__` >= 1 
            AND t1.`__row__` <= 3) t2
-- 第3条sql数据

    SELECT
        `id` 
    FROM
        `t_user` 
    WHERE
        `id` IN ('3b63ddd9-b038-4c24-969e-8b478fe862a5', '2e509ef4-0282-448f-ace0-43501d46ccf4', '70ec5f9f-7e9b-4f57-b2a4-9a35a163bd3e', 'f2bf383e-ee8d-44c5-968d-263191ab058e', 'eda79345-6fbf-4ca6-b9bf-4743a3f991e4')
  • 第一条sql我们看到用来查询返回post信息和对应的categoryNames字段使用groupJoin来代替多对多自查
  • 第二条sql我们看到框架使用patrtition by让用户可以轻松的返回评论信息前n条
  • 第三条sql我们使用NaviagteFlat二次查询杜绝n+1来返回用户信息

到此为止我们的帖子相关的查询已经结束 主要我们实现了框架对一对多 多对一和多对多下如何快速查询并且支持众多开窗函数的隐式使用

最后的最后我非常感谢您能看到这边我相信eq绝对是你不二的orm选择

框架地址 https://github.com/dromara/easy-query
文档地址 https://www.easy-query.com/easy-query-doc/
该文章demo地址 https://github.com/xuejmnet/eq-doc

一款基于 .NET + Vue 编写的仿钉钉的开源低代码工作流引擎,支持多种数据库,开箱即用! - 追逐时光者 - 博客园

mikel阅读(216)

来源: 一款基于 .NET + Vue 编写的仿钉钉的开源低代码工作流引擎,支持多种数据库,开箱即用! – 追逐时光者 – 博客园

一款基于 .NET + Vue 编写的仿钉钉的开源低代码工作流引擎,支持多种数据库,开箱即用! 

前言

今天大姚给大家分享一款基于 .NET + Vue 编写的仿钉钉的开源低代码工作流引擎,支持多种数据库,开箱即用:AntFlow.NET。

项目介绍

AntFlow.NET 是一款基于 .NET + Vue + FreeSQL + Natasha 编写的仿钉钉的开源(Apache-2.0)低代码工作流引擎,支持多种数据库,让工作流开发像普通 CURD 一样简单,即使没有流程开发经验的程序员也能快速开发流程,效率提升利器。

image

项目特征

  • 极简配置:告别繁琐设置,轻松上手,让开发变得更加简单直观。
  • 学习成本低:消除复杂概念,即便是工作流新手也能迅速掌握并应用于项目。
  • 功能对标钉钉/企业微信/飞书等审批流程 ,可用于多种业务场景,简单易用、可扩展性强、可定制、可SAAS化。
  • 支持流程设计、条件分支、顺序会签、会签、或签、加批、委托、转办、打回修改、重新提交等高级特性,为中国式办公量身定制。

项目技术栈

  • ASP.NET Core: 现代化的 Web 开发框架。
  • FreeSQL 高性能、强类型的 ORM 框架。
  • Natasha : 动态编译与运行支持,基于 Roslyn 的 C# 动态程序集构建库。

项目源代码

前端

image

后端

image

演示效果

image

image

image

image

image

image

image

项目源码地址

更多项目实用功能和特性欢迎前往项目开源地址查看👀,别忘了给项目一个Star支持💖。

优秀项目和框架精选

该项目已收录到C#/.NET/.NET Core优秀项目和框架精选中,关注优秀项目和框架精选能让你及时了解C#、.NET和.NET Core领域的最新动态和最佳实践,提高开发工作效率和质量。坑已挖,欢迎大家踊跃提交PR推荐或自荐(让优秀的项目和框架不被埋没🤞)。

分析C#项目的单元测试覆盖率,提高代码质量 - 程序设计实验室 - 博客园

mikel阅读(145)

来源: 分析C#项目的单元测试覆盖率,提高代码质量 – 程序设计实验室 – 博客园

分析C#项目的单元测试覆盖率,提高代码质量

前言#

正如我在前一篇介绍 ImageGlider 的文章里预告的那样,这篇同样属于那套「C# + 自动化发布」开发流程的系列分享,继续把踩过的坑和总结的经验都记录下来,大家一起少走弯路。

单元测试的重要性不用我多说了吧?😄

覆盖率,保证了单元测试的广度和有效性——它能帮助开发者发现遗漏的逻辑分支,避免“测试了但其实没测到”的尴尬场面

特别是在如今的AI编程时代,完善的测试可以让AI自动验证功能的实现结果

刚好 C# 拥有非常完善的基础设施,这种功能丰富的语言,特别适应 AI 时代,我有预感,dotnet 平台在 AI 时代未来可期😁

要进行覆盖率测试,方法有非常多,一开始我使用了一个第三方工具来生成 HTML 报告,后面发现 VSCode、VS、Rider 这些 IDE 里都可以🤣

C#工具库#

今年我陆续用 C# 开发了不少工具

涵盖的范围也不小

感觉都可以组成一个小工具库了

这些工具分别是:

依赖#

使用 dotnet-reportgenerator-globaltool 工具可以生成 HTML 报告

dotnet tool install -g dotnet-reportgenerator-globaltool

测试覆盖率#

以 ImageGlider 项目为例

使用以下命令分析项目的单元测试覆盖率,并生成测试报告网页

# 生成测试覆盖率报告
dotnet test --collect:"XPlat Code Coverage" --results-directory ./temp/TestResults

# 使用 reportgenerator 生成HTML报告
reportgenerator -reports:"./temp/TestResults/*/coverage.cobertura.xml" -targetdir:"./TestResults/CoverageReport" -reporttypes:Html

生成的测试报告路径示例

temp\TestResults\4eaa9684-a3b6-4b2a-81ac-d75e1e375e4b\coverage.cobertura.xml

直接打开这个网页就可以看到覆盖率的报告了

HTML 报告#

非常详细

总览#

查看详细覆盖率#

这里可以调整分组模式

默认是 By assembly

可以改成 By namespace ,命名空间模式又分 level 1 和 level 2

其中 Line coverage 和 Branch coverage 都是可以筛选的

项目大点的话,建议选择 By Namespace level 2 ,比较直观

方法的测试覆盖率#

点击具体的类,跳转到方法覆盖率页面

这里可以看到哪个方法没写测试

或者哪些 case 是没有覆盖到的

总之非常方便

image

VSCode#

在 tests 目录上右键,运行覆盖率测试

很快就会在各个目录上出现类似手机电池的图标(好可爱😄)

可以很方便地看到各个项目、各个代码文件的测试覆盖率

Rider#

我是先在 VSCode 里发现的

我就在想

VSCode 都有的功能,老牌 C# IDE 的这个 Rider,应该更加有吧

结果测了一下,还真有

菜单 Tests -> Cover Unit Tests

这个功能也不错,还能导出 HTML 什么的

小结#

总之,通过dotnet-reportgenerator-globaltool和VSCode、VS、Rider等IDE的内置功能,我们可以轻松分析C#项目的单元测试覆盖率,帮助提升代码质量和测试有效性。

c#中string字符串转为json与json转对象 - 任督二脉 - 博客园

mikel阅读(314)

来源: c#中string字符串转为json与json转对象 – 任督二脉 – 博客园

添加引用,Newtonsoft.Json.dll
using Newtonsoft.Json.Linq;

复制代码
 1 //字符串转json
 2 public static void strJson()
 3 {
 4 string jsonText = "{"shenzheng":"深圳","beijing":"北京","shanghai":[{"zj1":"zj11","zj2":"zj22"},"zjs"]}";
 5 
 6 JObject jo = (JObject)JsonConvert.DeserializeObject(jsonText);//或者JObject jo = JObject.Parse(jsonText);
 7 string zone = jo["shenzheng"].ToString();//输出 "深圳"
 8 string zone_en = jo["shanghai"].ToString();//输出 "[{"zj1":"zj11","zj2":"zj22"},"zjs"]"
 9 string zj1 = jo["shanghai"][1].ToString();//输出 "zjs"
10 Console.WriteLine(jo);
11 }
复制代码
复制代码
 1     //对象与数组转JSON
 2     public static void GetJsonString()
 3     {
 4         //初始化对象
 5         Obj product = new Obj() { Name = "苹果", Price = 5.5 };
 6         //序列化
 7         string o = new JavaScriptSerializer().Serialize(product);//值:"{\"Name\":\"苹果\",\"Price\":5.5}"
 8 
 9         //数组转json
10         List<Obj> products = new List<Obj>(){
11         new Obj(){Name="苹果",Price=5.5},
12         new Obj(){Name="橘子",Price=2.5},
13         new Obj(){Name="干柿子",Price=16.00}
14         };
15 
16         ProductList productlist = new ProductList();
17         productlist.GetProducts = products;
18         //序列化
19         string os = new JavaScriptSerializer().Serialize(productlist);
20         //输出 "{\"GetProducts\":[{\"Name\":\"苹果\",\"Price\":5.5},{\"Name\":\"橘子\",\"Price\":2.5},{\"Name\":\"干柿子\",\"Price\":16}]}"
21     }
22 
23     //json转对象、数组, 反序列化
24     public static void JSONStringToList()
25     {
26 
27         //json格式字符串
28         string JsonStr = "{Name:'苹果',Price:5.5}";
29         JavaScriptSerializer Serializer = new JavaScriptSerializer();
30 
31         //json字符串转为对象, 反序列化
32         Obj obj = Serializer.Deserialize<Obj>(JsonStr);
33             Console.Write(obj.Name + ":" + obj.Price + "\r\n");
34 
35         //json格式字符串
36         string JsonStrs = "[{Name:'苹果',Price:5.5},{Name:'橘子',Price:2.5},{Name:'柿子',Price:16}]";
37 
38         JavaScriptSerializer Serializers = new JavaScriptSerializer();
39 
40         //json字符串转为数组对象, 反序列化
41         List<Obj> objs = Serializers.Deserialize<List<Obj>>(JsonStrs);
42 
43         foreach (var item in objs)
44         {
45            Console.Write(item.Name + ":" + item.Price + "\r\n");
46         }
47     }
复制代码

抛开官方库,手撸一个轻量级 MCP 服务端 - .NET骚操作 - 博客园

mikel阅读(384)

来源: 抛开官方库,手撸一个轻量级 MCP 服务端 – .NET骚操作 – 博客园

大家好!在昨天的文章 官方文档没告诉你的:通过抓包,深入揭秘MCP协议底层通信 中,我们通过Fiddler工具,像侦探一样,一步步揭开了MCP(Model Context Protocol)在无状态HTTP模式下的神秘面纱。我们搞清楚了它的两步握手、SSE(Server-Sent Events)响应机制以及精巧的两种错误处理方式。

image

然而,仅仅停留在理论分析层面总感觉意犹未尽。更重要的是,当我们审视官方提供的 ModelContextProtocol.AspNetCore 这个NuGet包时(当前版本0.3.0-preview.3),会发现它目前引入了相当多的依赖项:

  • Microsoft.Bcl.Memory (>= 9.0.5)
  • Microsoft.Extensions.AI.Abstractions (>= 9.7.1)
  • Microsoft.Extensions.Logging.Abstractions (>= 8.0.3)
  • System.Diagnostics.DiagnosticSource (>= 8.0.1)
  • System.IO.Pipelines (>= 8.0.0)
  • System.Net.ServerSentEvents (>= 10.0.0-preview.4.25258.110)
  • System.Text.Json (>= 8.0.6)
  • System.Threading.Channels (>= 8.0.0)
  • Microsoft.Extensions.Hosting.Abstractions (>= 8.0.1)
  • ModelContextProtocol.Core (>= 0.3.0-preview.3)

其中,最令人不安的莫过于 System.Net.ServerSentEvents,它竟然是一个 .NET 10 的预览版包!在生产环境中使用预览版包,通常是大忌。

既然我们已经通过抓包掌握了协议的全部细节,那么,何不自己动手,实现一个轻量级、零预览版依赖的MCP服务端呢?这不仅是一次绝佳的学习实践,也能让我们对协议的理解更上一层楼。

今天,我们就来完成这个挑战:不依赖官方服务端库,直接用纯粹的ASP.NET Core代码,实现一个功能完备的MCP服务端。

我们的目标:保持工具定义的简洁性

在动手之前,我们先定一个目标。我们希望定义工具(Tools)的方式能够尽可能地简洁和直观,几乎和昨天的代码保持一致:

using System.ComponentModel;

public class Tools(IHttpContextAccessor http)
{
    [Description("Echoes the message back to the client.")]
    public string Echo(string message) => $"hello {message}";

    [Description("Returns the IP address of the client.")]
    public string EchoIP() => http.HttpContext?.Connection.RemoteIpAddress?.ToString() ?? "Unknown";

    [Description("Counts from 0 to n, reporting progress at each step.")]
    public async Task<int> Count(int n, IProgress<ProgressNotificationValue> progress)
    {
        for (int i = 0; i < n; ++i)
        {
            progress.Report(new ProgressNotificationValue()
            {
                Progress = i,
                Total = n,
                Message = $"Step {i} of {n}",
            });
            await Task.Delay(100);
        }
        return n;
    }

    [Description("Throws an exception for testing purposes.")]
    public string TestThrow()
    {
        throw new Exception("This is a test exception");
    }
}

注意到变化了吗?我们去掉了官方库定义的 [McpServerToolType] 和 [McpServerTool] 特性。取而代之的是一种更符合ASP.NET Core直觉的方式:任何 public 方法都自动成为一个工具,并使用标准的 System.ComponentModel.DescriptionAttribute 来提供工具描述。

理想中的使用方式

我们期望最终的使用方式能像下面这样优雅:

WebApplicationBuilder builder = WebApplication.CreateBuilder();

// 1. 注册原生服务和我们的工具类
builder.Services.AddHttpContextAccessor();
builder.Services.AddTransient<Tools>();

WebApplication app = builder.Build();

// 2. 映射 MCP 端点,自动发现并使用 Tools 类
app.MapMcpEndpoint<Tools>("/");

// 3. 启动应用
app.Run();

是的,你没看错。核心就在于 builder.Services.AddTransient<Tools>(); 和 app.MapMcpEndpoint<Tools>("/"); 这两行。前者负责将我们的工具类注册到依赖注入容器,后者则是我们即将创建的魔法扩展方法,它会自动处理所有MCP协议的细节。

第一步:定义协议的“语言” – DTOs

要实现协议,首先要定义好通信双方所使用的“语言”,也就是数据传输对象(DTOs)。根据昨天的抓包分析,我们用C#的 record 类型来精确描述这些JSON结构。

using System.Text.Json.Serialization;

// --- JSON-RPC Base Structures ---
public record JsonRpcRequest(
    [property: JsonPropertyName("jsonrpc")] string JsonRpc,
    [property: JsonPropertyName("method")] string Method,
    [property: JsonPropertyName("params")] object? Params,
    [property: JsonPropertyName("id")] int? Id
);

public record JsonRpcResponse(
    [property: JsonPropertyName("jsonrpc")] string JsonRpc,
    [property: JsonPropertyName("result")] object? Result,
    [property: JsonPropertyName("error")] object? Error,
    [property: JsonPropertyName("id")] int? Id
);

public record JsonRpcError(
    [property: JsonPropertyName("code")] int Code,
    [property: JsonPropertyName("message")] string Message
);

// --- MCP Specific Payloads ---

// For initialize method
public record InitializeParams(
    [property: JsonPropertyName("protocolVersion")] string ProtocolVersion,
    [property: JsonPropertyName("clientInfo")] ClientInfo ClientInfo
);
public record ClientInfo([property: JsonPropertyName("name")] string Name, [property: JsonPropertyName("version")] string Version);

public record InitializeResult(
    [property: JsonPropertyName("protocolVersion")] string ProtocolVersion,
    [property: JsonPropertyName("capabilities")] ServerCapabilities Capabilities,
    [property: JsonPropertyName("serverInfo")] ClientInfo ServerInfo
);
public record ServerCapabilities([property: JsonPropertyName("tools")] object Tools);


// For tools/call method
public record ToolCallParams(
    [property: JsonPropertyName("name")] string Name,
    [property: JsonPropertyName("arguments")] Dictionary<string, object?> Arguments,
    [property: JsonPropertyName("_meta")] ToolCallMeta? Meta
);
public record ToolCallMeta([property: JsonPropertyName("progressToken")] string ProgressToken);

// For tool call results
public record ToolCallResult(
    [property: JsonPropertyName("content")] List<ContentItem> Content,
    [property: JsonPropertyName("isError")] bool IsError = false
);
public record ContentItem([property: JsonPropertyName("type")] string Type, [property: JsonPropertyName("text")] string Text);

// For tools/list results
public record ToolListResult(
    [property: JsonPropertyName("tools")] List<ToolDefinition> Tools
);

public record ToolDefinition(
    [property: JsonPropertyName("name")] string Name,
    [property: JsonPropertyName("description")] string Description,
    [property: JsonPropertyName("inputSchema")] object InputSchema
);

// For progress notifications
public record ProgressNotification(
    [property: JsonPropertyName("jsonrpc")] string JsonRpc,
    [property: JsonPropertyName("method")] string Method,
    [property: JsonPropertyName("params")] ProgressParams Params
);
public record ProgressParams(
    [property: JsonPropertyName("progressToken")] string ProgressToken,
    [property: JsonPropertyName("progress")] int Progress,
    [property: JsonPropertyName("total")] int Total,
    [property: JsonPropertyName("message")] string Message
);

// This class is for the IProgress<T> interface in our Tools methods
public class ProgressNotificationValue
{
    public int Progress { get; set; }
    public int Total { get; set; }
    public string Message { get; set; } = string.Empty;
}

第二步:打造核心引擎 – McpEndpointExtensions

接下来,就是实现我们魔法的源泉:一个IEndpointRouteBuilder的扩展方法。我们将所有逻辑都封装在一个静态类 McpEndpointExtensions 中。

这个类将负责:

  1. 路由映射:监听指定路径的 POST 和 GET 请求。
  2. 请求分发:根据JSON-RPC请求中的method字段,调用不同的处理函数。
  3. 工具发现与调用:使用反射来查找和执行TTools类中的工具方法。
  4. 响应构建:手动构建符合SSE规范的响应流。
  5. 错误处理:精确复现抓包分析中发现的两种错误模型。
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Primitives;
using System.ComponentModel;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;

public static class McpEndpointExtensions
{
    // JSON-RPC Error Codes from your article's findings
    private const int InvalidParamsErrorCode = -32602; // Invalid params
    private const int MethodNotFoundErrorCode = -32601; // Method not found

    private static readonly JsonSerializerOptions s_jsonOptions = new()
    {
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    };

    /// <summary>
    /// Maps an endpoint that speaks the Model Context Protocol.
    /// </summary>
    public static IEndpointRouteBuilder MapMcpEndpoint<TTools>(this IEndpointRouteBuilder app, string pattern) where TTools : class
    {
        // 预先通过反射发现所有工具方法,并转换为snake_case以匹配MCP命名习惯
        Dictionary<string, MethodInfo> methods = typeof(TTools).GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
            .ToDictionary(k => ToSnakeCase(k.Name), v => v);

        app.MapPost(pattern, async (HttpContext context, [FromServices] IServiceProvider sp) =>
        {
            JsonRpcRequest? request = await JsonSerializer.DeserializeAsync<JsonRpcRequest>(context.Request.Body, s_jsonOptions);
            if (request == null)
            {
                context.Response.StatusCode = 400; // Bad Request
                return;
            }

            // 核心:处理不同的MCP方法
            switch (request.Method)
            {
                case "initialize":
                    await HandleInitialize(context, request);
                    break;
                case "notifications/initialized":
                    // 在无状态模式下,这个请求只是一个确认,我们返回与initialize类似的信息
                    await HandleInitialize(context, request);
                    break;
                case "tools/list":
                    await HandleToolList<TTools>(context, request);
                    break;
                case "tools/call":
                    await HandleToolCall<TTools>(context, request, sp, methods);
                    break;
                default:
                    JsonRpcResponse errorResponse = new("2.0", null, new JsonRpcError(MethodNotFoundErrorCode, "Method not found"), request.Id);
                    await WriteSseMessageAsync(context.Response, errorResponse);
                    break;
            }
        });

        // 旧版SDK会发送GET请求,我们明确返回405
        app.MapGet(pattern, context =>
        {
            context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed;
            context.Response.Headers.Allow = "POST";
            return Task.CompletedTask;
        });

        return app;
    }

    private static string ToSnakeCase(string name)
    {
        if (string.IsNullOrEmpty(name)) return name;
        var sb = new StringBuilder(name.Length);
        for (int i = 0; i < name.Length; i++)
        {
            char c = name[i];
            if (char.IsUpper(c))
            {
                if (sb.Length > 0 && i > 0 && !char.IsUpper(name[i-1])) sb.Append('_');
                sb.Append(char.ToLowerInvariant(c));
            }
            else
            {
                sb.Append(c);
            }
        }
        return sb.ToString();
    }

    private static async Task HandleInitialize(HttpContext context, JsonRpcRequest request)
    {
        // 复用或创建 Session ID
        string sessionId = context.Request.Headers.TryGetValue("Mcp-Session-Id", out StringValues existingSessionId)
            ? existingSessionId.ToString()
            : WebEncoders.Base64UrlEncode(Guid.NewGuid().ToByteArray());

        context.Response.Headers["Mcp-Session-Id"] = sessionId;

        // 构建与抓包一致的响应
        InitializeResult result = new(
            "2025-06-18", // Echo the protocol version
            new ServerCapabilities(new { listChanged = true }), // Mimic the capabilities
            new ClientInfo("PureAspNetCoreMcpServer", "1.0.0")
        );
        JsonRpcResponse response = new("2.0", result, null, request.Id);
        await WriteSseMessageAsync(context.Response, response);
    }

    private static async Task HandleToolList<TTools>(HttpContext context, JsonRpcRequest request) where TTools : class
    {
        EchoSessionId(context);

        List<ToolDefinition> toolDefs = [];
        MethodInfo[] toolMethods = typeof(TTools).GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly);

        foreach (MethodInfo method in toolMethods)
        {
            string description = method.GetCustomAttribute<DescriptionAttribute>()?.Description ?? "No description.";

            // 简化的动态Schema生成
            Dictionary<string, object> properties = [];
            List<string> required = [];
            foreach (ParameterInfo param in method.GetParameters())
            {
                if (param.ParameterType == typeof(IProgress<ProgressNotificationValue>)) continue; // 忽略进度报告参数
                properties[param.Name!] = new { type = GetJsonType(param.ParameterType) };
                if (!param.IsOptional)
                {
                    required.Add(param.Name!);
                }
            }
            var schema = new { type = "object", properties, required };
            toolDefs.Add(new ToolDefinition(ToSnakeCase(method.Name), description, schema));
        }

        ToolListResult result = new(toolDefs);
        JsonRpcResponse response = new("2.0", result, null, request.Id);
        await WriteSseMessageAsync(context.Response, response);
    }

    private static async Task HandleToolCall<TTools>(HttpContext context, JsonRpcRequest request, IServiceProvider sp, Dictionary<string, MethodInfo> methods) where TTools : class
    {
        EchoSessionId(context);

        ToolCallParams? toolCallParams = JsonSerializer.Deserialize<ToolCallParams>(JsonSerializer.Serialize(request.Params, s_jsonOptions), s_jsonOptions);
        if (toolCallParams == null) return;

        string toolName = toolCallParams.Name;
        methods.TryGetValue(toolName, out MethodInfo? method);

        // 场景1: 调用不存在的工具 -> 返回标准JSON-RPC错误
        if (method == null)
        {
            JsonRpcError error = new(InvalidParamsErrorCode, $"Unknown tool: '{toolName}'");
            JsonRpcResponse response = new("2.0", null, error, request.Id);
            await WriteSseMessageAsync(context.Response, response);
            return;
        }

        // 使用DI容器创建工具类的实例
        using IServiceScope scope = sp.CreateScope();
        TTools toolInstance = scope.ServiceProvider.GetRequiredService<TTools>();

        object? resultValue;
        bool isError = false;

        try
        {
            // 通过反射准备方法参数
            ParameterInfo[] methodParams = method.GetParameters();
            object?[] args = new object?[methodParams.Length];
            for (int i = 0; i < methodParams.Length; i++)
            {
                ParameterInfo p = methodParams[i];
                if (p.ParameterType == typeof(IProgress<ProgressNotificationValue>))
                {
                    // 创建一个IProgress<T>的实现,它会将进度作为SSE消息发回客户端
                    args[i] = new ProgressReporter(context.Response, toolCallParams.Meta!.ProgressToken);
                }
                else if (toolCallParams.Arguments.TryGetValue(p.Name!, out object? argValue) && argValue is JsonElement element)
                {
                    args[i] = element.Deserialize(p.ParameterType, s_jsonOptions);
                }
                else if (p.IsOptional)
                {
                    args[i] = p.DefaultValue;
                }
                else
                {
                     // 场景2a: 缺少必要参数 -> 抛出异常,进入catch块
                    throw new TargetParameterCountException($"Tool '{toolName}' requires parameter '{p.Name}' but it was not provided.");
                }
            }

            object? invokeResult = method.Invoke(toolInstance, args);

            // 处理异步方法
            if (invokeResult is Task task)
            {
                await task;
                resultValue = task.GetType().IsGenericType ? task.GetType().GetProperty("Result")?.GetValue(task) : null;
            }
            else
            {
                resultValue = invokeResult;
            }
        }
        // 场景2b: 工具执行时内部抛出异常 -> isError: true
        catch (Exception ex)
        {
            isError = true;
            // 将异常信息包装在result中,而不是顶层error
            resultValue = $"An error occurred invoking '{toolName}'. Details: {ex.InnerException?.Message ?? ex.Message}";
        }

        List<ContentItem> content = [new("text", resultValue?.ToString() ?? string.Empty)];
        ToolCallResult result = new(content, isError);
        JsonRpcResponse finalResponse = new("2.0", result, null, request.Id);
        await WriteSseMessageAsync(context.Response, finalResponse);
    }

    // 手动实现SSE消息写入,告别预览版包
    private static async Task WriteSseMessageAsync(HttpResponse response, object data)
    {
        if (!response.Headers.ContainsKey("Content-Type"))
        {
            response.ContentType = "text/event-stream";
            response.Headers.CacheControl = "no-cache,no-store";
            response.Headers.ContentEncoding = "identity";
            response.Headers.KeepAlive = "true";
        }

        string json = JsonSerializer.Serialize(data, s_jsonOptions);
        string message = $"event: message\ndata: {json}\n\n";
        await response.WriteAsync(message);
        await response.Body.FlushAsync();
    }

    private static void EchoSessionId(HttpContext context)
    {
        if (context.Request.Headers.TryGetValue("Mcp-Session-Id", out StringValues sessionId))
        {
            context.Response.Headers["Mcp-Session-Id"] = sessionId;
        }
    }

    private static string GetJsonType(Type type) => Type.GetTypeCode(type) switch
    {
        TypeCode.String => "string",
        TypeCode.Int32 or TypeCode.Int64 or TypeCode.Int16 or TypeCode.UInt32 => "integer",
        TypeCode.Double or TypeCode.Single or TypeCode.Decimal => "number",
        TypeCode.Boolean => "boolean",
        _ => "object"
    };

    // 专门用于处理进度报告的辅助类
    private class ProgressReporter(HttpResponse response, string token) : IProgress<ProgressNotificationValue>
    {
        public void Report(ProgressNotificationValue value)
        {
            ProgressParams progressParams = new(token, value.Progress, value.Total, value.Message);
            ProgressNotification notification = new("2.0", "notifications/progress", progressParams);
            // 警告: 在同步方法中调用异步代码,在真实生产环境中需要更优雅的处理
            WriteSseMessageAsync(response, notification).GetAwaiter().GetResult();
        }
    }
}

完整代码已备好!

为了方便大家动手实践,我已经将上述所有可直接运行的示例代码上传到了 GitHub Gist。您可以通过以下链接访问:

该Gist中包含了两个文件:

  • mcp-server-raw.linq: 我们刚刚从零开始构建的轻量级MCP服务端。
  • mcp-client.linq: 用于测试的客户端。

这两个文件都可以直接在最新版的 LINQPad 中打开并运行,让您能够立即体验和调试,如果您访问 Github Gist 有困难,则可以访问这个备用地址:https://github.com/sdcb/blog-data/tree/master/2025

bg

第三步:见证奇迹的时刻

现在,我们所有的准备工作都已就绪。我们可以用和昨天一模一样的客户端代码来测试我们的新服务端了:

// 客户端代码完全不变!
var clientTransport = new SseClientTransport(new SseClientTransportOptions()
{
    Name = "MyServer",
    Endpoint = new Uri("http://localhost:5000"), // 注意端口可能不同
});

var client = await McpClientFactory.CreateAsync(clientTransport);

// 1. 列出工具
(await client.ListToolsAsync()).Select(x => new { x.Name, Desc = JsonObject.Parse(x.JsonSchema.ToString()) }).Dump();

// 2. 调用简单工具
(await client.CallToolAsync(
    "echo",
    new Dictionary<string, object?>() { ["message"] = ".NET is awesome!" },
    cancellationToken: CancellationToken.None)).Dump();

// 3. 调用带进度的工具
(await client.CallToolAsync(
    "count",
    new Dictionary<string, object?>() { ["n"] = 5 },
    new Reporter(),
    cancellationToken: CancellationToken.None)).Dump();
    
// 4. 调用会抛出异常的工具
(await client.CallToolAsync("test_throw", cancellationToken: CancellationToken.None)).Dump();

// 5. 调用不存在的工具
(await client.CallToolAsync("not-existing-tool", cancellationToken: CancellationToken.None)).Dump();

// ... Reporter class as before ...

启动我们的新服务端,再运行客户端代码。打开抓包工具,你会发现,所有HTTP请求和SSE响应的格式、内容和行为,都与昨天分析的官方库实现完全一致!我们成功了!

对错误处理的进一步思考

值得一提的是,昨天的文章没有深入探讨参数错误的情况。比如 count 工具需要一个名为 n 的 int 类型参数,如果客户端错误地传递了一个 n2 参数,会发生什么?

在我今天实现的 HandleToolCall 方法中,参数匹配逻辑会因为找不到名为 n 的键而抛出 TargetParameterCountException。这个异常会被 try-catch 块捕获,然后和 test_throw 的情况一样,返回一个调用“成功”(HTTP 200)、但在 result 载荷中包含 "isError": true 和详细错误信息的响应。这恰好证明了MCP这种错误处理设计的健壮性:它能统一处理业务逻辑层面(工具内部异常)和参数绑定层面(调用约定不匹配)的多种失败情况。

总结

通过本次实践,我们不仅重温了MCP协议的通信原理,更重要的是,我们亲手实现了一个轻量级、无预览版依赖的MCP服务端。这次旅程的核心收获是:

  1. 协议是根基:一旦深刻理解了协议本身,即使没有官方SDK,我们也能在任何支持HTTP的环境中实现它。
  2. 化繁为简:我们用一个扩展方法和一些辅助类,就替代了官方库及其繁杂的依赖,代码清晰且易于掌控。
  3. 反射与元编程的威力:通过巧妙运用反射,我们实现了工具的自动发现和动态调用,大大提高了代码的灵活性和可扩展性。
  4. 知其然,知其所以然:现在,我们不仅知道MCP如何工作,更通过自己动手理解了它为何如此设计,比如两步握手、SSE流式响应以及分层的错误处理机制。

希望本文能帮助你彻底搞懂并掌握MCP协议的实现细节。现在,你拥有了完全控制MCP通信的能力,无论是进行二次开发、跨语言实现,还是仅仅为了满足那份技术探索的好奇心。


感谢您的阅读,如果您有任何问题或想法,欢迎在评论区留言讨论。

也欢迎加入我们的 .NET骚操作 QQ群一起探讨:495782587

官方文档没告诉你的:通过抓包,深入揭秘MCP协议底层通信 - .NET骚操作 - 博客园

mikel阅读(210)

来源: 官方文档没告诉你的:通过抓包,深入揭秘MCP协议底层通信 – .NET骚操作 – 博客园

大家好,今天我们来深入探讨一个很有意思的话题——MCP(Model Context Protocol)。

MCP 是Anthropic发起的一种开放协议,旨在标准化应用程序向大型语言模型(LLM)提供上下文的方式。我们可以把 MCP 想象成 AI 应用领域的 USB-C 接口。正如 USB-C 为你的设备与各种外设和配件的连接提供了标准化方式一样,MCP 也为 AI 模型与不同数据源和工具的连接提供了标准化的方法。

然而,MCP官网只模糊地提到它是基于 JSON-RPC 2.0 的协议,并提供了包括 C# 在内的八种语言的SDK。但对于其底层的HTTP请求和响应格式,官方文档并未给出清晰的描述,这让许多想要深入了解或自行实现的开发者感到困惑。

本文将通过一个具体的 C# 实例,结合抓包数据,一步步揭开 MCP 协议在 HTTP 层面上的神秘面纱。

image

准备工作:示例代码

为了抓包和演示,我们首先需要一个客户端和一个服务端。这里我们使用的是 ModelContextProtocol 0.3.0-preview.3 版本的 NuGet 包。

客户端 (Client)

客户端代码负责发起连接、列出可用工具并调用它们。

// 需要安装NuGet包:ModelContextProtocol 0.3.0-preview.3

var clientTransport = new SseClientTransport(new SseClientTransportOptions()
{
    Name = "MyServer",
    Endpoint = new Uri("http://localhost:5000/"),
});

var client = await McpClientFactory.CreateAsync(clientTransport);

// Print the list of tools available from the server.
(await client.ListToolsAsync()).Select(x => new { x.Name, Desc = JsonObject.Parse(x.JsonSchema.ToString()) }).Dump();

// Execute a tool (this would normally be driven by LLM tool invocations).
(await client.CallToolAsync(
    "echo",
    new Dictionary<string, object?>() { ["message"] = ".NET is awesome!" },
    cancellationToken: CancellationToken.None)).Dump();

(await client.CallToolAsync(
    "count",
    new Dictionary<string, object?>() { ["n"] = 5 },
    new Reporter(),
    cancellationToken: CancellationToken.None)).Dump();

(await client.CallToolAsync("test_throw", cancellationToken: CancellationToken.None)).Dump();

(await client.CallToolAsync("not-existing-tool", cancellationToken: CancellationToken.None)).Dump();

public class Reporter : IProgress<ProgressNotificationValue>
{
    public void Report(ProgressNotificationValue value)
    {
        value.Dump();
    }
}

服务端 (Server)

服务端代码定义了几个可供客户端调用的工具(Tool),并处理 MCP 请求。

// 需要安装NuGet包:ModelContextProtocol.AspNetCore 0.3.0-preview.3
var builder = WebApplication.CreateBuilder();

builder.Logging.AddConsole(consoleLogOptions =>
{
    // Configure all logs to go to stderr
    consoleLogOptions.LogToStandardErrorThreshold = LogLevel.Trace;
});
builder.Services
    .AddHttpContextAccessor()
    .AddMcpServer()
    .WithHttpTransport(c => c.Stateless = true) // 注意这里!
    .WithTools<Tools>();
var app = builder.Build();

app.MapMcp();
await app.RunAsync(QueryCancelToken);

[McpServerToolType]
public class Tools(IHttpContextAccessor http)
{
    [McpServerTool, Description("Echoes the message back to the client.")]
    public string Echo(string message) => $"hello {message}";

    [McpServerTool, Description("Returns the IP address of the client.")]
    public string EchoIP() => http.HttpContext?.Connection.RemoteIpAddress?.ToString() ?? "Unknown";

    [McpServerTool, Description("Counts from 0 to n, reporting progress at each step.")]
    public async Task<int> Count(int n, IProgress<ProgressNotificationValue> progress)
    {
        for (int i = 0; i < n; ++i)
        {
            progress.Report(new ProgressNotificationValue()
            {
                Progress = i,
                Total = n,
                Message = $"Step {i} of {n}",
            });
            await Task.Delay(100);
        }
        return n;
    }

    [McpServerTool, Description("Throws an exception for testing purposes.")]
    public string TestThrow()
    {
        throw new Exception("This is a test exception");
    }
}

特别注意:在我的服务端示例中,我明确指定了 .WithHttpTransport(c => c.Stateless = true)。这代表我使用的是无状态的HTTP传输方式。MCP目前默认是有状态的,如果使用有状态模式,具体的请求和响应格式会略有不同。本文的分析全部基于此处的无状态模式。

第一部分:初始化握手

MCP的连接始于一个分为两步的初始化过程,我们可以称之为“协商”与“确认”。

1. 协商 (Negotiation)

客户端首先向服务器发送一个initialize方法的JSON-RPC请求。

POST / HTTP/1.1
Host: localhost:5000
Accept: application/json, text/event-stream
Transfer-Encoding: chunked
Content-Type: application/json; charset=utf-8

{
  "method": "initialize",
  "params": {
    "protocolVersion": "2025-06-18",
    "capabilities": {},
    "clientInfo": {
      "name": "LINQPad.ScriptHost",
      "version": "1.0.0.0"
    }
  },
  "id": 1,
  "jsonrpc": "2.0"
}

这个请求告诉服务器:客户端期望使用2025-06-18版本的协议,并附上了自己的身份信息。

服务器收到后,会返回一个 Server-Sent Events (SSE) 响应。这个响应中包含一个关键的HTTP头 Mcp-Session-Id,以及对初始化请求的回复。

HTTP/1.1 200 OK
Content-Type: text/event-stream
Date: Mon, 21 Jul 2025 01:57:01 GMT
Server: Kestrel
Cache-Control: no-cache,no-store
Content-Encoding: identity
Transfer-Encoding: chunked
Mcp-Session-Id: CfDJ8PaF_EVr9adHn4ULGk-4ij7__UKHVt_9PM30T9KfWqDcHrSQUy3f34bIXzMKW-r2xhMrKclIQijzdY8FIWAAMLXnHVpWepSgNmZ02LKSIgsThMwffivlsALrlt_5PBExlLXRZo59M7NL3sDmWf22zTLPGymVcJHKk_lPOvSxV5ClxspnQbKLx-XgqPCAU6yt6D2E060A-fJoZ_vYNqpYe08bXkTvOdsvCrbweWBcsdL1cABx5jwfypX1CuZkcbuTUA

event: message
data: {"result":{"protocolVersion":"2025-06-18","capabilities":{"logging":{},"tools":{"listChanged":true}},"serverInfo":{"name":"LINQPad.ScriptHost","version":"1.0.0.0"}},"id":1,"jsonrpc":"2.0"}

从响应中可以看到,服务器同意使用2025-06-18协议版本,并返回了自己的能力(capabilities)。最重要的是,它提供了一个唯一的会话ID Mcp-Session-Id,这个ID将用于后续的所有通信。

2. 确认 (Confirmation)

拿到会话ID后,客户端会发送第二个请求,这次是notifications/initialized通知,用于确认初始化。

POST / HTTP/1.1
Host: localhost:5000
Accept: application/json, text/event-stream
Mcp-Session-Id: CfDJ8PaF_EVr9adHn4ULGk-4ij7__UKHVt_9PM30T9KfWqDcHrSQUy3f34bIXzMKW-r2xhMrKclIQijzdY8FIWAAMLXnHVpWepSgNmZ02LKSIgsThMwffivlsALrlt_5PBExlLXRZo59M7NL3sDmWf22zTLPGymVcJHKk_lPOvSxV5ClxspnQbKLx-XgqPCAU6yt6D2E060A-fJoZ_vYNqpYe08bXkTvOdsvCrbweWBcsdL1cABx5jwfypX1CuZkcbuTUA
MCP-Protocol-Version: 2025-06-18
Transfer-Encoding: chunked
Content-Type: application/json; charset=utf-8

{
  "method": "notifications/initialized",
  "params": {},
  "jsonrpc": "2.0"
}

这个请求在HTTP头中带上了上一步获取的Mcp-Session-Id和双方商定的MCP-Protocol-Version

服务器收到后,会再次返回一个SSE响应,内容与第一次类似,标志着握手完成,会话正式建立。

HTTP/1.1 200 OK
Content-Type: text/event-stream
Date: Mon, 21 Jul 2025 01:57:01 GMT
Server: Kestrel
Cache-Control: no-cache,no-store
Content-Encoding: identity
Transfer-Encoding: chunked
Mcp-Session-Id: CfDJ8PaF_EVr9adHn4ULGk-4ij7__UKHVt_9PM30T9KfWqDcHrSQUy3f34bIXzMKW-r2xhMrKclIQijzdY8FIWAAMLXnHVpWepSgNmZ02LKSIgsThMwffivlsALrlt_5PBExlLXRZo59M7NL3sDmWf22zTLPGymVcJHKk_lPOvSxV5ClxspnQbKLx-XgqPCAU6yt6D2E060A-fJoZ_vYNqpYe08bXkTvOdsvCrbweWBcsdL1cABx5jwfypX1CuZkcbuTUA

event: message
data: {"result":{"protocolVersion":"2025-06-18","capabilities":{"logging":{},"tools":{"listChanged":true}},"serverInfo":{"name":"LINQPad.ScriptHost","version":"1.0.0.0"}},"id":1,"jsonrpc":"2.0"}

深度解析:为何需要两步初始化?

你可能会问,为什么设计如此复杂的两步初始化过程?

根本原因在于,这是一个健壮且灵活的协议设计模式,其核心思想是分离“协商”与“确认”。这确保了客户端和服务器在开始正式数据交换前,就所有关键参数(如协议版本、会话ID、双方能力等)达成完全一致。

  • 第一步 initialize (协商阶段):客户端发起提议,服务器响应提议、确定通信参数并创建会话,返回会话ID(Mcp-Session-Id)。此时,双方只是达成了“如何沟通”的共识。
  • 第二步 initialized (确认阶段):客户端使用会话ID和协议版本发起确认,告诉服务器:“我已经收到你的响应,并准备好按商定的规则开始通信了。”

这种设计的必要性体现在:

  1. 避免竞态条件 (Race Conditions):如果没有第二步确认,客户端可能在收到initialize响应后立即发送业务请求,但此时服务器可能还未完全准备好。第二步就像一个明确的同步信号。
  2. 保证状态一致性:类似TCP的三次握手,这种模式确保了通信双方对会话状态的认知完全一致,为后续的稳定通信奠定基础。
  3. 灵活性和扩展性:该设计允许在协商阶段加入更复杂的逻辑。例如,服务器可以要求客户端在确认前完成某些额外设置。

简单类比一下,这就像一个正式的电话会议:

  1. 第一步 (initialize): 你打电话:“你好,我是张三,能现在开会讨论项目A吗?” 对方回答:“可以,我是李四。我们就用中文讨论,会议号是12345。”
  2. 第二步 (initialized): 你说:“好的,收到,会议号12345,我们正式开始吧。”

没有第二步,对方就无法确定你是否已准备就绪。总之,MCP通过两步初始化,实现了一个可靠、同步且灵活的握手过程

第二部分:方法确认 (GET请求)

在初始化完成后,SDK可能会尝试发送一个GET请求来确认连接。

GET / HTTP/1.1
Host: localhost:5000
Accept: text/event-stream
Mcp-Session-Id: CfDJ8PaF_EVr9adHn4ULGk-4ij7__UKHVt_9PM30T9KfWqDcHrSQUy3f34bIXzMKW-r2xhMrKclIQijzdY8FIWAAMLXnHVpWepSgNmZ02LKSIgsThMwffivlsALrlt_5PBExlLXRZo59M7NL3sDmWf22zTLPGymVcJHKk_lPOvSxV5ClxspnQbKLx-XgqPCAU6yt6D2E060A-fJoZ_vYNqpYe08bXkTvOdsvCrbweWBcsdL1cABx5jwfypX1CuZkcbuTUA
MCP-Protocol-Version: 2025-06-18

然而,在我使用的 ModelContextProtocol.AspNetCore 0.3.0-preview.3 版本中,服务端并未实现对GET请求的处理逻辑。因此,服务器返回了 HTTP 405 Method Not Allowed,并在 Allow 头中明确指出只支持 POST

HTTP/1.1 405 Method Not Allowed
Content-Length: 0
Date: Mon, 21 Jul 2025 01:57:01 GMT
Server: Kestrel
Allow: POST

第三部分:正常通信

握手成功后,客户端和服务端就可以开始真正的数据交换了。所有业务请求都通过POST方法进行。

1. 列出可用工具

首先,我们发送一个 tools/list 请求来获取服务端提供的所有工具。

POST / HTTP/1.1
Host: localhost:5000
Accept: application/json, text/event-stream
Mcp-Session-Id: CfDJ8PaF_EVr9adHn4ULGk-4ij7__UKHVt_9PM30T9KfWqDcHrSQUy3f34bIXzMKW-r2xhMrKclIQijzdY8FIWAAMLXnHVpWepSgNmZ02LKSIgsThMwffivlsALrlt_5PBExlLXRZo59M7NL3sDmWf22zTLPGymVcJHKk_lPOvSxV5ClxspnQbKLx-XgqPCAU6yt6D2E060A-fJoZ_vYNqpYe08bXkTvOdsvCrbweWBcsdL1cABx5jwfypX1CuZkcbuTUA
MCP-Protocol-Version: 2025-06-18
Transfer-Encoding: chunked
Content-Type: application/json; charset=utf-8

{
  "method": "tools/list",
  "params": {},
  "id": 2,
  "jsonrpc": "2.0"
}

服务器返回一个SSE消息,data字段中包含了工具列表的JSON数组,每个工具都有名称、描述和输入参数的Schema。

HTTP/1.1 200 OK
Content-Type: text/event-stream
Date: Mon, 21 Jul 2025 01:57:01 GMT
Server: Kestrel
Cache-Control: no-cache,no-store
Content-Encoding: identity
Transfer-Encoding: chunked
Mcp-Session-Id: CfDJ8PaF_EVr9adHn4ULGk-4ij7__UKHVt_9PM30T9KfWqDcHrSQUy3f34bIXzMKW-r2xhMrKclIQijzdY8FIWAAMLXnHVpWepSgNmZ02LKSIgsThMwffivlsALrlt_5PBExlLXRZo59M7NL3sDmWf22zTLPGymVcJHKk_lPOvSxV5ClxspnQbKLx-XgqPCAU6yt6D2E060A-fJoZ_vYNqpYe08bXkTvOdsvCrbweWBcsdL1cABx5jwfypX1CuZkcbuTUA

event: message
data: {"result":{"tools":[{"name":"echo","description":"Echoes the message back to the client.","inputSchema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}},{"name":"test_throw","description":"Throws an exception for testing purposes.","inputSchema":{"type":"object","properties":{}}},{"name":"count","description":"Counts from 0 to n, reporting progress at each step.","inputSchema":{"type":"object","properties":{"n":{"type":"integer"}},"required":["n"]}},{"name":"echo_ip","description":"Returns the IP address of the client.","inputSchema":{"type":"object","properties":{}}}]},"id":2,"jsonrpc":"2.0"}

2. 调用简单工具 (echo)

接下来,我们调用 echo 工具。请求的 method 为 tools/callparams 中指定了工具名称和参数。

POST / HTTP/1.1
Host: localhost:5000
Accept: application/json, text/event-stream
Mcp-Session-Id: CfDJ8PaF_EVr9adHn4ULGk-4ij52NgZZYYWNIR0QjlQMp-3gHqqQtWdqoCun83RIwOM6LbD-qaJs4wCuiWspDO0LfV39fueDbONZIRWdm8iEsSrFQTAgsgBkxNtsUqlHDtbPvnkFNScCfVtzljqHOc9xfiuxHaBGoLaQJFxWM98Ko9aLy7FcWEeKEOuyvYg7biTtdjyYzyFwZ3ijmP2UBC0mzbP7SrW2Kdu58E1i2MMF3y2p7XHmkaPL6RuOOWSFfwCTeA
MCP-Protocol-Version: 2025-06-18
Transfer-Encoding: chunked
Content-Type: application/json; charset=utf-8

{
  "method": "tools/call",
  "params": {
    "name": "echo",
    "arguments": {
      "message": ".NET is awesome!"
    }
  },
  "id": 3,
  "jsonrpc": "2.0"
}

服务器返回结果,result.content 字段包含了工具的输出。

HTTP/1.1 200 OK
Content-Type: text/event-stream
Date: Mon, 21 Jul 2025 02:28:19 GMT
Server: Kestrel
Cache-Control: no-cache,no-store
Content-Encoding: identity
Transfer-Encoding: chunked
Mcp-Session-Id: CfDJ8PaF_EVr9adHn4ULGk-4ij52NgZZYYWNIR0QjlQMp-3gHqqQtWdqoCun83RIwOM6LbD-qaJs4wCuiWspDO0LfV39fueDbONZIRWdm8iEsSrFQTAgsgBkxNtsUqlHDtbPvnkFNScCfVtzljqHOc9xfiuxHaBGoLaQJFxWM98Ko9aLy7FcWEeKEOuyvYg7biTtdjyYzyFwZ3ijmP2UBC0mzbP7SrW2Kdu58E1i2MMF3y2p7XHmkaPL6RuOOWSFfwCTeA

event: message
data: {"result":{"content":[{"type":"text","text":"hello .NET is awesome!"}]},"id":3,"jsonrpc":"2.0"}

3. 调用带进度报告的工具 (count)

MCP的一个强大功能是支持进度报告。我们通过调用 count 工具来演示。注意,请求的params中增加了一个 _meta 字段,其中包含一个客户端生成的 progressToken

POST / HTTP/1.1
Host: localhost:5000
Accept: application/json, text/event-stream
Mcp-Session-Id: CfDJ8PaF_EVr9adHn4ULGk-4ij52NgZZYYWNIR0QjlQMp-3gHqqQtWdqoCun83RIwOM6LbD-qaJs4wCuiWspDO0LfV39fueDbONZIRWdm8iEsSrFQTAgsgBkxNtsUqlHDtbPvnkFNScCfVtzljqHOc9xfiuxHaBGoLaQJFxWM98Ko9aLy7FcWEeKEOuyvYg7biTtdjyYzyFwZ3ijmP2UBC0mzbP7SrW2Kdu58E1i2MMF3y2p7XHmkaPL6RuOOWSFfwCTeA
MCP-Protocol-Version: 2025-06-18
Transfer-Encoding: chunked
Content-Type: application/json; charset=utf-8

{
  "method": "tools/call",
  "params": {
    "name": "count",
    "arguments": {
      "n": 5
    },
    "_meta": {
      "progressToken": "9021fd27304a48e8ada90e35a66bc1dd"
    }
  },
  "id": 4,
  "jsonrpc": "2.0"
}

这次,服务器的SSE响应是一个事件流。它会陆续发送多个 event: message,其中包含了进度更新。这些进度通知的methodnotifications/progress,并通过 progressToken 与原始请求关联。当任务完成后,最后一条消息才包含最终的result

HTTP/1.1 200 OK
Content-Type: text/event-stream
Date: Mon, 21 Jul 2025 02:28:19 GMT
Server: Kestrel
Cache-Control: no-cache,no-store
Content-Encoding: identity
Transfer-Encoding: chunked
Mcp-Session-Id: CfDJ8PaF_EVr9adHn4ULGk-4ij52NgZZYYWNIR0QjlQMp-3gHqqQtWdqoCun83RIwOM6LbD-qaJs4wCuiWspDO0LfV39fueDbONZIRWdm8iEsSrFQTAgsgBkxNtsUqlHDtbPvnkFNScCfVtzljqHOc9xfiuxHaBGoLaQJFxWM98Ko9aLy7FcWEeKEOuyvYg7biTtdjyYzyFwZ3ijmP2UBC0mzbP7SrW2Kdu58E1i2MMF3y2p7XHmkaPL6RuOOWSFfwCTeA

event: message
data: {"method":"notifications/progress","params":{"progressToken":"9021fd27304a48e8ada90e35a66bc1dd","progress":0,"total":5,"message":"Step 0 of 5"},"jsonrpc":"2.0"}

event: message
data: {"method":"notifications/progress","params":{"progressToken":"9021fd27304a48e8ada90e35a66bc1dd","progress":1,"total":5,"message":"Step 1 of 5"},"jsonrpc":"2.0"}

event: message
data: {"method":"notifications/progress","params":{"progressToken":"9021fd27304a48e8ada90e35a66bc1dd","progress":2,"total":5,"message":"Step 2 of 5"},"jsonrpc":"2.0"}

event: message
data: {"method":"notifications/progress","params":{"progressToken":"9021fd27304a48e8ada90e35a66bc1dd","progress":3,"total":5,"message":"Step 3 of 5"},"jsonrpc":"2.0"}

event: message
data: {"method":"notifications/progress","params":{"progressToken":"9021fd27304a48e8ada90e35a66bc1dd","progress":4,"total":5,"message":"Step 4 of 5"},"jsonrpc":"2.0"}

event: message
data: {"result":{"content":[{"type":"text","text":"5"}]},"id":4,"jsonrpc":"2.0"}

第四部分:异常与错误处理

一个健壮的协议必须能优雅地处理各种意外情况。MCP协议通过两种不同的方式来报告错误,我们通过调用 test_throw(在服务端会主动抛出异常)和调用一个不存在的工具 not-existing-tool 来观察这两种机制。

4. 工具执行时抛出异常 (test_throw)

现在,我们调用那个被设计为一定会失败的 test_throw 工具。

请求 (Request)

请求本身与调用普通工具无异,它遵循标准的 tools/call 格式。

POST / HTTP/1.1
Host: localhost:5000
Accept: application/json, text/event-stream
Mcp-Session-Id: CfDJ8PaF_EVr9adHn4ULGk-4ij7KnxH5A76vXaHfcu5WUlT2qwOcZKw7FC0F8iyfmDU4-weDzJNcH1AjCirhnrqpCjAXI52umwTrb8y7K4rEnuC-l89Frm26vXrg06cNEySoeTevw6g_SYt7fJRu-1vb3OprOeeUjJMQUJH4v5sf__UMpAkO9caBvjxc1Qiqko2Fy0UiB_gCq0jsTQ_keGq_kfDqD9LUSj41LLfUboRlnln4_xWhQ8jLmbNvDiR5F6B9LA
MCP-Protocol-Version: 2025-06-18
Transfer-Encoding: chunked
Content-Type: application/json; charset=utf-8

{
  "method": "tools/call",
  "params": {
    "name": "test_throw"
  },
  "id": 5,
  "jsonrpc": "2.0"
}

响应 (Response)

这是有趣的地方。服务器返回的HTTP状态码依然是 200 OK,表示HTTP通信本身是成功的。然而,响应体内的JSON-RPC报文揭示了真实情况。

HTTP/1.1 200 OK
Content-Type: text/event-stream
Date: Mon, 21 Jul 2025 03:17:20 GMT
Server: Kestrel
Cache-Control: no-cache,no-store
Content-Encoding: identity
Transfer-Encoding: chunked
Mcp-Session-Id: CfDJ8PaF_EVr9adHn4ULGk-4ij7KnxH5A76vXaHfcu5WUlT2qwOcZKw7FC0F8iyfmDU4-weDzJNcH1AjCirhnrqpCjAXI52umwTrb8y7K4rEnuC-l89Frm26vXrg06cNEySoeTevw6g_SYt7fJRu-1vb3OprOeeUjJMQUJH4v5sf__UMpAkO9caBvjxc1Qiqko2Fy0UiB_gCq0jsTQ_keGq_kfDqD9LUSj41LLfUboRlnln4_xWhQ8jLmbNvDiR5F6B9LA

event: message
data: {"result":{"content":[{"type":"text","text":"An error occurred invoking 'test_throw'."}],"isError":true},"id":5,"jsonrpc":"2.0"}

深度解析:

请注意,JSON-RPC报文返回的不是一个顶级的 error 对象,而是一个 result 对象。这说明从JSON-RPC协议的层面来看,这次调用是“成功”的。但是,result 对象内部增加了一个关键字段:"isError": true

这是一种精巧的设计:它区分了协议层面的错误业务逻辑层面的错误

  • 协议层面:客户端的请求格式正确,服务器也找到了名为 test_throw 的工具并成功尝试执行它。因此,JSON-RPC的交互流程是完整的。
  • 业务逻辑层面:工具在执行期间内部发生了未捕获的异常。MCP服务端捕获了这个异常,并将其封装成一个“错误结果”返回。isError: true 就是一个明确的信号,告诉客户端:“我尝试执行了,但工具自己出错了”。

这种方式让客户端可以统一处理所有 tools/call 的响应,然后通过检查 isError 标志来判断工具的执行是否真正成功。

5. 调用不存在的工具

接下来,我们尝试调用一个从未在服务端定义过的工具:not-existing-tool

请求 (Request)

请求结构依然是标准的 tools/call

POST / HTTP/1.1
Host: localhost:5000
Accept: application/json, text/event-stream
Mcp-Session-Id: CfDJ8PaF_EVr9adHn4ULGk-4ij7KnxH5A76vXaHfcu5WUlT2qwOcZKw7FC0F8iyfmDU4-weDzJNcH1AjCirhnrqpCjAXI52umwTrb8y7K4rEnuC-l89Frm26vXrg06cNEySoeTevw6g_SYt7fJRu-1vb3OprOeeUjJMQUJH4v5sf__UMpAkO9caBvjxc1Qiqko2Fy0UiB_gCq0jsTQ_keGq_kfDqD9LUSj41LLfUboRlnln4_xWhQ8jLmbNvDiR5F6B9LA
MCP-Protocol-Version: 2025-06-18
Transfer-Encoding: chunked
Content-Type: application/json; charset=utf-8

{
  "method": "tools/call",
  "params": {
    "name": "not-existing-tool"
  },
  "id": 6,
  "jsonrpc": "2.0"
}

响应 (Response)

这次的响应与上一个场景截然不同。

HTTP/1.1 200 OK
Content-Type: text/event-stream
Date: Mon, 21 Jul 2025 03:17:20 GMT
Server: Kestrel
Cache-Control: no-cache,no-store
Content-Encoding: identity
Transfer-Encoding: chunked
Mcp-Session-Id: CfDJ8PaF_EVr9adHn4ULGk-4ij7KnxH5A76vXaHfcu5WUlT2qwOcZKw7FC0F8iyfmDU4-weDzJNcH1AjCirhnrqpCjAXI52umwTrb8y7K4rEnuC-l89Frm26vXrg06cNEySoeTevw6g_SYt7fJRu-1vb3OprOeeUjJMQUJH4v5sf__UMpAkO9caBvjxc1Qiqko2Fy0UiB_gCq0jsTQ_keGq_kfDqD9LUSj41LLfUboRlnln4_xWhQ8jLmbNvDiR5F6B9LA

event: message
data: {"error":{"code":-32602,"message":"Unknown tool: 'not-existing-tool'"},"id":6,"jsonrpc":"2.0"}

深度解析:

看到区别了吗?这次的响应体直接包含了一个顶级的 error 对象,完全符合JSON-RPC 2.0的错误响应规范。

  • "code": -32602:这是JSON-RPC的一个标准错误码,代表 “Invalid params” (无效参数)。在这里,服务端认为tools/call方法中的name参数值"not-existing-tool"是无效的,因为找不到对应的工具。
  • "message": "Unknown tool: 'not-existing-tool'":提供了人类可读的错误描述。

这被视为一个协议层面的错误,因为客户端请求执行一个从服务器视角看根本不存在的方法(或资源)。服务器甚至都无法开始“执行工具”这个业务逻辑,因为它在第一步——查找工具时——就失败了。因此,它直接返回一个标准的JSON-RPC错误,终止了这次调用。

总结

通过以上的抓包分析,我们可以清晰地总结出(无状态)MCP协议的核心通信模式:

  1. 协议基础:MCP构建在 JSON-RPC 2.0 之上,通过 HTTP POST 请求进行交互。
  2. 会话管理:通过一个健壮的两步握手initialize 和 initialized)来建立会话,并使用 Mcp-Session-Id HTTP头来标识和维持该会话。
  3. 响应机制:服务端使用 Server-Sent Events (SSE) (Content-Type: text/event-stream) 来响应客户端。这种方式天然支持流式数据,非常适合长任务的进度报告。
  4. 数据格式:无论是请求的body还是SSE返回的data部分,都遵循JSON-RPC 2.0的报文结构 ({"jsonrpc": "2.0", "method": "...", "params": ..., "id": ...} 或 {"jsonrpc": "2.0", "result": ..., "id": ...})。
  5. 错误处理:MCP协议区分了两种错误。协议层面的错误(如调用不存在的工具)会返回标准的JSON-RPC error对象。而工具执行期间的业务逻辑错误则通过在成功的 result 对象中附加 isError: true 标志来表示,实现了协议与业务的分离。

希望本文能帮助你彻底搞懂MCP的底层通信原理。掌握了这些,你不仅能更好地使用官方SDK,甚至可以在不支持的语言或环境中实现自己的MCP客户端或服务端。


感谢您的阅读,如果您有任何问题或想法,欢迎在评论区留言讨论。

也欢迎加入我们的 .NET骚操作 QQ群一起探讨:495782587

C#AES CBC、PKCS7加密、解密_paddingmode.pkcs7-CSDN博客

mikel阅读(258)

来源: C#AES CBC、PKCS7加密、解密_paddingmode.pkcs7-CSDN博客

C#开发中,遇到AES加密,解密问题,因此,在这里记录一下,

该方法默认以下模式:

Mode:CBC

Padding:PKCS7

方法支持:

1.AES加密,返回base64的密文

string msg = AESHelper.AESEncrypt(“待加密内容”,”key”,”v”);

2.AES解密,返回utf-8的明文

string msg = AESHelper.AESEncrypt2(“待加密内容”,”key”,”v”);

代码类如下:

/// <summary>
/// AES 加密、解密
/// </summary>
public class AESHelper
{

#region AES加密,返回base64的密文
/// <summary>
/// <para>Describe:AES加密,返回base64的密文</para>
/// <para>Author:KAI</para>
/// </summary>
/// <param name=”toEncrypt”>源数据</param>
/// <param name=”key”>密钥</param>
/// <param name=”iv”>向量</param>
/// <returns>base64的密文</returns>
public static string AESEncrypt(string toEncrypt, string key, string iv)
{
byte[] keyArray = ConvertToByteAndAppend0(key, 24);
byte[] ivArray = ConvertToByteAndAppend0(iv, 16);

byte[] toEncryptArray = UTF8Encoding.UTF8.GetBytes(toEncrypt);
RijndaelManaged rDel = new RijndaelManaged();
rDel.Key = keyArray;
rDel.IV = ivArray;
rDel.Mode = CipherMode.CBC;
rDel.Padding = PaddingMode.PKCS7;
ICryptoTransform cTransform = rDel.CreateEncryptor();
byte[] resultArray = cTransform.TransformFinalBlock(toEncryptArray, 0, toEncryptArray.Length);
return Convert.ToBase64String(resultArray, 0, resultArray.Length);
}
public static byte[] ConvertToByteAndAppend0(string value, int intLength)
{
byte[] valueArray = new byte[intLength];
byte[] temp = UTF8Encoding.UTF8.GetBytes(value);
for (int i = 0; i < temp.Length; i++)
{
valueArray[i] = temp[i];
}
for (int i = temp.Length; i < intLength; i++)
{
valueArray[i] = 0x0;
}
return valueArray;
}
#endregion

#region AES解密,返回utf-8的明文
/// <summary>
/// <para>Describe:AES解密,返回utf-8的明文</para>
/// <para>Author:KAI</para>
/// </summary>
/// <param name=”toEncrypt”>密文</param>
/// <param name=”key”>密钥</param>
/// <param name=”iv”>向量</param>
/// <returns>utf-8的明文</returns>
public static string AESEncrypt2(string toDecrypt, string key, string iv)
{
byte[] keyArray = ConvertToByteAndAppend0(key, 24);
byte[] ivArray = ConvertToByteAndAppend0(iv, 16);

byte[] toEncryptArray = Convert.FromBase64String(toDecrypt);
RijndaelManaged rDel = new RijndaelManaged();
rDel.Key = keyArray;
rDel.IV = ivArray;
rDel.Mode = CipherMode.CBC;
rDel.Padding = PaddingMode.PKCS7;
ICryptoTransform cTransform = rDel.CreateDecryptor();
byte[] resultArray = cTransform.TransformFinalBlock(toEncryptArray, 0, toEncryptArray.Length);
string f = UTF8Encoding.UTF8.GetString(resultArray);
return UTF8Encoding.UTF8.GetString(resultArray);
}
#endregion
#region AES解密
/// <summary>
/// 解密
/// </summary>
/// <param name=”encryptResultStr”>待解密的密文字</param>
/// <param name=”decryptKey”>解密的key</param>
/// <returns>解密后的字符串</returns>
public static string AesDecrypt(String encryptResultStr, String decryptKey)
{
//base64 decode
String decrpt = EbotongDecrypto(encryptResultStr);
//hexStr to Str
byte[] decryptFrom = ParseHexStr2Str(decrpt);
//
return decrypt(decryptFrom, decryptKey);
}
/// <summary>
/// Base64解密
/// </summary>
/// <param name=”result”>待解密的密文</param>
/// <returns>解密后的字符串</returns>
public static string EbotongDecrypto(string result)
{
string decode = “”;
byte[] bytes = Convert.FromBase64String(result);
try
{
decode = Encoding.UTF8.GetString(bytes);
}
catch
{
decode = result;
}
return decode;
}

/// <summary>
/// 16进制字符串转字符串
/// </summary>
/// <param name=”hexStr”>16进制字符串</param>
/// <returns>转换后的字符串</returns>
public static byte[] ParseHexStr2Str(String hexStr)
{
if (hexStr == null || hexStr.Length < 1)
return null;
byte[] result = new byte[hexStr.Length / 2];
String s = “”;
for (int i = 0; i < hexStr.Length / 2; i++)
{
s = hexStr.Substring(i * 2, 1);
int high = Convert.ToInt32(s, 16);
s = hexStr.Substring(i * 2 + 1, 1);
int low = Convert.ToInt32(s, 16);
result[i] = (byte)(high * 16 + low);
}
return result;
}

/// <summary>
/// 解密
/// </summary>
/// <param name=”toDecrypt”>待解密的密文字节数组</param>
/// <param name=”key”>解密的key</param>
/// <returns>解密后的字符串</returns>
public static string decrypt(byte[] toDecrypt, string key)
{
//需要注意几点:
//1)C#默认运算模式为CBC,java默认为ECB,因此要将C#的加密方式改为ECB
//2)C#的Padding方式要设置为PaddingMode.PKCS7,否则解密出来后结尾可能有乱码
//key = “cGh19CbAIehVxt5ZqRDBJw==”;
byte[] keyArray = Convert.FromBase64String(key);
RijndaelManaged rDel = new RijndaelManaged();
rDel.Key = keyArray;
rDel.Mode = CipherMode.ECB;//必须设置为ECB
rDel.Padding = PaddingMode.PKCS7;//必须设置为PKCS7
ICryptoTransform cTransform = rDel.CreateDecryptor();
byte[] resultArray = cTransform.TransformFinalBlock(toDecrypt, 0, toDecrypt.Length);
return UTF8Encoding.UTF8.GetString(resultArray);
}
#endregion
}
————————————————

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

原文链接:https://blog.csdn.net/qq_35052673/article/details/134811853

c# 模拟Http请求 - Ysの陈憨憨 - 博客园

mikel阅读(400)

来源: c# 模拟Http请求 – Ysの陈憨憨 – 博客园

 

一、使用HttpClient

适用的.NET Framework 版本包括:.NET Framework 4.5+, .NET Standard 1.1+, .NET Core 1.0+
你可以通过Nuget:https://www.nuget.org/packages/Microsoft.Net.Http来安装

1.1 添加引用

using System.Net.Http;

1.2 实例化

建议在你应用程序的生命周期中使用同一个 HttpClient的实例

private static readonly HttpClient client = new HttpClient();

1.3 POST发送请求

var values = new Dictionary<string, string>
{
   { "thing1", "hello" },
   { "thing2", "world" }
};
var content = new FormUrlEncodedContent(values);
var response = await client.PostAsync("http://www.example.com/recepticle.aspx", content);
var responseString = await response.Content.ReadAsStringAsync();

1.4 GET发送请求

var responseString = await client.GetStringAsync("http://www.example.com/recepticle.aspx");

 

二、使用RestSharp

可以通过Nuget:https://www.nuget.org/packages/RestSharp来安装,具体使用示例:

2.1 发送请求

var client = new RestClient("http://example.com");
// client.Authenticator = new HttpBasicAuthenticator(username, password);

var request = new RestRequest("resource/{id}", Method.POST);
request.AddParameter("name", "value"); // adds to POST or URL querystring based on Method
request.AddUrlSegment("id", "123"); // replaces matching token in request.Resource

// easily add HTTP Headers
request.AddHeader("header", "value");

// add files to upload (works with compatible verbs)
request.AddFile(path);

// execute the request
IRestResponse response = client.Execute(request);
var content = response.Content; // raw content as string

// or automatically deserialize result
// return content type is sniffed but can be explicitly set via RestClient.AddHandler();
RestResponse<Person> response2 = client.Execute<Person>(request);
var name = response2.Data.Name;

// easy async support
client.ExecuteAsync(request, response => {
    Console.WriteLine(response.Content);
});

// async with deserialization
var asyncHandle = client.ExecuteAsync<Person>(request, response => {
    Console.WriteLine(response.Data.Name);
});

// abort the request on demand
asyncHandle.Abort();

 

三、使用Flurl.Http

3.1 Nuget安装

Nuget地址:https://www.nuget.org/packages/Flurl.Http

using Flurl.Http;

3.2 POST发送请求

var responseString = await "http://www.example.com/recepticle.aspx"
    .PostUrlEncodedAsync(new { thing1 = "hello", thing2 = "world" })
    .ReceiveString();

3.3 GET发送请求

var responseString = await "http://www.example.com/recepticle.aspx"
    .GetStringAsync();

 

四、使用HttpWebRequest

4.1 引入命名空间

适用的.NET framework: .NET Framework 1.1+, .NET Standard 2.0+, .NET Core 1.0+

using System.Net;
using System.Text;  // for class Encoding
using System.IO;    // for StreamReader

4.2 POST发送请求

var request = (HttpWebRequest)WebRequest.Create("http://www.example.com/recepticle.aspx");
var postData = "thing1=hello";
    postData += "&thing2=world";
var data = Encoding.ASCII.GetBytes(postData);
request.Method = "POST";
request.ContentType = "application/x-www-form-urlencoded";
request.ContentLength = data.Length;
using (var stream = request.GetRequestStream())
{
    stream.Write(data, 0, data.Length);
}
var response = (HttpWebResponse)request.GetResponse();
var responseString = new StreamReader(response.GetResponseStream()).ReadToEnd();

4.3 GET发送请求

var request = (HttpWebRequest)WebRequest.Create("http://www.example.com/recepticle.aspx");
var response = (HttpWebResponse)request.GetResponse();
var responseString = new StreamReader(response.GetResponseStream()).ReadToEnd();

 

五、使用WebClient

5.1 引入命名空间

适用的.NET framework: .NET Framework 1.1+, .NET Standard 2.0+, .NET Core 1.0+

using System.Net;
using System.Collections.Specialized;

5.2 Post发送请求

using (var client = new WebClient())
{
    var values = new NameValueCollection();
    values["thing1"] = "hello";
    values["thing2"] = "world";
    var response = client.UploadValues("http://www.example.com/recepticle.aspx", values);
    var responseString = Encoding.Default.GetString(response);
}

//或者
using (var client = new WebClient())
{
    byte[] dataToPost = Encoding.Default.GetBytes("thing1=hello&thing2=world");
    client.Header.Add("Content-Type","application/x-www-form-urlencoded");  //采用Post必须要加的Header,如果改成GET的话可以除去
    byte[] response = client.UploadData("http://example.com""POST",dataToPost);
    string strResult = Encoding.UTF8.GetString(response);
}

5.3 GET发送请求

using (var client = new WebClient())
{
    var responseString = client.DownloadString("http://www.example.com/recepticle.aspx");
}