赞
踩
Go 和 Rust 之间的许多比较都强调它们在语法和初始学习曲线上的差异。然而,最终的决定性因素是重要项目的易用性。
Rust vs Go 是一个不断出现的话题,并且已经有很多关于它的文章。部分原因是开发人员正在寻找信息来帮助他们决定下一个 Web 项目使用哪种语言,而这两种语言在这种情况下都经常被提及。我们环顾四周,但确实没有太多关于该主题的深入内容,因此开发人员只能自己解决这个问题,并冒着由于误导性原因而过早放弃某个选项的风险。
这两个社区都经常面临误解和偏见。一些人将 Rust 主要视为一种系统编程语言,质疑它是否适合 Web 开发。与此同时,其他人认为 Go 过于简单,怀疑它处理复杂 Web 应用程序的能力。然而,这些都只是表面的判断。
事实上,这两种语言都可以用来编写快速可靠的 Web 服务。然而,他们的方法截然不同,很难找到一个对两者都公平的比较。
这篇文章是我们试图通过用两种语言构建一个重要的现实世界中的应用程序来概述 Go 和 Rust 之间的差异,重点是 Web 开发。我们将超越语法,并仔细研究这些语言如何处理典型的 Web 任务,如路由、中间件、模板、数据库访问等。
读完本文后,您应该清楚哪种语言适合您。
尽管我们知道自己的偏见和偏好,但我们将尽力保持客观并强调两种语言的优点和缺点。
我们将讨论以下主题:
我们将省略客户端渲染或数据库迁移等主题,只关注服务器端。
选择一个代表 Web 开发的任务并不容易:一方面,我们希望保持它足够简单,以便我们可以专注于语言功能和库。另一方面,我们希望确保任务不会太简单,以便我们可以展示如何在现实环境中使用语言功能和库。
我们决定建立天气预报服务。用户应该能够输入城市名称并获取该城市当前的天气预报。该服务还应该显示最近搜索过的城市列表。
随着我们扩展服务,我们将添加以下功能:
对于天气预报,我们将使用 Open-Meteo API,因为它是开源的、易于使用,并且为非商业用途提供慷慨的免费套餐,每天最多可处理 10,000 个请求。
我们将使用这两个 API 接口:
这两种语言都有现成的库可用,Go (omgo) 和 Rust (openmeteo) ,我们将在生产服务中使用它们。然而,为了进行比较,我们希望了解如何用两种语言发出“原始”HTTP 请求并将响应转换为常用的数据结构。
Go 最初是为了简化构建 Web 服务而创建的,它拥有许多很棒的与 Web 相关的包。如果标准库不能满足您的需求,还有许多流行的第三方 Web 框架可供选择,例如 Gin、Echo 或 Chi。
选择哪一个是个人喜好问题。一些经验丰富的 Go 开发人员更喜欢使用标准库,并在其之上添加像 Chi 这样的路由库。其他人则更喜欢包含更多内置功能的方法,使用功能齐全的框架,例如 Gin 或 Echo。
这两个选项都很好,但为了比较的目的,我们将选择 Gin,因为它是最流行的框架之一,并且它支持我们的天气服务所需的所有功能。
让我们从一个简单的函数开始,该函数向 Open Meteo API 发出 HTTP 请求并以字符串形式返回响应正文:
func getLatLong(city string) (*LatLong, error) { endpoint := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=1&language=en&format=json", url.QueryEscape(city)) resp, err := http.Get(endpoint) if err != nil { return nil, fmt.Errorf("error making request to Geo API: %w", err) } defer resp.Body.Close() var response GeoResponse if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { return nil, fmt.Errorf("error decoding response: %w", err) } if len(response.Results) < 1 { return nil, errors.New("no results found") } return &response.Results[0], nil }
该函数将城市名称作为参数,并以 LatLong
结构体的形式返回城市的坐标。
请注意我们在每个步骤之后如何处理错误:我们检查 HTTP 请求是否成功、响应正文是否可以解码以及响应是否包含任何结果。如果这些步骤中的任何一个失败,我们将返回错误并中止该函数。到目前为止,我们只需要使用标准库,这样挺好。
defer
语句确保响应正文在函数返回后关闭。这是 Go 中避免资源泄漏的常见模式。如果我们忘记了,编译器不会警告我们,所以我们在这里需要小心。
错误处理占据了代码的很大一部分。它很简单,但编写起来可能很乏味,并且会使代码更难阅读。从好的方面来说,错误处理很容易遵循,并且很清楚发生错误时会发生什么。
由于 API 返回带有结果列表的 JSON 对象,因此我们需要定义一个与该响应匹配的结构:
type GeoResponse struct {
// A list of results; we only need the first one
Results []LatLong `json:"results"`
}
type LatLong struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}
json
标签(tag)告诉 JSON 解码器如何将 JSON 字段映射到结构体字段。默认情况下,JSON 响应中的额外字段将被忽略。
让我们定义另一个函数,它采用 LatLong
结构并返回该位置的天气预报:
func getWeather(latLong LatLong) (string, error) {
endpoint := fmt.Sprintf("https://api.open-meteo.com/v1/forecast?latitude=%.6f&longitude=%.6f&hourly=temperature_2m", latLong.Latitude, latLong.Longitude)
resp, err := http.Get(endpoint)
if err != nil {
return "", fmt.Errorf("error making request to Weather API: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("error reading response body: %w", err)
}
return string(body), nil
}
首先,让我们按顺序调用这两个函数并打印结果:
func main() {
latlong, err := getLatLong("London") // you know it will rain
if err != nil {
log.Fatalf("Failed to get latitude and longitude: %s", err)
}
fmt.Printf("Latitude: %f, Longitude: %f\n", latlong.Latitude, latlong.Longitude)
weather, err := getWeather(*latlong)
if err != nil {
log.Fatalf("Failed to get weather: %s", err)
}
fmt.Printf("Weather: %s\n", weather)
}
This will print the following output:
这将打印以下输出:
Latitude: 51.508530, Longitude: -0.125740
Weather: {"latitude":51.5,"longitude":-0.120000124, ... }
漂亮!我们得到了伦敦的天气预报。让我们将其作为 Web 服务提供。
路由是 Web 框架最基本的任务之一。首先,让我们将 gin 添加到我们的项目中。
go mod init github.com/user/goforecast
go get -u github.com/gin-gonic/gin
然后,我们将 main()
函数替换为服务器和路由,该路由将城市名称作为参数并返回该城市的天气预报。
Gin 支持路径参数和查询参数。
// Path parameter
r.GET("/weather/:city", func(c *gin.Context) {
city := c.Param("city")
// ...
})
// Query parameter
r.GET("/weather", func(c *gin.Context) {
city := c.Query("city")
// ...
})
您想使用哪一种取决于您的用例。在我们的例子中,我们希望最终从表单提交城市名称,因此我们将使用查询参数。
func main() { r := gin.Default() r.GET("/weather", func(c *gin.Context) { city := c.Query("city") latlong, err := getLatLong(city) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } weather, err := getWeather(*latlong) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"weather": weather}) }) r.Run() }
在终端中,我们可以使用 go run .
启动服务器并向其发出请求:
curl "localhost:8080/weather?city=Hamburg"
我们得到天气预报:
{"weather":"{\"latitude\":53.550000,\"longitude\":10.000000, ... }
我喜欢日志输出,而且速度也很快!
[GIN] 2023/09/09 - 19:27:20 | 200 | 190.75625ms | 127.0.0.1 | GET "/weather?city=Hamburg"
[GIN] 2023/09/09 - 19:28:22 | 200 | 46.597791ms | 127.0.0.1 | GET "/weather?city=Hamburg"
我们完成了api服务端,但原始 JSON 对于普通用户来说并不是很有用。在现实应用程序中,我们可能会在 API 端点(例如 /api/v1/weather/:city
)上提供 JSON 响应,并返回一个 HTML 页面。为了简单起见,我们直接返回 HTML 页面。
让我们添加一个简单的 HTML 页面,以表格形式显示给定城市的天气预报。我们将使用标准库中的 html/template
包来呈现 HTML 页面。
首先,我们为视图添加一些结构:
type WeatherData struct type WeatherResponse struct { Latitude float64 `json:"latitude"` Longitude float64 `json:"longitude"` Timezone string `json:"timezone"` Hourly struct { Time []string `json:"time"` Temperature2m []float64 `json:"temperature_2m"` } `json:"hourly"` } type WeatherDisplay struct { City string Forecasts []Forecast } type Forecast struct { Date string Temperature string }
这只是 JSON 响应中相关字段到结构的直接映射。有一些工具,例如transform,可以使从JSON 到Go 结构的转换变得更容易。你可以试一下!
接下来我们定义一个函数,它将来自天气 API 的原始 JSON 响应转换为新的 WeatherDisplay
结构:
func extractWeatherData(city string, rawWeather string) (WeatherDisplay, error) { var weatherResponse WeatherResponse if err := json.Unmarshal([]byte(rawWeather), &weatherResponse); err != nil { return WeatherDisplay{}, fmt.Errorf("error decoding weather response: %w", err) } var forecasts []Forecast for i, t := range weatherResponse.Hourly.Time { date, err := time.Parse(time.RFC3339, t) if err != nil { return WeatherDisplay{}, err } forecast := Forecast{ Date: date.Format("Mon 15:04"), Temperature: fmt.Sprintf("%.1f°C", weatherResponse.Hourly.Temperature2m[i]), } forecasts = append(forecasts, forecast) } return WeatherDisplay{ City: city, Forecasts: forecasts, }, nil }
日期处理是通过内置的 time
包完成的。要了解有关 Go 中日期处理的更多信息,请查看这篇文章。
我们扩展路由处理程序来呈现 HTML 页面:
r.GET("/weather", func(c *gin.Context) { city := c.Query("city") latlong, err := getLatLong(city) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } weather, err := getWeather(*latlong) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } NEW CODE STARTS HERE weatherDisplay, err := extractWeatherData(city, weather) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.HTML(http.StatusOK, "weather.html", weatherDisplay) // })
接下来让我们处理模板。创建一个名为 views
的模板目录并告诉 Gin:
r := gin.Default()
r.LoadHTMLGlob("views/*")
最后,我们可以在 views
目录下创建一个模板文件 weather.html
:
<!DOCTYPE html> <html> <head> <title>Weather Forecast</title> </head> <body> <h1>Weather for {{ .City }}</h1> <table border="1"> <tr> <th>Date</th> <th>Temperature</th> </tr> {{ range .Forecasts }} <tr> <td>{{ .Date }}</td> <td>{{ .Temperature }}</td> </tr> {{ end }} </table> </body> </html>
(有关如何使用模板的更多详细信息,请参阅 Gin 文档。)
这样,我们就有了一个可用的 Web 服务,它以 HTML 页面的形式返回给定城市的天气预报!
差点忘了!也许我们还想创建一个带有输入字段的index页面,它允许我们输入城市名称并显示该城市的天气预报。
让我们为index页添加一个新的路由处理程序:
r.GET("/", func(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", nil)
})
和一个新的模板文件 index.html
:
<!DOCTYPE html>
<html>
<head>
<title>Weather Forecast</title>
</head>
<body>
<h1>Weather Forecast</h1>
<form action="/weather" method="get">
<label for="city">City:</label>
<input type="text" id="city" name="city" />
<input type="submit" value="Submit" />
</form>
</body>
</html>
现在我们可以启动 Web 服务并在浏览器中打开 http://localhost:8080:
伦敦的天气预报是这样的。它不漂亮,但是…实用! (它无需 JavaScript 即可在终端浏览器中运行!)
作为练习,您可以向 HTML 页面添加一些样式,但由于我们更关心后端,因此我们将保留它。
我们的服务根据每个请求从外部 API 获取给定城市的纬度和经度。一开始这可能没问题,但最终我们可能希望将结果缓存在数据库中以避免不必要的 API 调用。
为此,我们将数据库添加到我们的 Web 服务中。我们将使用 PostgreSQL 作为数据库,使用 sqlx 作为数据库驱动程序。
首先,我们创建一个名为 init.sql
的文件,它将用于初始化我们的数据库:
CREATE TABLE IF NOT EXISTS cities (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
lat NUMERIC NOT NULL,
long NUMERIC NOT NULL
);
CREATE INDEX IF NOT EXISTS cities_name_idx ON cities (name);
我们存储给定城市的纬度和经度。 SERIAL
类型是 PostgreSQL 自增整数。为了加快速度,我们还将在 name
列上添加索引。
使用 Docker 或任何云提供商可能是最简单的。最终,您只需要一个数据库 URL,您可以将其作为环境变量传递到您的 Web 服务。
我们不会在这里详细介绍设置数据库的细节,但在本地使用 Docker 运行 PostgreSQL 数据库的一个简单方法是:
docker run -p 5432:5432 -e POSTGRES_USER=forecast -e POSTGRES_PASSWORD=forecast -e POSTGRES_DB=forecast -v `pwd`/init.sql:/docker-entrypoint-initdb.d/index.sql -d postgres
export DATABASE_URL="postgres://forecast:forecast@localhost:5432/forecast?sslmode=disable"
然而,一旦我们有了数据库,我们需要将 sqlx 依赖项添加到 go.mod
文件中:
go get github.com/jmoiron/sqlx
现在,我们可以使用 sqlx
包通过 DATABASE_URL
环境变量中的连接字符串连接到我们的数据库:
_ = sqlx.MustConnect("postgres", os.Getenv("DATABASE_URL"))
这样,我们就获取了一个数据库连接!
让我们添加一个函数来将城市插入到我们的数据库中。我们将使用之前的 LatLong
结构。
func insertCity(db *sqlx.DB, name string, latLong LatLong) error {
_, err := db.Exec("INSERT INTO cities (name, lat, long) VALUES ($1, $2, $3)", name, latLong.Latitude, latLong.Longitude)
return err
}
让我们将旧的 getLatLong
函数重命名为 fetchLatLong
并添加一个新的 getLatLong
函数,该函数使用数据库而不是外部 API:
func getLatLong(db *sqlx.DB, name string) (*LatLong, error) { var latLong *LatLong err := db.Get(&latLong, "SELECT lat, long FROM cities WHERE name = $1", name) if err == nil { return latLong, nil } latLong, err = fetchLatLong(name) if err != nil { return nil, err } err = insertCity(db, name, *latLong) if err != nil { return nil, err } return latLong, nil }
这里我们直接将 db
连接传递给 getLatLong
函数。在实际应用中,我们应该将数据库访问与API逻辑解耦,以使测试成为可能。我们可能还会使用内存缓存来避免不必要的数据库调用。这只是为了比较 Go 和 Rust 中的数据库访问。
我们需要更新我们的处理程序:
r.GET("/weather", func(c *gin.Context) {
city := c.Query("city")
// Pass in the db
latlong, err := getLatLong(db, city)
// ...
})
这样,我们就有了一个可用的 Web 服务,它将给定城市的纬度和经度存储在数据库中,并在后续请求时从那里获取它。
最后一点是向我们的 Web 服务添加一些中间件。我们已经从 Gin 免费获得了一些不错的日志记录。
让我们添加一个基本身份验证中间件并保护我们的 /stats
端点,我们将使用它来打印最后的搜索查询。
r.GET("/stats", gin.BasicAuth(gin.Accounts{
"forecast": "forecast",
}), func(c *gin.Context) {
// rest of the handler
}
)
就这样!
专业提示:您还可以将路由分组在一起,以便一次对多个路由应用身份验证。
以下是从数据库中获取最后搜索查询的逻辑:
func getLastCities(db *sqlx.DB) ([]string, error) {
var cities []string
err := db.Select(&cities, "SELECT name FROM cities ORDER BY id DESC LIMIT 10")
if err != nil {
return nil, err
}
return cities, nil
}
现在让我们连接 /stats
端点来打印最后的搜索查询:
r.GET("/stats", gin.BasicAuth(gin.Accounts{
"forecast": "forecast",
}), func(c *gin.Context) {
cities, err := getLastCities(db)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.HTML(http.StatusOK, "stats.html", cities)
})
我们的 stats.html
模板非常简单:
<!DOCTYPE html> <html> <head> <title>Latest Queries</title> </head> <body> <h1>Latest Lat/Long Lookups</h1> <table border="1"> <tr> <th>Cities</th> </tr> {{ range . }} <tr> <td>{{ . }}</td> </tr> {{ end }} </table> </body> </html>
这样,我们就有了一个可以运行的web服务!恭喜!
我们取得了以下成就:
/stats
端点上的最后一个搜索查询/stats
端点的基本身份验证对于几行代码来说,这已经是相当多的功能了!让我们看看 Rust 表现如何!
从历史上看,Rust 对于 Web 服务并没有一个好的支持。有一些框架,但它们的级别相当低。直到 async/await 的出现,Rust Web 生态系统才真正起飞。突然间,无需垃圾收集器且具有无所畏惧的并发性就可以编写高性能的 Web 服务。
我们将了解 Rust 与 Go 在效率、性能和安全性方面的比较。但首先,我们需要选择一个 Web 框架。
如果您希望更好地了解 Rust Web 框架及其优缺点,我们最近对 Rust Web 框架进行了深入研究。
出于本文的目的,我们考虑两个 Web 框架:Actix 和 Axum。
Actix 是 Rust 社区中非常流行的 Web 框架。它基于 Actor 模型,并在底层使用 async/await。在基准测试中,它经常被评为世界上最快的 Web 框架之一。
另一方面,Axum 是一个基于 tower 的新 Web 框架,tower 是一个用于构建异步服务的库。它正在迅速流行。它也是基于async/await。
两个框架在人体工程学和性能方面非常相似。它们都支持中间件和路由。对于我们的网络服务来说,它们都是不错的选择,但我们会选择 Axum,因为它与生态系统的其他部分紧密结合,并且最近得到了很多关注。
让我们从 cargo new forecast
开始项目,并将以下依赖项添加到 Cargo.toml
中。 (我们还需要一些,但我们稍后会添加它们。)
[dependencies]
# web framework
axum = "0.6.20"
# async HTTP client
reqwest = { version = "0.11.20", features = ["json"] }
# serialization/deserialization for JSON
serde = "1.0.188"
# database access
sqlx = "0.7.1"
# async runtime
tokio = { version = "1.32.0", features = ["full"] }
让我们为我们的 Web 服务创建一个小框架,它的作用不大。
use std::net::SocketAddr; use axum::{routing::get, Router}; // basic handler that responds with a static string async fn index() -> &'static str { "Index" } async fn weather() -> &'static str { "Weather" } async fn stats() -> &'static str { "Stats" } #[tokio::main] async fn main() { let app = Router::new() .route("/", get(index)) .route("/weather", get(weather)) .route("/stats", get(stats)); let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); axum::Server::bind(&addr) .serve(app.into_make_service()) .await .unwrap(); }
main
函数非常简单。我们创建一个路由器并将其绑定到一个套接字地址。 index
、 weather
和 stats
函数是我们的处理程序。它们是返回字符串的异步函数。稍后我们将用实际逻辑替换它们。
让我们使用 cargo run
运行 Web 服务,看看会发生什么。
$ curl localhost:3000
Index
$ curl localhost:3000/weather
Weather
$ curl localhost:3000/stats
Stats
好吧,可以运行。让我们向处理程序添加一些实际逻辑。
在我们继续之前,我想提一下 axum 有一些粗糙的地方。例如。如果忘记把处理程序函数标记为 async
,它会报出很多错误。因此,如果您遇到 Handler<_, _> is not implemented
错误,请添加 axum-macros crate并使用 #[axum_macros::debug_handler]
注释您的处理程序。这将为您提供更好的错误消息。
让我们编写一个函数,从外部 API 获取给定城市的纬度和经度。
以下是表示 API 响应的结构:
use serde::Deserialize;
pub struct GeoResponse {
pub results: Vec<LatLong>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct LatLong {
pub latitude: f64,
pub longitude: f64,
}
与 Go 相比,我们不使用标签来指定字段名称。相反,我们使用 serde 中的 #[derive(Deserialize)]
属性来自动派生结构的 Deserialize
特征。这些派生宏非常强大,允许我们用很少的代码做很多事情,包括处理类型的解析错误。这是 Rust 中非常常见的模式。
让我们使用新类型来获取给定城市的纬度和经度:
async fn fetch_lat_long(city: &str) -> Result<LatLong, Box<dyn std::error::Error>> {
let endpoint = format!(
"https://geocoding-api.open-meteo.com/v1/search?name={}&count=1&language=en&format=json",
city
);
let response = reqwest::get(&endpoint).await?.json::<GeoResponse>().await?;
response
.results
.get(0)
.cloned()
.ok_or("No results found".into())
}
该代码比 Go 版本稍微简洁一些。我们不必编写 if err != nil
构造,因为我们可以使用 ?
运算符来传播错误。这也是强制性的,因为每个步骤都会返回一个 Result
类型。如果我们不处理错误,我们将无法访问该值。
最后一部分可能看起来有点陌生:
response
.results
.get(0)
.cloned()
.ok_or("No results found".into())
这里发生了一些事情:
response.results.get(0)
返回 Option<&LatLong>
。它是 Option
,因为如果向量为空, get
函数可能会返回 None
。cloned()
拷贝Option
内的值并将 Option<&LatLong>
转换为 Option<LatLong>
。这是必要的,因为我们想要返回 LatLong
而不是引用。否则,我们必须在函数签名中添加生命周期说明符,这会降低代码的可读性。ok_or("No results found".into())
将 Option<LatLong>
转换为 Result<LatLong, Box<dyn std::error::Error>>
。如果 Option
为 None
,则会返回错误信息。 into()
函数将字符串转换为 Box<dyn std::error::Error>
。另一种写法是:
match response.results.get(0) {
Some(lat_long) => Ok(lat_long.clone()),
None => Err("No results found".into()),
}
您喜欢哪个版本只是品味问题。
Rust 是一种基于表达式的语言,这意味着我们不必使用 return
从函数返回值。相反,返回函数的最后一个值。
我们现在可以更新 weather
函数以使用 fetch_lat_long
。
我们的第一次尝试可能如下所示:
async fn weather(city: String) -> String {
println!("city: {}", city);
let lat_long = fetch_lat_long(&city).await.unwrap();
format!("{}: {}, {}", city, lat_long.latitude, lat_long.longitude)
}
首先,我们将城市打印到控制台,然后获取纬度和经度并unwrap(即“unwrap”)结果。如果结果错误,程序就会出现恐慌。这并不理想,但我们稍后会修复它。
然后,我们使用纬度和经度创建一个字符串并返回它。
让我们运行该程序,看看会发生什么:
curl -v "localhost:3000/weather?city=Berlin"
* Trying 127.0.0.1:3000...
* Connected to localhost (127.0.0.1) port 3000 (#0)
> GET /weather?city=Berlin HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/8.1.2
> Accept: */*
>
* Empty reply from server
* Closing connection 0
curl: (52) Empty reply from server
此外,我们得到这个输出:
city:
city
参数为空。what???
问题是我们使用 String
类型作为 city
参数。此类型不是有效的提取器(extractor)。
我们可以使用 Query
提取器来代替:
async fn weather(Query(params): Query<HashMap<String, String>>) -> String {
let city = params.get("city").unwrap();
let lat_long = fetch_lat_long(&city).await.unwrap();
format!("{}: {}, {}", *city, lat_long.latitude, lat_long.longitude)
}
这能用,但不是很常用。我们必须 unwrap
Option
才能获取 城市。我们还需要将 *city
传递给 format!
宏以获取值而不是引用。 (这在 Rust 术语中称为“解引用”。)
我们可以创建一个表示查询参数的结构:
#[derive(Deserialize)]
pub struct WeatherQuery {
pub city: String,
}
然后我们可以使用这个结构作为提取器(extractor)并避免 unwrap
:
async fn weather(Query(params): Query<WeatherQuery>) -> String {
let lat_long = fetch_lat_long(¶ms.city).await.unwrap();
format!("{}: {}, {}", params.city, lat_long.latitude, lat_long.longitude)
}
整洁多了!它比 Go 版本稍微复杂一些,但它也更类型安全。您可以想象我们可以向结构添加约束以添加验证。例如,我们可能要求城市的长度至少为 3 个字符。
现在介绍 weather
函数中的 unwrap
。理想情况下,如果找不到城市,我们会返回错误。我们可以通过更改返回类型来做到这一点。
在 axum 中,任何实现 IntoResponse
的内容都可以从处理程序返回,但是建议返回具体类型,因为[返回 impl IntoResponse
时有一些注意事项](https:// docs.rs/axum/latest/axum/response/index.html)
在我们的例子中,我们可以返回 Result
类型:
async fn weather(Query(params): Query<WeatherQuery>) -> Result<String, StatusCode> {
match fetch_lat_long(¶ms.city).await {
Ok(lat_long) => Ok(format!(
"{}: {}, {}",
params.city, lat_long.latitude, lat_long.longitude
)),
Err(_) => Err(StatusCode::NOT_FOUND),
}
}
如果未找到城市,这将返回 404
状态代码。我们使用 match
来匹配 fetch_lat_long
的结果。如果是 Ok
,我们将天气作为 String
返回。如果是 Err
,我们返回 StatusCode::NOT_FOUND
。
我们还可以使用 map_err
函数将错误转换为 StatusCode
:
async fn weather(Query(params): Query<WeatherQuery>) -> Result<String, StatusCode> {
let lat_long = fetch_lat_long(¶ms.city)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
Ok(format!(
"{}: {}, {}",
params.city, lat_long.latitude, lat_long.longitude
))
}
这种变体的优点是我们的控制流更加线性:我们立即处理错误,然后可以继续正常的路径。另一方面,需要一段时间才能习惯这些组合器模式,直到它们成为第二种天性。
在 Rust 中,通常有多种方法可以做事。您喜欢哪个版本只是品味问题。一般来说,不要想太多,保持简单就行。
无论如何,让我们测试一下程序:
curl "localhost:3000/weather?city=Berlin"
Berlin: 52.52437, 13.41053
和
curl -I "localhost:3000/weather?city=abcdedfg"
HTTP/1.1 404 Not Found
让我们编写第二个函数,它将返回给定纬度和经度的天气:
async fn fetch_weather(lat_long: LatLong) -> Result<String, Box<dyn std::error::Error>> {
let endpoint = format!(
"https://api.open-meteo.com/v1/forecast?latitude={}&longitude={}&hourly=temperature_2m",
lat_long.latitude, lat_long.longitude
);
let response = reqwest::get(&endpoint).await?.text().await?;
Ok(response)
}
在这里,我们发出 API 请求并以 String
形式返回原始响应正文。
我们可以扩展我们的处理程序以连续进行两个调用:
async fn weather(Query(params): Query<WeatherQuery>) -> Result<String, StatusCode> {
let lat_long = fetch_lat_long(¶ms.city)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
let weather = fetch_weather(lat_long)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(weather)
}
这可行,但它会从 Open Meteo API 返回原始响应正文。让我们解析响应并返回类似于 Go 版本的数据。
提醒一下,这是 Go 的定义:
type WeatherResponse struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Timezone string `json:"timezone"`
Hourly struct {
Time []string `json:"time"`
Temperature2m []float64 `json:"temperature_2m"`
} `json:"hourly"`
}
这是 Rust 版本:
#[derive(Deserialize, Debug)]
pub struct WeatherResponse {
pub latitude: f64,
pub longitude: f64,
pub timezone: String,
pub hourly: Hourly,
}
#[derive(Deserialize, Debug)]
pub struct Hourly {
pub time: Vec<String>,
pub temperature_2m: Vec<f64>,
}
在这样做的同时,还可以定义需要的其他结构:
#[derive(Deserialize, Debug)]
pub struct WeatherDisplay {
pub city: String,
pub forecasts: Vec<Forecast>,
}
#[derive(Deserialize, Debug)]
pub struct Forecast {
pub date: String,
pub temperature: String,
}
现在可以将响应主体解析为结构:
async fn fetch_weather(lat_long: LatLong) -> Result<WeatherResponse, Box<dyn std::error::Error>> {
let endpoint = format!(
"https://api.open-meteo.com/v1/forecast?latitude={}&longitude={}&hourly=temperature_2m",
lat_long.latitude, lat_long.longitude
);
let response = reqwest::get(&endpoint).await?.json::<WeatherResponse>().await?;
Ok(response)
}
让我们调整处理程序。使其编译的最简单方法是返回 String
:
async fn weather(Query(params): Query<WeatherQuery>) -> Result<String, StatusCode> { let lat_long = fetch_lat_long(¶ms.city) .await .map_err(|_| StatusCode::NOT_FOUND)?; let weather = fetch_weather(lat_long) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let display = WeatherDisplay { city: params.city, forecasts: weather .hourly .time .iter() .zip(weather.hourly.temperature_2m.iter()) .map(|(date, temperature)| Forecast { date: date.to_string(), temperature: temperature.to_string(), }) .collect(), }; Ok(format!("{:?}", display)) }
请注意我们如何将解析逻辑与处理程序逻辑混合在一起。让我们通过将解析逻辑移至构造函数中来清理一下:
impl WeatherDisplay { /// Create a new `WeatherDisplay` from a `WeatherResponse`. fn new(city: String, response: WeatherResponse) -> Self { let display = WeatherDisplay { city, forecasts: response .hourly .time .iter() .zip(response.hourly.temperature_2m.iter()) .map(|(date, temperature)| Forecast { date: date.to_string(), temperature: temperature.to_string(), }) .collect(), }; display } }
这是一个开始。我们的处理程序现在看起来像这样:
async fn weather(Query(params): Query<WeatherQuery>) -> Result<String, StatusCode> {
let lat_long = fetch_lat_long(¶ms.city)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
let weather = fetch_weather(lat_long)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let display = WeatherDisplay::new(params.city, weather);
Ok(format!("{:?}", display))
}
这已经好一点了。令人分心的是 map_err
样板代码。我们可以通过引入自定义错误类型来删除它。例如,我们可以按照 axum
存储库中的示例,无论如何使用一个流行的错误处理包:
cargo add anyhow
Let’s copy the code from the example into our project:
让我们将示例中的代码复制到我们的项目中:
// Make our own error that wraps `anyhow::Error`. struct AppError(anyhow::Error); // Tell axum how to convert `AppError` into a response. impl IntoResponse for AppError { fn into_response(self) -> Response { ( StatusCode::INTERNAL_SERVER_ERROR, format!("Something went wrong: {}", self.0), ) .into_response() } } // This enables using `?` on functions that return `Result<_, anyhow::Error>` to turn them into // `Result<_, AppError>`. That way you don't need to do that manually. impl<E> From<E> for AppError where E: Into<anyhow::Error>, { fn from(err: E) -> Self { Self(err.into()) } }
您不必完全理解这段代码。可以说,这将为应用程序设置错误处理,这样我们就不必在处理程序中处理它。
我们必须调整 fetch_lang_long
和 fetch_weather
函数以返回 Result
和 anyhow::Error
:
async fn fetch_lat_long(city: &str) -> Result<LatLong, anyhow::Error> {
let endpoint = format!(
"https://geocoding-api.open-meteo.com/v1/search?name={}&count=1&language=en&format=json",
city
);
let response = reqwest::get(&endpoint).await?.json::<GeoResponse>().await?;
response.results.get(0).cloned().context("No results found")
}
和
async fn fetch_weather(lat_long: LatLong) -> Result<WeatherResponse, anyhow::Error> {
// code stays the same
}
以添加依赖项并添加用于错误处理的附加样板为代价,我们设法简化了我们的处理程序:
async fn weather(Query(params): Query<WeatherQuery>) -> Result<String, AppError> {
let lat_long = fetch_lat_long(¶ms.city).await?;
let weather = fetch_weather(lat_long).await?;
let display = WeatherDisplay::new(params.city, weather);
Ok(format!("{:?}", display))
}
axum
没有附带模板引擎。我们必须自己选择一个。我通常使用 tera 或 Askama,稍微偏爱 askama
,因为它支持编译时语法检查。这样,您就不会意外地在模板中引入拼写错误。模板中使用的每个变量都必须在代码中定义。
# Enable axum support
cargo add askama --features=with-axum
# I also needed to add this to make it compile
cargo add askama_axum
让我们创建一个 templates
目录并添加一个 weather.html
模板,类似于我们之前创建的 Go 表模板:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <title>Weather</title> </head> <body> <h1>Weather for {{ city }}</h1> <table> <thead> <tr> <th>Date</th> <th>Temperature</th> </tr> </thead> <tbody> {% for forecast in forecasts %} <tr> <td>{{ forecast.date }}</td> <td>{{ forecast.temperature }}</td> </tr> {% endfor %} </tbody> </table> </body> </html>
让我们将 WeatherDisplay
结构转换为 Template
:
#[derive(Template, Deserialize, Debug)]
#[template(path = "weather.html")]
struct WeatherDisplay {
city: String,
forecasts: Vec<Forecast>,
}
处理程序变成:
async fn weather(Query(params): Query<WeatherQuery>) -> Result<WeatherDisplay, AppError> {
let lat_long = fetch_lat_long(¶ms.city).await?;
let weather = fetch_weather(lat_long).await?;
Ok(WeatherDisplay::new(params.city, weather))
}
到达这里需要做一些工作,但我们现在已经很好地分离了关注点,没有太多的样板代码。
如果您在 http://localhost:3000/weather?city=Berlin
打开浏览器,您应该会看到天气表。
添加我们的输入很容易。我们可以使用与 Go 版本完全相同的 HTML:
<form action="/weather" method="get"> <!DOCTYPE html> <html> <head> <title>Weather Forecast</title> </head> <body> <h1>Weather Forecast</h1> <form action="/weather" method="get"> <label for="city">City:</label> <input type="text" id="city" name="city" /> <input type="submit" value="Submit" /> </form> </body> </html> </form>
这是处理程序:
#[derive(Template)]
#[template(path = "index.html")]
struct IndexTemplate;
async fn index() -> IndexTemplate {
IndexTemplate
}
让我们继续将纬度和经度存储在数据库中。
我们将使用 sqlx 进行数据库访问。这是一个非常受欢迎的包,支持多个数据库。在我们的例子中,我们将使用 Postgres,就像在 Go 版本中一样。
Add this to your Cargo.toml
:
将其添加到您的 Cargo.toml
中:
sqlx = { version = "0.7", features = [
"runtime-tokio-rustls",
"macros",
"any",
"postgres",
] }
需要将 DATABASE_URL
环境变量添加到 .env
文件中:
export DATABASE_URL="postgres://forecast:forecast@localhost:5432/forecast?sslmode=disable"
如果 Postgres 尚未运行,您可以使用 Go 部分中的相同 Docker 代码片段来启动它。
这样,调整代码以使用数据库。首先是 main
函数:
#[tokio::main] async fn main() -> anyhow::Result<()> { let db_connection_str = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?; let pool = sqlx::PgPool::connect(&db_connection_str) .await .context("can't connect to database")?; let app = Router::new() .route("/", get(index)) .route("/weather", get(weather)) .route("/stats", get(stats)) .with_state(pool); let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); axum::Server::bind(&addr) .serve(app.into_make_service()) .await?; Ok(()) }
变化如下:
DATABASE_URL
环境变量并在 main
中读取它。sqlx::PgPool::connect
创建一个数据库连接池。pool
传递给 with_state
以使其可供所有处理程序使用。在每个路由中,可以(但不必)像这样访问数据库池:
async fn weather(
Query(params): Query<WeatherQuery>,
State(pool): State<PgPool>,
) -> Result<WeatherDisplay, AppError> {
let lat_long = fetch_lat_long(¶ms.city).await?;
let weather = fetch_weather(lat_long).await?;
Ok(WeatherDisplay::new(params.city, weather))
}
要了解有关 State
的更多信息,请查看文档。
为了使我们的数据可以从数据库中获取,我们需要向结构添加 FromRow
特征:
#[derive(sqlx::FromRow, Deserialize, Debug, Clone)]
pub struct LatLong {
pub latitude: f64,
pub longitude: f64,
}
让我们添加一个函数来从数据库中获取纬度和经度:
async fn get_lat_long(pool: &PgPool, name: &str) -> Result<LatLong, anyhow::Error> { let lat_long = sqlx::query_as::<_, LatLong>( "SELECT lat AS latitude, long AS longitude FROM cities WHERE name = $1", ) .bind(name) .fetch_optional(pool) .await?; if let Some(lat_long) = lat_long { return Ok(lat_long); } let lat_long = fetch_lat_long(name).await?; sqlx::query("INSERT INTO cities (name, lat, long) VALUES ($1, $2, $3)") .bind(name) .bind(lat_long.latitude) .bind(lat_long.longitude) .execute(pool) .await?; Ok(lat_long) }
最后,让我们更新 weather
路由以使用新函数:
async fn weather(
Query(params): Query<WeatherQuery>,
State(pool): State<PgPool>,
) -> Result<WeatherDisplay, AppError> {
let lat_long = fetch_lat_long(¶ms.city).await?;
let weather = fetch_weather(lat_long).await?;
Ok(WeatherDisplay::new(params.city, weather))
}
就是这样!我们现在有了一个带有数据库后端的可用 Web 应用程序。功能与之前相同,但现在我们缓存纬度和经度。
比Go 版本中缺少的最后一个功能是 /stats
api。请记住,它显示最近的查询并且支持基本身份验证。
让我们从基本的身份验证开始。
我花了一段时间才弄清楚如何做到这一点。 axum 有许多身份验证库,但有关如何进行基本身份验证的信息很少。
我最终编写了一个自定义中间件,这将
Authorization
标头WWW-Authenticate
标头,指示浏览器显示登录对话框。这是代码:
/// A user that is authorized to access the stats endpoint. /// /// No fields are required, we just need to know that the user is authorized. In /// a production application you would probably want to have some kind of user /// ID or similar here. struct User; #[async_trait] impl<S> FromRequestParts<S> for User where S: Send + Sync, { type Rejection = axum::http::Response<axum::body::Body>; async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> { let auth_header = parts .headers .get("Authorization") .and_then(|header| header.to_str().ok()); if let Some(auth_header) = auth_header { if auth_header.starts_with("Basic ") { let credentials = auth_header.trim_start_matches("Basic "); let decoded = base64::decode(credentials).unwrap_or_default(); let credential_str = from_utf8(&decoded).unwrap_or(""); // Our username and password are hardcoded here. // In a real app, you'd want to read them from the environment. if credential_str == "forecast:forecast" { return Ok(User); } } } let reject_response = axum::http::Response::builder() .status(StatusCode::UNAUTHORIZED) .header( "WWW-Authenticate", "Basic realm=\"Please enter your credentials\"", ) .body(axum::body::Body::from("Unauthorized")) .unwrap(); Err(reject_response) } }
FromRequestParts 是一个允许我们从请求中提取数据的特征。还有 FromRequest,它消耗整个请求正文,因此只能为处理程序运行一次。在我们的例子中,我们只需要读取 Authorization
标头,因此 FromRequestParts
就足够了。
美妙之处在于,我们可以简单地将 User
类型添加到任何处理程序中,它将从请求中提取用户:
async fn stats(user: User) -> &'static str {
"We're authorized!"
}
现在了解 /stats
api的实际逻辑。
#[derive(Template)] #[template(path = "stats.html")] struct StatsTemplate { pub cities: Vec<City>, } async fn get_last_cities(pool: &PgPool) -> Result<Vec<City>, AppError> { let cities = sqlx::query_as::<_, City>("SELECT name FROM cities ORDER BY id DESC LIMIT 10") .fetch_all(pool) .await?; Ok(cities) } async fn stats(_user: User, State(pool): State<PgPool>) -> Result<StatsTemplate, AppError> { let cities = get_last_cities(&pool).await?; Ok(StatsTemplate { cities }) }
最后,我们来谈谈部署。
由于这两种语言都编译为静态链接的二进制文件,因此它们可以托管在任何虚拟机 (VM) 或虚拟专用服务器 (VPS) 上。这是令人惊奇的,因为这意味着如果您愿意,您可以在裸机上本机运行您的应用程序。
另一种选择是使用容器,它在隔离的环境中运行您的应用程序。它们非常受欢迎,因为它们易于使用并且几乎可以部署在任何地方。
对于 Golang,您可以使用任何支持运行静态二进制文件或容器的云提供商。更受欢迎的选项之一是 Google Cloud Run。
当然,您也可以使用容器来运送 Rust,但还有其他选择。当然,其中之一就是 Shuttle,它的工作方式与其他服务不同:您不需要构建 Docker 映像并将其推送到注册表。相反,您只需将代码推送到 Git 存储库,Shuttle 就会为您构建并运行二进制文件。
借助 Rust 的过程宏,您可以通过附加功能快速增强代码。
只需在 main 函数上使用 #[shuttle_runtime::main]
即可开始:
#[shuttle_runtime::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Rest of your code goes here
}
首先,安装 Shuttle CLI 和依赖项。
您可以使用 Cargo binstall,这是一个 Cargo 插件,旨在安装来自 crates.io 的二进制文件。首先,确保您已安装该插件。之后,您将能够安装 Shuttle CLI:
cargo binstall cargo-shuttle
cargo add shuttle-axum shuttle-runtime
让我们修改 main
函数以使用 Shuttle。请注意,我们不再需要端口绑定,因为 Shuttle 会为我们处理这个问题!我们只需将路由器交给它,它就会处理剩下的事情。
#[shuttle_runtime::main]
async fn main() -> shuttle_axum::ShuttleAxum {
let db_connection_str = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
let pool = sqlx::PgPool::connect(&db_connection_str)
.await
.context("can't connect to database")?;
let router = Router::new()
.route("/", get(index))
.route("/weather", get(weather))
.route("/stats", get(stats))
.with_state(pool);
Ok(router.into())
}
接下来,让我们设置生产 postgres 数据库。也有一个宏。
cargo add shuttle-shared-db --features=postgres
然后
#[shuttle_runtime::main]
async fn main(#[shuttle_shared_db::Postgres] pool: PgPool) -> shuttle_axum::ShuttleAxum {
pool.execute(include_str!("../schema.sql"))
.await
.context("Failed to initialize DB")?;
let router = Router::new()
.route("/", get(index))
.route("/weather", get(weather))
.route("/stats", get(stats))
.with_state(pool);
Ok(router.into())
}
看到关于架构的部分了吗?这就是我们如何使用现有的表定义来初始化数据库。还通过 sqlx 和 sqlx-cli 支持迁移。
我们摆脱了很多样板代码,现在可以轻松部署我们的应用程序。
# We only need to run this once
cargo shuttle project start
# Run as often as you like
cargo shuttle deploy
When it’s done, it will print the URL to the service. It should work just like before, but now it’s running on a server in the cloud.
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。