Go 已成为 web 开发领域中一种强大且高效的语言。它的简单性、高性能和强大的并发支持使其成为构建可扩展的分布式系统的流行选择,尤其适合用于云原生环境。Go 致旨在提高实用性和开发者的生产力,已经成为注重高吞吐量应用和微服务架构团队的首选语言。
另一方面,Rust 作为一种高性能的系统编程语言,变得非常流行。因其注重安全性、内存效率和零成本抽象特性,Rust 在对性能要求高的领域越来越受欢迎,包括网络服务行业。虽然传统上主要用于系统级编程,Rust 不断扩展的生态系统和像 Actix 这样的框架已经使 Rust 成为网络开发任务中的有力竞争者。
在这篇帖子中,我将通过在 Go 和 Rust 中实现和比较一个 CPU 密集型的 web 服务来探讨这两种语言的性能特点。
介绍让我们用 Go 和 Rust 语言开发 web 服务,来比较两个文本并返回它们的相似度,使用 词频-逆文档频率 (TF-IDF) 和 余弦相似度 算法。这些算法在自然语言处理和信息检索领域广泛用于衡量和比较文本数据的相关性和相似度。在深入实现之前,我先解释一下这些算法的工作原理。
词频-逆文档频率 (TF-IDF),一种用于评估文本中词汇重要性的统计方法。TF-IDF 是一种统计度量,用于评估一个词在一个文档中的相对重要性,相对于文档集合。它结合了两个度量。
词频(TF): 表示一个词在文档中出现的次数占文档总词数的比例。
逆文档频率(IDF):减少常见词(如the、and)在多个文档中出现的权重。
结合来看,TF-IDF 突出那些在特定文档中频繁出现但在整个数据集中却较少见的词,使它们在比较时更具相关性。
余弦相似度的概念余弦相似度 是一种用于衡量两个向量之间相似性的度量,与向量的大小无关。在比较文本时,向量代表了文本中词汇的 TF-IDF 权重。相似性是通过计算两个向量之间夹角的余弦值来计算的。
值区间:
1
表示两个文本完全相同。0
表示两个文本完全不同。- 中间的值表示两个文本之间不同程度的相似性。
这些算法互相补充:TF-IDF 将原始文本转换为反映单词重要性的数值表示,而 余弦相似性 则量化这些表示之间的关系。两者结合起来,共同构成了文本相似性分析的坚实基础。
开发工作现在,我们将分别用 Rust 和 Go 来开发这个 web 服务项目。我们先用 Rust 来开始:
use std::{collections::{HashMap, HashSet}, env};
use actix_web::{post, web, App, HttpResponse, HttpServer, Responder};
use once_cell::sync::Lazy;
use regex::Regex;
use serde::{Deserialize, Serialize};
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let addr = env::var("ADDR").unwrap_or("127.0.0.1".to_string());
let port = env::var("PORT").unwrap_or("8081".to_string()).parse().unwrap();
HttpServer::new(|| App::new().service(similarity))
.bind((addr.as_ref(), port))?
.run()
.await
}
/// 相似度端点的请求数据。
#[derive(Deserialize)]
struct SimilarityRequest {
text1: String,
text2: String,
}
/// 相似度端点的响应数据。
#[derive(Serialize)]
struct SimilarityResponse {
similarity: f64,
interpretation: String,
}
/// 计算两个文本之间的相似度。
#[post("/similarity")]
pub async fn similarity(data: web::Json<SimilarityRequest>) -> impl Responder {
let normalized1 = normalize_text(&data.text1);
let normalized2 = normalize_text(&data.text2);
let words1: Vec<&str> = normalized1.split_whitespace().collect();
let words2: Vec<&str> = normalized2.split_whitespace().collect();
// 为两个文本生成词频表
let freq_map1 = generate_frequency_map(&words1);
let freq_map2 = generate_frequency_map(&words2);
// 创建唯一词向量
let uniq: Vec<&str> = freq_map1
.keys()
.chain(freq_map2.keys())
.cloned()
.collect::<HashSet<&str>>()
.into_iter()
.collect();
// 计算两个文本的词频
let total1 = words1.len();
let total2 = words2.len();
let tf1 = calculate_tf(&uniq, &freq_map1, total1);
let tf2 = calculate_tf(&uniq, &freq_map2, total2);
// 计算逆文档频率值
let idf = calculate_idf(&uniq, &freq_map1, &freq_map2);
// 计算TF-IDF向量
let tf_idf1 = calculate_tf_idf(&tf1, &idf);
let tf_idf2 = calculate_tf_idf(&tf2, &idf);
// 计算余弦相似度值
let similarity = calculate_similarity(&tf_idf1, &tf_idf2);
// 将相似度保留三位小数
let similarity = (similarity * 1000.0).round() / 1000.0;
let interpretation = interpret_similarity(similarity);
// 以 JSON 格式返回相似度
HttpResponse::Ok().json(SimilarityResponse {
similarity,
interpretation,
})
}
/// 将文本转换为小写、移除标点和合并空格
fn normalize_text(text: &str) -> String {
static RE_PUNCT: Lazy<Regex> = Lazy::new(|| Regex::new(r"[^\w\s]").unwrap());
static RE_WHITESPACE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\s+").unwrap());
let lower = text.to_lowercase();
let no_punct = RE_PUNCT.replace_all(&lower, "");
let clean_text = RE_WHITESPACE.replace_all(&no_punct, " ");
clean_text.trim().to_string()
}
/// 为单词列表生成词频表。
fn generate_frequency_map<'a>(words: &[&'a str]) -> HashMap<&'a str, usize> {
let mut freq_map = HashMap::new();
for word in words {
*freq_map.entry(*word).or_insert(0) += 1;
}
freq_map
}
/// 计算唯一词列表和词频表的词频(TF)。
fn calculate_tf(uniq: &[&str], fm: &HashMap<&str, usize>, total: usize) -> Vec<f64> {
// 使用词频表计算 TF
uniq.iter()
.map(|word| *fm.get(word).unwrap_or(&0) as f64 / total as f64)
.collect()
}
/// 计算唯一词列表和两个词频表的逆文档频率值(IDF)。
fn calculate_idf(
uniq: &[&str],
fm1: &HashMap<&str, usize>,
fm2: &HashMap<&str, usize>,
) -> Vec<f64> {
let mut doc_freq = HashMap::new();
// 填充文档频率
for &word in uniq {
doc_freq.insert(
word,
fm1.contains_key(word) as usize + fm2.contains_key(word) as usize,
);
}
uniq.iter()
.map(|word| {
let count = *doc_freq.get(word).unwrap_or(&0);
(1.0 + 2.0 / (count as f64 + 1.0)).ln()
})
.collect()
}
/// 计算 TF-IDF 向量。
fn calculate_tf_idf(tf: &[f64], idf: &[f64]) -> Vec<f64> {
tf.iter().zip(idf.iter()).map(|(a, b)| a * b).collect()
}
/// 计算两个向量之间的余弦相似度。
fn calculate_similarity(tf_idf1: &[f64], tf_idf2: &[f64]) -> f64 {
let dot_product: f64 = tf_idf1.iter().zip(tf_idf2.iter()).map(|(a, b)| a * b).sum();
let norm1 = tf_idf1.iter().map(|a| a * a).sum::<f64>().sqrt();
let norm2 = tf_idf2.iter().map(|a| a * a).sum::<f64>().sqrt();
if norm1.abs() < f64::EPSILON || norm2.abs() < f64::EPSILON {
return 0.0;
}
dot_product / (norm1 * norm2)
}
/// 解释相似度值为可读字符串。
fn interpret_similarity(s: f64) -> String {
match s {
0.0..=0.2 => "不相似".to_string(),
0.2..=0.4 => "稍微相似".to_string(),
0.4..=0.6 => "中等相似".to_string(),
0.6..=0.8 => "相当相似".to_string(),
0.8..=1.0 => "高度相似".to_string(),
_ => "处理意外情况".to_string(), // 捕获意外值
}
}
以下这段 Rust 代码实现了一个网络服务,使用 Actix-web 框架,用于比较两个文本输入的相似度。它会清理文本(去掉标点并转换成小写),将文本切分成词,并计算词语频率。
另外,这是将此Rust服务容器化部署所需的Dockerfile。
FROM rust:latest as builder
# 设置工作目录为 /text-similarity-rust
WORKDIR /text-similarity-rust
# 复制当前目录的所有文件到容器中的相同路径
COPY . .
# 以发布模式构建项目
RUN cargo build --release
FROM gcr.io/distroless/cc-debian12
# 设置工作目录为 /app
WORKDIR /app
# 从构建阶段复制可执行文件到当前工作目录
COPY --from=builder /text-similarity-rust/target/release/text-similarity-rust .
# 设置默认命令,运行生成的可执行文件
CMD ["./text-similarity-rust"]
我们现在转到 Go
package main
import (
"math"
"regexp"
"strings"
"github.com/gofiber/fiber/v2"
"golang.org/x/exp/maps"
)
type 相似度请求 struct {
文本1 string `json:"text1"`
文本2 string `json:"text2"`
}
type 解释结果 string
const (
解释结果不相似 解释结果 = "不相似"
解释结果稍微相似 解释结果 = "稍微相似"
解释结果中等相似 InterpretationResult = "中等相似"
解释结果相当相似 InterpretationResult = "相当相似"
解释结果高度相似 InterpretationResult = "高度相似"
解释结果未知 InterpretationResult = "未知"
)
type 相似度响应 struct {
相似度 float64 `json:"similarity"`
解释 string `json:"interpretation"`
}
var (
标点符号正则表达式 = regexp.MustCompile(`[^\w\s]`)
空格正则表达式 = regexp.MustCompile(`\s+`)
)
func 相似度处理器(c *fiber.Ctx) error {
var 请求 相似度请求
if err := c.BodyParser(&请求); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"错误": "请求错误",
})
}
文本1 := 格式化文本(请求.文本1)
文本2 := 格式化文本(请求.文本2)
单词1 := strings.Split(文本1, " ")
单词2 := strings.Split(文本2, " ")
词频1 := 生成词频地图(单词1)
词频2 := 生成词频地图(单词2)
唯一词 := make(map[string]any, 0)
for 单词 := range 词频1 {
唯一词[单词] = struct{}{}
}
for 单词 := range 词频2 {
唯一词[单词] = struct{}{}
}
唯一单词 := maps.Keys(唯一词)
总数1 := len(单词1)
总数2 := len(单词2)
tf1 := 计算TF(唯一单词, 词频1, 总数1)
tf2 := 计算TF(唯一单词, 词频2, 总数2)
idf := 计算IDF(唯一单词, 词频1, 词频2)
tfidf1 := 计算TFIDF(tf1, idf)
tfidf2 := 计算TFIDF(tf2, idf)
相似度 := 计算相似度(tfidf1, tfidf2)
相似度 = math.Round(相似度 * 1000) / 1000
解释 := 解释相似度(相似度)
return c.JSON(相似度响应{
相似度: 相似度,
解释: string(解释),
})
}
func main() {
app := fiber.New()
app.Post("/similarity", 相似度处理器)
app.Listen(":8082")
}
func 格式化文本(文本 string) string {
低位 := strings.ToLower(文本)
无标点 := 标点符号正则表达式.ReplaceAllString(低位, "")
清洁文本 := 空格正则表达式.ReplaceAllString(无标点, " ")
return strings.Trim(清洁文本, " ")
}
func 生成词频地图(单词 []string) map[string]int {
词频图 := make(map[string]int)
for _, 单词 := range 单词 {
词频图[单词]++
}
return 词频图
}
func 计算TF(唯一单词 []string, 词频图 map[string]int, 总数 int) []float64 {
tf := make([]float64, len(唯一单词))
for i, 单词 := range 唯一单词 {
tf[i] = float64(词频图[单词]) / float64(总数)
}
return tf
}
func 计算IDF(唯一单词 []string, 词频图1, 词频图2 map[string]int) []float64 {
文档频率 := make(map[string]int)
for _, 单词 := range 唯一单词 {
计数1, 计数2 := 0, 0
if _, 存在 := 词频图1[单词]; 存在 {
计数1 = 1
}
if _, 存在 := 词频图2[单词]; 存在 {
计数2 = 1
}
文档频率[单词] = 计数1 + 计数2
}
idf := make([]float64, len(唯一单词))
for i, 单词 := range 唯一单词 {
idf[i] = math.Log(1.0 + 2.0/(float64(文档频率[单词])+1.0))
}
return idf
}
func 计算TFIDF(tf, idf []float64) []float64 {
tfidf := make([]float64, len(tf))
for i := range len(tf) {
tfidf[i] = tf[i] * idf[i]
}
return tfidf
}
func 计算相似度(tfidf1, tfidf2 []float64) float64 {
点积 := 0.0
for i := range len(tfidf1) {
点积 += tfidf1[i] * tfidf2[i]
}
模1 := 0.0
for _, 值 := range tfidf1 {
模1 += 值 * 值
}
模1 = math.Sqrt(模1)
模2 := 0.0
for _, 值 := range tfidf2 {
模2 += 值 * 值
}
模2 = math.Sqrt(模2)
if 模1 <= 1e-9 || 模2 <= 1e-9 {
return 0.0
}
return 点积 / (模1 * 模2)
}
func 解释相似度(相似度 float64) 解释结果 {
if 相似度 <= 0.2 {
return 解释结果不相似
} else if 相似度 <= 0.4 {
return 解释结果稍微相似
} else if 相似度 <= 0.6 {
return 解释结果中等相似
} else if 相似度 <= 0.8 {
return 解释结果相当相似
} else if 相似度 <= 1 {
return 解释结果高度相似
}
return 解释结果未知
}
相同的功能,但使用 Go 和 Fiber 实现。接下来我们来检查一下 Dockerfile:
FROM golang:1.23-alpine AS builder # 设置 Go 语言环境以构建应用程序
WORKDIR /app
COPY go.mod go.sum ./ # 复制 go.mod 和 go.sum 文件
RUN go mod download # 下载 Go 依赖
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/app main.go # 编译 Go 应用程序为 Linux/amd64 架构
FROM alpine # 创建一个最小的运行时环境
WORKDIR /out
COPY --from=builder /out/app /out/app # 从构建阶段复制编译的应用程序到运行时环境
CMD ["./app"] # 指定运行应用程序的命令
我编写了一个简单的k6(测试工具)测试来模拟对Go和Rust服务的负载情况。该测试使用包含文本数据的CSV文件来生成随机输入对,这些输入对用于计算文本相似度的/similarity
端点。下面就是测试脚本:
import { check } from 'k6';
import { SharedArray } from 'k6/data';
import http from 'k6/http';
import papaparse from './papaparse.min.js';
const data = new SharedArray('共享数组', function () {
return papaparse.parse(open('./data.csv'), { header: true }).data;
});
const serviceUrl = 'http://localhost:8082/similarity';
export const options = {
stages: [
{ duration: '30秒', target: 100 },
{ duration: '1分钟', target: 100 },
{ duration: '30秒', target: 200 },
{ duration: '1分钟', target: 200 },
{ duration: '30秒', target: 400 },
{ duration: '1分钟', target: 400 },
{ duration: '30秒', target: 800 },
{ duration: '1分钟', target: 800 },
{ duration: '2分钟', target: 0 },
],
thresholds: {
"http_req_failed": ["rate<0.01"],
"http_req_duration": ["p(95)<1500"],
},
};
export default function () {
const text1 = data[Math.floor(Math.random() * (data.length - 1))].Text;
const text2 = data[Math.floor(Math.random() * (data.length - 1))].Text;
const payload = JSON.stringify({
text1: text1,
text2: text2,
});
const params = {
headers: { 'Content-Type': 'application/json' },
};
const response = http.post(serviceUrl, payload, {
...params,
tags: { service: 'Go服务' },
});
check(response, {
"成功": (r) => r.status === 200,
"返回相似度": (r) =>
typeof JSON.parse(r.body).similarity === 'number',
});
}
以下是对两个服务进行的8分钟负载测试的结果。
Rust 结果展示请求: 处理了1,865,618个请求,只有13次失败(失败率几乎为0%)。
吞吐率: 每秒 3,887 个请求。
响应时间 :
- 平均值:90.77毫秒
- 中位:74.79毫秒
- 第90百分位数:161.71毫秒
- 第95百分位数:219.01毫秒
网络带宽:
- 发送数据:38 GB,
- 收到的数据:310 MB,
Rust展示了很高的吞吐量,请求失败很少,并且响应时间比较稳定,大多数请求的延迟都很低。
Go 成果请求: 完成了 960,483 个请求,无失败 (100% 成功)。
每秒请求数: 每秒 2,001 个请求。
回复时间:
- 平均值:177.35毫秒
- 中间值:22.28毫秒
- 第90百分位数:651.66毫秒
- 第95百分位数:905.77毫秒
网速
- 发送的数据:19 GB(吉字节)
- 接收的数据:159 MB(兆字节)
Go 处理的请求数量比 Rust 少,但表现出稳定可靠的性能,从未出现过故障。响应时间的变化更大,平均响应时间和第95百分位响应时间更高,这表明在负载增加时,性能的一致性有所下降。
比较篇吞吐:
- Rust 在同一时间段内处理的请求数量大约是 Go 的两倍,并且每秒处理的请求数更高(3,887 比 2,001)。
回复时间:
- Rust的平均响应时间比Go更短,但Go的中位响应时间更短。
- Go的响应时间变化更大,其第95百分位的响应时间显著增加。
可靠性方面:
- 两项服务都表现出极高的可靠性,但是 Go 的成功率达到了完美的 100%,而 Rust 的成功率则是 99.99%。
资源利用如下:
- Rust 传输的数据是 Go 的两倍多(38 GB 比 19 GB)。
你可以在这里找到它的所有代码,这里的项目指的就是这个项目。
参与misikdmytro/text-similarity贡献代码,您可以在此创建一个账户如果你觉得有帮助,别忘了给仓库点个星——你的支持真的很重要!🌟
结论:Rust 和 Go 都有自己的优势,选择哪一个取决于你的优先考虑事项。
如果你最看重的是速度,那么Rust就当仁不让。它的运行速度明显更快,非常适合那些对每一毫秒都锱铢必较的高性能应用。但是有个问题:写Rust代码就像解谜一样。它非常强大,但是需要更多的努力才能把一切都做得恰到好处。
另一方面,Go就像一个乐于助人的邻居,总是默默地把事情办好。代码更简洁,编写和维护都更容易,这使得它非常适合快速构建可靠的系统——尤其是在性能“足够好”时。
简而言之,如果你追求纯粹的速度并且不介意更陡的学习曲线,Rust 就是你的不二之选。但如果你想保持简单并快速交付,Go 就是最好的选择。
他们都是赢家——这全看你是怎么玩的,🏆(奖杯)
共同学习,写下你的评论
评论加载中...
作者其他优质文章