在多线程编程中,线程安全的数据结构是确保数据一致性和避免竞争条件的关键。.NET 提供了多种线程安全的数据结构,适用于不同的场景,本篇将介绍它们的简单使用以及在 .NET Core 和 .NET Framework 中的可用性。

1. ConcurrentQueue

ConcurrentQueue

是一个线程安全的先进先出 (FIFO) 队列。它允许多个线程同时进行入队和出队操作,而不会导致数据不一致。

适用场景

  • 生产者-消费者模式
    :多个生产者线程将数据项添加到队列中,多个消费者线程从队列中取出数据项进行处理
  • 任务调度
    :将任务添加到队列中,由工作线程从队列中取出任务并执行

优点

  • 高效的并发操作
    :支持多个线程同时进行入队和出队操作
  • 无锁设计
    :内部使用无锁算法,避免了锁竞争,提高了性能
  • 易于使用
    :提供简单的 API,如
    Enqueue

    TryDequeue

可用性

  • .NET Framework 4.0 及以上
  • .NET Core 1.0 及以上

示例代码

using System.Collections.Concurrent;

var queue = new ConcurrentQueue<int>();
var cts = new CancellationTokenSource();
var token = cts.Token;

// 生产者任务
var producer = Task.Run(() =>
{
    for (int i = 0; i < 10; i++)
    {
        queue.Enqueue(i);
        Console.WriteLine($"Enqueued {i}");
        Thread.Sleep(100); // 模拟生产延迟
    }
}, token);

// 消费者任务
var consumer = Task.Run(() =>
{
    while (!token.IsCancellationRequested)
    {
        if (queue.TryDequeue(out int result))
        {
            Console.WriteLine($"Dequeued {result}");
        }
        Thread.Sleep(50); // 模拟消费延迟
    }
}, token);

await Task.WhenAll(producer);
cts.Cancel(); // 停止消费者任务
await consumer;

2. ConcurrentStack

ConcurrentStack

是一个线程安全的后进先出 (LIFO) 堆栈。它允许多个线程同时进行入栈和出栈操作。

适用场景

  • 深度优先搜索算法
    :在图或树结构中进行深度优先搜索时使用
  • 撤销操作
    :实现撤销功能时,将操作记录入栈,撤销时从栈中弹出操作

优点

  • 高效的并发操作
    :支持多个线程同时进行入栈和出栈操作
  • 无锁设计
    :内部使用无锁算法,避免了锁竞争,提高了性能
  • 易于使用
    :提供简单的 API,如
    Push

    TryPop

可用性

  • .NET Framework 4.0 及以上
  • .NET Core 1.0 及以上

示例代码

using System.Collections.Concurrent;

var stack = new ConcurrentStack<int>();
var cts = new CancellationTokenSource();
var token = cts.Token;

// 生产者任务
var producer = Task.Run(() =>
{
    for (int i = 0; i < 10; i++)
    {
        stack.Push(i);
        Console.WriteLine($"Pushed {i}");
        Thread.Sleep(100); // 模拟生产延迟
    }
}, token);

// 消费者任务
var consumer = Task.Run(() =>
{
    while (!token.IsCancellationRequested)
    {
        if (stack.TryPop(out int result))
        {
            Console.WriteLine($"Popped {result}");
        }
        Thread.Sleep(50); // 模拟消费延迟
    }
}, token);

await Task.WhenAll(producer);
cts.Cancel(); // 停止消费者任务
await consumer;

3. ConcurrentBag

ConcurrentBag

是一个线程安全的无序集合,适用于频繁添加和删除元素的场景。

适用场景

  • 任务池
    :将任务添加到集合中,工作线程从集合中取出任务并执行
  • 缓存
    :将临时数据存储在集合中,多个线程可以并发地添加和删除数据

优点

  • 高效的并发操作
    :支持多个线程同时进行添加和删除操作
  • 无锁设计
    :内部使用无锁算法,避免了锁竞争,提高了性能
  • 适用于无序数据
    :不关心元素顺序的场景非常适用

可用性

  • .NET Framework 4.0 及以上
  • .NET Core 1.0 及以上

示例代码

using System.Collections.Concurrent;

var bag = new ConcurrentBag<int>();
var cts = new CancellationTokenSource();
var token = cts.Token;

// 生产者任务
var producer = Task.Run(() =>
{
    for (int i = 0; i < 10; i++)
    {
        bag.Add(i);
        Console.WriteLine($"Added {i}");
        Thread.Sleep(100); // 模拟生产延迟
    }
}, token);

// 消费者任务
var consumer = Task.Run(() =>
{
    while (!token.IsCancellationRequested)
    {
        if (bag.TryTake(out int result))
        {
            Console.WriteLine($"Took {result}");
        }
        Thread.Sleep(50); // 模拟消费延迟
    }
}, token);

await Task.WhenAll(producer);
cts.Cancel(); // 停止消费者任务
await consumer;

4. ConcurrentDictionary<TKey, TValue>

ConcurrentDictionary<TKey, TValue>
是一个线程安全的键值对集合,类似于 Dictionary<TKey, TValue>。

适用场景

  • 缓存
    :存储键值对数据,多个线程可以并发地读取和写入缓存
  • 计数器
    :存储计数器数据,多个线程可以并发地更新计数器值

优点

  • 高效的并发操作
    :支持多个线程同时进行读取和写入操作
  • 原子操作
    :支持原子操作,如
    AddOrUpdate

    GetOrAdd
    ,确保数据一致性
  • 灵活性
    :提供丰富的 API,支持多种操作

可用性

  • .NET Framework 4.0 及以上
  • .NET Core 1.0 及以上

示例代码

using System.Collections.Concurrent;

var dictionary = new ConcurrentDictionary<int, string>();

// 添加元素
var addTask = Task.Run(() =>
{
    for (int i = 0; i < 10; i++)
    {
        dictionary.TryAdd(i, $"value{i}");
        Console.WriteLine($"Added key {i} with value value{i}");
    }
});

