SAPE アーキテクチャ
SAPE (サップ) アーキテクチャは, S, A, P, E の各層に分けて書くソフトウェアアーキテクチャ及び開発手法だよ.
がんばって私が考えました, えっへん.
各層の役割
以下からは各層の役割の解説をしていく. 実際のアプリ開発では, どの層から書き始めてもまんべんなくすべての層を書き上げる感じになるよ.
Exp(ression)
Exp では, このアプリで登場する データ構造と, その制約を維持しつつ操作する関数群 を書くよ.
Rust でのコード例: 電卓なら, 数と演算子の定義, 計算木構造の定義, 木構造から数値を算出する関数, その単体テストなどをここに置く.
#[derive(Debug, Clone, Copy)]
pub enum BinaryOp {
Add,
Sub,
Mul,
Div,
}
#[derive(Debug, Clone)]
pub enum Term {
Num(f64),
Binary(Box<Term>, BinaryOp, Box<Term>),
}
impl Term {
pub fn calc(&self) -> f64 {
use BinaryOp::*;
use Term::*;
match &self {
Num(num) => *num,
Binary(left, Add, right) => left.calc() + right.calc(),
Binary(left, Sub, right) => left.calc() - right.calc(),
Binary(left, Mul, right) => left.calc() * right.calc(),
Binary(left, Div, right) => left.calc() / right.calc(),
}
}
}
// 後略
Exp に置いたコードは, Exp 内の他コードと基本データ型にしか依存してはいけない よ.
class
が書けるオブジェクト指向プログラミング言語ならそれを使ったほうがいいね.
Play
Play では, この アプリ自体がやる仕事のアルゴリズム とそのエラー系を書くよ.
Rust でのコード例: 電卓なら,「数や演算子の入力を計算して出力する」処理をここに置く.
use crate::abst::{Controller, Input, Presenter};
use crate::exp::{BinaryOp, SyntaxError, Term};
pub fn calculate(controller: &impl Controller, presenter: &impl Presenter) {
let mut inputs = controller.get_inputs();
match parse_add_sub(&mut inputs) {
Ok((term, _)) => presenter.show_result(term.calc()),
Err(err) => presenter.show_error(err),
}
}
type ParseResult<'a> = Result<(Term, &'a [Input]), SyntaxError>;
/*
AddSub :=
| MulDiv + MulDiv
| MulDiv - MulDiv
| MulDiv
MulDiv :=
| Factor * Factor
| Factor / Factor
| Factor
Factor :=
| LParen AddSub RParen
| Num
*/
fn parse_add_sub(tokens: &[Input]) -> ParseResult {
let (mut term, mut tokens) = parse_mul_div(tokens)?;
loop {
match tokens {
[Input::Plus, rest @ ..] => {
let (right, rest) = parse_mul_div(rest)?;
term = Term::Binary(term.into(), BinaryOp::Add, right.into());
tokens = rest;
}
[Input::Minus, rest @ ..] => {
let (right, rest) = parse_mul_div(rest)?;
term = Term::Binary(term.into(), BinaryOp::Sub, right.into());
tokens = rest;
}
_ => break Ok((term, tokens)),
}
}
}
fn parse_mul_div(tokens: &[Input]) -> ParseResult {
let (mut term, mut tokens) = parse_factor(tokens)?;
loop {
match tokens {
[Input::Cross, rest @ ..] => {
let (right, rest) = parse_factor(rest)?;
term = Term::Binary(term.into(), BinaryOp::Mul, right.into());
tokens = rest;
}
[Input::Division, rest @ ..] => {
let (right, rest) = parse_factor(rest)?;
term = Term::Binary(term.into(), BinaryOp::Div, right.into());
tokens = rest;
}
_ => break Ok((term, tokens)),
}
}
}
fn parse_factor(tokens: &[Input]) -> ParseResult {
match tokens {
[Input::Num(n), ..] => {
return Ok((Term::Num(*n), &tokens[1..]));
}
[Input::LParen, rest @ ..] => {
let (term, rest) = parse_add_sub(rest)?;
if let [Input::RParen, ..] = rest {
Ok((term, &rest[1..]))
} else {
Err(SyntaxError)
}
}
_ => parse_add_sub(tokens),
}
}
Play に置いたコードは, 参照透過な関数しか作ってはいけない よ. インターネットや DB や UI といった外部は, Abst で抽象化して引数からもらうようにするよ. こうすることで, テスト性が向上 する.
Play は Exp と Abst に依存しているからこそ, Play が 書きやすくなるように, Exp と Abst のコードをリファクタしていく ようにね.
Abst(ract)
Abst では, Exp が使いたい外部を抽象化して定義 したり, それを合成/自動生成するようなコードを書くよ.
Rust でのコード例: 電卓なら, 外部からの入力と出力の
trait
とそれ専用のデータ構造をここに置く.use crate::exp::SyntaxError;
#[derive(Debug, Clone)]
pub enum Input {
Plus,
Minus,
Cross,
Division,
LParen,
RParen,
Num(f64),
}
pub trait Controller {
fn get_inputs(&self) -> Vec<Input>;
}
pub trait Presenter {
fn show_error(&self, error: SyntaxError);
fn show_result(&self, calculated: f64);
}
Abst に置いたコードは, Abst 内の他コードか Exp にしか依存してはいけない よ.
Skin
Skin では, Abst に対する実装 として外部の API やフレームワークを直接触るコードを書くよ.
Rust でのコード例: 電卓なら, CUI や GUI とのやり取りをここに置く.
use crate::abst::{Controller, Input, Presenter};
use crate::exp::SyntaxError;
pub struct Console;
fn string_to_tokens(string: &[char]) -> Vec<Input> {
use Input::*;
let mut tokens = vec![];
let mut num_buf = String::new();
for raw_token in string {
if raw_token.is_digit(10) || raw_token == &'.' {
num_buf.push(*raw_token);
continue;
}
if !num_buf.is_empty() {
tokens.push(Num(num_buf.parse().unwrap()));
num_buf.clear();
}
tokens.push(match raw_token {
'+' => Plus,
'-' => Minus,
'*' => Cross,
'/' => Division,
'(' => LParen,
')' => RParen,
' ' => continue,
_ => unimplemented!("Unimplemented token: {}", raw_token),
});
}
if !num_buf.is_empty() {
tokens.push(Num(num_buf.parse().unwrap()));
num_buf.clear();
}
tokens
}
impl Controller for Console {
fn get_inputs(&self) -> Vec<Input> {
println!("Input the expression:");
let mut buffer = String::new();
let stdin = std::io::stdin();
stdin.read_line(&mut buffer).expect("no input");
let raw_tokens: Vec<_> = buffer.trim().chars().collect();
string_to_tokens(&raw_tokens)
}
}
impl Presenter for Console {
fn show_error(&self, _: SyntaxError) {
println!("Syntax error");
}
fn show_result(&self, result: f64) {
println!("{}", result);
}
}
Skin に置いたコードは, Abst の実装以外のことをしてはいけない よ. アプリケーションで必要なロジックは Skin に紛れ込ませずに Play や Exp に分けよう.
まとめ
- Skin
- Abst
- Play
- Exp
下図に層のコードの関係を表しといたよ.
Rust のサンプルコードたちは, この sape_sample レポジトリ に全文載せてあるから気になるなら見ていってね.
存在意義
これは, The Clean Architecture を削ぎ落としてもっと効率的にしようと考えて作ったよ. また, Onion Architecture や DDD からも考え方を借りてる.
おかげで, 以下のような強みを得られた.
外部の変更から守る
さっきの図でも分かる通り, すべての依存の矢印がシステムの内側に向いている. であるから, 外部の変更があっても Skin の修正だけで済み, 他のコードに影響しない.
いわゆる DIP (依存関係の逆転の原則) が適用されている.
テストを作る粒度が明確
Exp が独立した純粋人工物になるようにしているから, そこに単体テストを書くしそれ自体の使い方も分かりやすくなる. また, Exp に単体テストを書くので, Exp 自体のバグをできるだけ排除できる.
反対に, 結合テストは Play に対して行う. Abst を実装したモックを Skin に置いておけば, あとはなんらかのテストフレームワークなどを使って楽に結合テストが書ける.
単体テストやモックのまとめ方が定義されているから, テスト目的のファイル配置が人によって変わったりしないのでプロジェクト運用が効率的になる.
モジュール = フォルダの階層
同様に, 再配置/追加した機能の場所に迷うこともない. プログラムの役割ごとに配置するモジュール名とディレクトリが明確なので, いちいち責務から配置を考えたりしなくていい.
プログラムの階層名とフォルダの名前が同じになるので, あとから見直したり探したりしやすくなる.
完全に独立したアーキテクチャ
このアーキテクチャをプロジェクト全体に渡って採用する必要はない. 小さなモジュール単位でこのやり方を実践するだけでも, 恩恵が受けられると思う.
このアーキテクチャの中にこのアーキテクチャを採用した... というような階層構造を作っても問題ない. このアーキテクチャで組んだモジュールをこのアーキテクチャで再利用する場合は, Exp の中に入れるようにね.
abst
-
exp
-
another
abst
-
exp
- :
play
skin
- :
-
play
skin
おわりに
お手軽なアーキテクチャだと思うので, ぜひ試していってね. ここまで読んでくれてありがと.