使用WebSocket技术实现ChatGPT应用

在之前的文章当中介绍了利用Server-sent events技术实现了ChatGPT消息流回复,也就是ChatGPT在回复的时候的打字效果。本文介绍如何使用WebSocket技术来实现类似的效果,同时还实现在实时聊天的过程当中可以停止生成的功能。 WebSocket简介 WebSocket是一种网络通信协议,通常在web应用当中作用他。看这名字就知道他跟Socket有一些相似之处,但又没有太多的联系。 什么是Socket Socket翻译为套接字,就是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。一个套接字就是网络上进程通信的一端,提供了应用层进程利用网络协议交换数据的机制。从所处的地位来讲,套接字上联应用进程,下联网络协议栈,是应用程序通过网络协议进行通信的接口,是应用程序与网络协议栈进行交互的接口。 Socket 通信示例 主机 A 的应用程序必须通过 Socket 建立连接才能与主机B的应用程序通信,而建立 Socket 连接需要底层 TCP/IP 协议来建立 TCP 连接。而建立 TCP 连接需要底层 IP 协议来寻址网络中的主机。 上图直接画出来了编程模型,如果用C语言或者Java语言编写过Socket应用,对上面的一些函数调用就非常的熟悉了。后面介绍的WebSocket编程模型与上面的有非常相似的地方,熟悉Socket编程模型对实现WebSocket是非常有帮助的。 什么WebSocket WebSocket是一种与HTTP不同的协议。两者都位于OSI模型的应用层,并且都依赖于传输层的TCP协议。 虽然它们不同,但是RFC 6455中规定:it is designed to work over HTTP ports 80 and 443 as well as to support HTTP proxies and intermediaries(WebSocket通过HTTP端口80和443进行工作,并支持HTTP代理和中介),从而使其与HTTP协议兼容。 为了实现兼容性,WebSocket握手使用HTTP Upgrade头从HTTP协议更改为WebSocket协议。 WebSocket协议支持Web浏览器(或其他客户端应用程序)与Web服务器之间的交互,具有较低的开销,便于实现客户端与服务器的实时数据传输。 服务器可以通过标准化的方式来实现,而无需客户端首先请求内容,并允许消息在保持连接打开的同时来回传递。通过这种方式,可以在客户端和服务器之间进行双向持续对话。 通信通过TCP端口80或443完成,这在防火墙阻止非Web网络连接的环境下是有益的。另外,Comet之类的技术以非标准化的方式实现了类似的双向通信。 大多数浏览器都支持该协议,包括Google Chrome、Firefox、Safari、Microsoft Edge、Internet Explorer和Opera。 与HTTP不同,WebSocket提供全双工通信。此外,WebSocket还可以在TCP之上实现消息流。TCP单独处理字节流,没有固有的消息概念。 在WebSocket之前,使用Comet可以实现全双工通信。但是Comet存在TCP握手和HTTP头的开销,因此对于小消息来说效率很低。WebSocket协议旨在解决这些问题。 早期,很多网站为了实现推送技术,所用的技术都是轮询。轮询是指由浏览器每隔一段时间(如每秒)向服务器发出HTTP请求,然后服务器返回最新的数据给客户端。这种传统的模式带来很明显的缺点,即浏览器需要不断的向服务器发出请求,然而HTTP请求与回复可能会包含较长的头部,其中真正有效的数据可能只是很小的一部分,所以这样会消耗很多带宽资源。 比较新的轮询技术是Comet。这种技术虽然可以实现双向通信,但仍然需要反复发出请求。而且在Comet中普遍采用的HTTP长连接也会消耗服务器资源。 在这种情况下,HTML5定义了WebSocket协议,能更好的节省服务器资源和带宽,并且能够更实时地进行通讯。 Websocket使用ws或wss的统一资源标志符(URI)。其中wss表示使用了TLS的Websocket。如: ws://example.com/wsapi wss://secure.example.com/wsapi Websocket与HTTP和HTTPS使用相同的TCP端口,可以绕过大多数防火墙的限制。默认情况下,Websocket协议使用80端口;运行在TLS之上时,默认使用443端口。 优点 较少的控制开销。在连接创建后,服务器和客户端之间交换数据时,用于协议控制的数据包头部相对较小。在不包含扩展的情况下,对于服务器到客户端的内容,此头部大小只有2至10字节(和数据包长度有关);对于客户端到服务器的内容,此头部还需要加上额外的4字节的掩码。相对于HTTP请求每次都要携带完整的头部,此项开销显著减少了。 更强的实时性。由于协议是全双工的,所以服务器可以随时主动给客户端下发数据。相对于HTTP请求需要等待客户端发起请求服务端才能响应,延迟明显更少;即使是和Comet等类似的长轮询比较,其也能在短时间内更多次地传递数据。 保持连接状态。与HTTP不同的是,Websocket需要先创建连接,这就使得其成为一种有状态的协议,之后通信时可以省略部分状态信息。而HTTP请求可能需要在每个请求都携带状态信息(如身份认证等)。 更好的二进制支持。Websocket定义了二进制帧,相对HTTP,可以更轻松地处理二进制内容。 可以支持扩展。Websocket定义了扩展,用户可以扩展协议、实现部分自定义的子协议。如部分浏览器支持压缩等。 更好的压缩效果。相对于HTTP压缩,Websocket在适当的扩展支持下,可以沿用之前内容的上下文,在传递类似的数据时,可以显著地提高压缩率。 WebSocket的基本教程推荐阮一峰的博客https://www.ruanyifeng.com/blog/2017/05/websocket.html ...