// 更新元素
var updateTask = Task.Run(() =>
{ 
    for (int i = 0; i < 10; i++)
    {
        var ii = i;
        dictionary.AddOrUpdate(i, $"new_value{i}", (key, oldValue) => $"new_value{ii}");
        Console.WriteLine($"Updated key {i} with value new_value{i}");
    } 
});

// 读取元素
var readTask = Task.Run(() =>
{
    foreach (var key in dictionary.Keys)
    {
        if (dictionary.TryGetValue(key, out string? value))
        {
            Console.WriteLine($"Key {key} has value {value}");
        }
    }
});

await Task.WhenAll(addTask, updateTask, readTask);

5. BlockingCollection

BlockingCollection

提供线程安全的添加和移除操作,并支持阻塞和限界功能。可以与
ConcurrentQueue<T>
,
ConcurrentStack<T>
,
ConcurrentBag<T>
等一起使用。

适用场景

  • 生产者-消费者模式
    :多个生产者线程将数据项添加到集合中,多个消费者线程从集合中取出数据项进行处理
  • 任务调度
    :将任务添加到集合中,由工作线程从集合中取出任务并执行

优点

  • 阻塞操作
    :支持阻塞添加和移除操作,适用于生产者-消费者模式
  • 限界功能
    :支持设置集合的最大容量,防止过度填充
  • 灵活性
    :可以与多种集合类型一起使用,如
    ConcurrentQueue<T>

可用性

  • .NET Framework 4.0 及以上
  • .NET Core 1.0 及以上

示例代码

using System.Collections.Concurrent;

var collection = new BlockingCollection<int>(boundedCapacity: 5);
var cts = new CancellationTokenSource();
var token = cts.Token;

// 生产者任务
var producer = Task.Run(() =>
{
    for (int i = 0; i < 10; i++)
    {
        collection.Add(i);
        Console.WriteLine($"Added {i}");
        Thread.Sleep(100); // 模拟生产延迟
    }
    collection.CompleteAdding();
}, token);

// 消费者任务
var consumer = Task.Run(() =>
{
    foreach (var item in collection.GetConsumingEnumerable(token))
    {
        Console.WriteLine($"Consumed {item}");
        Thread.Sleep(50); // 模拟消费延迟
    }
}, token);

await Task.WhenAll(producer, consumer);

6. ImmutableList

ImmutableList

是线程安全的,因为所有修改操作都会返回一个新的集合实例。

适用场景

  • 配置数据
    :存储配置数据,多个线程可以并发地读取配置数据,而无需担心数据被修改
  • 快照
    :在某个时间点获取数据的快照,多个线程可以并发地读取快照数据

优点

  • 天然线程安全
    :由于集合不可变,多个线程可以安全地并发读取
  • 数据一致性
    :所有修改操作都会返回一个新的集合实例,保证数据一致性
  • 易于使用
    :提供丰富的 API,支持多种操作

可用性

  • .NET Framework 4.5 及以上(需要安装
    System.Collections.Immutable
    NuGet 包)
  • .NET Core 1.0 及以上(需要安装
    System.Collections.Immutable
    NuGet 包)

示例代码

var list = ImmutableList.Create(1, 2, 3);
var newList = list.Add(4);

Console.WriteLine(string.Join(", ", newList)); // 输出 1, 2, 3, 4

7. SynchronizedCollection

SynchronizedCollection

是一个线程安全的集合,适用于需要同步访问的场景。

适用场景

  • 共享资源管理
    :管理共享资源的集合,多个线程可以并发地访问和修改集合
  • 事件订阅
    :存储事件订阅者,多个线程可以并发地添加和移除订阅者

优点

  • 内置同步机制
    :自动处理同步,确保线程安全
  • 易于使用
    :提供简单的 API,如
    Add

    Remove
  • 灵活性
    :支持多种集合操作

可用性

  • .NET Framework 3.5 及以上
  • .NET Core 1.0 及以上

示例代码

var collection = new SynchronizedCollection<int>();
collection.Add(1);
collection.Add(2);

foreach (var item in collection)
{
    Console.WriteLine(item); // 输出 1 和 2
}

8. SynchronizedReadOnlyCollection

SynchronizedReadOnlyCollection

是一个线程安全的只
读集合。

适用场景

  • 配置数据
    :存储只读的配置数据,多个线程可以并发地读取配置数据
  • 共享数据
    :存储共享数据,多个线程可以并发地读取数据,而无需担心数据被修改

优点

  • 内置同步机制
    :自动处理同步,确保线程安全
  • 易于使用
    :提供简单的 API,如
    Contains

    CopyTo
  • 数据保护
    :只读特性确保数据不会被修改

可用性

  • .NET Framework 3.0 及以上
  • .NET Core 1.0 及以上

示例代码

var list = new List<int> { 1, 2, 3 };
var readOnlyCollection = new SynchronizedReadOnlyCollection<int>(list);

foreach (var item in readOnlyCollection)
{
    Console.WriteLine(item); // 输出 1, 2, 3
}

9. SynchronizedKeyedCollection<K, T>

SynchronizedKeyedCollection<K, T>
是一个线程安全的键控集合。

使用场景

  • 缓存
    :存储键控数据,多个线程可以并发地读取和写入缓存
  • 资源管理
    :管理键控资源的集合,多个线程可以并发地访问和修改集合

优点

  • 内置同步机制
    :自动处理同步,确保线程安全
  • 键控访问
    :支持通过键快速访问元素
  • 灵活性
    :支持多种集合操作

可用性

  • .NET Framework 3.0 及以上
  • .NET Core 1.0 及以上

示例代码

public class MyItem
{
    public int Id { get; set; }
    public string Name { get; set; }
}

var collection = new SynchronizedKeyedCollection<int, MyItem>(item => item.Id);
collection.Add(new MyItem { Id = 1, Name = "Item1" });
collection.Add(new MyItem { Id = 2, Name = "Item2" });

