2024年1月

目录

前言

近日心血来潮想做一个开源项目,目标是做一款可以适配多端、功能完备的模板工程,包含后台管理系统和前台系统,开发者基于此项目进行裁剪和扩展来完成自己的功能开发。

本项目基于Java21和SpringBoot3开发,序列化工具使用的是默认的Jackson,使用Spring Data Redis操作Redis缓存。

在定义实体类过程中,日期时间类型的属性我使用了
java.time
包下的
LocalDate

LocalDateTime
类,而没有使用
java.util
包下的
Date
类。

但在使用过程中遇到了一些问题,于是在此记录下来与诸位分享。

一、为什么推荐使用java.time包的LocalDateTime而不是java.util的Date?

LocalDateTime和Date是Java中表示日期和时间的两种不同的类,它们有一些区别和特点。

  • 类型:LocalDateTime是Java 8引入的新类型,属于Java 8日期时间API(java.time包)。而Date是旧版Java日期时间API(java.util包)中的类。
  • 不可变性:LocalDateTime是不可变的类型,一旦创建后,其值是不可变的,对该类对象的加减等计算操作不会修改原对象,而是会返回一个新的LocalDateTime对象。而Date是可变的类型,可以通过方法修改其值。
  • 线程安全性:LocalDateTime是线程安全的,多个线程可以同时访问和操作不同的LocalDateTime实例。而Date是非线程安全的,如果多个线程同时访问和修改同一个Date实例,可能会导致不可预期的结果。
  • 时间精度:LocalDateTime提供了纳秒级别的时间精度,可以表示更加精确的时间。而Date只能表示毫秒级别的时间精度。
  • 时区处理:LocalDateTime默认不包含时区信息,表示的是本地日期和时间。而Date则包含时区信息,它的实际值会受到系统默认时区的影响。

由于LocalDateTime是Java 8及以上版本的新类型,并提供了更多的功能和灵活性,推荐在新的项目中使用LocalDateTime来处理日期和时间。

对于旧版Java项目,仍然需要使用Date类,但在多线程环境下需要注意其线程安全性。

如果需要在LocalDateTime和Date之间进行转换,可以使用相应的方法进行转换,例如通过LocalDateTime的atZone()方法和Date的toInstant()方法进行转换。

二、使用LocalDateTime和LocalDate时遇到了哪些坑?

2.1 Redis序列化报错

2.1.1 问题现象

在使用RedisTemplate向Redis中插入数据时,遇到了如下报错:

2024-01-11T21:33:25.233+08:00 ERROR 13212 --- [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception

org.springframework.data.redis.serializer.SerializationException: Could not write JSON: Java 8 date/time type java.time.LocalDateTime not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling (through reference chain: java.util.ArrayList[0]->com.fast.alden.data.model.SysApiResource["createdTime"])
at org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer.serialize(Jackson2JsonRedisSerializer.java:157) ~[spring-data-redis-3.2.0.jar:3.2.0]
at org.springframework.data.redis.core.AbstractOperations.rawValue(AbstractOperations.java:128) ~[spring-data-redis-3.2.0.jar:3.2.0]
at org.springframework.data.redis.core.DefaultValueOperations.set(DefaultValueOperations.java:236) ~[spring-data-redis-3.2.0.jar:3.2.0]

image

2.1.2 问题分析

在使用Redis缓存含有LocalDateTime类型变量的实体类时会产生序列化问题,因为Jackson库在默认情况下不支持Java8的LocalDateTime类型的序列化和反序列化。

错误堆栈中也给出了解决方案,添加
com.fasterxml.jackson.datatype:jackson-datatype-jsr310
依赖,但光添加依赖是不够的,还我们需要自定义序列化和反序列化的行为。

2.1.3 解决方案

  1. 添加maven依赖
<dependency>
  <groupId>com.fasterxml.jackson.datatype</groupId>
  <artifactId>jackson-datatype-jsr310</artifactId>
  <version>2.13.0</version>
</dependency>
  1. 修改RedisSerializer Bean配置

在定义RedisSerializer Bean的代码中自定义ObjectMapper对象处理时间属性时的序列化和反序列化行为,
LocalDate

LocalDateTime

LocalTime
的序列化和反序列化都要自定义,还要禁用将日期序列化为时间戳。

@Configuration
public class RedisConfig {
    @Bean
    public RedisSerializer<Object> redisSerializer() {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        // 必须设置,否则无法将JSON转化为对象,会转化成Map类型
        objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);
    <span class="hljs-comment">// 自定义ObjectMapper的时间处理模块</span>
    <span class="hljs-type">JavaTimeModule</span> <span class="hljs-variable">javaTimeModule</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">JavaTimeModule</span>();

    javaTimeModule.addSerializer(LocalDateTime.class, <span class="hljs-keyword">new</span> <span class="hljs-title class_">LocalDateTimeSerializer</span>(DateTimeFormatter.ofPattern(<span class="hljs-string">"yyyy-MM-dd HH:mm:ss"</span>)));
    javaTimeModule.addDeserializer(LocalDateTime.class, <span class="hljs-keyword">new</span> <span class="hljs-title class_">LocalDateTimeDeserializer</span>(DateTimeFormatter.ofPattern(<span class="hljs-string">"yyyy-MM-dd HH:mm:ss"</span>)));

    javaTimeModule.addSerializer(LocalDate.class, <span class="hljs-keyword">new</span> <span class="hljs-title class_">LocalDateSerializer</span>(DateTimeFormatter.ofPattern(<span class="hljs-string">"yyyy-MM-dd"</span>)));
    javaTimeModule.addDeserializer(LocalDate.class, <span class="hljs-keyword">new</span> <span class="hljs-title class_">LocalDateDeserializer</span>(DateTimeFormatter.ofPattern(<span class="hljs-string">"yyyy-MM-dd"</span>)));

    javaTimeModule.addSerializer(LocalTime.class, <span class="hljs-keyword">new</span> <span class="hljs-title class_">LocalTimeSerializer</span>(DateTimeFormatter.ofPattern(<span class="hljs-string">"HH:mm:ss"</span>)));
    javaTimeModule.addDeserializer(LocalTime.class, <span class="hljs-keyword">new</span> <span class="hljs-title class_">LocalTimeDeserializer</span>(DateTimeFormatter.ofPattern(<span class="hljs-string">"HH:mm:ss"</span>)));

    objectMapper.registerModule(javaTimeModule);
    
    <span class="hljs-comment">// 禁用将日期序列化为时间戳的行为</span>
    objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
    
    <span class="hljs-comment">//创建JSON序列化器</span>
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Jackson2JsonRedisSerializer</span>&lt;&gt;(objectMapper, Object.class);
}

}

2.2 LocalDateTime和LocalDate类型的属性返回给前端的值格式不正确

2.2.1 问题现象

在application.yml中设置了全局的日期类型的序列化和反序列化格式,在对应字段上也并没有使用@JsonFormat进行特殊设置,但是LocalDateTime类型的属性返回给前端时并没有生效,返回的仍是LocalDateTime默认的ISO标准时间格式的字符串。

spring:
  jackson:
    date-format: yyyy-MM-dd HH:mm:ss
    time-zone: GMT+8
    default-property-inclusion: always
  mvc:
    format:
      date-time: yyyy-MM-dd HH:mm:ss
      date: dd/MM/yyyy

image

2.2.2 解决方案

自定义Jackson配置,代码如下:

