Rust 与 Go Web开发实战比较


这两种语言都可以用来编写快速可靠的 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、 EchoChi 。

选择哪一个是个人喜好问题。一些经验丰富的 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 框架: ActixAxum

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:

参数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(&params.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(&params.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(&params.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(&params.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(&params.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(&params.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

让我们将示例中的代码复制到我们的项目中:


// 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(&params.city).await?;
    let weather = fetch_weather(lat_long).await?;
    let display = WeatherDisplay::new(params.city, weather);
    Ok(format!("{:?}", display))
}

模板
axum没有附带模板引擎。我们必须自己选择一个。我通常使用teraassama,并稍有偏好,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(&params.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(&params.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(&params.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 相同的功能,并编写我们自己的小型中间件。
  • 由于我们使用了自己的错误类型和 ? 运算符,所以最终的处理程序代码没有令人分心的错误处理。这使得代码非常易读,但代价是必须编写额外的适配器逻辑。