📖

クリーンアーキテクチャ入門 Part 3: 外部システムとの連携(Infrastructure層・Presentation層)

に公開

クリーンアーキテクチャ入門 Part 3: 外部システムとの連携(Infrastructure層・Presentation層)

はじめに

Part2でビジネスロジックの設計について学んだら、次は外部システムとの連携について詳しく学んでいきます。この記事では、Infrastructure層とPresentation層の実装に焦点を当て、データベース、外部API、HTTPレスポンスの処理について理解を深めていきます。

この記事で学べること:

  • Infrastructure層での外部システムとの連携
  • Presentation層でのHTTPリクエスト・レスポンスの処理
  • 技術的詳細の隠蔽方法
  • 外部APIとの連携例

Infrastructure層の詳細実装

Infrastructure層は、外部システムとの連携を担当し、技術的詳細を他の層に隠蔽します。

なぜ技術的詳細を隠蔽するのか?

  1. 他の層への影響を防ぐ:データベースの変更や外部APIの仕様変更が、ビジネスロジックに影響しない
  2. テストの容易さ:外部システムなしでビジネスロジックをテストできる
  3. 技術スタックの変更:MySQLからPostgreSQL、REST
    APIからGraphQLへの変更が容易
  4. 障害の局所化:外部システムの障害が他の層に波及しない

どの層にデータを返すのか?

  • Application層:リポジトリパターンを通じて、ドメインオブジェクトを返す
  • Domain層:インターフェースの契約に従って、純粋なドメインオブジェクトを返す
  • 外部システムの詳細は隠蔽:HTTPレスポンス、SQL結果、ファイルパスなどの技術的詳細は返さない

データベースリポジトリの実装

// src/infrastructure/repositories/task_repository_impl.rs
use async_trait::async_trait;
use sqlx::MySqlPool;
use crate::domain::entities::Task;
use crate::domain::repositories::TaskRepository;

pub struct TaskRepositoryImpl {
    pool: MySqlPool,
}

impl TaskRepositoryImpl {
    pub fn new(pool: MySqlPool) -> Self {
        Self { pool }
    }
}

#[async_trait]
impl TaskRepository for TaskRepositoryImpl {
    async fn save(&self, task: &Task) -> Result<Task, Box<dyn Error + Send + Sync>> {
        // 技術的詳細:SQLクエリの実行
        let query = "INSERT INTO tasks (title, description, created_at, status) VALUES (?, ?, ?, ?)";

        let result = sqlx::query(query)
            .bind(&task.title)
            .bind(&task.description)
            .bind(task.created_at)
            .bind(task.status.to_string())
            .execute(&self.pool)
            .await?;

        let id = result.last_insert_id() as i32;

        // ドメインオブジェクトとして返す(技術的詳細は隠蔽)
        Ok(Task {
            id: Some(id),
            title: task.title.clone(),
            description: task.description.clone(),
            created_at: task.created_at,
            status: task.status.clone(),
        })
    }

    async fn find_by_id(&self, id: i32) -> Result<Option<Task>, Box<dyn Error + Send + Sync>> {
        // 技術的詳細:SQLクエリの実行
        let row = sqlx::query_as!(
            TaskRow,
            "SELECT id, title, description, created_at, status FROM tasks WHERE id = ?",
            id
        )
        .fetch_optional(&self.pool)
        .await?;

        // データベースの行データをドメインオブジェクトに変換
        match row {
            Some(row) => Ok(Some(Task {
                id: Some(row.id),
                title: row.title,
                description: row.description,
                created_at: Some(row.created_at),
                status: TaskStatus::from_string(&row.status)?,
            })),
            None => Ok(None),
        }
    }
}

// データベース固有の構造体(技術的詳細)
#[derive(sqlx::FromRow)]
struct TaskRow {
    id: i32,
    title: String,
    description: String,
    created_at: DateTime<Utc>,
    status: String,
}

外部APIとの連携

// src/infrastructure/external_apis/notification_service.rs
use async_trait::async_trait;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use crate::domain::entities::Task;
use crate::domain::services::NotificationService;

pub struct ExternalNotificationServiceImpl {
    client: Client,
    api_base_url: String,
    api_key: String,
}