@Configuration
public class JacksonConfig {
    @Bean
    public Jackson2ObjectMapperBuilderCustomizer customizer() {
        return builder ->
                builder.simpleDateFormat("yyyy-MM-dd HH:mm:ss")
                        // long类型转string, 前端处理Long类型,数值过大会丢失精度
                        .serializerByType(Long.class, ToStringSerializer.instance)
                        .serializerByType(Long.TYPE, ToStringSerializer.instance)
                        .serializationInclusion(JsonInclude.Include.NON_NULL)
                        //指定反序列化类型,也可以使用@JsonFormat(pattern = "yyyy-MM-dd")替代。主要是mvc接收日期时使用
                        .deserializerByType(LocalTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("HH:mm:ss")))
                        .deserializerByType(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd")))
                        .deserializerByType(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")))
                        // 日期序列化,主要返回数据时使用
                        .serializerByType(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern("HH:mm:ss")))
                        .serializerByType(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd")))
                        .serializerByType(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
    }
}

三、总结

在使用java.time API的过程中,除了会遇到前文所说的序列化问题之外,可能还会遇到以下问题:

  • 时区问题:LocalDateTime不包含时区信息,这可能导致在不同时区的用户之间出现不一致性。为了避免这个问题,您应该考虑使用ZonedDateTime或OffsetDateTime,并确保在处理日期和时间时考虑时区。
  • 数据库交互:当与数据库交互时,要确保数据库列的数据类型与正在使用的Java日期类型相匹配。例如,如果使用的是PostgreSQL,则可能需要使用timestamp without time zone列类型来存储日期和时间。
  • 默认值和验证:在某些情况下,可能希望为日期或时间字段设置默认值或进行验证。使用Spring的验证注解(如@NotNull或@Size)可以帮助我们确保输入的有效性。
  • 跨时区处理:由于LocalDateTime不包含时区信息,当与全球用户互动时,需要特别注意时区转换。考虑使用像Joda-Time这样的库来帮助我们处理复杂的时区转换。
  • 处理过去和未来的日期:在处理历史事件或计划未来的活动时,请确保我们的应用程序能够正确地处理这些日期。考虑使用像Period或Duration这样的类来计算日期之间的差异。

我也会及时的更新后续实践中所遇到的问题,希望与诸位看官一起进步。

如有错误,还望批评指正。

消息队列的解释

每个任务都有一个任务类型。
同一个类型的任务必须在一个队列中。
不同类型的任务可以分属于不同的队列中。
在一次事件循环中,浏览器可以【根据实际情况】从不同的队列中取出任务执行。
浏览器必须准备好一个微队列,微队列中的任务优先其他所有类型的任务。

chrome中的常见队列

在 chrome 的实现中,至少包含了下面的队列:
1,延时队列:用于存放计时器到达后的回调任务,优先级 中
2,交互队列:也就我们说的点击事件,浏览器缩放窗口等;优先级高。
3,微队列:用户存放需要最快执行的任务,优先级[最高]

阐述一下 JS的事件循环

事件循环又叫做消息循环,是浏览器渲染主线程的工作方式。
在Chrome 的源码中,它开启一个不会结束的 for 循环;
每次循环从消息队列中取出第一个任务执行。
而其他线程只需要在合适的时候将任务加入到队列末尾即可。
过去把消息队列简单分为宏队列和微队列,
这种说法目前已无法满足复杂的浏览器环境,
取而代之的是一种更加灵活多变的处理方式。
根据 W3C官方的解释:每个任务有不同的类型,同类型的任务必须在同一个队列。
不同的任务可以属于不同的队列。
不同任务队列有不同的优先级。
在一次事件循环中,由浏览器自行决定取哪一个队列的任务。
但浏览器必须有一个微队列,微队列的任务一定具有最高的优先级,必须优先调度执行。

代码执行顺序问题

你现在记住:主线程肯定是最先执行的;
代码必须要等到主线程执行完之后;
才能去消息队列中去拿取任务执行;
然后消息队列中有优先级; 微队列 ==> 交互队列 ==> 延时队列

1,理解先同步-后异步(延时队列)

setTimeout(()=>{
  console.log(1)
},0)
console.log(2)
输出的结果:2 然后是1
为什么呢?
因为:主线程代码肯定是先执行;哪怕你延时0毫秒。
主线程执行完毕后。
然后从消息队列中拿取延时队列执行;
所以先输出了2;然后是1

2,理解先同步-后异步(延时队列)

 function yanShi(time){
  var start = Date.now();
  while(Date.now() - start < time){

  }
}
setTimeout(()=>{
  console.log(2)
},0)
yanShi(1000)
console.log(1)
先执行 yanShi函数;这个函数会延时1s后;
我们去输出1;这个时候我们的主线程已经执行完了;
然后我们去执行延时队列中的代码;输出2;
所以:等待1s后,先输出2;然后是1 

3,new Promise(callback)与 new Promise().then的区别

很多时候,我们都以为new Promise(callback) 是异步的;
其实这个观点是错误的;
当我们调用new Promise(callback)时,它是一个同步代码,回调函数会立即执行。
new Promise().then这个才是异步的;
他们的区别之一就是说:前者是同步的;后者是异步的;
下面我们来看一段代码

4,new Promise(callback)是同步代码的

console.log('start');
const promise1 = new Promise((resolve, reject) => {
  console.log(1)
})
console.log('end');
有些同学认为是 start - end - 1;
有的同学认为是 start- 1 -end
大家可以去输出一下;这里就不说争取答案了.

5,promise.then是异步

console.log('start');
const promise1 = new Promise((resolve, reject) => {
  console.log(1)
  resolve(2)
})
promise1.then(res => {
  console.log(res)
})
console.log('end');
我们先是执行主线的代码;输出start;
new Promise 是一个同步的代码;输出 1;
promise1.then是一个微队列;
需要等待主线程执行完毕之后在执行;所以先输出end;
等待主线执行完毕之后,最后输出 2

6,promise.then要状态发生改变才会执行,否则不会执行then

console.log('start');
const promise1 = new Promise((resolve, reject) => {
  console.log(1)
})
promise1.then(res => {
  console.log(2)
})
console.log('end');

这个输出结果很多小伙伴会认为是: 
start - 1 - end - 2
但是实际2不会输出的;
为啥2不会输出呢?
因为 Promise的then方法必须要等到 padding 状态发生改变时才会触发then方法;
也就时说:要触发then方法必须要调用 resolve 或者 reject才会触发;其他不会触发

7,先主线程 - 计时器 - 微队列

const promise = new Promise((resolve, reject) => {
  console.log(1);
  setTimeout(() => {
    console.log("2");
    resolve("3");
    console.log("4");
  }, 0);
  console.log(5);
});

promise.then((res) => {
  console.log(res);
});

console.log(6);
我们都知道主线程先执行; 
new Promise下的代码是主线程的;刚刚我们说过:
小技巧:我们可以把 console.log(1) 当作 fn1函数输出1;
所以先输出 1 - 5 - 6
然后我们看 setTimeout[延时队列] 和  promise.then[微队列] 谁先执行;
正常情况下是微任务 >  延时队列
但是 then方法是需要状态改变时才会被触发,要想触发then需要先执行 setTimeout
所以又输出了 2 - 4 -3
//  1 - 5 - 6 - 2 - 4 -3

8,延时队列

const timer1 = setTimeout(() => {
 console.log('1');
  const promise1 = Promise.resolve().then(() => {
    console.log('2')
  })
}, 0)

const timer2 = setTimeout(() => {
  console.log('3')
}, 0)

// 1 - 2 - 3

9,主线程 - 添加到微队列 - 延时队列

setTimeout(()=>{
  console.log(1)
},0)
Promise.resolve().then(function(){
  console.log(2)
})
console.log(3)
同样,我们先去执行主线程的代码;
所以先输出的是3;
然后我们去消息队列中去获取任务;
先拿取微队列,然后是交互队列(这里没有),然后是延时队列;
这里 Promise.resolve().then(Fn)就是把一个函数Fn添加到微队列中;
因此执行2;最后输出1;
3--2--1

把一个任务添加到微队列中的方式

Promise.resolve().then(fn函数)
fn这个函数就会添加到微队列中

10,主线程- 添加到微队列 - 延时队列

function a(){
  console.log(1)
  Promise.resolve().then(function(){
    console.log(2)
  })
}
setTimeout(()=>{
  console.log(3)
  Promise.resolve().then(a)
},0)
Promise.resolve().then(()=>{
  console.log(4)
})
console.log(5)
首先我们执行主线的代码;输出5;
然后我们看是否有微队列;如果有去执行;所以输出4;
微队列执行完后我们看交互队列;没有
我们去执行延时队列;所以输出3;
最后我们执行1;然后数2
最终的结果是:5-4-3-1-2
注意点:延时队列中,有输出语句,有添加到微队列;
它是一个一个去执行;并不会说先去做微队列。
而是按照正常代码执行顺序去做。

11,主线程-立即添加到微队列-延时队列

function a(){
  console.log(1)
  Promise.resolve().then(function(){
    console.log(2)
  })
}

setTimeout(()=>{
  console.log(3)
},0)

Promise.resolve().then(a)
console.log(5)

最后的输出: 5-1-2-3

下面这输出比较难-特别是第2个

console.log('0')

const fn = () => (new Promise((resolve, reject) => {
  console.log(1);
  resolve('ok')
}))

console.log('2')

fn().then(res => {
  console.log(res)
})

console.log('3')
//  微任务 - 延时队列
const promise1 = Promise.resolve().then(() => {
  console.log('1');
  const timer2 = setTimeout(() => {
    console.log('2')
  }, 0)
});
// 延时队列 - 微任务
const timer1 = setTimeout(() => {
  console.log('3')
  const promise2 = Promise.resolve().then(() => {
    console.log('4')
  })
}, 0)
这两个是比较难的;如果小伙伴能正确答对;
就算理解了。
第1个的输出是:0 - 2 - 1 - 3 - ok
第2个的输出是:1 - 3 - 4 - 2

JS中的计时器能够做到精确计时吗?为什么?

不行。因为:
1,计算机硬件没有原子钟,无法做到精确计时。
2,操作系统的计时函数本身就有少量偏差,由于 JS 的计时器最终调用的是操作系统的函数,这又带来了偏差。
3,按照 W3C 的标准,浏览器实现计时器时,如果嵌套层级超过 5 层,至少有4毫秒的偏差。
4,受事件循环的影响,计时器的回调函数只能在主线程空闲时运行,因此又带来了偏差
因此不能够准确计时。 
也就是说:setTimeout,setInterval,的计时是不准确的。

计时器嵌套层级超过 5 层,至少有4毫秒的偏差的解释

setTimeout(function(){
  setTimeout(function(){
    setTimeout(function(){
      setTimeout(function(){
        setTimeout(function(){
          // 这个延时器在第6层,超过了5层,即使我们延时的是0毫秒;
          // 最终也会被修改为4毫秒后执行
          setTimeout(function(){

          },0)
        },0)
      },0)
    },0)
  },0)
},0)

单线程是异步产生的原因。

事件循环是异步的实现方式


scikit-learn
中,回归模型的
可视化评估
是一个重要环节。
它帮助我们理解模型的性能,分析模型的预测能力,以及检查模型是否存在潜在的问题。
通过
可视化评估
,我们可以更直观地了解回归模型的效果,而不仅仅依赖于传统的评估指标。

1. 残差图

所谓
残差
,就是实际观测值与预测值之间的差值。

残差图
是指以
残差
为纵坐标,以任何其他指定的量为横坐标的散点图。
如果
残差图
中描绘的点围绕残差等于0的直线上下随机散布,说明回归直线对原观测值的拟合情况良好。反之,则说明回归直线对原观测值的拟合不理想。

下面做一个简单的线性回归模型,然后绘制残差图。

from sklearn.datasets import make_regression
import matplotlib.pyplot as plt
from sklearn.linear_model import LinearRegression
from sklearn.metrics import PredictionErrorDisplay

fig, ax = plt.subplots(1, 2)
fig.set_size_inches(10, 4)

X, y = make_regression(n_samples=100, n_features=1, noise=10)
ax[0].scatter(X[:, 0], y, marker="o")
ax[0].set_title("样本数据")

# 初始化最小二乘法线性模型
reg = LinearRegression()
# 训练模型
reg.fit(X, y)
y_pred = reg.predict(X)

ax[0].plot(X, y_pred, color="red")
display = PredictionErrorDisplay(y_true=y, y_pred=y_pred)
ax[1].set_title("残差图")
display.plot(ax=ax[1])

plt.show()

image.png
左边是随机生成的
样本数据
,其中的
红线
是训练之后拟合的线性模型。
右边是根据
scikit-learn
中提供的
PredictionErrorDisplay
模块生成的
残差图

2. 对比图

对比图
将实际目标值与模型预测值进行对比,直观地展示模型的预测能力。
通常,我们希望看到
实际值

预测值
沿着一条
\(y=x\)
的直线分布,这意味着模型预测非常准确。

下面用一些混乱度高的样本,来看看对比图的效果。

from sklearn.datasets import make_regression
import matplotlib.pyplot as plt
from sklearn.linear_model import LinearRegression
from sklearn.metrics import PredictionErrorDisplay

fig, ax = plt.subplots(1, 2)
fig.set_size_inches(12, 6)

# 混乱度高,noise=100
X, y = make_regression(n_samples=100, n_features=1, noise=100)
ax[0].scatter(X[:, 0], y, marker="o")
ax[0].set_title("样本数据")

# 初始化最小二乘法线性模型
reg = LinearRegression()
# 训练模型
reg.fit(X, y)
y_pred = reg.predict(X)

ax[0].plot(X, y_pred, color="red")
display = PredictionErrorDisplay(y_true=y, y_pred=y_pred)
ax[1].set_title("对比图")
display.plot(ax=ax[1], kind="actual_vs_predicted")

plt.show()

image.png
原始样本比较混乱,
线性模型
很难拟合,所以看
对比图
就可以发现,
真实值

预测值
差别很大。
越靠近
对比图
中间那个虚线的点,
真实值

预测值
越接近。

换一个混乱程度低的样本,再看看
对比图
的效果。

# 混乱度 noise=10,比如上面那个示例降10倍
# 上面代码只改这一行,其它部分代码不用改
X, y = make_regression(n_samples=100, n_features=1, noise=10)

image.png
从图中也可以看出,这次的模型拟合效果要好很多。

3. 总结

可视化的图形向我们传达了模型预测的
准确性
、线性假设的
满足程度
、误差项的
独立性
以及特征对预测的
影响程度
等信息,让我们对模型有更深入的了解。

通过图形化的方式,帮助我们更直观地理解回归模型的性能,发现模型潜在的问题,指导我们改进模型。
不过,
可视化评估
虽然直观,但并不能完全替代传统的量化评估指标。
两者应该相互补充,共同构成对回归模型性能的全面评价。

前言

在我们日常开发过程中常常会使用到很多其他封装好的第三方类库(NuGet依赖项)或者是.NET框架中自带的库。如果可以设置断点并在NuGet依赖项或框架本身上使用调试器的所有功能,那么我们的源码调试体验和生产效率会得到大大的提升。今天我们就一起来学习一下如何使用Visual Studio调试.NET源代码。

Visual Studio更多实用技巧

https://github.com/YSGStudyHards/DotNetGuide

取消选中启用仅我的代码

首先需要在在“工具”->“选项”->“调试”->“常规”中取消选中“启用仅我的代码”。

选中启用源链接支持

选中启用符号服务器

在“工具”->“选项”->“调试”->“符号”中,启用“Microsoft符号服务器”和“NuGet.org符合服务器”。

启用在模块加载时取消JIT优化(仅限托管)[可选]

在Visual Studio中启用“在模块加载时取消 JIT 优化(仅限托管)”功能是为了在调试代码时更容易地定位和解决问题。这个功能会防止 JIT 编译器对代码进行优化,从而使得调试器能够更准确地显示代码的执行过程。

在“工具”->“选项”->“调试”->“常规”中,启用“在模块加载时取消 JIT 优化(仅限托管)”:

启用Visual Studio调试源码

注意:第一次设置完之后会加载的比较缓慢,需要耐心等等。

我们使用System.Text.Json来进行JSON字符串反序列化成.NET对象的源码调试,查看
JsonSerializer.Deserialize
方法的内部实现:

程序代码:

    internal class Program
    {
        static void Main(string[] args)
        {
            var userInfoStr = "[{\"UserName\":\"追逐时光者\",\"Description\":\"一个热爱捣鼓的全栈软件工程师\",\"Sex\":\"男\"},{\"UserName\":\"大姚\",\"Description\":\"帅气小伙\",\"Sex\":\"男\"},{\"UserName\":\"小艺子\",\"Description\":\"超级大美女\",\"Sex\":\"女\"}]";
            var userInfoObj = JsonSerializer.Deserialize<List<UserModel>>(userInfoStr);
        }
    }

    public class UserModel
    {
        public string UserName { get; set; }
        public string Description { get; set; }
        public string Sex { get; set; }
    }

F11逐语句源代码调试:

DotNetGuide技术社区交流群

  • DotNetGuide技术社区是一个面向.NET开发者的开源技术社区,旨在为开发者们提供全面的C#/.NET/.NET Core相关学习资料、技术分享和咨询、项目推荐、招聘资讯和解决问题的平台。
  • 在这个社区中,开发者们可以分享自己的技术文章、项目经验、遇到的疑难技术问题以及解决方案,并且还有机会结识志同道合的开发者。
  • 我们致力于构建一个积极向上、和谐友善的.NET技术交流平台,为广大.NET开发者带来更多的价值和成长机会。





欢迎加入DotNetGuide技术社区微信交流群

欢迎阅读本系列文章!我将带你一起探索如何使用OpenAI API来开发GPT应用。无论你是编程新手还是资深开发者,都能在这里获得灵感和收获。

本文将继续展示AI助手的开发方式,在OpenAPI中它的名字是Assistants。

什么是Assistants?

在之前的文章中我演示了插件的使用方法,比如查询实时天气、进行数学运算等,这些都是大模型自身做不到的事情,因此可以说插件的主要作用是扩展了大模型的处理能力。那么Assistants能干什么呢?

Assistants的主要作用是强化大模型在某方面的应用能力,比如目前已经大范围使用的AI客服和知识库助手,它们可以准确的理解用户的问题,并在限定的知识范围内进行精准回答。另外借助Assistants的能力,我们还可以做更多有趣的事情,比如让它按照指定的规范对代码进行Review,按照某种指定的风格或者模式来进行文学创作,等等。

本文我们将通过一个AI客服来演示Assistants的使用方法。先看效果:

这里我开发了一个空气净化器的AI客服,然后用户向AI客服提了四个问题,前三个问题AI都理解准确并回答正确,回答内容全部来源于产品手册,最后一个问题脱离了产品手册的内容范围,AI只能拒绝回答。

Assistants的运行原理

工欲善其事,必先知其理。在编写Assistants的代码之前,我们先要搞清楚它是怎么运行的,然后写代码的时候才能有的放矢、逻辑清晰。

请看下边这张图:

1、创建智能助手(Assistant):这一步我们要给智能助手下个定义,包括起个名字、声明它的能力、使用的大模型版本、增强能力的方式(执行代码、从知识库检索、调用外部函数)等。

2、创建用户会话(Thread):会话就是用户和智能助手之间的一次聊天,GPT可以通过会话方便的管理聊天上下文。

3、添加用户消息到会话(Message):就是用户向智能助手说的话,必须添加到会话中。

4、在会话中运行智能助手(Run):将会话和智能助手进行绑定,运行智能助手来处理用户的消息。这一步实际上会创建一个智能助手的执行对象,然后把这个执行对象添加到一个处理队列中,最终处理状态会更新到运行对象中。

5、获取GPT响应的消息(Response):通过不断检查运行对象的状态,获取智能助手的响应结果。

实现AI客服

我们这里就按照Assistant的运行原理来实现一个AI客服。

产品手册

首先我们要准备一个产品手册,随便写点什么都行,为了方便大家,可以直接下载我这个:

https://github.com/bosima/openai-api-demo/blob/main/niubiclean-book.txt

然后我们需要将这个文件上传到OpenAI,注意把文件放到程序能够访问到的地方。

niubiclean_book = client.files.create(
    file=open("niubiclean-book.txt", "rb"),
    purpose="assistants"
)

purpose 可选的值有两个:fine-tune 和 assistants。

创建助手

这里使用的是 client.beta.assistants.create 来创建客服,因为assistants还没有正式发布,所以这里的包空间名称中包含了一个beta,正式发布时会去掉。具体代码如下:

waiter = client.beta.assistants.create(
    name="牛逼净化器智能客服",
    description="24小时为您服务",
    instructions="你是牛逼净化器公司的智能客服,请引用文件中的内容回答问题,表达要通俗易懂、尽量简短;若问题超出文件内容,请委婉拒绝。",
    model="gpt-3.5-turbo-1106",
    tools=[
        {
            "type": "retrieval",
        }
    ],
    # 知识文件,通过File接口上传的
    file_ids=[niubiclean_book.id]
)

简单说下这几个参数:

name:智能助手的名字,随便起。

description:智能助手的简介描述,最长 512 字符。

instructions:给智能助手的指令,也就是提示词,让智能助手按照这里的提示词提供服务。这里我用了一个常见的提示词套路,让它扮演一个角色,有什么样的能力,如何回答用户的问题等。最长 32768 字符。

model:使用的GPT大模型,这里用便宜的3.5,你也可以换成GPT-4。

tools:assistants开启的工具,共有三种类型:code_interpreter、retrieval、function。

  • code_interpreter:是代码解释器,能让GPT在一个沙盒环境中执行python代码,能从文件读取数据,也能生成文件,需要通过instructions提示assistant可以执行代码。
  • retrieval:从文件检索内容,这里我们的AI客服只能根据产品手册回答问题,所以这里只开启了retrieval的能力。
  • function:和聊天插件的使用方法一样,调用执行函数,根据执行结果向用户返回内容。

file_ids:指定GPT要检索的文件Id,可以设置多个。这里设置为我们上一步上传的手册。

创建用户会话

使用 client.beta.threads.create 创建用户会话,具体代码如下。

thread_userjia = client.beta.threads.create(
    metadata={
        "姓名": "用户甲",
        "年龄": 36,
        "性别": "男"
    }
)

metadata是可选的,可以设置一些附加信息,无固定属性,key-value格式即可。

添加用户消息到会话

我们其实可以在创建 thread 时初始化一些消息,不过既然要对话,演示下如何添加消息更有意义。

使用 client.beta.threads.messages.create 来创建一条用户消息,并绑定到某个会话,代码如下:

message = client.beta.threads.messages.create(
        thread_id=thread_userjia.id,  
        role="user",          
        content="净化器有什么功能?",
    )

这里有三个参数:

  • thread_id:消息绑定到的会话Id。
  • role:消息的角色,目前只支持 user,只能向其中添加用户消息。至于完整的聊天上下文,GPT内部会自动维护。
  • content:消息内容,这个很好理解。

在会话中运行智能助手

使用 client.beta.threads.runs.create 来运行智能助手,代码如下:

run = client.beta.threads.runs.create(
        thread_id=thread_userjia.id,
        assistant_id=waiter.id,
    )

这里有两个关键的参数:

  • thread_id:要在哪个会话中运行智能助手。
  • assistant_id:要运行哪个智能助手。

这里还有一些其它的参数,比如model、instructions、tools等,使用它们会覆盖我们在创建 assistant 设置的参数。

获取智能助手的回应

运行智能助手后得到的返回值 run 是一个对象,代表运行在会话中的一个执行,这个执行是通过队列异步处理的,我们不能立即得到执行结果,需要定期检查 run 的状态,处理完毕了才能获取到GPT的回应消息。

先看检查状态的处理:

while run.status == "queued" or run.status == "in_progress":
    time.sleep(1)
    run = client.beta.threads.runs.retrieve(
        thread_id=thread_userjia.id,
        run_id=run.id,
    )

run 有多个状态: queued, in_progress, requires_action, cancelling, cancelled, failed, completed, expired,这个例子中如果不是 queued 或者 in_progress 状态就代表已经有结果了。requires_action 是智能助手使用 function 工具时才会存在的状态,这个例子不涉及。

状态

含义

queued

创建run之后 或者 使用function时确定了要调用的function及其参数 之后,就会进入这个状态,这个状态很短,马上会进入 in_progress状态。

in_progress

使用模型或者tools处理消息。

completed

本次运行成功完成,可以读取GPT响应的消息了。

requires_action

使用function时,一旦模型确定要
调用的
function及其参数,run将进入这个状态

expired

function执行的时间太长或者整个run运行的时间太长,达到了过期阈值(大约10分钟)。

cancelling

可以在queued和in_progress状态时发起取消,将进入这个状态。

cancelled

已成功取消。

failed

您运行失败了,可以在 run.last_error 中获得失败原因。

使用 client.beta.threads.messages.list 获取GPT响应消息,代码如下:

 if run.status=="failed":
    print(run.last_error.message)
else:
    messages = client.beta.threads.messages.list(
        thread_id=thread_userjia.id, order="asc", after=message.id
    )
    print("牛逼智能客服:",extract_message_content(messages.data[0]),'\n')

获取响应消息时用到了3个参数:

  • thread_id:会话Id。
  • order:消息排序,asc代表正序,也就是先产生的消息在前边。
  • after:指定消息的起始位置,因为我们要获取GPT针对某条用户消息的响应,所以这里通过after指定获取某条用户消息之后的消息,也就是GPT的响应消息。

最后我们还使用了一个函数来提取消息内容:extract_message_content,代码如下:

def extract_message_content(message):
    # Extract the message content
    message_content = message.content[0].text
    annotations = message_content.annotations

    # Iterate over the annotations and add footnotes
    for index, annotation in enumerate(annotations):
        # Replace the text with a footnote
        # print(annotation.text)
        message_content.value = message_content.value.replace(annotation.text, ' ')
    
    return  message_content.value

注意这里有一个annotation的概念,中文就是注解的意思。因为AI客服生成的内容可能来自多个产品文档,有了注解,用户就可以通过它跳转到相关的文档进行详细阅读。这个和论文中的引用注解是同一种方式。

不过我们这里的产品手册比较简单,所以就把注解都替换成空字符串了。完整的处理方法可以参考下边这个:

# Extract the message content
message_content = message.content[0].text
annotations = message_content.annotations
citations = []

# Iterate over the annotations and add footnotes
for index, annotation in enumerate(annotations):
    # Replace the text with a footnote
    message_content.value = message_content.value.replace(annotation.text, f' [{index}]')

    # Gather citations based on annotation attributes
    if (file_citation := getattr(annotation, 'file_citation', None)):
        cited_file = client.files.retrieve(file_citation.file_id)
        citations.append(f'[{index}] {file_citation.quote} from {cited_file.filename}')
    elif (file_path := getattr(annotation, 'file_path', None)):
        cited_file = client.files.retrieve(file_path.file_id)
        citations.append(f'[{index}] Click <here> to download {cited_file.filename}')
        # Note: File download functionality not implemented above for brevity

# Add footnotes to the end of the message before displaying to user
message_content.value += '\n' + '\n'.join(citations)

完整示例

我在完整的示例程序中向智能助手循环提出了四个问题,每个问题都需要重新创建一个run,然后再检查状态,获取响应结果。

需要完整代码的同学请访问Github:

https://github.com/bosima/openai-api-demo/blob/main/assistants_demo.ipynb


以上就是本文的主要内容,有兴趣的同学快去试试吧,效果绝对震惊你的小伙伴!

如需GPT账号、学习陪伴群、AI编程训练营,推荐关注小册:
大模型应用开发 | API 实操

关注萤火架构,加速技术提升!