В этой статье мы узнаем, как построить простой API для подсчета просмотров профиля на GitHub с помощью Rust, Actix и MongoDB. Вы можете рассматривать его как учебник по созданию типичного проекта HTTP API с нуля.
Идея сбора количества просмотров проста. Нам нужно встроить изображение отслеживания в страницу github, которую пользователи смогут просматривать и отображать собранную статистику. Для отображения собранной статистики мы воспользуемся проектом shields
который позволяет создавать бейджи с пользовательскими поставщиками данных.
Предварительные условия
Прежде всего, вам необходимо установить соответствующие инструменты для начала разработки. Вот минимальный список:
- Rust Language — Rust SDK для сборки и запуска приложений
- VSCode — редактор для изменения исходного кода приложения
- Rust Analyzer — расширение VSCode, которое обеспечивает лучший опыт разработки с Rust
- Docker Desktop — для создания или использования образов Docker.
Docker также полезен для локальной разработки приложений в случае, если вам нужен экземпляр базы данных без установки ее в операционную систему.
Настройка проекта
Давайте создадим проект Rust с помощью этой команды cargo:
cargo new counter
Теперь вы можете открыть папку проекта в VSCode
и добавить зависимости, необходимые для разработки HTTP API. В этом проекте зависимостями будут:
- Actix — популярный веб-фреймворк на языке Rust
- MongoDB — драйвер Rust для MongoDB
- Serde — библиотека Rust для сериализации/десериализации JSON.
Вы можете сделать это либо редактируя файл Cargo.toml
, либо с помощью этой команды в директории проекта:
cargo add actix-web mongodb serde
Вам также необходимо изменить флаги функций serde
, чтобы разрешить использование макроса derive
для более декларативной сериализации.
Таким образом, раздел зависимостей будет выглядеть следующим образом:
[dependencies]
actix-web = "4.1.0"
mongodb = "2.3.0"
serde = { version = "1.0.140", features = ["derive"] }
База данных
Прежде чем мы начнем писать, нам понадобится экземпляр MongoDB
для хранения данных приложения. Мы создадим его, используя официальный образ Docker из реестра DockerHub.
Давайте создадим Docker-контейнер для локальной разработки:
docker run -d --name local-mongo
-p 27017:27017
-e MONGO_INITDB_ROOT_USERNAME=admin
-e MONGO_INITDB_ROOT_PASSWORD=pass
mongo:latest
После этого вы можете подключиться к локальной базе данных MongoDB с помощью строки подключения mongodb://admin:pass@localhost:27017
.
Чтобы разрешить приложению доступ к базе данных, необходимо добавить строку подключения в файл config.toml
в директории .cargo
проекта:
[env]
DATABASE_URL = "mongodb://admin:pass@localhost:27017"
Эта команда также может понадобиться для (повторного) запуска контейнера MongoDB:
docker restart local-mongo
Структура проекта
В этом проекте у нас будет два слоя для организации кода. Первый слой будет представлять собой сервис данных, который мы будем использовать для работы с коллекцией MongoDB для добавления и подсчета просмотров профиля GitHub. Второй слой будет представлять конечные точки API для отслеживания просмотров и получения собранной статистики.
Давайте создадим сервис данных с этими двумя функциями, описанными выше:
// service.rs
// This structure represents a view event of a GitHub or Web page.
#[derive(Serialize, Deserialize)]
pub struct View {
// The unique tracker name of this view event.
name: String,
// The date time of this view event.
date: DateTime,
}
// The data service to add views and collect stats.
#[derive(Clone)]
pub struct ViewService {
collection: Collection<View>,
}
impl ViewService {
// Create an instance of the ViewService struct from a given db collection.
pub fn new(collection: Collection<View>) -> Self {
Self { collection }
}
// Register a view event in the storage.
pub async fn add_view<T: AsRef<str>>(&self, name: T) -> Result<InsertOneResult> {
let view = View {
name: name.as_ref().to_owned(),
date: DateTime::now(),
};
self.collection.insert_one(view, None).await
}
// Collect the view counts for a give unique tracker name.
pub async fn get_view_count<T: AsRef<str>>(&self, name: T) -> Result<u64> {
self.collection
.count_documents(doc! {"name": name.as_ref()}, None)
.await
}
}
И слой конечных точек для нашего API:
// endpoint.rs
// The `shields.io` custom endpoint contract.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Shield {
schema_version: i32,
label: String,
message: String,
cache_seconds: Option<u32>,
}
impl Shield {
pub fn new(label: String, message: String) -> Self {
Self {
schema_version: 1,
label,
message,
cache_seconds: Some(300),
}
}
}
#[derive(Deserialize)]
pub struct ViewParams {
name: String,
label: Option<String>,
}
#[derive(Deserialize)]
pub struct AddViewParams {
name: String,
}
// Count the view events for a given unique tracker name.
// The response is compatible with the `shields.io` project.
// GET /views?name=NAME&label=LABEL
#[get("/views")]
pub async fn get_view_count(app_state: Data<AppState>, query: Query<ViewParams>) -> impl Responder {
let ViewParams { name, label } = query.into_inner();
if name.is_empty() {
return HttpResponse::BadRequest().finish();
}
match app_state.view_service.get_view_count(name.as_str()).await {
Ok(count) => {
let label = label.unwrap_or_else(|| String::from("Views"));
let message = count.to_string();
let shield = Shield::new(label, message);
HttpResponse::Ok().json(shield)
}
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
// Render a tracking pixel and register a view in the storage.
// GET /tracker?name=NAME
#[get("/tracker")]
pub async fn add_view(app_state: Data<AppState>, query: Query<AddViewParams>) -> impl Responder {
// Statically load the svg pixel image from the external file during compilation.
// No need for escaping and other funny stuff.
const PIXEL: &str = include_str!("pixel.svg");
const SVG_MIME: &str = "image/svg+xml";
// Disable caching to prevent GitHub or any other proxy to cache the rendered image.
const CACHE_CONTROL: (&str, &str) = (
"Cache-Control",
"max-age=0, no-cache, no-store, must-revalidate",
);
let AddViewParams { name } = query.into_inner();
if name.is_empty() {
return HttpResponse::BadRequest().finish();
}
let _ = app_state.view_service.add_view(name).await;
HttpResponse::Ok()
.append_header(CACHE_CONTROL)
.content_type(SVG_MIME)
.body(PIXEL)
}
Давайте завернем все вместе:
// main.rs
// The settings to run an application instance.
struct Settings {
/// The web server port to run on.
port: u16,
/// The MongoDB database url.
database_url: String,
}
impl Settings {
// Create an instance of the Settings struct
// from the environment variables.
pub fn from_env() -> Self {
let port: u16 = env::var("PORT")
.expect("PORT expected")
.parse()
.expect("PORT must be a number");
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL expected");
Self { port, database_url }
}
}
// The shareable state for accessing
// across different parts of the application.
pub struct AppState {
view_service: ViewService,
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
// Read the application settings from the env.
let Settings { port, database_url } = Settings::from_env();
// Create the database connection for the application.
let options = ClientOptions::parse(database_url).await.unwrap();
let client = Client::with_options(options).unwrap();
let db = client.database("counter_db");
let view_collection = db.collection::<View>("views");
// Initialize and start the web server.
HttpServer::new(move || {
// Create the shareable state for the application.
let view_service = ViewService::new(view_collection.clone());
let app_state = AppState { view_service };
// Create the application with the shareable state and
// wired-up API endpoints.
App::new()
.app_data(Data::new(app_state))
.service(endpoint::get_view_count)
.service(endpoint::add_view)
})
.bind(("0.0.0.0", port))?
.run()
.await
}
Чтобы запустить веб-сервер, просто соберите и запустите приложение:
cargo run
Заключение
Итак, теперь вы можете вызывать конечные точки API и создавать собственные значки для отображения статистики просмотров.
Материалы:
- Пример сбора и отображения просмотров профиля GitHub.
- Полный исходный код можно найти здесь.
Надеюсь, вы найдете этот урок полезным.