impl ExternalNotificationServiceImpl {
    pub fn new(api_base_url: String, api_key: String) -> Self {
        Self {
            client: Client::new(),
            api_base_url,
            api_key,
        }
    }
}

#[async_trait]
impl NotificationService for ExternalNotificationServiceImpl {
    async fn send_task_notification(
        &self,
        task: &Task,
        user_email: &str,
    ) -> Result<NotificationResult, Box<dyn Error + Send + Sync>> {
        // 技術的詳細:外部APIへのHTTPリクエスト
        let notification_data = NotificationRequest {
            to: user_email.to_string(),
            subject: format!("Task Update: {}", task.title),
            message: self.format_notification_message(task),
            priority: self.calculate_notification_priority(task),
        };

        let response = self.client
            .post(&format!("{}/notifications", self.api_base_url))
            .header("Authorization", format!("Bearer {}", self.api_key))
            .header("Content-Type", "application/json")
            .json(&notification_data)
            .send()
            .await?;

        // 外部APIのレスポンスを処理
        if response.status().is_success() {
            let api_response: NotificationResponse = response.json().await?;

            // 外部APIの詳細は隠蔽し、ドメイン層に必要な情報のみを返す
            if api_response.success {
                Ok(NotificationResult::Success {
                    task_id: task.id.unwrap(),
                    email: user_email.to_string(),
                    notification_id: api_response.notification_id,
                    sent_at: Utc::now(),
                })
            } else {
                Ok(NotificationResult::Failure {
                    task_id: task.id.unwrap(),
                    email: user_email.to_string(),
                    error: api_response.error.unwrap_or_else(|| "Unknown error".to_string()),
                    sent_at: Utc::now(),
                })
            }
        } else {
            Ok(NotificationResult::Failure {
                task_id: task.id.unwrap(),
                email: user_email.to_string(),
                error: format!("HTTP error: {}", response.status()),
                sent_at: Utc::now(),
            })
        }
    }
}

// 外部API固有の構造体(技術的詳細)
#[derive(Serialize)]
struct NotificationRequest {
    to: String,
    subject: String,
    message: String,
    priority: String,
}

#[derive(Deserialize)]
struct NotificationResponse {
    success: bool,
    error: Option<String>,
    notification_id: Option<String>,
    // 外部API固有のフィールド(技術的詳細)
    api_version: Option<String>,
    rate_limit_remaining: Option<i32>,
    response_time_ms: Option<u64>,
}

// ドメイン層に返す構造体(技術的詳細は隠蔽)
#[derive(Debug)]
pub struct NotificationResult {
    pub task_id: i32,
    pub email: String,
    pub notification_id: Option<String>, // 外部APIから取得したID
    pub sent_at: DateTime<Utc>, // 送信時刻
    pub success: bool,
    pub error: Option<String>,
}

外部APIからのデータ取得例

// src/infrastructure/external_apis/weather_service.rs
use async_trait::async_trait;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use crate::domain::entities::WeatherInfo;
use crate::domain::services::WeatherService;

pub struct ExternalWeatherServiceImpl {
    client: Client,
    api_base_url: String,
    api_key: String,
}

#[async_trait]
impl WeatherService for ExternalWeatherServiceImpl {
    async fn get_weather_info(
        &self,
        location: &str,
    ) -> Result<WeatherInfo, Box<dyn Error + Send + Sync>> {
        // 技術的詳細:外部APIへのHTTPリクエスト
        let response = self.client
            .get(&format!("{}/weather", self.api_base_url))
            .query(&[
                ("q", location),
                ("appid", &self.api_key),
                ("units", &"metric".to_string()),
            ])
            .send()
            .await?;

        // 外部APIのレスポンスを処理
        if response.status().is_success() {
            let api_response: WeatherApiResponse = response.json().await?;

            // 外部APIのデータをドメインオブジェクトに変換
            Ok(WeatherInfo {
                location: api_response.name,
                temperature: api_response.main.temp,
                humidity: api_response.main.humidity,
                description: api_response.weather.first()
                    .map(|w| w.description.clone())
                    .unwrap_or_default(),
                wind_speed: api_response.wind.speed,
                // 外部API固有のフィールドは隠蔽
                // api_response.sys.country, api_response.coord.lat などは使用しない
            })
        } else {
            Err(format!("Weather API error: {}", response.status()).into())
        }
    }
}

