胖胖Model
減重的五個方法
by 尤川豪
阿川先生
Wordcorp一元翻譯 - 工程師
這樣寫一定會過胖
/views
/controllers
/models
/Article.php
/User.php
/Order.php
/Product.php
/Category.php
/Coupon.php
- 1000/6 = 166.667
- 2000/6 = 333.333
- 3000/6 = 500
- ...
- 6000/6 = 1000
先談談兩個名詞☺
-
model
-
entity
Model
光在後端就非常含糊,再把前端算進來就根本無法討論 => MVC是一個巨大誤會
Entity
精準的名詞。訂單、商品、文章...etc
=> 常被誤解為MVC中「Model」的全部
胖胖Model減重的五個方法
-
Presenter
-
Repository
-
Form
-
Service
-
Package
#1 Presenter
把日期、金額、名稱之類的呈現(presentation)邏輯抽離出來!
這樣很胖
class Article extends Eloquent
{
public function getDate(){/*...*/}
// 呈現給台灣地區的時間格式
public function getTaiwaneseDateTime(){/*...*/}
// 呈現給歐美地區的時間格式
public function getWesternDateTime(){/*...*/}
public function getTaiwaneseDate(){/*...*/}
public function getWesternDate(){/*...*/}
}
1st 試試Decorator Pattern
class ArticlePresenter
{
protected $article;
public function __construct(Article $article)
{
$this->article = $article;
}
// 呈現給台灣地區的時間格式
public function getTaiwaneseDateTime(){
return date('Y-m-d', $this->article->created_at);
}
// 呈現給歐美地區的時間格式
public function getWesternDateTime(){/*...*/}
public function getTaiwaneseDate(){/*...*/}
public function getWesternDate(){/*...*/}
}
要在view初始化物件嗎...
@foreach($articles as $article)
<?php $presenter = new ArticlePresenter($article); ?>
發文日期:{{ $presenter->getTaiwaneseDate() }}
@endforeach
2nd 讓Entity變聰明
class Article extends Eloquent
{
public function present()
{
return new ArticlePresenter($this);
}
}
漂亮多了
@foreach($articles as $article)
發文日期:{{ $article->present()->getTaiwaneseDate() }}
@endforeach
3rd 進一步改善
class Article extends Eloquent
{
protected $presenterInstance;
public function present()
{
if ( ! $this->presenterInstance)
{
$this->presenterInstance = new ArticlePresenter($this);
}
return $this->presenterInstance;
}
}
4th 用trait
trait PresentableTrait {
protected $presenterInstance;
public function present()
{
// ...
if ( ! $this->presenterInstance)
{
$this->presenterInstance = new $this->presenter($this);
}
return $this->presenterInstance;
}
}
Laracasts/Presenter
use Laracasts\Presenter\PresentableTrait;
class Article extends \Eloquent {
use PresentableTrait;
protected $presenter = 'ArticlePresenter';
}
use Laracasts\Presenter\Presenter;
class ArticlePresenter extends Presenter {
// 呈現給台灣地區的時間格式
public function getTaiwaneseDateTime(){/*...*/}
// 呈現給歐美地區的時間格式
public function getWesternDateTime(){/*...*/}
public function getTaiwaneseDate(){/*...*/}
public function getWesternDate(){/*...*/}
}
用起來一樣漂亮
@foreach($articles as $article)
發文日期:{{ $article->present()->getTaiwaneseDate() }}
@endforeach
不要重新發明輪子
#2 Repository
把查詢(query)的邏輯,也就是取得entity的各種方式抽離出來!
難讀的controller
$users = User::where('votes', '>', 100)
->whereGender('W')
->orderBy('created_at')
->get();
還容易duplicate
Query Scope幫上忙了嗎?
$users = User::popular()->women()->orderBy('created_at')->get();
$users = User::where('votes', '>', 100)->whereGender('W')->orderBy('created_at')->get();
用了Query Scope
沒用Query Scope
這樣很胖
class User extends Eloquent {
public function scopePopular($query)
{
return $query->where('votes', '>', 100);
}
public function scopeWomen($query)
{
return $query->whereGender('W');
}
}
Laravel官方提供的Query Scopes...
寫個類別封裝吧
class UserRepository
{
public function getPopularWomen()
{
return User::where('votes', '>', 100)->whereGender('W')->orderBy('created_at')->get();
}
}
controller簡潔多了
$repository = new UserRepository();
$users = $repository->getPopularWomen();
搭配Automatic Resolution真的是讚
class UserController extends BaseController
{
protected $users;
public function __construct(UserRepository $repository)
{
parent::__construct();
$this->users = $repository;
}
public function getIndex()
{
$women = $this->users->getPopularWomen();
}
}
BJ4
$users = User::where('votes', '>', 100)->whereGender('W')->orderBy('created_at')->get();
$users = User::popular()->women()->orderBy('created_at')->get();
$users = $this->users->getPopularWomen();
據說可以寫個通用的abstract class...
<?php
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Collection;
abstract class EloquentRepository
{
protected $model;
public function __construct($model = null)
{
$this->model = $model;
}
public function getById($id)
{
return $this->model->find($id);
}
public function getAll()
{
return $this->model->all();
}
public function save($data)
{
if ($data instanceOf Model) {
return $this->storeEloquentModel($data);
}
}
public function saveMany($collection)
{
foreach($collection as $model)
{
$this->storeEloquentModel($model);
}
}
public function delete($model)
{
return $model->delete();
}
protected function storeEloquentModel($model)
{
if ($model->getDirty()) {
return $model->save();
} else {
return $model->touch();
}
}
}
有要測試controller再用...
都用Active Record了,
testing避開資料庫可不容易...
#3 Form
把參數驗證(validation)的邏輯(例如字串長度、日期、金額大小)抽離出來!
難讀的controller
$validation = Validator::make(
array(
'name' => Input::get( 'name' ),
'email' => Input::get( 'email' ),
),
array(
'name' => array( 'required', 'alpha_dash' ),
'email' => array( 'required', 'email' ),
)
);
if ( $validation->fails() ) {
$errors = $validation->messages();
}
// ...
還容易duplicate
放進entity如何?
class Ball extends Eloquent
{
private $rules = array(
'color' => 'required|alpha|min:3',
'size' => 'required',
// .. more rules here ..
);
public function validate($data)
{
// make a new validator object
$v = Validator::make($data, $this->rules);
// return the result
return $v->passes();
}
}
$b = new Ball();
if ($b->validate(Input::all())){
// success code
}else{
// failure code
}
到底放哪好?
controller還是entity?
1st 寫個類別封裝吧
class ArticleForm
{
protected $validationRules = [
'title' => 'required',
'content' => 'required',
];
protected $inputData;
protected $validator;
public function __construct($input)
{
$this->inputData = $input;
}
public function isValid()
{
$this->validator = Validator::make($this->input, $this->validationRules);
return $this->validator->passes();
}
public function getErrors()
{
return $this->validator->errors();
}
}
controller變得苗條又優雅
$form = new ArticleForm( Input::all() );
if ( ! $form->isValid() ){
return Redirect::back()->with( [ 'errors' => $form->getErrors() ] );
}
$article = new Article( Input::only('title', 'content', 'status') );
$article->save();
看得出哪邊容易duplicate嗎?
class ArticleForm
{
protected $validationRules = [
'title' => 'required',
'content' => 'required',
];
protected $inputData;
protected $validator;
public function __construct($input)
{
$this->inputData = $input;
}
public function isValid()
{
$this->validator = Validator::make($this->input, $this->validationRules);
return $this->validator->passes();
}
public function getErrors()
{
return $this->validator->errors();
}
}
2nd 來繼承嘍
class FormModel
{
protected $validationRules;
protected $inputData;
protected $validator;
public function __construct($input)
{
$this->inputData = $input;
}
public function isValid()
{
$this->validator = Validator::make($this->input, $this->validationRules);
return $this->validator->passes();
}
public function getErrors()
{
return $this->validator->errors();
}
}
v( ̄︶ ̄)y
class ArticleForm extends FormModel
{
protected $validationRules = [
'title' => 'required',
'content' => 'required',
];
}
面對多種表單也OK
protected $happyArticleValidationRules;
protected $angryArticleValidationRules;
protected $funnyArticleValidationRules;
public function isValidHappy()
{
$this->validator = Validator::make($this->input, $this->happyArticleValidationRules);
return $this->validator->passes();
}
public function isValidAngry()
{
$this->validator = Validator::make($this->input, $this->angryArticleValidationRules);
return $this->validator->passes();
}
public function isValidFunny()
{
$this->validator = Validator::make($this->input, $this->funnyArticleValidationRules);
return $this->validator->passes();
}
實作參考
#4 Service
把施加在多種entity上
或是
複雜的商業行為
抽離出來!
來創業,做一個創新線上訂閱音樂服務!
月租費就收400元好了
客戶下訂單(付月租費)
-
信用卡扣款
-
建立訂單(存進資料庫)
-
發送確認email
寫在controller
class CheckoutController extends Controller
{
public function postSubmitOrder()
{
// 1. 利用Stripe從信用卡扣款
\Stripe\Stripe::setApiKey("sk_test_BQokikJOvBiI2HlWgH4olfQ2");
\Stripe\Charge::create(array(
"amount" => 400,
"currency" => "usd",
"source" => array(
// 這是一個簡化的範例。請別把信用卡資料存在資料庫。
"number" => $user->creditCard->number,
"exp_month" => $user->creditCard->expMonth,
"exp_year" => $user->creditCard->expYear,
"cvc" => $user->creditCard->cvc
),
));
// 2. 建立訂單
$order = new Order();
$order->price = 400;
$order->user_id = Auth::user()->id;
$order->save();
// 3. 發送確認Email
Mail::send('emails.confirmation', [], function($message) use ($user)
{
$message->to( $user->email )
->subject( '謝謝您的訂購。' );
});
}
}
永遠透過這個controller結帳?
那這樣寫OK
執行長:來改變商業模式吧!
-
推廣成本太高,鼓勵藝人幫我們推廣好了!
分一半利潤給他們!
-
手機App也寫好了,要支援付款功能~
-
在瀏覽專輯頁面支援Ajax付款好了~
讓客戶在結帳頁之外也能付款...
- 用iframe做plugin放在藝人官網接受付款
- 手機App發送request結帳
- 在瀏覽專輯頁面一鍵AJAX付款
- ...執行長的其他神奇點子...etc
// 1. 利用Stripe從信用卡扣款
\Stripe\Stripe::setApiKey("sk_test_BQokikJOvBiI2HlWgH4olfQ2");
\Stripe\Charge::create(array(
"amount" => 400,
"currency" => "usd",
"source" => array(
// 這是一個簡化的範例。請別把信用卡資料存在資料庫。
"number" => $user->creditCard->number,
"exp_month" => $user->creditCard->expMonth,
"exp_year" => $user->creditCard->expYear,
"cvc" => $user->creditCard->cvc
),
));
// 2. 建立訂單
$order = new Order();
$order->price = 400;
$order->user_id = Auth::user()->id;
$order->save();
// 3. 發送確認Email
Mail::send('emails.confirmation', [], function($message) use ($user)
{
$message->to( $user->email )
->subject( '謝謝您的訂購。' );
});
// 1. 利用Stripe從信用卡扣款
\Stripe\Stripe::setApiKey("sk_test_BQokikJOvBiI2HlWgH4olfQ2");
\Stripe\Charge::create(array(
"amount" => 400,
"currency" => "usd",
"source" => array(
// 這是一個簡化的範例。請別把信用卡資料存在資料庫。
"number" => $user->creditCard->number,
"exp_month" => $user->creditCard->expMonth,
"exp_year" => $user->creditCard->expYear,
"cvc" => $user->creditCard->cvc
),
));
// 2. 建立訂單
$order = new Order();
$order->price = 400;
$order->user_id = Auth::user()->id;
$order->save();
// 3. 發送確認Email
Mail::send('emails.confirmation', [], function($message) use ($user)
{
$message->to( $user->email )
->subject( '謝謝您的訂購。' );
});
// 1. 利用Stripe從信用卡扣款
\Stripe\Stripe::setApiKey("sk_test_BQokikJOvBiI2HlWgH4olfQ2");
\Stripe\Charge::create(array(
"amount" => 400,
"currency" => "usd",
"source" => array(
// 這是一個簡化的範例。請別把信用卡資料存在資料庫。
"number" => $user->creditCard->number,
"exp_month" => $user->creditCard->expMonth,
"exp_year" => $user->creditCard->expYear,
"cvc" => $user->creditCard->cvc
),
));
// 2. 建立訂單
$order = new Order();
$order->price = 400;
$order->user_id = Auth::user()->id;
$order->save();
// 3. 發送確認Email
Mail::send('emails.confirmation', [], function($message) use ($user)
{
$message->to( $user->email )
->subject( '謝謝您的訂購。' );
});
// 1. 利用Stripe從信用卡扣款
\Stripe\Stripe::setApiKey("sk_test_BQokikJOvBiI2HlWgH4olfQ2");
\Stripe\Charge::create(array(
"amount" => 400,
"currency" => "usd",
"source" => array(
// 這是一個簡化的範例。請別把信用卡資料存在資料庫。
"number" => $user->creditCard->number,
"exp_month" => $user->creditCard->expMonth,
"exp_year" => $user->creditCard->expYear,
"cvc" => $user->creditCard->cvc
),
));
// 2. 建立訂單
$order = new Order();
$order->price = 400;
$order->user_id = Auth::user()->id;
$order->save();
// 3. 發送確認Email
Mail::send('emails.confirmation', [], function($message) use ($user)
{
$message->to( $user->email )
->subject( '謝謝您的訂購。' );
});
Duplicate Code
Duplicate code只是問題之一
- controller可讀性早已很差
- 可以視為business logic洩漏 - 整段business logic無法獨立測試
放進Entity好了!
把這段執行順序視為
business logic的一種
工程師A:這屬於User的行為!
class User extends Eloquent
{
public function checkout()
{
// 1. 利用Stripe從信用卡扣款
\Stripe\Stripe::setApiKey("sk_test_BQokikJOvBiI2HlWgH4olfQ2");
\Stripe\Charge::create(array(
"amount" => 400,
"currency" => "usd",
"source" => array(
// 這是一個簡化的範例。請別把信用卡資料存在資料庫。
"number" => $this->creditCard->number,
"exp_month" => $this->creditCard->expMonth,
"exp_year" => $this->creditCard->expYear,
"cvc" => $this->creditCard->cvc
),
));
// 2. 建立訂單
$order = new Order();
$order->price = 400;
$order->user_id = $this->id;
$order->save();
// 3. 發送確認Email
Mail::send('emails.confirmation', [], function($message) use ($user)
{
$message->to( $user->email )
->subject( '謝謝您的訂購。' );
});
}
}
// 原本的Checkout controller
$user->checkout();
// 瀏覽專輯頁面一鍵AJAX付款的對應controller
$user->checkout();
// 用iframe做plugin讓藝人能放進官網的controller
$user->checkout();
// 處理手機App結帳的controller
$user->checkout();
工程師A:是否變得優雅許多!
工程師B:這屬於Order的行為!
class Order extends Eloquent
{
public function checkout()
{
// 1. 利用Stripe從信用卡扣款
\Stripe\Stripe::setApiKey("sk_test_BQokikJOvBiI2HlWgH4olfQ2");
$user = User::find($this->user_id);
\Stripe\Charge::create(array(
"amount" => 400,
"currency" => "usd",
"source" => array(
// 這是一個簡化的範例。請別把信用卡資料存在資料庫。
"number" => $user->creditCard->number,
"exp_month" => $user->creditCard->expMonth,
"exp_year" => $user->creditCard->expYear,
"cvc" => $user->creditCard->cvc
),
));
// 2. 發送確認Email
Mail::send('emails.confirmation', [], function($message) use ($user)
{
$message->to( $user->email )
->subject( '謝謝您的訂購。' );
});
}
}
// 原本的Checkout controller
$order = new Order(
Auth::user()->id,
400
);
$order->checkout();
// 瀏覽專輯頁面一鍵AJAX付款的對應controller
$order = new Order(
Auth::user()->id,
400
);
$order->checkout();
// 用iframe做plugin讓藝人能放進官網的controller
$order = new Order(
Auth::user()->id,
400
);
$order->checkout();
// 處理手機App結帳的controller
$order = new Order(
Auth::user()->id,
400
);
$order->checkout();
工程師B:這才叫做優雅!
工程師A:活人才有行為!不該放在訂單!
工程師B:堅持放在訂單!
有更新orders資料表,沒更新users資料表!
工程師C:它不屬於誰,它就是它!
class BillService
{
public function checkout(User $user)
{
// 1. 利用Stripe從信用卡扣款
\Stripe\Stripe::setApiKey("sk_test_BQokikJOvBiI2HlWgH4olfQ2");
\Stripe\Charge::create(array(
"amount" => 400,
"currency" => "usd",
"source" => array(
// 這是一個簡化的範例。請別把信用卡資料存在資料庫。
"number" => $user->creditCard->number,
"exp_month" => $user->creditCard->expMonth,
"exp_year" => $user->creditCard->expYear,
"cvc" => $user->creditCard->cvc
),
));
// 2. 建立訂單
$order = new Order();
$order->price = 400;
$order->user_id = $user->id;
$order->save();
// 3. 發送確認Email
Mail::send('emails.confirmation', [], function($message) use ($user)
{
$message->to( $user->email )
->subject( '謝謝您的訂購。' );
});
}
}
// 原本的Checkout controller
$service = new BillService();
$service->checkout(
Auth::user()
);
// 瀏覽專輯頁面一鍵AJAX付款的對應controller
$service = new BillService();
$service->checkout(
Auth::user()
);
// 用iframe做plugin讓藝人能放進官網的controller
$service = new BillService();
$service->checkout(
Auth::user()
);
// 處理手機App結帳的controller
$service = new BillService();
$service->checkout(
Auth::user()
);
工程師C:兩位別吵了!都沒擁有它!
多個entity爭執不下、
行為本身又很複雜的時候,
獨立成service。
除了最簡單的封裝之外...
- Domain event dispatcher
- Laravel 5 可封進Command Bus
#5 Package
把其他公司也能使用、概念上獨立於當前專案的程式碼抽離出來!
The secret to building large apps is never build large apps.
Break your applications into small pieces.
Then, assemble those testable, bite-sized pieces into your big application.
- 有可能分享給其他公司使用嗎?
新功能不要急著加進entity或是service裡面
常見情境
- 用法瑣碎的第三方SDK
- 想寫在controller內,但實際上可以包起來
範例一:Google Drive API
class Document extends Eloquent
{
public function addPermission($value, $type, $role, $withLink = false)
{
$googleClient = new Google_Client();
$googleClient->setClientId(Config::get('google_drive.clientId'));
$googleClient->setClientSecret(Config::get('google_drive.clientSecret'));
$googleClient->setRedirectUri(Config::get('google_drive.redirectUri'));
$googleClient->setAccessType('offline');
$googleClient->setScopes(array('https://www.googleapis.com/auth/drive'));
$googleClient->setApprovalPrompt('force');
$googleService = new Google_Service_Drive($this->googleClient);
$googleClient->setAccessToken(/*...*/);
$newPermission = new Google_Service_Drive_Permission();
$newPermission->setValue($value);
$newPermission->setType($type);
$newPermission->setRole($role);
$newPermission->setWithLink($withLink);
$googleService->permissions->insert($this->fileId, $newPermission);
}
}
獨立成service
<?php
class GoogleDriveService{
protected $googleClient;
protected $googleService;
public function __construct(){
$this->googleClient = new Google_Client();
$this->googleClient->setClientId(Config::get('google_drive.clientId'));
$this->googleClient->setClientSecret(Config::get('google_drive.clientSecret'));
$this->googleClient->setRedirectUri(Config::get('google_drive.redirectUri'));
$this->googleClient->setAccessType('offline');
$this->googleClient->setScopes(array('https://www.googleapis.com/auth/drive'));
$this->googleClient->setApprovalPrompt('force');
$this->googleService = new Google_Service_Drive($this->googleClient);
$this->googleClient->setAccessToken(TokenServiceProider::getAccessToken());
}
public function insertPermission( $fileId, $value, $type, $role, $withLink = false) {
$newPermission = new Google_Service_Drive_Permission();
$newPermission->setValue($value);
$newPermission->setType($type);
$newPermission->setRole($role);
$newPermission->setWithLink($withLink);
$this->googleService->permissions->insert($fileId, $newPermission);
}
}
class Document extends Eloquent
{
public function addPermission($value, $type, $role, $withLink = false)
{
$service = new GoogleDriveService();
$service->insertPermission($this->fileId, $value, $type, $role, $withLink = false) ;
}
}
獨立成serivce?
<?php
class GoogleDriveService{
protected $googleClient;
protected $googleService;
public function __construct(){
$this->googleClient = new Google_Client();
$this->googleClient->setClientId(Config::get('google_drive.clientId'));
$this->googleClient->setClientSecret(Config::get('google_drive.clientSecret'));
$this->googleClient->setRedirectUri(Config::get('google_drive.redirectUri'));
$this->googleClient->setAccessType('offline');
$this->googleClient->setScopes(array('https://www.googleapis.com/auth/drive'));
$this->googleClient->setApprovalPrompt('force');
$this->googleService = new Google_Service_Drive($this->googleClient);
$this->googleClient->setAccessToken(TokenServiceProider::getAccessToken());
}
public function insertPermission( $fileId, $value, $type, $role, $withLink = false) {
$newPermission = new Google_Service_Drive_Permission();
$newPermission->setValue($value);
$newPermission->setType($type);
$newPermission->setRole($role);
$newPermission->setWithLink($withLink);
$this->googleService->permissions->insert($fileId, $newPermission);
}
}
獨立成package
<?php namespace Howtomakeaturn\GoogleDriveKing;
use Google_Clinet;
use Google_Service_Drive;
class GoogleDriveKing{
protected $googleClient;
protected $googleService;
public function __construct(){
$this->googleClient = new Google_Client();
$this->googleClient->setClientId(Config::get('google_drive.clientId'));
$this->googleClient->setClientSecret(Config::get('google_drive.clientSecret'));
$this->googleClient->setRedirectUri(Config::get('google_drive.redirectUri'));
$this->googleClient->setAccessType('offline');
$this->googleClient->setScopes(array('https://www.googleapis.com/auth/drive'));
$this->googleClient->setApprovalPrompt('force');
$this->googleService = new Google_Service_Drive($this->googleClient);
$this->googleClient->setAccessToken(TokenServiceProider::getAccessToken());
}
// ...
}
範例二:把資料庫轉成csv備份
<?php
class BackupController extends Controller
{
public function postDownload()
{
$results = DB::select('select * from users');
// ...
}
}
獨立成Package
<?php
namespace Howtomakeaturn\CSVDumper;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
class CSVDumper{
protected $tableName;
protected $doctrineSchemaManager;
protected $queryBuilder;
public function __construct($tableName)
{
$this->tableName = $tableName;
$this->doctrineSchemaManager = Schema::getConnection()->getDoctrineSchemaManager();
$this->queryBuilder = DB::table($tableName);
}
// ...
}
<?php
class BackupController extends Controller
{
public function postDownload()
{
// ...
foreach ($tableNames as $name) {
$dumper = new Howtomakeaturn\CSVDumper\CSVDumper($name);
$result = $dumper->dumpAndStoreTable(storage_path() . '/database-backup');
}
// ...
}
}
所以,怎麼改善這個?
/views
/controllers
/models
/Article.php
/User.php
/Order.php
/Product.php
/Category.php
/Coupon.php
方法一:照Domain分
/views
/controllers
/MyApp
/Article
/Article.php
/Presenter.php
/Repository.php
/Form.php
/User
/User.php
/Presenter.php
/Repository.php
/Form.php
/Order
// ...
/Service
/FirstService.php
/SecondService.php
/GithubName
/CoolPackageOne
/CoolPackageTwo
/CoolPackageThree
方法二:照功能分
/views
/controllers
/MyApp
/Entities
/Article.php
/User.php
/Presenters
/Article.php
/User.php
/Repositories
/Article.php
/User.php
/Forms
/Article.php
/User.php
// ...
/Service
/FirstService.php
/SecondService.php
/GithubName
/CoolPackageOne
/CoolPackageTwo
/CoolPackageThree
方法三:
請您自由發揮創意:)
Factory, Search, Utility, Observer, Listener...etc
- Domain Driven Design
- Hexagonal Architecture
- Command Query Responsibility Segregation
- Service Oriented Architecture
延伸閱讀...
- Domain Driven Design
- Hexagonal Architecture
- Command Query Responsibility Segregation
- Service Oriented Architecture
Active Record?
最後
一個祕密...
一個建議...
胖胖Model減重的五個方法
By howtomakeaturn
胖胖Model減重的五個方法
隨著軟體專案不斷增加新功能,你是否隱約感到不安?覺得Model越來越胖、開發速度越來越慢?
- 15,800