iT邦幫忙

2022 iThome 鐵人賽

DAY 9
0
Modern Web

LV的全端開發體驗系列 第 9

Day09 拆分後端的邏輯-Service And Repository

  • 分享至 

  • xImage
  •  

昨天我們說到前期的建置專案和測試留下了不少缺失,今天來把這些問題都處理一下;當然,這種作業不會一次到位,所以在開發專案時要每隔一段時間就檢視一下有沒有更好的做法或調整架構,不要都做完了,才想到要檢查,到時就不是調整而是可能需要翻掉重寫了,有團隊一起開發的好處也在這裏,大家互相 code review 可以看到彼此的盲點,一起成長,一起進步;

後端最基本的拆分可以分為 Service 和 Repository :

Services - 處理商業邏輯的地方,如果對商業邏輯一詞不太明白,可以想成是把資料做加工的地方,比如計算價格,排序,大小寫,合併,拆分等等工作都可以放在Service。

Repository - 處理資料進出資料表的地方,比如存入資料前的格式轉換,或是取出資料時有些欄位想隱藏不傳出去,或是定義特殊的資料取得方式,比如要取出某個特定日期中最多人測驗的題庫,或是每次取出使用者資料時就需要根據生日計算一下目前的年時,就可以在這裹寫一個函式來處理,不過由於Laravel已經在Model上加了不少功能了,所以Repository是否有必要的,要看專案的狀況來決定。

至於 Service / Repository 兩者和 Controller / Model 的關係,有人採用的是單一出入的關係,這會比較有規範,好理解,但有時會多一些沒必要的資料傳遞:

有人則是彈性至上,只是單純取資料時直接引入 Model,資料有比較多再製手續時才會透過 Repository 來過一手,ControllerService 的關係也一樣,依專案需要來決定。

循此邏輯,我們也可以依照自己的需求來建立其它的功能拆分,讓專案的分工可以更清楚一些,比如在前後端分離的原則下,有時餵給前端的Json資料欄位名會轉成駝峰式命名,而不是資料表中的蛇型命名,如果這個工作是頻繁的,那與其在每個Service中重覆的做轉換的工作,倒不如再分出個 Formatter 來,專門處理這種格式轉換的工作。

也就是說 Laravel 的架構並不是不能更動的,依照專案的需求自行調整才能真正發揮框架的優勢。

說那麼多,怎麼拆咧?

先在App下建立兩個資料夾 ServicesRepositories

接著建立需要的類別及內容

BackstageService

我希望這個 BackstageController 只處理後台首頁的顯示,各個功能的詳細操作應該由各個功能的 Controller 去處理,所以我們需要一個後台的 Service 來控管所有的功能和資料。

這裏我們使用依賴注入(DI)的方式把 Service 類別注入到 Controller,這樣之後的使用就不用再 new Service 了,同時我們也使用到PHP8的建構式寫法,可以簡化指定變數的工作。

BackstageController

use App\Services\BackstageServices;

class BackstageController extends Controller
{
    function __construct( protected BackstageServices $backstage){}

    function controlPanel() {
        $infos=$this->backstage->getInfos();
        return Inertia::render('Backstage/Backstage',['infos'=>$infos]);
    }

app/Services/BackstageService.php

namespace App\Services;

use App\Services\BankService;
use App\Services\GroupService;
use App\Services\TestService;
use App\Services\QuizService;

class BackstageService
{
    protected $items=['bank','group','test','quiz'];  

    function __construct( protected BankService $bank,
                          protected GroupService $group,
                          protected TestService $test,
                          protected QuizService $quiz,){}

    function getInfos()
    {
        $infos=[]; //宣告一個空陣列來裝每個功能回傳的資料
        foreach($this->items as $item)
        {
            $infos[$item]=$this->$item->infos();
        }
        return $infos;
    }
}

這邊的 $this->$item 是使用了PHP一個可變變數的用法,詳請請見官網說明

接著是在每個 Service 中加上 infos 的方法,內容可以自由設計:

app/Services/BankService.php

namespace App\Services;

class BankService 
{
    function infos()
    {
        return ['count'=>$this->all()->count()];
    }
    