// 外部API固有の構造体(技術的詳細)
#[derive(Deserialize)]
struct WeatherApiResponse {
    name: String,
    main: MainWeatherData,
    weather: Vec<WeatherDescription>,
    wind: WindData,
    sys: SystemData,
    coord: Coordinates,
    // 外部API固有のフィールド
    dt: i64,
    timezone: i32,
    id: i32,
}

#[derive(Deserialize)]
struct MainWeatherData {
    temp: f64,
    humidity: i32,
    pressure: i32,
    temp_min: f64,
    temp_max: f64,
}

#[derive(Deserialize)]
struct WeatherDescription {
    description: String,
    icon: String,
    id: i32,
    main: String,
}

#[derive(Deserialize)]
struct WindData {
    speed: f64,
    deg: i32,
}

#[derive(Deserialize)]
struct SystemData {
    country: String,
    sunrise: i64,
    sunset: i64,
}

#[derive(Deserialize)]
struct Coordinates {
    lat: f64,
    lon: f64,
}

// ドメイン層に返す構造体(技術的詳細は隠蔽)
#[derive(Debug, Clone)]
pub struct WeatherInfo {
    pub location: String,
    pub temperature: f64,
    pub humidity: i32,
    pub description: String,
    pub wind_speed: f64,
    // 外部API固有のフィールドは含まない
    // 例:api_version, response_time_ms, rate_limit_remaining など
}

ファイルシステムとの連携

// src/infrastructure/file_system/task_export_service.rs
use async_trait::async_trait;
use std::path::PathBuf;
use tokio::fs;
use crate::domain::entities::Task;
use crate::domain::services::TaskExportService;

pub struct FileSystemTaskExportServiceImpl {
    export_directory: PathBuf,
}

impl FileSystemTaskExportServiceImpl {
    pub fn new(export_directory: PathBuf) -> Self {
        Self { export_directory }
    }
}

#[async_trait]
impl TaskExportService for FileSystemTaskExportServiceImpl {
    async fn export_tasks_to_csv(
        &self,
        tasks: &[Task],
        filename: &str,
    ) -> Result<ExportResult, Box<dyn Error + Send + Sync>> {
        // 技術的詳細:CSVファイルの生成
        let mut csv_content = String::new();
        csv_content.push_str("ID,Title,Description,Status,Created At\n");

        for task in tasks {
            csv_content.push_str(&format!(
                "{},{},{},{},{}\n",
                task.id.unwrap_or(0),
                escape_csv_field(&task.title),
                escape_csv_field(&task.description),
                task.status,
                task.created_at
                    .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
                    .unwrap_or_default(),
            ));
        }

        // 技術的詳細:ファイルの書き込み
        let file_path = self.export_directory.join(filename);
        fs::write(&file_path, csv_content).await?;

        // ドメイン層に必要な情報のみを返す(ファイルパスは隠蔽)
        Ok(ExportResult {
            success: true,
            exported_count: tasks.len(),
            file_size: fs::metadata(&file_path).await?.len() as usize,
        })
    }
}

// ドメイン層に返す構造体(技術的詳細は隠蔽)
#[derive(Debug)]
pub struct ExportResult {
    pub success: bool,
    pub exported_count: usize,
    pub file_size: usize,
}

fn escape_csv_field(field: &str) -> String {
    if field.contains(',') || field.contains('"') || field.contains('\n') {
        format!("\"{}\"", field.replace("\"", "\"\""))
    } else {
        field.to_string()
    }
}

Presentation層の詳細実装

Presentation層は、ユーザーインターフェースを担当し、HTTPリクエスト・レスポンスの処理を行います。

なぜコントローラーからの返却データを扱うのか?

  1. ビジネスロジックの結果をHTTP形式に変換:ドメインオブジェクトをJSONレスポンスに変換
  2. HTTPステータスコードの決定:ビジネスロジックの成功・失敗を適切なHTTPステータスに変換
  3. エラーメッセージの整形:ビジネスエラーをユーザーに分かりやすい形式で返す
  4. レスポンスヘッダーの設定:Content-Type、Location、Cache-Controlなどの設定

コントローラーの実装

