这两种语言都可以用来编写快速可靠的 Web 服务。另一方面,它们实现这一目标的方法截然不同,很难找到对两种语言都公平的良好比较。
这篇文章是我试图向您概述 Go 和 Rust 之间的差异,重点是 Web 开发。我们将比较语法、Web 生态系统以及它们处理典型 Web 任务(如路由、中间件、模板等)的方式。我们还将快速了解两种语言的并发模型以及它们如何影响您编写 Web 应用程序的方式。
Go 和 Rust 之间的许多比较都集中在语法和语言特性上。但最终,重要的是如何轻松地将它们用于重要的项目。
由于我们是一家平台即服务提供商,因此我们认为通过向您展示如何用两种语言构建小型 Web 服务,我们可以做出最大的贡献。我们将使用两种语言的相同任务和流行库来并排比较解决方案,以便您做出自己的决定。
我们将讨论以下主题:
我们将省略客户端渲染或迁移等主题,只关注服务器端。任务
选择一个代表 Web 开发的任务并不容易:一方面,我们希望保持它足够简单,以便我们可以专注于语言功能和库。另一方面,我们希望确保任务不会太简单,以便我们可以展示如何在现实环境中使用语言功能和库。
我们决定建立一个天气预报服务。用户应该能够输入城市名称并获取该城市当前的天气预报。该服务还应该显示最近搜索过的城市列表。
随着我们扩展服务,我们将添加以下功能:
- 一个简单的 UI 显示天气预报
- 用于存储最近搜索的城市的数据库
天气 API
对于天气预报,我们将使用Open-Meteo API,因为它是开源的、易于使用,并且为非商业用途提供慷慨的免费套餐,每天最多可处理 10,000 个请求。
我们将使用这两个 API 端点:
- 用于获取城市坐标的GeoCoding API 。
- 天气预报API,用于获取给定坐标的天气预报。
Go ( omgo ) 和 Rust ( openmeteo )都有库,我们将在生产服务中使用它们。然而,为了进行比较,我们希望了解如何用两种语言发出“原始”HTTP 请求并将响应转换为惯用的数据结构。
Go Web 服务
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 struct形式返回该城市的坐标。
请注意我们是如何在每个步骤后处理错误的:我们会检查 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 标记告诉 JSON 解码器如何将 JSON 字段映射到 struct 字段。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() 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) }
|
输出:
Latitude: 51.508530, Longitude: -0.125740 Weather: {"latitude":51.5,"longitude":-0.120000124, ... }
|
我们得到了伦敦的天气预报。让我们把它作为一项网络服务来提供吧:路由
路由是网络框架最基本的任务之一。首先,将 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") // ... }) ```go
Which one you want to use depends on your use case. In our case, we want to submit the city name from a form in the end, so we will use a query parameter.
```go 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, ... }
模板
我们有了端点,但原始 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 }
|
日期处理是通过内置的时间包完成的。要了解有关 Go 中日期处理的更多信息,请查看这篇 "Go by Example "文章。
我们扩展路由处理程序,以渲染 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/*")
最后,我们可以在视图目录下创建一个模板文件 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 文档)。
这样,我们就有了一个可用的网络服务,它能以 HTML 页面的形式返回给定城市的天气预报!
哦!也许我们还想创建一个带有输入框的索引页面,让我们输入城市名称并显示该城市的天气预报。
让我们为索引页面添加一个新的路由处理程序:
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>
|
现在,我们可以启动网络服务,并在浏览器中打开 http://localhost:8080
数据库访问
我们的服务会在每次请求时从外部 API 获取指定城市的经纬度。这在一开始可能没什么问题,但最终我们可能希望将结果缓存到数据库中,以避免不必要的 API 调用。
为此,让我们为网络服务添加一个数据库。我们将使用 PostgreSQL 作为数据库,并使用 pgx 作为数据库驱动程序。
首先,我们创建一个名为 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 自动递增整数。否则,我们将不得不在插入时自己生成 ID。为了加快速度,我们还将在 name 列上添加一个索引。
使用 Docker 或任何云提供商可能是最简单的方法。最后,您只需要一个数据库 URL,将其作为环境变量传递给网络服务即可。
我们不会在这里详细介绍数据库的设置,但有一个简单的方法可以让 PostgreSQL 数据库在本地通过 Docker 运行:
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"
|
不过,一旦有了数据库,我们就需要在 go.mod 文件中添加 sqlx 依赖项:
go get github.com/jmoiron/sqlx
现在,我们可以通过 DATABASE_URL 环境变量中的连接字符串,使用 sqlx 软件包连接数据库:
_ = 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 }
|
在这里,我们直接将数据库连接传递给 getLatLong 函数。在实际应用中,我们应该将数据库访问与 API 逻辑解耦,以便进行测试。我们可能还会使用内存缓存来避免不必要的数据库调用。以上只是对 Go 和 Rust 中数据库访问的比较。
我们需要更新处理程序:
r.GET("/weather", func(c *gin.Context) { city := c.Query("city") // Pass in the db latlong, err := getLatLong(db, city) // ... })
|
这样,我们就有了一个可以运行的网络服务,它可以将给定城市的经度和纬度存储在数据库中,并在后续请求中从数据库中获取。
中间件
最后一点是为我们的网络服务添加一些中间件。我们已经从 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>
|
这样,我们就有了一个可以正常工作的网络服务!祝贺你
我们实现了以下目标:
- 从外部应用程序接口获取指定城市经纬度的网络服务
- 将经纬度存储到数据库中
- 在后续请求中从数据库中获取经纬度
- 在 /stats 端点上打印最近的搜索查询结果
- 使用基本认证保护 /stats 端点
- 使用中间件记录请求
- 渲染 HTML 的模板
几行代码就能实现如此多的功能!让我们看看 Rust 是如何做到的!Rust Web 服务
从历史上看,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,因为它与生态系统的其他部分紧密结合,并且最近得到了很多关注。
路由
让我们以 a 启动项目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是我们的处理程序。它们是返回字符串的异步函数。稍后我们将用实际逻辑替换它们。让我们运行 Web 服务,cargo run看看会发生什么。
$ curl localhost:3000 Index $ curl localhost:3000/weather Weather $ curl localhost:3000/stats Stats
|
好吧,那行得通。让我们向处理程序添加一些实际逻辑。Axum宏
在我们继续之前,我想提一下 axum 有一些粗糙的地方。例如,如果您忘记使处理程序函数异步,它会对您大喊大叫。因此,如果遇到Handler<_, _> is not implemented错误,请添加axum-macros箱并使用#[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 相比,我们不使用标签来指定字段名称。相反,我们使用该#[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>。这是必要的,因为我们要返回的是纬度而不是引用。否则,我们就必须在函数签名中添加一个 lifetime 指定符,这会降低代码的可读性。
- 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) }
|
首先,我们将城市打印到控制台,然后获取纬度和经度并解包(即“解包”)结果。如果结果错误,程序就会出现恐慌。这并不理想,但我们稍后会修复它。然后,我们使用纬度和经度创建一个字符串并返回它。
让我们运行该程序,看看会发生什么:
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为空。发生了什么?
问题是我们使用参数String的类型city 。该类型不是有效的提取器。
我们可以使用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, }
|
然后我们可以使用这个结构作为提取器并避免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 个字符。
现在谈谈天气函数中的解包。理想情况下,如果找不到城市,我们就会返回错误信息。我们可以通过改变返回类型来做到这一点。
在 axum 中,任何实现 IntoResponse 的函数都可以从处理程序中返回,但我们建议返回一个具体的类型,因为[返回 impl IntoResponse 时有一些注意事项] (https://docs.rs/axum/latest/axum/response/index.html)
在本例中,我们可以返回一个结果类型:
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,我们将以字符串形式返回天气信息。如果是 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 } }```
|
处理器代码:
```rust 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并使用无论如何,一个流行的错误处理包:
让我们将示例中的代码复制到我们的项目中:
// 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带有 an 的 a 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或assama,并稍有偏好,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>
|
这是处理程序:
#[derive(Template)] #[template(path = "index.html")] struct IndexTemplate;
async fn index() -> IndexTemplate { IndexTemplate }
|
我们与 Go 版本达到了“功能对等”。让我们继续将纬度和经度存储在数据库中。数据库访问
我们将使用sqlx进行数据库访问。这是一个非常受欢迎的包,支持多个数据库。在我们的例子中,我们将使用 Postgres,就像在 Go 版本中一样。
将其添加到您的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 版本并按照那里的说明进行操作。
接下来,让我们调整代码以使用数据库。
一、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。
- 然后我们将池传递给以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端点。请记住,它显示最近的查询并且支持基本身份验证。
让我们从基本的身份验证开始。
我花了一段时间才弄清楚如何做到这一点。axum 有许多身份验证库,但有关如何进行基本身份验证的信息很少。
我最终编写了一个自定义中间件,这将
- 检查请求是否有Authorizationheader
- 如果是,检查标头是否包含有效的用户名和密码
- 如果是,则返回“未经授权”的响应和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。
#[derive(Template)] #[template(path = "stats.html")] struct StatsTemplate { pub cities: Vec<City>, }
async fn stats(_user: User, State(pool): State<PgPool>) -> Result<StatsTemplate, AppError> { let cities = get_last_cities(&pool).await?; Ok(StatsTemplate { cities }) }
|
部署
最后,我们来谈谈部署。
对于 Golang,您可以使用任何支持 Docker 的云提供商。我们不会在这里详细介绍,因为有很多服务支持这一点。
您可以对 Rust 执行相同的操作,但还有一些其他选项。当然,其中之一是Shuttle,它的工作方式与其他服务不同:您不需要构建 Docker 映像并将其推送到注册表。相反,您只需将代码推送到 git 存储库,Shuttle 就会为您运行二进制文件。
借助 Rust 的过程宏,您可以通过附加功能快速增强代码。
哪种语言适合您?
Go:
- 易学、快速、适合网络服务
- 包含电池。我们只用标准库就能做很多事情。
- 我们唯一依赖的是 Gin,它是一个非常流行的网络框架。
Rust:
- 快速、安全、不断发展的网络服务生态系统
- 没有电池。我们不得不添加大量的依赖项,以获得与 Go 相同的功能,并编写我们自己的小型中间件。
- 由于我们使用了自己的错误类型和 ? 运算符,所以最终的处理程序代码没有令人分心的错误处理。这使得代码非常易读,但代价是必须编写额外的适配器逻辑。