foreach (var item in collection)
{
    Console.WriteLine(item.Name); // 输出 Item1 和 Item2
}

夜莺 v8 大版本已经启动开发,预计 25 年 7、8 月份发正式版,相比 v7 大概会做四五个大功能,每个功能做完了做稳定了都会提前放出来供大家体验,虽然以 beta 来命名,实际是稳定的,大家可以放心升级。

夜莺 v5 v6 v7 三个大版本算是一脉相承,一直在打基础,最后一个稳定版是 v7.7.2,可以看作是这个系列的终极版。其实这个系列中有些功能早就想改进了,但是由于兼容性、迁移成本、人力的考虑,一直没有动作。现在基础打好了,从 v8 开始,我们计划开始做一些有意思的功能。当然,也不会完全不兼容 v7,v7 用户还是可以直接升级的,只是我们会通过 feature flag 等方式,在某些功能上引入一些新的做法。

v8 重点想做的事情如下:

  • 改造部署方式,减少依赖,没有 mysql、redis 也可以一键拉起,这样做功能测试就简单多了
  • 机器相关的告警规则不止可以通过 promql 来定义,还可以和业务组打通,比如不同的业务组机器,阈值不同
  • 大幅重构告警通知逻辑,支持灵活的分派策略、更方便的自定义通知媒介、同一媒介支持多个通知模板
  • 支持更多数据源的告警,比如 ElasticSearch、ClickHouse 等,力争把夜莺打造成一个统一的告警引擎
  • 提供新的、更直观的仪表盘功能,虽说无法像 Grafana 那样支持如此多的图表类型,至少把现有的做稳定好用

当然,还会有很多其他小改动计划,这里就不一一列举了。这两天发了 v8.0.0-beta.1 版本,已经完成了一些有意思的功能。下面给大家介绍一下。

部署方式改造

v8.0.0-beta.1 版本中,我们引入了 sqlite 和 miniredis,不再依赖 mysql、redis,这样就可以用一个二进制一键拉起了,测试时就方便多了。不过这种方式仅适合个人功能测试,还不能用于生产环境。生产环境还是需要 mysql、redis 的。

下面我来演示一下如何一键拉起 v8.0.0-beta.1 版本。

到如下地址下载 v8.0.0-beta.1 版本的发布包:
https://flashcat.cloud/download/nightingale/

我本地有一个 arm64 的 linux 虚机来做测试,所以下载了 arm64 版本的发布包
n9e-v8.0.0-beta.1-linux-arm64.tar.gz
。解包之后直接运行夜莺的二进制即可。操作如下:

wget https://download.flashcat.cloud/n9e-v8.0.0-beta.1-linux-arm64.tar.gz
mkdir n9e-v8.0.0-beta.1-linux-arm64
tar zxvf n9e-v8.0.0-beta.1-linux-arm64.tar.gz -C n9e-v8.0.0-beta.1-linux-arm64
cd n9e-v8.0.0-beta.1-linux-arm64
./n9e

哦了。超级简单。接下来可以在夜莺的页面上接入数据源,类似 Grafana,然后即可对数据源的数据做告警、查询分析等。

如果你想采集监控数据,选择就比较多了,比如:

  • 复用你以前的 Prometheus 摘取 Exporter 的模式,只要数据进入 Prometheus 了,你就可以在夜莺里配置告警规则、看图查询
  • 使用 Categraf 采集监控数据,尤其是机器层面的监控数据。此时 Categraf 的 writer 的地址和 heartbeat 地址都配置为 n9e 的地址,然后 n9e 的配置文件中配置时序库的地址即可,即 Categraf 通过 heartbeat 请求把本机的元信息上报给 n9e,n9e 存入 mysql 和 redis,Categraf 通过 writer 请求把监控数据上报给 n9e,n9e 存入时序库(在 n9e 的配置文件 config.toml 中指定,就是 Pushgw 相关的那些配置项)。

告警规则支持使用业务组筛选机器

这是一个重大的改造。想解决特殊机器的阈值配置问题。举个例子,假设 DBA 团队有 100 台机器,默认情况下,希望
cpu_usage_idle < 20
告警,但是有几台机器比较特殊,CPU 核数较多,希望
cpu_usage_idle < 15
才告警。此时应该如何解决?

原本在 Prometheus 生态中,一切皆标签,故而我们可以把这几台机器打上特殊标签表示这几台机器配置高,比如打上
hardware=high
的标签,然后其他普通机器打上
hardware=normal
的标签。然后配置两条告警规则:

cpu_usage_idle{hardware="normal"} < 20
cpu_usage_idle{hardware="high"} < 15

看起来可以解决,实际会有如下一些问题:

  1. 新增标签会让原本的 TimeSeries 断掉,会影响其他告警逻辑,比如原本活跃的告警会报恢复(因为原来的数据断了查不到数据了,告警引擎以为恢复了)
  2. 新增机器都要打上 hardware 标签,特殊的机器较少,手工打上
    hardware=high
    感觉尚可接受,但是普通机器较多,每次新机器启用都要打上 hardware="normal" 标签,着实有点烦
  3. 标签通常是定义维度信息,这类硬件特性信息,姑且也可以看做是一种维度信息,但是这类信息也放标签里,总感觉稍显混乱,而且后面如果某个 normal 的机器升配了,还要改标签,一旦改标签,TimeSeries 又会断掉

再举一个例子。假设现在有部分机器有混部的情况。比如同时部署了 redis 和 etcd 服务。etcd 的服务对硬盘 IO 比较敏感,所以想配置一个 IO 使用率超过 80% 就告警的规则,你可能会这么想:

diskio_util{service="etcd"} > 80

然后其他的服务所在机器的大于 90% 才告警,于是:

diskio_util{service!="etcd"} > 90

对于混部有 etcd 和 redis 的机器,希望同时打上
service="etcd"

service="redis"
的标签。但是很遗憾,Prometheus 生态里不允许出现这种同 Key 不同 Value 的做法。这就非常棘手了,相当于没法使用标签体系来描述混部的场景。