// src/presentation/controllers/task_controller.rs
use async_trait::async_trait;
use std::sync::Arc;
use crate::application::use_cases::task::add_task::AddTaskUseCase;
use crate::presentation::view_models::task::TaskResponse;

pub struct TaskControllerImpl {
    add_task_use_case: Arc<AddTaskUseCase>,
}

impl TaskControllerImpl {
    pub fn new(add_task_use_case: Arc<AddTaskUseCase>) -> Self {
        Self { add_task_use_case }
    }

    pub async fn add_task(&self, request: AddTaskHttpRequest) -> Result<TaskResponse, Box<dyn Error + Send + Sync>> {
        // HTTPリクエストをドメインオブジェクトに変換
        let input = AddTaskInput {
            user_id: request.user_id,
            title: request.title,
            description: request.description,
        };

        // ユースケースを実行(Application層の呼び出し)
        let output = self.add_task_use_case.execute(input).await?;

        // コントローラーからの返却データをHTTPレスポンス形式に変換
        Ok(TaskResponse {
            success: true,
            data: Some(TaskData {
                id: output.id,
                title: output.title,
                description: output.description,
                status: output.status,
                created_at: output.created_at,
                priority: output.priority.0,
            }),
            error: None,
            timestamp: Utc::now().to_rfc3339(),
        })
    }

    pub async fn get_task(&self, task_id: i32) -> Result<TaskResponse, Box<dyn Error + Send + Sync>> {
        // ユースケースを実行
        let output = self.get_task_use_case.execute(task_id).await?;

        // コントローラーからの返却データを処理
        match output {
            Some(task) => Ok(TaskResponse {
                success: true,
                data: Some(TaskData::from(task)),
                error: None,
                timestamp: Utc::now().to_rfc3339(),
            }),
            None => Ok(TaskResponse {
                success: false,
                data: None,
                error: Some("Task not found".to_string()),
                timestamp: Utc::now().to_rfc3339(),
            }),
        }
    }
}

#[derive(Debug, Deserialize)]
pub struct AddTaskHttpRequest {
    pub user_id: i32,
    pub title: String,
    pub description: String,
}

HTTPハンドラーでのコントローラー返却データの処理

// src/presentation/handlers/task_handlers.rs
use axum::{
    extract::{Path, Query, State},
    http::StatusCode,
    response::Json,
};
use std::sync::Arc;
use crate::TaskAppState;

