Rust Vs Go:从头构建一个web服务

时间:2024-02-22 18:57:06

在这里插入图片描述

Go 和 Rust 之间的许多比较都强调它们在语法和初始学习曲线上的差异。然而,最终的决定性因素是重要项目的易用性。

“Rust 与 Go”争论

Rust vs Go 是一个不断出现的话题,并且已经有很多关于它的文章。部分原因是开发人员正在寻找信息来帮助他们决定下一个 Web 项目使用哪种语言,而这两种语言在这种情况下都经常被提及。我们环顾四周,但确实没有太多关于该主题的深入内容,因此开发人员只能自己解决这个问题,并冒着由于误导性原因而过早放弃某个选项的风险。

这两个社区都经常面临误解和偏见。一些人将 Rust 主要视为一种系统编程语言,质疑它是否适合 Web 开发。与此同时,其他人认为 Go 过于简单,怀疑它处理复杂 Web 应用程序的能力。然而,这些都只是表面的判断。

事实上,这两种语言都可以用来编写快速可靠的 Web 服务。然而,他们的方法截然不同,很难找到一个对两者都公平的比较。

这篇文章是我们试图通过用两种语言构建一个重要的现实世界中的应用程序来概述 Go 和 Rust 之间的差异,重点是 Web 开发。我们将超越语法,并仔细研究这些语言如何处理典型的 Web 任务,如路由、中间件、模板、数据库访问等。

读完本文后,您应该清楚哪种语言适合您。

尽管我们知道自己的偏见和偏好,但我们将尽力保持客观并强调两种语言的优点和缺点。

构建一个小型web服务

我们将讨论以下主题:

  • Routing 路由
  • Templating 模板
  • Database access 数据库访问
  • Deployment 部署

我们将省略客户端渲染或数据库迁移等主题,只关注服务器端。

任务

选择一个代表 Web 开发的任务并不容易:一方面,我们希望保持它足够简单,以便我们可以专注于语言功能和库。另一方面,我们希望确保任务不会太简单,以便我们可以展示如何在现实环境中使用语言功能和库。

我们决定建立天气预报服务。用户应该能够输入城市名称并获取该城市当前的天气预报。该服务还应该显示最近搜索过的城市列表。

随着我们扩展服务,我们将添加以下功能:

  • 一个简单的 UI 显示天气预报
  • 用于存储最近搜索的城市的数据库

The Weather API 天气 API

对于天气预报,我们将使用 Open-Meteo API,因为它是开源的、易于使用,并且为非商业用途提供慷慨的免费套餐,每天最多可处理 10,000 个请求。

我们将使用这两个 API 接口:

  • 用于获取城市坐标的 GeoCoding API
  • Weather Forecast API ,用于获取给定坐标的天气预报。

这两种语言都有现成的库可用,Go (omgo) 和 Rust (openmeteo) ,我们将在生产服务中使用它们。然而,为了进行比较,我们希望了解如何用两种语言发出“原始”HTTP 请求并将响应转换为常用的数据结构。

Go Web 服务

选择网络框架

Go 最初是为了简化构建 Web 服务而创建的,它拥有许多很棒的与 Web 相关的包。如果标准库不能满足您的需求,还有许多流行的第三方 Web 框架可供选择,例如 Gin、Echo 或 Chi。

选择哪一个是个人喜好问题。一些经验丰富的 Go 开发人员更喜欢使用标准库,并在其之上添加像 Chi 这样的路由库。其他人则更喜欢包含更多内置功能的方法,使用功能齐全的框架,例如 Gin 或 Echo。

这两个选项都很好,但为了比较的目的,我们将选择 Gin,因为它是最流行的框架之一,并且它支持我们的天气服务所需的所有功能。

HTTP 请求

让我们从一个简单的函数开始,该函数向 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 服务提供。

Routing 路由

路由是 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"
Templates 模板

我们完成了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:

index page

伦敦的天气预报是这样的。它不漂亮,但是…实用! (它无需 JavaScript 即可在终端浏览器中运行!)

forecast page

作为练习,您可以向 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 服务,它将给定城市的纬度和经度存储在数据库中,并在后续请求时从那里获取它。

Middleware 中间件

最后一点是向我们的 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)