怎么搞?

夜莺从 v7.4.1 开始,支持把机器挂载到多个业务组,其实就相当于可以用业务组来描述现实中的混部的场景。当然,对于刚提到的硬件不同规格的场景,也可以用业务组来描述,业务组很灵活,想怎么划分就怎么划分。

但是,仅仅把机器挂到不同的业务组,只是方便我们去分类查看,和告警规则并没有联动。告警规则强依赖 promql,而 promql 还是需要使用标签来做过滤。从 v8.0.0.-beta.1 开始,我们提供了一个新的手段。

即,在告警规则的 promql 中支持配置变量,变量类型可以设置为机器,然后告警规则中也支持配置机器的筛选方式(支持通过机器名或业务组来筛选),于是就可以让告警规则和业务组联动起来。这么说可能比较抽象。下面举个例子。

alerts

告警规则中增加了一个 switch 开关:启用变量。如果你启用了变量就会出现一堆比较复杂的配置。

在上例中,我定义了两个变量,一个是 host,类型是机器标识,默认值是所有机器,另一个是 threshold,类型是阈值,默认值是 30。然后我在 promql 中引用了这两个变量。如果没有下面的变量筛选部分,仅有变量配置 + promql 的话,告警引擎的行为是:

拿到 host 变量的所有值,即全部机器,假设是 100 台,然后就是 for 循环 100 次,每次把 promql 中的
$host
$threshold
变量替换掉,执行检测,看这个最终的 promql 是否查到了数据,如果查到了数据并满足持续时长的配置,就产生告警。

而我上例中还配置了变量筛选以及一个子筛选。最终的效果就是:

  • DBA-redis 业务组的机器,并且机器标识是 mac-vm-001,则以阈值 10 代入 promql 做判断
  • 除了 mac-vm-001 之外的 DBA-redis 业务组的其他机器,则以阈值 20 代入 promql 做判断
  • 除了 DBA-redis 业务组的机器,剩下的公司的其他全部机器,则以阈值 30 代入 promql 做判断

promql 中那个下划波浪线先不用关心,并非是你的 promql 写的不对,而是这个编辑器自身的问题。

建议:如果可以按照之前的普通 promql 就可以解决的告警场景,建议继续使用老方式,如果老方式实在不好解决,可以尝试这种启用变量的告警规则,这个方式和业务组联动,性能会稍差,但是灵活性更好。另外业务组是夜莺的特有的东西,所以一旦你这么用了,就和夜莺深度绑定了,将来如果想迁移到其他告警引擎也会麻烦一些,这点也请注意。

关于这个告警规则启用变量的更多信息,可以参考
文档

Webhook 支持代理

有些公司的网络环境比较复杂,可能会有代理的需求。夜莺的 webhook 一直不支持代理,如果夜莺的机器不通外网,发钉钉、企微等就没法搞了,虽说可以走自定义通知脚本的方式,但是就比较麻烦。

实际从 v7.7.2 开始就支持了代理模式,不过一直没宣传,借由 v8.0.0-beta.1 版本的发布,做一下周知。

比如你希望通过代理调用钉钉和企微,可以配置三个环境变量,HTTP_PROXY、HTTPS_PROXY、N9E_PROXY_URL,前面两个环境变量指定代理地址,N9E_PROXY_URL 指定要被代理的 URL,比如:

N9E_PROXY_URL=qyapi.weixin.qq.com,oapi.dingtalk.com

多个 URL 用逗号分隔,每个 URL 不用写全,只写一部分就行,能匹配到就行,就像上面,我只写了域名,没有把整个回调地址写上。

其他改动

v8.0.0-beta.1 还有一些其他改动,比如告警规则支持 Cron 表达式配置执行周期,可灵活设置定时执行策略,更多改动请参考
Changelog

结语

一个新的大版本启程了,兄弟们跟上脚步,一起打造最好用的国产开源监控系统。如果你有什么建议,欢迎在
https://github.com/ccfos/nightingale
上提 issue,如果能来个 star 就更好了,让更多人知道并参与,即便现在还有瑕疵也会越来越好。

后端开发,我们对于Api接口调试测试大致有以下方法:单元测试、Swagger、Postman。

但是每种方式也都有其局限性,几年前使用Visual Studio Code开发过一段时间,接触了REST Client扩展工具印象特别深刻,简单、轻量、可编码、与开发工具无缝衔接,整体效率相当高。

再此之后我一直在关注Visual Studio是否有类似的工具,直到最近发现Visual Studio 2022版本17.8开始支持类似REST Client扩展工具相关功能了,虽然功能还不够完善但是也基本够用了。

今天和大家分享怎么通过.http文件便捷调试测试Api接口。

01
、.http文件创建方式

.http文件有两种创建方式:其一为通过添加文件,其二为通过终结点资源管理器生成。

1、添加文件方式

就像平时添加类文件一样,通过选择类库右击选择添加,选择新建项,然后找到HTTP文件选项,可以修改名称最后点击添加按钮即可。

2、终结点资源管理器生成方式

首先选择视图菜单,找到其他窗口,然后找到终结点资源管理器并点击,即可打开。

打开后效果如下:

然后我们可以任意选择一个接口,右击按钮并点击生成请求,即可自动创建.http文件并自动生成当前接口的默认请求示例,如下图:

02
、.http 文件语法

1、请求

HTTP请求格式为[HTTPMethod URL HTTPVersion]。

HTTPMethod:
表示HTTP方法包括增删改查四大常见方法:GET、POST、PUT、DELETE,以及一些其他HTTP方法OPTIONS、HEAD、PATCH、TRACE、CONNECT;

URL:
表示发送请求的URL,即请求目标URL,像正常方法URL一样可以包含查询字符串参数;

HTTPVersion:
此项为可选项,是指应用的HTTP版本,即 HTTP/1.1、HTTP/2 或 HTTP/3。