pub async fn add_task(
    State(state): State<Arc<TaskAppState>>,
    Json(request): Json<AddTaskHttpRequest>,
) -> impl axum::response::IntoResponse {
    // コントローラーを呼び出し
    match state.task_controller.add_task(request).await {
        Ok(response) => {
            // コントローラーからの返却データをHTTPレスポンスに変換
            let status_code = if response.success {
                StatusCode::CREATED
            } else {
                StatusCode::BAD_REQUEST
            };

            // レスポンスヘッダーの設定
            let headers = [
                ("Content-Type", "application/json"),
                ("Location", &format!("/tasks/{}", response.data.as_ref().unwrap().id)),
            ];

            (status_code, headers, Json(response))
        }
        Err(error) => {
            // エラーレスポンスの生成
            let error_response = ErrorResponse {
                success: false,
                error: error.to_string(),
                timestamp: Utc::now().to_rfc3339(),
            };

            (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
        }
    }
}

pub async fn get_task(
    State(state): State<Arc<TaskAppState>>,
    Path(task_id): Path<i32>,
) -> impl axum::response::IntoResponse {
    // コントローラーを呼び出し
    match state.task_controller.get_task(task_id).await {
        Ok(response) => {
            // コントローラーからの返却データを処理
            if response.success {
                // 成功時:200 OK
                (StatusCode::OK, Json(response))
            } else {
                // ビジネスエラー時:404 Not Found
                (StatusCode::NOT_FOUND, Json(response))
            }
        }
        Err(error) => {
            // システムエラー時:500 Internal Server Error
            let error_response = ErrorResponse {
                success: false,
                error: error.to_string(),
                timestamp: Utc::now().to_rfc3339(),
            };

            (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
        }
    }
}

pub async fn list_tasks(
    State(state): State<Arc<TaskAppState>>,
    Query(params): Query<ListTasksQuery>,
) -> impl axum::response::IntoResponse {
    // クエリパラメータをドメインオブジェクトに変換
    let input = ListTasksInput {
        user_id: params.user_id,
        page: params.page.unwrap_or(1),
        page_size: params.page_size.unwrap_or(10),
        status_filter: params.status,
        sort_by: params.sort_by.unwrap_or_else(|| "priority".to_string()),
    };

    // コントローラーを呼び出し
    match state.task_controller.list_tasks(input).await {
        Ok(response) => {
            // コントローラーからの返却データをHTTPレスポンスに変換
            let status_code = if response.success {
                StatusCode::OK
            } else {
                StatusCode::BAD_REQUEST
            };

            // ページネーション情報をヘッダーに追加
            let headers = [
                ("X-Total-Count", &response.pagination.total_items.to_string()),
                ("X-Total-Pages", &response.pagination.total_pages.to_string()),
                ("X-Current-Page", &response.pagination.current_page.to_string()),
            ];

            (status_code, headers, Json(response))
        }
        Err(error) => {
            let error_response = ErrorResponse {
                success: false,
                error: error.to_string(),
                timestamp: Utc::now().to_rfc3339(),
            };

            (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
        }
    }
}

レスポンス構造体の定義

// src/presentation/view_models/task/task_response.rs
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize)]
pub struct TaskResponse {
    pub success: bool,
    pub data: Option<TaskData>,
    pub error: Option<String>,
    // HTTPレスポンス固有の情報
    pub timestamp: String,
    pub request_id: Option<String>,
}

#[derive(Debug, Serialize)]
pub struct TaskData {
    pub id: i32,
    pub title: String,
    pub description: String,
    pub status: String,
    pub created_at: String,
    pub priority: u32,
    // ビジネスロジックの結果をHTTP形式に変換
    pub is_editable: bool,
    pub can_delete: bool,
}

#[derive(Debug, Serialize)]
pub struct ErrorResponse {
    pub success: bool,
    pub error: String,
    pub timestamp: String,
    // エラー情報の詳細
    pub error_code: Option<String>,
    pub details: Option<Vec<String>>,
}

// クエリパラメータの構造体
#[derive(Debug, Deserialize)]
pub struct ListTasksQuery {
    pub user_id: i32,
    pub page: Option<u32>,
    pub page_size: Option<u32>,
    pub status: Option<String>,
    pub sort_by: Option<String>,
}

プレゼンターでのデータ変換

// src/presentation/presenters/task_presenter.rs
use crate::domain::entities::Task;
use crate::presentation::view_models::task::{TaskData, ErrorResponse};

pub struct TaskPresenter;

impl TaskPresenter {
    // コントローラーからの返却データをHTTPレスポンス形式に変換
    pub fn present_task(&self, task: Task) -> TaskData {
        TaskData {
            id: task.id.unwrap(),
            title: task.title,
            description: task.description,
            status: task.status.to_string(),
            created_at: task.created_at
                .map(|dt| dt.to_rfc3339())
                .unwrap_or_default(),
            priority: 1, // デフォルト優先度
            is_editable: task.is_editable(),
            can_delete: task.status == TaskStatus::Pending,
        }
    }

    // 複数のタスクをHTTPレスポンス形式に変換
    pub fn present_tasks(&self, tasks: Vec<Task>) -> Vec<TaskData> {
        tasks.into_iter().map(|task| self.present_task(task)).collect()
    }

    // エラーをHTTPレスポンス形式に変換
    pub fn present_error(&self, error: &str) -> ErrorResponse {
        ErrorResponse {
            success: false,
            error: error.to_string(),
            timestamp: Utc::now().to_rfc3339(),
            error_code: self.extract_error_code(error),
            details: self.extract_error_details(error),
        }
    }

    // エラーコードの抽出
    fn extract_error_code(&self, error: &str) -> Option<String> {
        if error.contains("not found") {
            Some("TASK_NOT_FOUND".to_string())
        } else if error.contains("validation") {
            Some("VALIDATION_ERROR".to_string())
        } else {
            Some("INTERNAL_ERROR".to_string())
        }
    }

    // エラー詳細の抽出
    fn extract_error_details(&self, error: &str) -> Option<Vec<String>> {
        if error.contains("title") && error.contains("empty") {
            Some(vec!["Task title cannot be empty".to_string()])
        } else {
            None
        }
    }
}

Application層での外部APIデータの活用例

// src/application/use_cases/task/add_task_with_weather/add_task_with_weather_use_case.rs
use std::sync::Arc;
use crate::domain::entities::Task;
use crate::domain::repositories::TaskRepository;
use crate::domain::services::WeatherService;

pub struct AddTaskWithWeatherUseCase {
    task_repository: Arc<dyn TaskRepository + Send + Sync>,
    weather_service: Arc<dyn WeatherService + Send + Sync>,
}

impl AddTaskWithWeatherUseCase {
    pub fn new(
        task_repository: Arc<dyn TaskRepository + Send + Sync>,
        weather_service: Arc<dyn WeatherService + Send + Sync>,
    ) -> Self {
        Self {
            task_repository,
            weather_service,
        }
    }

    pub async fn execute(&self, input: AddTaskWithWeatherInput) -> Result<AddTaskWithWeatherOutput, Box<dyn Error + Send + Sync>> {
        // 1. 外部APIから天気情報を取得
        let weather_info = self.weather_service.get_weather_info(&input.location).await?;

        // 2. 天気情報に基づいてタスクの優先度を調整
        let adjusted_priority = self.adjust_priority_by_weather(&input, &weather_info);

        // 3. タスクを作成
        let task = Task::new(input.title, input.description)?;

        // 4. リポジトリに保存
        let saved_task = self.task_repository.save(&task).await?;

        // 5. ドメイン層に必要な情報のみを返す
        Ok(AddTaskWithWeatherOutput {
            task: saved_task,
            weather_info,
            adjusted_priority,
            // 外部API固有の情報は含まない
            // 例:api_response_time, rate_limit_remaining など
        })
    }

    fn adjust_priority_by_weather(&self, input: &AddTaskWithWeatherInput, weather: &WeatherInfo) -> TaskPriority {
        let base_priority = TaskPriority(1);

        // 天気に基づく優先度調整(ビジネスロジック)
        let weather_multiplier = match weather.description.to_lowercase().as_str() {
            "rain" | "storm" => 2, // 雨の日は優先度を上げる
            "sunny" | "clear" => 1, // 晴れの日は通常
            _ => 1,
        };

        TaskPriority(base_priority.0 * weather_multiplier)
    }
}

// Application層で使用する入力・出力構造体
#[derive(Debug)]
pub struct AddTaskWithWeatherInput {
    pub title: String,
    pub description: String,
    pub location: String,
}

#[derive(Debug)]
pub struct AddTaskWithWeatherOutput {
    pub task: Task,
    pub weather_info: WeatherInfo,
    pub adjusted_priority: TaskPriority,
    // 外部API固有の情報は含まない
}

まとめ

この記事では、Infrastructure層とPresentation層の詳細な実装について学びました:

Infrastructure層のポイント

  1. 技術的詳細の隠蔽:SQL、HTTP、ファイルI/Oの詳細を他の層に漏らさない
  2. 外部システムの抽象化:外部APIの仕様変更や障害を他の層に影響させない
  3. データ変換の責任:外部システムのデータ形式をドメインオブジェクトに変換
  4. 障害の局所化:外部システムの障害がビジネスロジックに波及しない

Presentation層のポイント

  1. コントローラーからの返却データの処理:ビジネスロジックの結果をHTTP形式に変換
  2. HTTPステータスコードの決定:ビジネスロジックの成功・失敗を適切なHTTPステータスに変換
  3. レスポンスヘッダーの設定:HTTPプロトコル固有の情報を設定
  4. エラーハンドリングの統一:ビジネスエラーを一貫したHTTPレスポンス形式に変換

外部システムとの連携の重要性

  1. 技術的詳細の完全隠蔽:外部システムの詳細を他の層に漏らさない
  2. データ変換の責任:外部システムのデータ形式をドメインオブジェクトに変換
  3. 障害の局所化:外部システムの障害がビジネスロジックに波及しない
  4. 交換可能性:技術スタックの変更が他の層に影響しない

次回のPart 4では、実践的な開発フローとAI活用について詳しく学んでいきます。

コラボスタイル Developers

Discussion