    function all(){ return $this->bank->all(); }
}

app/Services/GroupService.php

namespace App\Services;

class GroupService 
{
    function infos(){ return ['count'=>10]; }
}

app/Services/QuizService.php

namespace App\Services;

class QuizService 
{
    function infos(){ return ['count'=>30]; }
}

app/Services/TestService.php

namespace App\Services;

class TestService 
{
    function infos(){ return ['count'=>22]; }
}

以這個範例來說,BackstageController 的部份幾乎日後不用再去動了,如果回傳給前端的資料有需要變動,我們會去service找看看要變動的是那一個資料,如果是全體統一的變更,那就可以在 BackstageService 中加個方法來統一修改,如果是個別的資料變動,那就是去個別的Repository中修改;

前端使用資料的方法:
resources\js\Pages\Backstage\Backstage.vue

<script setup>
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout.vue";
import { Head, Link } from "@inertiajs/inertia-vue3";

const props=defineProps({infos:Object})
</script>

<template>
  <Head title="管理中心" />

  <AuthenticatedLayout>
    <template #header>
      <h2 class="font-semibold text-xl text-gray-800 leading-tight">
          管理中心
      </h2>
    </template>

    <!--右側內容區-->
    <div class="w-[calc(100%_-_12rem)] p-4 flex flex-wrap">
      <div class="w-1/2 border rounded-xl flex p-4 h-48 bg-green-400">
          題庫:{{ infos.bank.count }}
      </div>
      <div class="w-1/2 border rounded-xl flex p-4 h-48 bg-sky-400">
          試卷:{{ infos.quiz.count }}
      </div>
      <div class="w-1/2 border rounded-xl flex p-4 h-48 bg-yellow-400">
          測驗:{{ infos.test.count }}
      </div>
      <div class="w-1/2 border rounded-xl flex p-4 h-48 bg-orange-400">
          群組:{{ infos.group.count }}
      </div>
    </div>
  </AuthenticatedLayout>
</template>

這樣修改的另一個好處是日後如果要新增後台的功能,只要把新功能的類別引入到BackstageService,前端就會收到新的資料了,不需要在 BackstageController 中再加一堆方法了。

突然拆出這麼多檔案,又讓你胃食道逆流了嗎?別擔心,我們會拆更多出來...

BankRepository

我們之前是直接在 BackstageController 直接引入 SubjectOption 來做題庫的CRUD,相信有不少朋友完全看不懂這幾個東西和題庫的關聯是什麼,題庫就是題目嗎?

沒錯,我就是亂寫一通,所以現在要來還債了,原本計畫的題庫並不是單純的只把題目新增上去就算了,考量到未來可能會有幾千幾萬個題目在系統中,不可能直接在一個畫面中顯示全部的題目;

所以 Bank 這個字的用意就是要把題目像銀行帳戶一樣分門別類的收好;因此在題目之上其實還有個題庫的分類功能需要製作,題目則應該要回歸到它的 SubjectController 去做管理。

之前是借用 BackstageController 來處理所有功能的內容,現在要讓所有的功能回到各自的控制器去,所以題庫相關的都回到 BankController 去。

接者我們把應該要建立的Controller 和 Repository 都補上吧

php artisan make:controller BankController
php artisan make:controller SubjectController
php artisan make:model Bank -m

banks 的資料表設計:

public function up()
{
    Schema::create('banks', function (Blueprint $table) {
        $table->id();
        $table->string('code');
        $table->string('name');
        $table->string('level');
    });
}

先前我們在測試時把level欄位設定成數字格式,但技術士分類中,級別是用A,B,C來代表的,因此這邊我們把level欄位設為字串格式。

接著建立 Models 中 BankSubject 的關聯:

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Bank extends Model
{
    use HasFactory;

    protected $guarded=[];
    public $timestamps = false; //不使用timestamp欄位

    function subjects()
    {
        return $this->hasMany(Subject::class);
    }    
}

因為 Bank 中已經帶有 code(題庫代碼)level(級別) 兩個欄位了,所以 Subject 的欄位可以做一些精簡,使用migration來移除欄位:

php artisan make:migration drop_column_from_table_subjects --table=subjects

原本的 group 指的是題庫中的題組,題組的功能後面也會獨立出來成為一張資料表做管理,所以我們也同時把資料表中的group 欄位改成 group_id,但也要把欄位資料型態改為長度較大的整數,所以做法就是先刪後加。
database/migrations/xxxxx_drop_column_from_table_subjects.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up()
    {
        Schema::table('subjects', function (Blueprint $table) {
            $table->dropColumn('code');   //刪除code欄位
            $table->dropColumn('level');  //刪除level欄位
            $table->dropColumn('group');  //刪除題組group欄位
            
            //使用bank_group_id來代表題庫中的題組資料
            $table->unsignedInteger('bank_group_id');
            
            //使用bank_id來代表題庫的資料
            $table->unsignedInteger('bank_id'); 
        });
    }