当然一个.http文件中可以包含多个请求,可以通过###作为分隔符把多个请求分开

POST https://localhost:5137/orders

###

GET https://localhost:5137/orders?id=98006

###

DELETE https://localhost:5137/orders HTTP/3

###

2、请求头、请求体

在实际请求中,我们不单单要指定请求方法,请求URL,还需要指定请求头以及请求体。

常见的请求有请求内容类型、响应内容类型、编码方式、缓存控制、内容类型、身份验证、跨域请求等等。

请求头格式为[HeaderName: Value],一个请求头类型占一行,可以有多个请求头,并且每个请求头之间不能有空白行,请求头和请求行之间也不能有空白行。

GET https://localhost:5137/orders
Accept: application/json, text/html

###

GET https://localhost:5137/orders
Cache-Control: max-age=604800
Age: 100

###

请求体是指HTTP请求中携带的实际数据,在请求行或者请求头后空白行之后添加,示例如下:

GET https://localhost:5137/orders
Content-Type: application/json

{
  "id": "897",
  "date": "2024-12-24",
  "price": 5,
  "priceF": 2,
  "name": "小红",
  "status": "Pending"
}

###

3、注释、变量

注释是以#或//开头的行,可以加强代码的可读性。

变量是以@开头的行,其语法格式为[@VariableName=Value],定义好变量后可以通过双大括号{{ VariableName }}来使用变量,同时也可以使用已经定义好的变量来定义新的变量。

@hostname=localhost
@port=5137
@host={{hostname}}:{{port}}
GET https://{{host}}/orders?id=98006

4、环境文件

在实际开发过程中,针对开发环境、测试环境,甚至生产环境,我们需要对同一个变量提供不同的值,比如不同环境首先域名就不同,其次针对不同环境的测试数据也不同。

这时候我们就可以使用环境文件,我们可以在.http文件所在的目录中或者其父目录中创建名为http-client.env.json的文件。如下代码,我们创建了开发环境和生产环境两个不同的域名。

{
  "dev": {
    "HostAddress": "http://localhost:5137"
  },
  "pro": {
    "HostAddress": "http://localhost:8888"
  }
}

此时我们可以在.http文件窗口右上角进行切换不同环境。

下面我们选择pro环境进行测试一下,结果如下。

可以看到此时域名读取了正式环境域名。

此时我们是可以把环境文件提交到代码库中和团队共享测试变量,那如果有些敏感的数据我们并不想提交到代码库和别人分享要怎么办呢?

我们可以在环境文件同级目录中创建http-client.env.json.user文件,它和环境文件编写规则完全一样,只是优先级比环境文件优先级更高。当然我们需要在代码管理的忽略文件.gitignore排查.user后缀的文件,防止其被意外提交至代码块。

03
、身份验证

可以说我们的每个后端接口都有相关认证授权,可能使用Jwt,OAuth令牌,API密钥,用户密码等方式,这就导致我们平时测试的时候,需要先登录,然后拿到相关的认证凭证,再去调相关的接口。

这意味这我们面临一种情况,调用B接口需要依赖A接口的返回结果。首先答案很明确可以做到,要怎么做呢?