June 25, 2023

在Spacemacs中使用conda环境启用lsp-pyright

如果你是Emacs的用户,那么肯定听说过Spacemacs,以开箱即用而出名,界面也非常的漂亮。放弃了一段时间的Emacs,之前都是自己写的配置,没有时间维护,便尝试了一下Spacemacs。 在使用Spacemacs开发Python的时候,启动lsp-pyright对Python的支持,lsp-pyright可以识别virtualenv虚拟环境,暂时还没有对conda env的支持。好在还是有办法设置python程序的路径。 实现方式用到了几个关键词方法: dotspacemacs-configuration-layers列表添加python、lsp以及conda directory local variable with-eval-after-load 下面解释每个点的配置 Spacemacs layers 启动python和lsp,这两个layer不需要做过多的设置。 启动conda的时候需要设置conda-anaconda-home变量,如下: (conda :variables conda-anaconda-home "D:/path/to/miniconda3") python解释器我使用的是miniconda,可以修改成自己对应的路径。 Directory local variable Emacs支持在项目目录创建一个 .dir-locals.el 文件,来创建针对项目文件的本地变量,而不会覆盖全局的变量。这样多个项目之间就不会相互影响了。这块可以参考官方文档。 在我们的Python项目根目录下创建一个 .dir-locals.el 文件,写入以下内容: ((python-mode . ((eval . (with-eval-after-load 'lsp-pyright (progn (lsp-register-custom-settings `(("python.pythonPath" "D:/path/to/miniconda3/envs/stringle_pro/python.exe")))) ))))) 首先python-mode 指定只有在python模式下后面的代码才会执行; (eval . func_call)是一个cons pair,eval函数相当于是标记后面的fanc_call是一个可以执行的S表达式; 如果要设置指定模式下的变量值,则使用下面的代码: ((python-mode . ((variable-name variable-value)))) with-eval-after-load with-eval-after-load 是Emacs内置的一个宏,定义在 subr.el。 这个地方是很需要注意代码运行的顺序与时机的。在Emacs打开python代码的时候会调用 lsp 启动lsp-mode,lsp会询问使用的lsp后端,python的lsp后端有非常的多,pyls, mspy等。我选择的是lsp-pyright。然后lsp根据设置的python lsp后端,加载lsp-pyright的代码。 这时上面的with-eval-after-load就会起作用了,在加载完lsp-pyright.el的代码后就会执行with-eval-after-load里面的代码。把 python.pythonPath 这个lsp变量设置完成后。 lsp就会调用lsp-pyright里面lsp相关函数,启动pyright后端。 总结 本文说明了如何在spacemacs下启用lsp来支持在conda env虚拟环境下开发python的配置工作,需要对Emacs和lsp的工作原理有一定的认识与了解。

June 19, 2023

Python Docker FastAPI快速开发部署RESTful API

本文介绍如何快速使用Python, Docker, FastAPI快速开发RESTful API,以及如何编译为Docker镜像,使用Docker容器运行程序。 预备知识 在开始开发之前,需要先安装Python和Docker。 Python是一种流行的编程语言,因此在许多操作系统上都可以很容易地安装。可以使用Miniconda,也可以使用官方提供的标准安装包进行安装,个人喜欢Miniconda,不会像Anaconda那么大,同时又提供了非常多必要的依赖模块。 而Docker则是一种容器化平台,可帮助我们轻松地将应用程序封装在容器中,便于部署和管理。目前Windows、Linx、macOS对Docker的支持也非常的完善了。 FastAPI 是一个用于构建 API 的现代、快速(高性能)的 web 框架,使用 Python 3.6+ 并基于标准的 Python 类型提示。 初始化项目及运行环境 Python没有像Java那样极其成熟的一站式的Maven项目管理工具,但是Python有其自身的简洁与规范。像pyproject.toml,以及最近用Rust写的rye,都是很出色的工具。本文使用的例子,完全使用手动管理即可😄 创建一个项目目录my-project,创建文件requirements.txt,main.py,Dockerfile,后面逐一填写里面的内容。 上面我们安装了Miniconda,建议使用conda来创建一个独立的运行环境,这样即不会影响其他项目的运行环境,还可以保持本项目运行环境的整洁。 conda create -n my-project python=3.9 pip 这个命令创建一个名为my-project的运行环境,同时安装3.9版本的Python,和最新的pip工具。 然后激活这个运行环境 conda activate my-project 添加依赖模块 我们使用requirements.txt来管理程序依赖的模块,requirements.txt文件内容如下 -i https://mirrors.aliyun.com/pypi/simple/ fastapi uvicorn[standard] 本文只需要用到这两个模块。第一行的-i指定pip使用阿里pypi镜像地址,如果在国内这会大大加快依赖安装的速度,可以换成阿里或者其他的镜像源。 在本地开发电脑上可以使用下面的命令安装依赖 pip install -r requirements.txt 编写代码 Python代码 FastAPI非常容易使用,也非常适合快速开发RESTful API。以下是我们快速入门的示例代码: from fastapi import FastAPI app = FastAPI() @app.get("/") def read_root(): return "Hello World" @app.get("/items/{item_id}") def read_item(item_id: int, q: str = None): return {"item_id": item_id, "q": q} 在这个简单的示例中,我们创建了一个FastAPI应用程序,它包括两个路由。第一个路由处理根路径并返回一个简单的“Hello World”字符串。第二个路由处理传递给它的路径参数,并返回这些参数的值。第二个路由会以json格式响应内容给调用者(FastAPI框架会自动帮我们字典转换为json数据)。 ...

June 6, 2023

Python函数式编程之自定义函数get_in

Python函数式编程之 get_in 编程语言支持通过以下几种方式来解构具体问题: 大多数的编程语言都是 过程式 的,所谓程序就是一连串告诉计算机怎样处理程序输入的指令。C、Pascal 甚至 Unix shells 都是过程式语言。 在 声明式 语言中,你编写一个用来描述待解决问题的说明,并且这个语言的具体实现会指明怎样高效的进行计算。 SQL 可能是你最熟悉的声明式语言了。 一个 SQL 查询语句描述了你想要检索的数据集,并且 SQL 引擎会决定是扫描整张表还是使用索引,应该先执行哪些子句等等。 面向对象 程序会操作一组对象。 对象拥有内部状态,并能够以某种方式支持请求和修改这个内部状态的方法。Smalltalk 和 Java 都是面向对象的语言。 C++ 和 Python 支持面向对象编程,但并不强制使用面向对象特性。 函数式 编程则将一个问题分解成一系列函数。 理想情况下,函数只接受输入并输出结果,对一个给定的输入也不会有影响输出的内部状态。 著名的函数式语言有 ML 家族(Standard ML,Ocaml 以及其他变种)和 Haskell。 一些语言的设计者选择强调一种特定的编程方式。 这通常会让以不同的方式来编写程序变得困难。其他多范式语言则支持几种不同的编程方式。Lisp,C++ 和 Python 都是多范式语言;使用这些语言,你可以编写主要为过程式,面向对象或者函数式的程序和函数库。在大型程序中,不同的部分可能会采用不同的方式编写;比如 GUI 可能是面向对象的而处理逻辑则是过程式或者函数式。 在函数式程序里,输入会流经一系列函数。每个函数接受输入并输出结果。函数式风格反对使用带有副作用的函数,这些副作用会修改内部状态,或者引起一些无法体现在函数的返回值中的变化。完全不产生副作用的函数被称作“纯函数”。消除副作用意味着不能使用随程序运行而更新的数据结构;每个函数的输出必须只依赖于输入。 真实案例 上面是一些基本的理论,在实际的工作当中,经常会看到从一个多层嵌套的字典当中取一个值,会使用连续的下标,但是如果中间某个值并非一个字典,就会导致程序的运行时错误。而这种错误经常得等到代码上线之后才会被发现,开发的时候经常都是测试的正常数据。 例如: # 查询Elasticsearch,获得检索结果 resp = es_request("_search", dsl) # 从检索结果当中取出结果总数 total = resp["hits"]["total"]["value"] 上面的代码通常不会有问题,因为Elasticsearch的检索结果总是会包含 hits/total/value 这个值,如果没有结果,这个值就为0。但是,看看下面的动态取值: value = resp["data"]["aggs"][aggs_key]["value"] 这是在封装了Elasticsearch查询之后的结果,假设是一个叫search_service的微服务,如果aggs下的值为空,那么再取动态的 aggs_key 的值就会报 Key Error 的错误。 ...

May 2, 2023

我来学Kotlin-面向对象之属性和字段

定义属性 // TODO

June 20, 2017

我来学Kotlin-面向对象之类和继承

使用关键字class来定义类: class Invoice { } 类的定义由header(类名,主构造函数,参数及类型等)和header构成,主体使用花括号包裹。头和主体都不是必须的,如果没有主体花括号可以省略。 class Empty 构造函数 类有一个主构造函数以及一个或多个辅构造函数。主构造函数是header的一部分,紧跟在类名的后面。 class Person constructor(firstName: String) { } 如果主构造函数没有注解或者可见性修饰符,关键字constructor可以省略: class Person(firstName: String) { } 主构造函数不能包含任何的求值表达式,仅仅是定义参数及类型。如果要做一些初始化的工作,可以放在以init关键字开头的代码块当中: class Customer(name: String) { init { logger.info("Customer initialized with value ${name}") } } 主构造函数的参数可以在初始化代码块中使用。同样也可以在属性的定义及初始化的时候使用: class Customer(name: String) { val customerKey = name.toUpperCase() } 属性的定义及初始化可以简单的通过构造函数完成,同时可以定义属性的可变性(mutable/immutable),var是可变,val是不可变: class Person(val firstName: String, val lastName: String, var age: Int) { // ... } 如果有可见性及注释修饰符,那么constructor关键字是不可以省略的,而且要请在constructor前面: class Customer public @Inject constructor(name: String) { ... } 辅助构造函数 辅助构造函数就像Java的多构造函数类似。在Kotlin使用constructorr关键字定义,在Java中是使用与类同名的函数名定义的。 class Person { constructor(parent: Person) { parent.children.add(this) } } 如果类有辅助构造函数同时还有主构造函数,那么辅助构造函数必须要显示的调用主构造函数,直接调用,或者调用其他的辅助构造函数。调用其他的构造函数可以使用this关键字: ...

June 16, 2017

我来学Kotlin-基础之返回值与跳转

Kotlin有三种结构性的跳转表达式: return默认从最近的函数或者匿名函数返回,后面的代码不再执行 break跳出break最近的一个循环 continue忽略后面的代码,跳转到下一个步骤 所有这些表达式都可以用作更大的表达式的一部分: val s = person.name ?: return 这个表达式的类型是Nothing类型 break和continue标签 在Kotlin里所有的表达式都可以用一个标签(label)来标记。标签由一个合法的标识符跟上一个@符号缓存,例如:abc@, fooBar@都是合法的标签。要标记一个表达式,只需要把标签放在表达式前面即可: loop@ for (i in 1..10) { for (j in 1..10) { if (j > 5) break@loop println("i, j = $i, $j") } } 上面这段代码的输出如下: i, j = 1, 1 i, j = 1, 2 i, j = 1, 3 i, j = 1, 4 i, j = 1, 5 如果没有loop标签,那么break的行为是跳出里面一层的循环,输出结果还会有i, j = 2, 1等等,加了标签后直接跳出到最外层的循环。而continue是返回到标签处继续下一次的循环,下面的代码中带标签的continue和不带标签的break作用是一样的: abc@ for (i in 1..4) { for (j in 1..4) { if (j > 2) continue@abc println("i, j = $i, $j") } } for (i in 1..4) { for (j in 1..4) { if (j > 2) break println("i, j = $i, $j") } } return 关键字return跟Java的差不多,但是功能要复杂一些,个人认为没有Scala的函数式那么纯粹。return是用在函数里面来返回一个值给调用者。 ...

June 16, 2017

我来学Kotlin-基础之流程控制

流程控制与Java差不多,但是更加强大,像if, when是语句也是表达式,表达式有返回值,可以赋值给一个变量。 if表达式 在Kotlin里,if是一个表达式,也就是他会返回一个值。因此没有三目运算符(? :),因为完全可以用if替代 // Traditional usage var max = a if (a < b) max = b // With else var max: Int if (a > b) { max = a } else { max = b } // As expression val max = if (a > b) a else b if分支可以是代码块,代码块的最后一个表达式的值就是if的返回值。 val max = if (a > b) { print("Choose a") a } else { print("Choose b") b } 如果if被当作表达式而不是语句使用时,这个表达式必须要有else分支。 ...

June 15, 2017

我来学Kotlin-基础之包

包对于模块化是非常重要的,Kotlin的包与Java的定义/语法类似,但是限制完全不同。 一个源文件的包定义应该放在代码开始处: package foo.bar fun baz() {} class Goo {} // ... 所有的内容被包含在此包的定义下面,在上面的例子中,baz()的全名是foo.bar.bax,Goo的全名是foo.bar.Goo。如果没有指定包名,那么些文件的代码属于默认没有名字的包。 默认引入 有一些包被默认引入到每一个源文件中,不需要再另外显示的引入: kotlin.* kotlin.annotation.* kotlin.collections.* kotlin.comparisons.* (从1.1开始) kotlin.io.* kotlin.ranges.* kotlin.sequences.* kotlin.text.* 根据不同的平台还会默认引入其他的包: JVM: java.lang.* kotlin.jvm.* JS: kotlin.js.* 引入 (Imports) 除了默认引入的一些包以外,每个文件应该包含自己的引入指令。 可以引入一个单独的名称,如: import foo.Bar // Bar可以直接使用,而不需要前面的前缀foo 或者一个包下面的所有内容(包,类,对象等等): import foo.* // foo下面的所有内容都可以直接访问 如果有名称冲突了,可以使用as关键字在文件范围内重命名来消除冲突: import foo.Bar // Bar可以直接访问 import bar.Bar as bBar // 使用bBar代替'bar.Bar' 关键字import不局限于引入类,还可以引入其他的定义/声明: 顶层(包级别)的函数和属性 单例对象内定义的方法和属性 枚举常量 跟Java不同的一点是,Kotlin没有import static语法,所有的定义都是通过import关键字实现 顶层定义可见性 如果声明/定义被标记为了private,那么这个声明/定义仅仅对当前文件可见,其他包不能引用。

June 14, 2017

我来学Kotlin-基础之基础类型

在Kotlin,一切皆对象,我们可以在任何变量上调用成员函数和属性。有些类型是内置的,因为它们的实现是经过优化的,但是对于用户来说,它们看起来就像普通的类。本节描述这些类型中的大多数: numbers, characters, booleans and arrays. 数字类型 Kotlin以一种接近Java的方式处理数字,但不完全一样。例如,对于数字没有隐式转换,而在某些情况下,字面量略有不同。Kotlin提供如下内置的数字类型(与Java接近): Type Bit width Double 64 Float 32 Long 64 Int 32 Short 16 Byte 8 注意:在Kotlin中字符不是数字 字面量 整数字面量如下: 十进制:123 Long长整形使用L标识:123L 十六进制:0x0F 二进制:0b00001011 注意:不支持八进制字面量 Kotlin支持常见的浮点数字: Double双精度是默认的浮点数字:123.5, 123.5e10 单精度使用f或者F标识:123.5f 使用下划线(1.1或更高版本) 比较长的数字使用下划线可以更易于阅读,这和Swift的使用方法一样 val oneMillion = 1_000_000 val creditCardNumber = 1234_5678_9012_3456L val socialSecurityNumber = 999_99_9999L val hexBytes = 0xFF_EC_DE_5E val bytes = 0b11010010_01101001_10010100_10010010 Representation 在JVM平台,数字被物理地存储为JVM原始类型,除非我们需要一个可空的数字引用(例如Int?)或者泛型。在后一种情况下数字会被装箱(boxed)。 注意:装箱后的数字不一定会保持引用 val a: Int = 10000 print(a === a) // Prints 'true' val boxedA: Int? = a val anotherBoxedA: Int? = a print(boxedA === anotherBoxedA) // !!!Prints 'false'!!! 但是会保持值的相等(使用两个等号==)。三个等号(===)会判断是否是同一个对象,就是上面说的引用,内存地址相等。 ...

June 14, 2017