    public function down()
    {
        Schema::table('subjects', function (Blueprint $table) {
            $table->dropColumn('bank_id');
            $table->dropColumn('bank_group_id');
            $table->string('code');
            $table->unsignedTinyInteger('level');
            $table->unsignedTinyInteger('group');
        });
    }
};

要使用欄位修改函式時,需要先安裝 doctrine/dbal 這個套件

composer require doctrine/dbal

因為多了一個 Bank 出來,所以 Subject 的關聯也要調整一下:

app/Models/Subject.php

function bank()
{
    return $this->belongsTo(Bank::class);
}

記得執行 migrate 來讓欄位的變更生效

最後在 App/Repositories/ 目錄下建立 BankRepository,並引入到 BankService:
app/Repositories/BankRepository.php

namespace App\Repositories;

use App\Models\Bank;

class BankRepository 
{
    function __construct(protected Bank $bank){}
}

app/Services/BankService.php

namespace App\Services;

use App\Repositories\BankRepository;

class BankService
{
    function __construct( protected BankRepository $bank){}
}

QuizTestGroup 也都比照辦理,程式碼就不放上來了。

規範資料的存取邏輯

繞這麼一大圈,搞出這麼多檔案,到底要做什麼?

合理的資料流應該是這樣的:

  1. 後台首頁的資料由 BackstageController 來提供,而 BackstageController 會去向 BackstageService 取得要出現在頁面上的資料。
  2. BackstageService 會去逐一查詢每個註冊在這裹的Service,並取得相關的資料回傳給 BackstagController
  3. 每一個有註冊在 BackstageServiceService 會提供自身的資料給 BackstageService 去使用。
  4. 每一個個別的 Service 會向自己的 Repository 去請求資料。
  5. 每一個個別的 Repository 會向自己的 Model 去撈取資料。

為什麼要搞得這麼複雜呢?我一開始也覺得很心累,但是做過幾個案子後,才漸漸體會到這種模式帶來的好處:

  1. 每一個Class都會比較精簡,而且功能單一,一眼就知道它在做什麼
  2. 分工清楚,不會擔心某個方法會有未知的變化
  3. 好抽換,將來可以抽換其中一個模組或功能的內容而不需要動到原本的流程或邏輯

拆出BackstageController中的Subject CRUD

原本我們是把題目的CRUD寫在 BackstageController 的,現在我們要把CRUD移回它們各自該去的地方,也就是 SubjectController,同時也別忘了,我們還少一個題庫的功能還沒完成,這工程不小:

先調整路由(幾乎重寫了)
routes\web.php

Route::get('/subjects',[SubjectController::class,'index'])->name('subject.index');
Route::get('/subject/{bank}/create',[SubjectController::class,'create'])->name('subject.create');
Route::post('/subject',[SubjectController::class,'store'])->name('subject.store');
Route::get('/subject/edit/{id}',[SubjectController::class,'edit'])->name('subject.edit');
Route::put('/subject/{id}',[SubjectController::class,'update'])->name('subject.update');
Route::delete('/subject/{id}',[SubjectController::class,'destroy'])->name('subject.destroy');

Route::get('/bank/{id}/subjects',[SubjectController::class,'subjectsInBank'])->name('bank.subjects');

Route::get('/backstage/banks',[BankController::class,'index'])->name('backstage.banks');
Route::get('/backstage/quizzes',[QuizController::class,'index'])->name('backstage.quizzes');
Route::get('/backstage/tests',[TestController::class,'index'])->name('backstage.tests');
Route::get('/backstage/groups',[GroupController::class,'index'])->name('backstage.groups');