我们可以在A接口请求上面使用以下语法[# @name VariableName],来定义承载接口返回结果变量。

下面我们实现一个登录接口,直接返回Jwt凭证即token字符串,然后使用这个token请求查询订单接口。

//登录
# @name login
POST {{Web_HostAddress}}/login
Accept: application/json

###

//查询订单
@id=897
GET {{Web_HostAddress}}/orders?id={{id}}
Authorization: Bearer {{login.response.body.$.[0]}}

###

需要注意的是,因为我们登录即可是直接返回的字符串,所以这里使用的是login.response.body.$.[0],如果我们返回的是对象并且token是赋值在token字段上的,则应该使用login.response.body.$.token。

并且当我们点击查询订单接口请求上面的调试时,即使我们程序没有运行起来,它也会自动运行,并且也会自动去执行登录接口,拿到它所需的token,然后运行自身。

而发送请求按钮就必须要求程序运行起来以后才能生效。

到这里.http文件使用就介绍完了,可以说基本够用了,当然还有很多地方需要完善的,比如说上传文件,终结点资源管理器不能直接自动生成请求体结构等等,相信要不了多久这些功能就会完善起来的,安心等待即可。


:测试方法代码以及示例源码都已经上传至代码库,有兴趣的可以看看。
https://gitee.com/hugogoos/Planner

过去 9 年里,HelloGitHub 月刊累计收录了 3000 多个开源项目。然而,随着项目数量的增加,不少用户反馈:“搜索功能不好用,找不到想要的项目!” 这让我意识到,仅仅收录项目是不够的,还需要通过更智能的方式,帮助用户找到心仪的开源项目。于是,
我开始探索如何通过 RAG 技术解决这个问题

检索增强生成(RAG),是赋予生成式人工智能模型信息检索能力的技术。

RAG 技术我早有耳闻,但却一直不知道该从哪里入手。虽然现在有不少容易上手的 RAG 低代码平台,但我不想只停留在“会用”的层面,更希望了解它的实现细节,否则不敢在生产环境中用。不过,要让我直接用 LangChain 和 Ollama 从零搭建一个 RAG 系统,还真有点心里没底。

还好最近 OceanBase 搞事情,在 4.3.3 版本里支持了向量检索功能,更贴心的是,还专门为像我这样对 RAG 感兴趣的新手,准备了一个用 Python 搭建 RAG 聊天机器人的
实战教程

GitHub 地址:
github.com/oceanbase/oceanbase

光看永远只是纸上谈兵,所以我干脆上手把玩了一番。

接下来,我将分享如何基于该项目,打造一款 HelloGitHub 开源社区的聊天机器人,内容包括实现过程、细节优化,以及对 RAG 技术的理解与未来展望。

一、介绍

OceanBase 开源的 RAG 聊天机器人,能够通过自然对话更精准地回答与 OceanBase 文档相关的问题。

该项目是基于 langchain、langchain-oceanbase 和 streamlit 构建,处理流程是先将 OceanBase 数据库的文档,通过 Embedding 模型转化为向量数据,并存储在 OceanBase 数据库中。当用户提问时,系统会用相同的模型将问题转化为向量,然后通过向量检索找到相关的文档内容,再将这些文档作为上下文提交给大语言模型,从而生成更精准的回答。

在线体验:
oceanbase.com/obi

体验后,我感觉效果还不错,于是就萌生了一个想法:
能不能把 OceanBase 的文档,换成 HelloGitHub 月刊的 Markdown 文件,灌进系统里,这样不就摇身一变,成为 HelloGitHub 专属的聊天机器人了吗?
说干就干!

二、安装运行

在开始改造之前,首先需要把项目跑起来。安装运行的步骤在 OceanBase 提供的
实战教程
中已经很详细了,这里不再过多介绍。运行步骤如下:

  1. 执行
    embed_docs.py
    脚本,将文档内容向量化后存储到 OB
  2. 启动项目
    streamlit run --server.runOnSave false chat_ui.py

启动成功将自动跳转至此界面:

建议

  1. Python 版本管理:运行需要 Python 3.9+,建议使用
    pyenv
    管理项目的 Python 版本。
  2. 查看数据库:不论是通过 Docker 部署 OceanBase 还是使用 OB Cloud,都建议在本地通过 GUI 工具查看数据库,有助于开发和调试。

运行
embed_docs.py
脚本后,查看数据库中的表,你会发现这些字段:

  • document
    :存储原始的文档内容
  • embedding
    :存储文档向量化后的数据
  • metadata
    :记录文档的名称、路径以及切分后的标题等信息

其中
embedding
列是一个类似数组形式的数据,这个就是通过 Embedding 模型将文档片段转化为向量数据的结果。这些向量数据能够捕捉文本的语义信息,使计算机能够更好地理解文本的含义,从而实现类似语义搜索的功能(计算距离),为后续问题与文档内容的匹配提供基础。

三、动手改造

这个项目除了支持 LLMs API,还可以切换为本地的 Ollama API 使用,只需修改
.env
配置文件即可完成调整:

# 使用支持 embed API 的模型
OLLAMA_URL=localhost:11434/api/embed
OLLAMA_TOKEN=
OLLAMA_MODEL=all-minilm

注意
:在调用第三方付费 API 时,一定要注意使用量,建议仅导入部分文档用于测试,或用本地 LLM 调试逻辑,避免不必要的花费。

3.1 导入 HelloGitHub 月刊

通过
embed_docs.py
脚本,将 HelloGitHub 月刊内容向量化并导入到 OceanBase 数据库,命令如下:

python embed_docs.py --doc_base /HelloGitHub/content --table_name hg

参数说明:

  • doc_base
    :HelloGitHub 内容目录
  • table_name
    :脚本会自动创建该表,并将数据存储到表中。

但是运行后,我查看数据库时发现
document
字段中包含了许多无意义的内容,例如格式符号或无关信息:

面对这些噪声数据,我编写了一个脚本,清洗 HelloGitHub 月刊文件中无关的格式符号和冗余内容,并重新导入数据库。

3.2 启动服务

在启动服务时,需要通过环境变量
TABLE_NAME
指定要使用的表。命令如下:

TABLE_NAME=hg2 streamlit run --server.runOnSave false chat_ui.py

我试了一下,回答的效果并不理想:

经过测试,我分析问答效果欠佳的原因,可能包括以下几点:

  1. 向量化效果:所选用的模型 all-minilm 仅有 384 维度,可以尝试更大的 Embedding 模型;
  2. 数据清理:虽然清理了一部分无用内容,但可能还有一些噪声数据未处理完全;
  3. 文档完整性:HelloGitHub 的内容结构是否适合问答模型需要进一步分析;
  4. 提示词:需要完善提示词设计,补充更多上下文;

四、优化问答效果

我开始对 RAG 有些感觉了,所以准备切换到付费但效果更好的通义千问
text-embedding-v3
模型(1024 维度),进行调试。

4.1 数据优化

为提升问答效果,我决定进一步优化 document 的构造方式。具体思路是:
将 HelloGitHub 网站中的表导入至 OceanBase 数据库,并基于这些表的数据,构建更干净和精准的内容
。这样可以最大程度地确保项目数据的全面性,同时减少无关内容的干扰,提升向量检索相关性。

导入表到 OceanBase

OceanBase 和 MySQL 高度兼容,因此,我直接用 Navicat 将 HelloGitHub 的数据表结构和内容,从 MySQL 无缝迁移到了 OceanBase。然后我写了一个
embed_sql.py
脚本,通过直接查询相关表的数据,进而生成更精简的内容(document),同时补充元数据(metadata),并存储到数据库。核心代码如下:

# 构建内容(document)
content = f"""{row.get('name', '未知')}:{row.get('title', '未知标题')}。{row.get('summary', '暂无概要')}"""

# 构建元数据(metadata)
metadata = {
    "repository_name": row.get("name", "N/A"),  # 仓库名称
    "repository_url": row.get("url", "N/A"),  # 仓库链接
    "description": row.get("summary", "N/A"),  # 项目描述
    "category_name": row.get("category_name", "N/A"),  # 类别名称
    "language": row.get("primary_lang", "N/A"),  # 主要编程语言
    "chunk_title": row.get("name", "N/A"), 
    "enhanced_title": f'内容 -> {row.get("category_name", "N/A")} -> {row.get("name", "N/A")}'
    ...
}

# 将内容和元数据添加到文档对象
docs.append(Document(page_content=content.strip(), metadata=metadata))
# 存储到数据库
vs.add_documents(
    docs,
    ids=[str(uuid.uuid4()) for _ in range(len(docs))],
)

经过多轮调试和对比,我发现
document 数据越精简,向量检索效果越好
,随后将完整的数据集存入 OceanBase 数据库的
hg5
表。

python embed_sql.py --table_name hg5 --limit=4000              
args Namespace(table_name='hg5', batch_size=4, limit=4000, echo=False)
Using RemoteOpenAI
Processing: 100%|███████████████████████████████████████████████████████████████████████▉| 3356/3357 [09:33<00:00,  5.85row/s]

至此,基于数据库表构造的 document 数据,已经非常干净了。

4.2 提示词优化

在优化完数据后,我开始思考如何优化提示词,并对 LLM 的回答进行引导和强化。以下是针对 LLM 提示词优化的方向:

  1. 明确背景和任务:在提示词中设定问答的背景并限制问题的范围,例如,确保问题只涉及开源项目或 HelloGitHub 的内容。
  2. 丰富上下文:将 metadata(元数据) 和 document(项目描述)同时提供给大模型,让 LLM 有更多上下文来生成精确回答。
  3. 高质量示例:提供高质量的回答示例,统一输出格式。
  4. 约束逻辑:明确要求 LLM 不得虚构答案。如无法回答问题,需清楚指出知识盲点,并合理提供方向性建议。

4.3 处理流程优化

在优化向量检索和回答的流程方面,我做了以下改进:

  1. 扩大检索范围:向量检索默认只返回前 10 条最高相似度的内容。我将其扩展至 20 条,为 LLM 提供更多上下文选择。
  2. 判断相关性:使用提示词指导 LLM 在输出答案前,先判断问题是否与 HelloGitHub 或开源项目相关,避免生成无关回答。
  3. 提炼回答:基于用户输入分析意图后,选出最相关的 5 个项目,并结合元数据生成更贴合用户需求的回答。

4.4 效果展示

除了上面的优化,我还进一步简化了页面、删除用不到的代码,最终呈现效果如下:

回答效果对比:

通过切换至通义千问 text-embedding-v3 模型,同时优化数据、提示词策略和问答流程,让这套 RAG 系统的回答质量有了明显提升,我打算自己盘一盘再上线。所以先放出源码,感兴趣的小伙伴可以作为参考:

GitHub 地址:
github.com/521xueweihan/ai-workshop-2024

五、最后

在构建 HelloGitHub 的 RAG 聊天机器人过程中,回答效果一直不好,让我一度产生了放弃的念头。但当我通过查询表里的数据构造 document,并使用维度更大的 Embeding 模型后,回答效果直线提升,才让我重新看到了希望。

这段经历也让我开始认真思考:优化 RAG 的关键是什么?我的答案是
数据+检索
。如今,许多企业希望借助 AI 技术赋能已有服务,RAG 则是一种门槛较低的通用解决方案。在这一过程中,
数据质量决定了基础,高质量数据往往是从海量数据提纯而来。检索则是确保内容能够被快速且准确提取的关键
。否则不管提示词再怎么优化,也无法检索到有价值的内容,就无法实现增强的效果。

另外,我认为在未来的 RAG 应用中,除了向量数据,数据库还需要具备一些关键能力来确保检索和生成的高效性。例如,支持关系型数据和向量数据的混合搜索,不仅能处理结构化和非结构化数据,还能有效减少 RAG 模型中的“幻觉”问题,从而让生成的答案更准确、更有根据。图搜索(知识图谱)同样很重要,它为 RAG 提供复杂推理所需的背景信息,提升生成质量。此外,RAG 应用在许多场景中需要频繁更新和同步数据,因此数据库还需支持实时查询、低延迟响应、事务处理和高可用性,这些是确保 RAG 高效运行的基础。

OceanBase 的分布式架构优势,让它在面对海量数据时依然游刃有余
。而新引入的向量存储和检索能力,使得我们能够通过 SQL 轻松获取最“干净”的数据,并在同一个数据库内完成向量化操作。OceanBase 未来可期!

GitHub 地址:
github.com/oceanbase/oceanbase

虽然 RAG 技术目前还不像 Web 开发那么成熟,但作为一个潜力巨大的技术方向,值得我们持续关注和学习。作为一名刚刚踏上 RAG 探索之旅的小白,我还有很多需要学习的地方。如果你也对 RAG 充满兴趣,欢迎结伴而行、共同成长!


Manim
中,移动一个元素除了之前介绍的方法之外,还可以通过同伦运算来移动一个元素。

与普通的移动元素方式相比,使用同伦运算移动一个元素时,实际上是在考虑整个空间的连续变形过程中元素的相应变化。

这种移动不是孤立地看待元素的位置改变,而是将元素置于空间的整体结构中,通过连续变形的方式实现元素的 “移动”。

简单来说,元素的
同伦移动
是通过连续变形实现的,不存在突然的跳跃或中断。

在同伦运算中,元素的每一个中间状态都与前后状态连续过渡,保持了整个移动过程的连贯性。

Manim
中关于同伦移动的动画类主要有:

  1. ComplexHomotopy
    :用于展示复函数之间的同伦变换
  2. Homotopy
    :更通用的同伦动画效果类,它可以用于展示任意两个对象之间的同伦变换,不仅仅局限于复函数
  3. SmoothedVectorizedHomotopy
    :对向量场进行平滑的同伦变换

1. 动画概述

1.1. ComplexHomotopy

ComplexHomotopy
通常需要接收两个复函数作为参数,通过在复平面上对这两个函数进行采样,得到一系列的复数点。

然后,在动画过程中,按照一定的插值方法,逐步将第一个函数对应的点变换到第二个函数对应的点。

它的参数主要有:

参数名称 类型 说明
complex_homotopy func 定义了复平面上的同伦变换规则的函数
mobject Mobject 要应用同伦变换的数学对象

complex_homotopy
参数是一个函数,它接受一个复数作为参数,并返回一个浮点数。

1.2. Homotopy

Homotopy
需要定义起始对象和目标对象,以及一个描述同伦过程的函数。

在动画运行时,根据时间参数,通过同伦函数计算出每个时刻对象的中间状态,

这个中间状态的计算可能涉及到对象的几何属性(如点的坐标、图形的形状参数等)的插值和变换。

它的参数主要有:

参数名称 类型 说明
homotopy func 定义了对象在动画过程中的变形规则的函数
mobject Mobject 要应用同伦变换的数学对象
apply_function_kwargs dict 在应用同伦变换函数时提供额外的控制或配置信息

homotopy
参数是一个参数,它接受四个数$ (x,y,z,t)
\(作为参数,分别表示对象在三维空间中的坐标\)
(x,y,z)
\(以及动画时间参数\)
t $(的取值范围从 0 到 1),

并返回一个包含三个数的元组$ (x',y',z')
\(,表示在时间\)
t $时,原始坐标对应的点经过同伦变换后的新坐标。

1.3. SmoothedVectorizedHomotopy

SmoothedVectorizedHomotopy
首先需要获取起始向量场和目标向量场的信息,例如向量的大小和方向。

在动画过程中,通过一种平滑的插值算法来计算中间时刻向量场的向量值,这种算法可能会考虑向量场的梯度、散度等属性,以确保向量的变化是连续且符合物理规律的。

在每一帧动画中,根据计算得到的向量值绘制出相应的向量场,展示出向量场的平滑同伦变换。

它的参数主要有:

参数名称 类型 说明
homotopy func 定义了对象在动画过程中的变形规则的函数
mobject Mobject 要应用同伦变换的数学对象
apply_function_kwargs dict 在应用同伦变换函数时提供额外的控制或配置信息

SmoothedVectorizedHomotopy
参数和
Homotopy
参数的含义是一样的。

SmoothedVectorizedHomotopy
侧重于向量场的平滑同伦变换,虽然参数类似但主要针对向量场,确保其变换过程平滑,在涉及向量场的场景(如物理场模拟)中发挥独特作用。

2. 使用示例

这几个同伦变换的类使用起来没有那么直观,下面通过几个示例来演示如何使用这些类。

2.1. 复函数同伦变换展示

这个示例利用
ComplexHomotopy
展示了复函数$ f(z)=e^z
\(到\)
g(z)=sin(z) $的同伦变换过程。

通过在复平面上创建一系列的点来模拟这个变换的过程。

# 创建复平面
plane = ComplexPlane(
    x_range=[-8, 8],
    y_range=[-8, 8],
    x_length=6,
    y_length=6,
)
self.add(plane)

# 定义两个复函数
def f(z):
    return np.exp(z)

def g(z):
    return np.sin(z)

# ComplexHomotopy动画
homotopy = lambda z, t: (1 - t) * f(z) + t * g(z)
# 要应用动画的点集
points = [
    complex(x, y) for x in np.arange(-3, 3, 0.5) for y in np.arange(-3, 3, 0.5)
]
mobject = VGroup(*[Dot(plane.n2p(p)) for p in points])
self.add(mobject)
self.play(
    ComplexHomotopy(homotopy, mobject),
    run_time=3,
)

2.2. 圆形的同伦收缩演示

借助
Homotopy
实现圆形在三维空间沿轴逐渐收缩为点的动画,

展示拓扑学中同伦变换下几何图形的连续变化,辅助理解拓扑性质。

# 创建一个圆形
circle = Circle(radius=2)
self.add(circle)

# 定义同伦函数
def homotopy(x, y, z, t):
    return (x * (1 - t), y * (1 - t), z)

# 创建Homotopy动画
self.play(
    Homotopy(homotopy, circle),
    run_time=3,
)

2.3. 向量场强度均匀减弱模拟

通过
SmoothedVectorizedHomotopy
呈现二维向量场强度随时间均匀减弱,

在物理学电场或磁场教学中,能有效帮助学生理解向量场动态变化。

# 创建一个向量场
vector_field = ArrowVectorField(
    lambda pos: (pos[0], pos[1], 0)
)
self.add(vector_field)

# 定义同伦函数
def homotopy(x, y, z, t):
    return ((1 - t) * x, (1 - t) * y, 0)

# SmoothedVectorizedHomotopy动画
self.play(
    SmoothedVectorizedHomotopy(homotopy, vector_field),
    run_time=3,
)

3. 附件

文中的代码只是关键部分的截取,完整的代码共享在网盘中(
homotopy.py
),

下载地址:
完整代码
(访问密码: 6872)

4. 补充说明:什么是同伦运算

同伦运算
是代数拓扑学中的概念,本质上是在同伦范畴中对映射或空间的操作。

从直观上来说,如果两个连续映射 可以通过
连续变形
从一个变成另一个,那么这两个映射是
同伦的
,而对这些同伦的映射进行的各种操作和研究就是同伦运算的范畴。

具体的数学定义这里不赘述,举几个实际生活中可以当做
同伦变换
的例子来帮助大家理解一下:

  1. 揉面团
    :把一块面团从初始的球形揉成各种形状,比如椭圆形、扁平状,甚至可以在面团上捏出一些起伏但不切断面团。这个过程中,面团的形状发生了连续变化,但它始终是一个整体,没有被撕裂或粘连,不同形状之间存在同伦关系。
  2. 吹气球
    :从气球未吹气时的扁平状态,到逐渐吹起变成球形的过程,气球的表面在连续地扩张变形。只要气球没有破裂,这个过程就是一个连续的变换。
  3. 绳结的变换
    :在不剪断绳子和不使绳子打结部分相互穿越的前提下,绳结可以从一种形状连续地变换为另一种形状。
  4. 衣服的变形
    :一件衣服在被人穿着的过程中,随着人的动作,衣服会发生各种变形。比如从平整的挂在衣架上的状态,到穿在人身上随着身体的弯曲、伸展而改变形状。
  5. 等等... ...

注意,上面的示例可以帮助理解
同伦变换
,但并不是完全严谨的
同伦变换

比如,面团弄断了,衣服撕裂了等等情况就不是同伦变换,但却是实际生活中经常发生的事。