bel1k0v 8 ноября 2023 в 11:13 MSK
109 просмотров

Rust против Go: практическое сравнение

https://www.shuttle.rs/blog/2023/09/27/rust-vs-go-comparison

Rust против Go — тема, которая постоянно возникает, и о ней уже много написано. Частично это связано с тем, что разработчики ищут информацию, которая поможет им решить, какой язык использовать для своего следующего (веб) проекта.

В конце концов, оба языка можно использовать для написания быстрых и надежных веб-сервисов. С другой стороны, их подходы к достижению этой цели совершенно разные, и трудно найти хорошее сравнение, которое было бы справедливо по отношению к обоим языкам. Этот пост — моя попытка дать вам обзор различий между Go и Rust с акцентом на веб-разработку. Мы сравним синтаксис, веб-экосистему, способы решения типичных веб-задач, таких как роутинг, middleware, шаблоны и многое другое. Мы также кратко рассмотрим модели параллелизма обоих языков и то, как они влияют на способ написания веб-приложений.

К концу этой статьи вы должны иметь четкое представление о том, какой язык вам подходит. Несмотря на то, что мы осознаем свои собственные предубеждения и предпочтения, мы постараемся быть максимально объективными и подчеркнуть сильные и слабые стороны обоих языков.

Создание небольшого веб-сервиса

Многие сравнения Go и Rust сосредоточены на синтаксисе и особенностях языка. Но в конечном итоге важно то, насколько легко их использовать для нетривиального проекта.

Поскольку мы являемся поставщиком платформы как услуги, мы считаем, что можем внести наибольший вклад, показав вам, как создать небольшой веб-сервис на обоих языках. Мы будем использовать одну и ту же задачу и популярные библиотеки для обоих языков, чтобы сравнить решения, чтобы вы могли принять собственное решение.

Мы рассмотрим следующие темы:

  • Роутинг
  • Шаблонизация
  • Доступ к базе данных
  • Деплой

Мы оставим такие темы, как рендеринг на стороне клиента или миграцию, и сосредоточимся только на стороне сервера.

Задание

Выбрать задачу, репрезентативную для веб-разработки, непросто: с одной стороны, мы хотим, чтобы она была достаточно простой, чтобы мы могли сосредоточиться на функциях языка и библиотеках. С другой стороны, мы хотим убедиться, что задача не слишком проста, чтобы мы могли показать, как использовать возможности языка и библиотеки в реалистичных условиях.

Мы решили создать сервис прогноза погоды. Пользователь должен иметь возможность ввести название города и получить текущий прогноз погоды для этого города. Сервис также должен показать список недавно найденных городов.

По мере расширения сервиса мы добавим следующие функции:

  • Простой пользовательский интерфейс для отображения прогноза погоды
  • База данных для хранения недавно найденных городов.

API погоды

Для прогноза погоды мы будем использовать API Open-Meteo, поскольку он имеет открытый исходный код, прост в использовании и предлагает щедрый уровень бесплатного использования для некоммерческих целей до 10 000 запросов в день.

Мы будем использовать эти два эндпоинта API:

Существуют библиотеки как для Go (omgo), так и для Rust (openmeteo), которые мы будем использовать в производственном сервисе. Однако ради сравнения мы хотим посмотреть, что нужно, чтобы сделать «необработанный» HTTP-запрос на обоих языках и преобразовать ответ в идиоматическую структуру данных.

Веб-сервис Go

Выбор веб-фреймворка

Изначально созданный для упрощения создания веб-сервисов, Go имеет ряд отличных пакетов, связанных с вебом. Если стандартная библиотека не соответствует вашим потребностям, на выбор можно выбрать ряд популярных сторонних веб-фреймворков, таких как Gin , Echo или Chi.

Какой из них выбрать – вопрос личных предпочтений. Некоторые опытные разработчики Go предпочитают использовать стандартную библиотеку и добавлять поверх нее библиотеку для роутинга, например Chi. Другие предпочитают подход, требующий большего количества плюшек, и используют полнофункциональную среду, такую ​​​​как Gin или Echo.

Оба варианта хороши, но для целей этого сравнения мы выберем Gin, поскольку это один из самых популярных фреймворков, который поддерживает все функции, необходимые для нашего метеорологического сервиса.

Выполнение HTTP-запросов

Начнем с простой функции, которая отправляет HTTP-запрос к Open Meteo API и возвращает ответ в виде строки:

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сообщают декодеру 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)
}

Это напечатает следующее:

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 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"

Шаблоны

Мы получили наш эндпоинт, но необработанный JSON обычному пользователю не очень полезен. В реальном приложении мы, вероятно, отдаём ответ JSON на эндпоинте API (скажем /api/v1/weather/:city) и добавим отдельный эндпоинт, который возвращает 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. Взгляните!