app\Http\Controllers\SubjectController.php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Services\SubjectService;
use Inertia\Inertia;

class SubjectController extends Controller
{
    function __construct(protected SubjectService $subject){}

    function index()
    {
        $subjects=$this->subject->all();
        $count=$subjects->count();
        return Inertia::render('Backstage/Banks',
                        [
                         'subjects'=>$subjects,
                         'count'=>$count
                        ]);
    }

    function subjectsInBank($bank_id)
    {
        $subjects=$this->subject->subjectsInBank($bank_id);
        return Inertia::render('Backstage/Subjects',
                        [
                         'subjects'=>$subjects,
                         'count'=>$subjects->count()
                        ]);
    }
    
    function store(Request $request)
    {
        $this->subject->store($request->input());
        
        //因為我們在表單的欄位中有一個bankId,所以可以直接取出來使用
        return redirect()->route('bank.subjects',$request->bankId);
    }

    function edit($id)
    {
        /* 因為新增題目的同時要帶入題庫和題組的資料,所以這題目的表單初始資料
         * 我們交由Service中的subjectEdit函式來產生,再丟去給前端頁面使用
         * 同時我們會讓新增和編輯都在同一個前端檔案中處理*/ 
        return Inertia::render('Backstage/SubjectForm',
                                $this->subject->subjectEdit($id));
    }

    function update(Request $request,$id)
    {
        $this->subject->update($request->input());
        return redirect()->route('bank.subjects',$request->bankId);
    }

    function destroy($id)
    {
        $this->subject->destroy($id);
        return redirect()->back();
    }
}

調整後的Controller不再直接處理資料,Controller主要的工作會是:

  • 驗證參數或請求的身份、權限、授權
  • 參數傳給service去,或是依據參數向service來取得資料
  • 決定回傳的頁面或是導向到其他路由或類別去

app\Services\SubjectService.php

namespace App\Services;

use App\Repositories\SubjectRepository;

class SubjectService
{
    function __construct( protected SubjectRepository $subject){}

    function all()
    {
        return $this->subject->all();
    }

    function subjectsInBank($bank_id)
    {
        return $this->subject->subjectsInBank($bank_id);
    }
    
    //處理編輯題目時會用到的資料,暫時先簡單寫,後面會再回來補齊
    function subjectEdit($id)
    {
        return  [
                 'bank'=>$bank,
                 'groups'=>$groups,
                 'subject'=>$subject,
                 'header'=>'編輯題目',
                 'button'=>'修改'
                ];
    }
    
    function store($subject)
    {
        $this->subject->create($subject);
    }

    function update($subject)
    {
        $this->subject->update($subject);
    }

    function find($id)
    {
        return $this->subject->find($id);
    }

    function destroy($id)
    {
        $this->subject->destroy($id);
    }
}

app\Repositories\SubjectRepository.php

namespace App\Repositories;

use App\Models\Subject;
use App\MOdels\Option;

class SubjectRepository 
{
    function __construct(protected Subject $subject,
                         protected Option $option){}

    function all()
    {
        return $this->subject->all();
    }

    function find($id)
    {
        return $this->subject->find($id);
    }
    
    function subjectsInBank($bank_id)
    {
        return $this->subject->where('bank_id',$bank_id)->get();
    }

    //在create中拆分不同model需要的資料,
    //再分配給不同model去執行各自的新增功能
    function create($subject)
    {
        //在題目傳入的陣列中取得選項的資料
        $options=$subject['options'];

        //取得題目儲存後的id
        $subjectId=$this->createSubject($subject);
        
        //把題目id分配給選項去做新增
        $this->createOptions($options,$subjectId);
    }

    function createSubject($subject)
    {
        //把前端傳來的駝峰命名欄位轉為資料表的蛇形命名
        $subject['bank_id']=$subject['bankId'];
        $subject['bank_group_id']=$subject['bankGroupId'];
        
        //移除不需要的資料
        unset($subject['bankId'],$subject['bankGroupId'],$subject['options']);
        
        //新增題目進資料表並回傳id
        return $this->subject->create($subject)->id;
    }

