Rust で学ぶ関数型プログラミング Part VI: 実践的アプリケーション構築¶
はじめに¶
Part V では並行処理(Arc、Mutex、チャネル)を学びました。Part VI では、これまで学んだ FP の概念を総動員して実践的なアプリケーションを構築します。
Scala では trait による抽象化、Resource によるリソース管理、そしてテスト戦略を学びますが、Rust でも同様のパターンを async-trait、RAII パターン、proptest を使って実現します。
第12章: 実践的なアプリケーション構築¶
12.1 ドメインモデルの定義¶
実際のアプリケーション開発では、ドメインモデルを適切に定義することが重要です。TravelGuide アプリケーションを例に見ていきます。
/// Newtype パターンで型安全な ID を定義
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct LocationId(pub String);
impl LocationId {
pub fn new(id: &str) -> Self {
Self(id.to_string())
}
}
/// ロケーション
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Location {
pub id: LocationId,
pub name: String,
pub population: i32,
}
/// アトラクション(観光地)
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Attraction {
pub name: String,
pub description: Option<String>,
pub location: Location,
}
/// 旅行ガイド - 最終的な出力モデル
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TravelGuide {
pub attraction: Attraction,
pub subjects: Vec<String>, // 関連するアーティストや映画
pub search_report: SearchReport, // 検索メタデータ
}
Scala と比較すると:
| Scala | Rust |
|---|---|
case class LocationId(value: String) |
struct LocationId(pub String) |
Option[String] |
Option<String> |
List[String] |
Vec<String> |
12.2 DataAccess トレイト - 依存性の抽象化¶
外部データソースへのアクセスをトレイトで抽象化します。これにより、テスト時にスタブ実装に差し替えることができます。
use async_trait::async_trait;
/// データアクセス層のトレイト
#[async_trait]
pub trait DataAccess: Send + Sync {
/// アトラクションを検索
async fn find_attractions(
&self,
name: &str,
ordering: AttractionOrdering,
limit: usize,
) -> Vec<Attraction>;
/// ロケーションからアーティストを検索
async fn find_artists_from_location(
&self,
location_id: &LocationId,
limit: usize,
) -> Result<Vec<MusicArtist>, String>;
/// ロケーションに関する映画を検索
async fn find_movies_about_location(
&self,
location_id: &LocationId,
limit: usize,
) -> Result<Vec<Movie>, String>;
}
Scala の trait と比較:
| Scala | Rust |
|---|---|
trait DataAccess[F[_]] |
#[async_trait] trait DataAccess |
def findAttractions(...): F[List[Attraction]] |
async fn find_attractions(...) -> Vec<Attraction> |
F[Either[String, List[A]]] |
Result<Vec<A>, String> |
12.3 テスト用スタブ実装¶
Builder パターンを使って柔軟にテストデータを設定できるスタブ実装を作成します。
pub struct StubDataAccess {
attractions: Vec<Attraction>,
artists: HashMap<LocationId, Vec<MusicArtist>>,
movies: HashMap<LocationId, Vec<Movie>>,
artists_error: Option<String>,
movies_error: Option<String>,
}
impl StubDataAccess {
pub fn new() -> Self {
Self {
attractions: vec![],
artists: HashMap::new(),
movies: HashMap::new(),
artists_error: None,
movies_error: None,
}
}
// Builder パターンでメソッドチェーン
pub fn with_attractions(mut self, attractions: Vec<Attraction>) -> Self {
self.attractions = attractions;
self
}
pub fn with_artists(mut self, location_id: LocationId, artists: Vec<MusicArtist>) -> Self {
self.artists.insert(location_id, artists);
self
}
pub fn with_artists_error(mut self, error: &str) -> Self {
self.artists_error = Some(error.to_string());
self
}
}
使用例:
#[tokio::test]
async fn test_travel_guide_with_error() {
let data = Arc::new(
StubDataAccess::new()
.with_attractions(vec![create_test_attraction()])
.with_artists_error("Artist service unavailable")
);
let guide = travel_guide(data.as_ref(), "Test").await;
assert!(guide.is_some());
assert!(!guide.unwrap().search_report.errors.is_empty());
}
12.4 キャッシュ付き DataAccess¶
デコレーターパターンを使って、既存の DataAccess にキャッシュ機能を追加します。
pub struct CachedDataAccess<D: DataAccess> {
inner: Arc<D>,
attractions_cache: Arc<RwLock<HashMap<String, Vec<Attraction>>>>,
}
impl<D: DataAccess> CachedDataAccess<D> {
pub fn new(inner: Arc<D>) -> Self {
Self {
inner,
attractions_cache: Arc::new(RwLock::new(HashMap::new())),
}
}
fn cache_key(name: &str, ordering: AttractionOrdering, limit: usize) -> String {
format!("{}-{:?}-{}", name, ordering, limit)
}
}
#[async_trait]
impl<D: DataAccess + 'static> DataAccess for CachedDataAccess<D> {
async fn find_attractions(
&self,
name: &str,
ordering: AttractionOrdering,
limit: usize,
) -> Vec<Attraction> {
let key = Self::cache_key(name, ordering, limit);
// キャッシュを確認(読み取りロック)
{
let cache = self.attractions_cache.read().await;
if let Some(cached) = cache.get(&key) {
return cached.clone();
}
}
// キャッシュにない場合は取得
let result = self.inner.find_attractions(name, ordering, limit).await;
// キャッシュに保存(書き込みロック)
{
let mut cache = self.attractions_cache.write().await;
cache.insert(key, result.clone());
}
result
}
// 他のメソッドは inner に委譲
}
12.5 TravelGuide アプリケーション¶
トレイトを使った依存性注入により、ビジネスロジックをテスト可能な形で実装します。
/// 旅行ガイドを生成
pub async fn travel_guide(
data: &dyn DataAccess,
attraction_name: &str,
) -> Option<TravelGuide> {
// アトラクションを検索
let attractions = data
.find_attractions(attraction_name, AttractionOrdering::ByLocationPopulation, 1)
.await;
let attraction = attractions.into_iter().next()?;
// 関連情報を並行して取得
let artists_result = data
.find_artists_from_location(&attraction.location.id, 2)
.await;
let movies_result = data
.find_movies_about_location(&attraction.location.id, 2)
.await;
// エラーを収集(失敗しても継続)
let mut errors = Vec::new();
if let Err(e) = &artists_result {
errors.push(e.clone());
}
if let Err(e) = &movies_result {
errors.push(e.clone());
}
// 成功した結果を結合
let artists = artists_result.unwrap_or_default();
let movies = movies_result.unwrap_or_default();
let subjects: Vec<String> = artists
.into_iter()
.map(|a| a.name)
.chain(movies.into_iter().map(|m| m.name))
.collect();
Some(TravelGuide::new(
attraction,
subjects,
SearchReport::new(1, errors),
))
}
12.6 純粋関数ユーティリティ¶
副作用のない純粋関数は、単体テストが容易で再利用性が高いです。
/// 人口でロケーションをフィルタリング
pub fn filter_popular_locations(locations: Vec<Location>, min_population: i32) -> Vec<Location> {
locations
.into_iter()
.filter(|loc| loc.population >= min_population)
.collect()
}
/// 人口順でソート
pub fn sort_by_population(mut locations: Vec<Location>) -> Vec<Location> {
locations.sort_by(|a, b| b.population.cmp(&a.population));
locations
}
/// 複数のサブジェクトを結合
pub fn combine_subjects(subjects_list: Vec<Vec<String>>) -> Vec<String> {
subjects_list.into_iter().flatten().collect()
}
/// 複数の SearchReport を集約
pub fn aggregate_reports(reports: Vec<SearchReport>) -> SearchReport {
let total_searched = reports.iter().map(|r| r.attractions_searched).sum();
let all_errors: Vec<String> = reports.into_iter().flat_map(|r| r.errors).collect();
SearchReport::new(total_searched, all_errors)
}
12.7 リソース管理パターン¶
Scala の Resource に相当する概念を Rust で実装します。
/// リソース管理のトレイト
pub trait Resource {
type Item;
fn use_resource<F, R>(&self, f: F) -> R
where
F: FnOnce(&Self::Item) -> R;
}
/// ファイルリソース
pub struct FileResource {
path: String,
}
impl Resource for FileResource {
type Item = Result<String, std::io::Error>;
fn use_resource<F, R>(&self, f: F) -> R
where
F: FnOnce(&Self::Item) -> R,
{
let content = std::fs::read_to_string(&self.path);
f(&content)
}
}
Rust の強みは、RAII(Resource Acquisition Is Initialization)パターンにより、リソースの解放が自動的に行われることです。
12.8 バリデーション¶
独自のバリデーション型を定義して、型安全なバリデーションを実装します。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Validation<T> {
Valid(T),
Invalid(Vec<String>),
}
impl<T> Validation<T> {
pub fn valid(value: T) -> Self {
Validation::Valid(value)
}
pub fn invalid(errors: Vec<String>) -> Self {
Validation::Invalid(errors)
}
pub fn is_valid(&self) -> bool {
matches!(self, Validation::Valid(_))
}
pub fn map<U, F>(self, f: F) -> Validation<U>
where
F: FnOnce(T) -> U,
{
match self {
Validation::Valid(v) => Validation::Valid(f(v)),
Validation::Invalid(e) => Validation::Invalid(e),
}
}
pub fn and_then<U, F>(self, f: F) -> Validation<U>
where
F: FnOnce(T) -> Validation<U>,
{
match self {
Validation::Valid(v) => f(v),
Validation::Invalid(e) => Validation::Invalid(e),
}
}
}
/// Location のバリデーション
pub fn validate_location(location: Location) -> Validation<Location> {
let mut errors = Vec::new();
if location.name.trim().is_empty() {
errors.push("Name cannot be empty".to_string());
}
if location.population < 0 {
errors.push("Population cannot be negative".to_string());
}
if errors.is_empty() {
Validation::valid(location)
} else {
Validation::invalid(errors)
}
}
12.9 プロパティベーステスト¶
proptest を使って、ランダムな入力に対する不変条件をテストします。
use proptest::prelude::*;
proptest! {
/// ソートは要素を保持する
#[test]
fn sort_by_population_preserves_elements(
populations in prop::collection::vec(0i32..10_000_000, 0..10)
) {
let locations: Vec<Location> = populations
.iter()
.enumerate()
.map(|(i, &pop)| Location::new(
LocationId::new(&format!("loc{}", i)),
&format!("City{}", i),
pop,
))
.collect();
let sorted = sort_by_population(locations.clone());
prop_assert_eq!(sorted.len(), locations.len());
}
/// ソート結果は降順
#[test]
fn sort_by_population_is_sorted(
populations in prop::collection::vec(0i32..10_000_000, 0..10)
) {
let locations: Vec<Location> = populations
.iter()
.enumerate()
.map(|(i, &pop)| Location::new(
LocationId::new(&format!("loc{}", i)),
&format!("City{}", i),
pop,
))
.collect();
let sorted = sort_by_population(locations);
for window in sorted.windows(2) {
prop_assert!(window[0].population >= window[1].population);
}
}
/// 空の名前は無効
#[test]
fn validate_location_empty_name_is_invalid(
population in 0i32..10_000_000
) {
let location = Location::new(LocationId::new("test"), "", population);
let result = validate_location(location);
prop_assert!(!result.is_valid());
}
/// 負の人口は無効
#[test]
fn validate_location_negative_population_is_invalid(
name in "[a-zA-Z]+",
population in i32::MIN..-1i32
) {
let location = Location::new(LocationId::new("test"), &name, population);
let result = validate_location(location);
prop_assert!(!result.is_valid());
}
}
Scala の ScalaCheck と比較:
| ScalaCheck | proptest |
|---|---|
forAll { (n: Int) => ... } |
proptest! { fn test(n in any::<i32>()) { ... } } |
Gen.choose(0, 100) |
0i32..100 |
Gen.listOfN(10, gen) |
prop::collection::vec(gen, 0..10) |
Prop.passed |
prop_assert!(true) |
まとめ¶
Part VI で学んだ重要なポイント:
- ドメインモデル: Newtype パターンで型安全性を確保
- トレイト抽象化:
async_traitで非同期メソッドを持つトレイトを定義 - 依存性注入: トレイトオブジェクト (
&dyn DataAccess) で実装を差し替え可能に - デコレーターパターン: キャッシュなどの横断的関心事を分離
- 純粋関数: テスタブルで再利用可能なユーティリティ
- バリデーション: 独自型でエラーを収集
- プロパティベーステスト: ランダム入力で不変条件を検証
Scala と Rust の対応表:
| 概念 | Scala | Rust |
|---|---|---|
| 非同期トレイト | trait DataAccess[F[_]] |
#[async_trait] trait DataAccess |
| テストスタブ | Stub extends DataAccess |
impl DataAccess for StubDataAccess |
| リソース管理 | Resource[IO, A] |
trait Resource / RAII |
| バリデーション | Validated[E, A] |
enum Validation<T> |
| プロパティテスト | ScalaCheck | proptest |