Далее мы определяем функцию, которая преобразует необработанный ответ JSON от API погоды в нашу новую структуру 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, прочтите эту статью «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/*")

Наконец, мы можем создать файл шаблона weather.html в каталоге views:

<!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 в нашем браузере:

Прогноз погоды в Лондоне выглядит так. Это некрасиво, но... функционально! (И это работает без JavaScript и в терминальных браузерах!)

В качестве упражнения вы можете добавить некоторые стили к HTML-странице, но поскольку нас больше волнует серверная часть, мы оставим все как есть.

Доступ к базе данных

Наш сервис получает широту и долготу данного города из внешнего 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. В противном случае нам пришлось бы самим генерировать идентификаторы при вставке. Чтобы ускорить работу, мы также добавим индекс к столбцу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"

Однако, как только у нас есть база данных, нам нужно добавить зависимость 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
}

Давайте переименуем нашу старую функцию getLatLongfetchLatLong и добавим новую функцию 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)

    // ...
})

При этом у нас есть работающий веб-сервис, который хранит широту и долготу данного города в базе данных и извлекает их оттуда при последующих запросах.

Middleware

Последний шаг — добавить middleware к нашему веб-сервису. Мы уже получили неплохое логгирование от Gin бесплатно.

Давайте добавим middleware базовой аутентификации и защитим наш эндпоинт /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>

Latest QueriesИ теперь у нас есть работающий веб-сервис! Поздравляем!

Мы добились следующего:

  • Веб-сервис, который получает широту и долготу данного города из внешнего API.
  • Сохраняет широту и долготу в базе данных.
  • Извлекает широту и долготу из базы данных при последующих запросах.
  • Печатает последние поисковые запросы на эндпоинте /stats
  • Базовая аутентификация для защиты эндпоинта/stats
  • Использует middleware для регистрации запросов
  • Шаблоны для рендеринга HTML

Это довольно много функциональности для нескольких строк кода! Давайте посмотрим, как сложится с Rust!

Веб-сервис Rust

Исторически сложилось так, что у Rust не было хорошей истории для веб-сервисов. Фреймворков было несколько, но они были довольно низкоуровневыми. Лишь недавно, с появлением async/await, веб-экосистема Rust начала по-настоящему развиваться. Внезапно стало возможным писать высокопроизводительные веб-сервисы без сборщика мусора и с бесстрашным параллелизмом.

Мы увидим, как Rust сравнивается с Go с точки зрения эргономики, производительности и безопасности. Но сначала нам нужно выбрать веб-фреймворк.

Какой веб-фреймворк?

Если вы хотите получить более полное представление о веб-фреймворках Rust, а также об их сильных и слабых сторонах, то недавно мы провели углубленное изучение веб-фреймворков Rust .

Для целей этой статьи мы рассматриваем два веб-фреймворка: Actix и Axum.

Actix — очень популярный веб-фреймворк в сообществе Rust. Он основан на модели актора и использует async/await под капотом. В тестах он регулярно оказывается одним из самых быстрых веб-фреймворков в мире .

Axum, с другой стороны, — это новый веб-фреймворк, основанный на Tower , библиотеке для создания асинхронных сервисов. Он быстро набирает популярность. Он также основан на async/await.

Обе платформы очень похожи с точки зрения эргономики и производительности. Они обе поддерживают middleware и роутинг. Каждый из них был бы хорошим выбором для нашего веб-сервиса, но мы выберем 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"] }

Давайте создадим небольшой скелет нашего веб-сервиса, который мало что делает.

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и посмотрим, что произойдет.

$ 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, а не ссылку. В противном случае нам пришлось бы добавить к сигнатуре функции спецификатор времени жизни, и это сделало бы код менее читабельным.
  • ok_or("No results found".into()) преобразует Option в Result>. Если Option - None, он вернет сообщение об ошибке. Функция into() преобразует строку в Box.

Альтернативный способ написать это:

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 символов.

Теперь оunwrap в функции weather. В идеале мы бы возвращали ошибку, если город не найден. Мы можем сделать это, изменив тип возвращаемого значения.

В 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(&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, мы возвращаем погоду в формате String. Если это 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)
}

Это сработает, но вернет необработанное тело ответа из API Open Meteo. Давайте разберем ответ и вернем данные, аналогичные версии 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
	}
}```

That's a start. Our handler now looks like this:

```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репозитория и использовать anyhow, популярный крейт для обработки ошибок:

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 с 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 не поставляется с шаблонизатором. Мы должны выбрать его сами. Обычно я использую либо 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(&params.city).await?;
	let weather = fetch_weather(lat_long).await?;
	Ok(WeatherDisplay::new(params.city, weather))
}

Пришлось приложить немало усилий, чтобы прийти к этому, но теперь у нас есть хорошее разделение задач без слишком большого количества шаблонов.

Если вы откроете браузер на http://localhost:3000/weather?city=Berlin, вы должны увидеть таблицу погоды.

Добавить маску ввода легко. Мы можем использовать тот же HTML-код, который мы использовали для версии Go:

<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
}

Мы достигли «паритета функций» с версией 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))
}

Вот и все! Теперь у нас есть работающее веб-приложение с серверной базой данных. Поведение идентично предыдущему, но теперь мы кэшируем широту и долготу.

Middleware

Последняя функция, которой нам не хватает в нашей версии Go, — это эндпоинт /stats. Помните, что он показывает последние запросы и находится за базовой аутентификацией.

Начнем с базовой аутентификации.

Мне потребовалось некоторое время, чтобы понять, как это сделать. Для axum существует множество библиотек аутентификации, но очень мало информации о том, как выполнить базовую аутентификацию.

В итоге я написал собственное middleware, которое

  • проверяет, есть ли у запроса заголовок 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.

#[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 })
}

Деплой

Наконец, давайте поговорим о деплое.

Для Golang вы можете использовать любого облачного провайдера, поддерживающего Docker. Мы не будем здесь вдаваться в подробности, поскольку существует множество сервисов, которые это поддерживают.

Вы можете сделать то же самое с Rust, но есть и другие варианты. Конечно, одним из них является Shuttle, и его работа отличается от других сервисов: вам не нужно создавать образ Docker и помещать его в реестр. Вместо этого вы просто отправляете свой код в репозиторий git, и Shuttle запустит для вас двоичный файл.

Благодаря процедурным макросам Rust вы можете быстро расширить свой код дополнительными функциями.

Все, что нужно для начала, — это добавить #[shuttle_runtime::main] в вашу main функцию:

#[shuttle_runtime::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
	// Rest of your code goes here
}

Для начала установите 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

Когда это будет сделано, он напечатает URL-адрес службы. Он должен работать так же, как и раньше, но теперь он работает на сервере в облаке. 🚀

Какой язык подходит вам?

Go

  • простой в освоении, быстрый, хорош для веб-сервисов
  • плюшки в комплекте. Мы многое сделали, используя только стандартную библиотеку.
  • Нашей единственной зависимостью был Gin, очень популярный веб-фреймворк.

Rust

  • быстрый, безопасный, развивающаяся экосистема веб-сервисов
  • в комплекте нет плюшек. Нам пришлось добавить множество зависимостей, чтобы получить ту же функциональность, что и в Go, и написать собственное middleware.
  • окончательный код обработчика не отвлекал от обработки ошибок, поскольку мы использовали собственный тип ошибки и оператор ?. Это делает код очень читаемым за счет необходимости писать дополнительную логику адаптера.

Это ставит вопрос...

Rust лучше Go или Rust заменит Go?

Лично я большой поклонник Rust и считаю, что это отличный язык для веб-сервисов. Но в экосистеме все еще есть много неровностей и недостающих частей.

Если вы только начинаете новый проект и вы и ваша команда можете свободно выбирать язык для использования, вы можете задаться вопросом: «Должен ли я использовать Rust или Go в 2023 году?».

Это зависит от сроков реализации проекта и опыта вашей команды. Если вы хотите быстро начать работу, Go может быть лучшим выбором. Он предлагает среду разработки со всеми плюшками и отлично подходит для веб-приложений.

Однако не стоит недооценивать долгосрочные преимущества Rust. Его богатая система типов в сочетании с потрясающими механизмами обработки ошибок и проверками во время компиляции может помочь вам создавать приложения, которые не только быстры, но также надежны и расширяемы.

Что касается скорости разработки, Shuttle может существенно снизить операционную нагрузку, связанную с запуском кода Rust в рабочей среде. Как мы видели, вам не нужно писать Dockerfile, чтобы начать работу, и ваш код создается в облаке, что позволяет очень быстро выполнять циклы развертывания и итерации.

Так что, если вы ищете долгосрочное решение и готовы инвестировать в изучение Rust, я бы сказал, что это отличный выбор.

Предлагаю вам сравнить оба решения и решить для себя, какое из них вам больше нравится.

В любом случае было весело создать один и тот же проект на двух разных языках и посмотреть на различия в идиомах и экосистеме. Несмотря на то, что конечный результат тот же, путь, которым мы к нему пришли, был совершенно разным.