    function createOptions($options,$subjectId)
    {
        foreach($options as $option)
        {
            $options['subject_id']=$subjectId;
            $this->option->create($option);
        }
    }

    function update($subject)
    {
        $options=$subject['options'];
        unset($subject['options']);
        $this->subject->updateSubject($subject);
        $this->subject->updateOptions($options);
    }

    function updateSubject($subject)
    {
        $id=$subject['id'];
        $subject['bank_id']=$subject['bankId'];
        $subject['bank_group_id']=$subject['bankGroupId'];
        unset($subject['id'],$subject['bankId'],$subject['bankGroupId']);
        $this->subject->find($id)->update($subject);
    }

    function updateOptions($options)
    {
        foreach($options as $option)
        {
            $id=$option['id'];
            unset($option['id']);
            $this->option->find($id)->update($option);
        }
    }

    function destroy($id)
    {
        $subject=$this->subject->find($id);
        $options=$subject->options;

        $options->map(function($opt){
            $opt->delete();
        });

        $subject->delete();
    }
}

呼~終於拆完了,不過目前前端頁面應該是都爛掉了,因為我們改了路由,改了後端流程和一些邏輯,還加了一層題庫,所以前端的頁面現在應該是什麼都讀不到的狀況,而且我們還得把題庫和題組的功能補上去,後台的動作才算正常。

休息一下,接著來拆解前端。


上一篇
Day08 基本CRUD測試-新增題目,編輯,刪除
下一篇
Day10 拆分前端的組件及頁面流程
系列文
LV的全端開發體驗30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中
0
Pallas
iT邦新手 3 級 ‧ 2023-07-01 19:43:02

我覺得這篇文章雖然很長,但是有太多東西沒有解釋

從第1 ~ 8 都寫得好好的
突然第 9 篇突然來個大轉,這是正常??
也沒有什麼原源可以參考,就直接跳了

mackliu iT邦新手 4 級 ‧ 2023-07-01 20:40:03 檢舉

1~8篇只是很基礎的框架使用流程,和官網寫的其實差不多,只是主題不一樣而已。

第9篇開始會有比較多接近我自己實務開發的做法,這沒有什麼對錯或正不正常的問題,就是看專案的需要去做一些調整,使用官方建議的做法也是一樣可以完成的,有時我的做法只是靈光一閃,也許三天後我又會換一個做法了,至於這些做法的來源大多是來自我平時閱讀的各種技術內容,有來自書籍的,部落格,影片等等。

鐵人賽的文章就是分享我自己做專案的過程,也是給自己留個紀錄,真要問我為什麼這樣寫,我最後的答案可能都是"習慣"。

0
Pallas
iT邦新手 3 級 ‧ 2023-07-01 20:01:46

另外

BackstageController 寫說要去查找
use App\Services\BackstageServices;

結果你確寫說
app/Services/BackstageService.php

namespace App\Services;

use App\Service\BankService;
use App\Service\GroupService;
use App\Service\TestService;
use App\Service\QuizService;

class BackstageService
{
protected $items=['bank','group','test','quiz'];

function __construct( protected BankService $bank,
                      protected GroupService $group,
                      protected TestService $test,
                      protected QuizService $quiz,){}

function getInfos()
{
    $infos=[]; //宣告一個空陣列來裝每個功能回傳的資料
    foreach($this->items as $item)
    {
        $infos[$item]=$this->$item->infos();
    }
    return $infos;
}

}

裡面的
use App\Service\BankService;
use App\Service\GroupService;
use App\Service\TestService;
use App\Service\QuizService;

應該都要改成
use App\Services\BankService;
use App\Services\GroupService;
use App\Services\TestService;
use App\Services\QuizService;

mackliu iT邦新手 4 級 ‧ 2023-07-01 20:20:01 檢舉

感謝指正...
看來要找時間全文再重新校對一次了...

0
Pallas
iT邦新手 3 級 ‧ 2023-07-04 10:00:19

還是很感謝您寫的文章,對於新手的我來說幫助很大
我看了你全系列的文章,每篇都認真照著作,萬分感謝

我要留言

立即登入留言