用Nested Set Model資料結構來實作圖片庫

建立時間: 2018-01-31 13:40:49
更新時間: 2018-02-03 10:43:01

Demo

基本的圖片庫操作

 

開始之前

先簡單敘述一下這個需求要實作的功能

既然要實作圖片庫

最基本至少要有以下幾個功能

  1. 上傳圖片
  2. 自由的建立資料夾
  3. 圖片可設定在某個資料夾內
  4. 圖片可自由搬移到其他資料夾內
  5. 資料夾可自由搬移到其他資料夾內
  6. 重新命名圖片或資料夾
  7. 刪除圖片或資料夾

當然除了這些基本功能

還可以實作更多進階功能

像是一次搬移多個資料夾、拖曳上傳等功能

但本篇內容主要以圖片庫的資料結構設計為主

進階實作可依照需求自行決定是否實作

 

本範例使用框架

  • VueJS 2
  • Laravel 5.5

 

主要使用套件

 

Nested Set Model

Nested Set Model是一個巢狀的資料結構

會需要用到這個結構是因為圖片庫要能夠建立巢狀的資料夾

而每個資料夾將會是Nested Set Model中的節點

所以我們並不會真的去建立一個資料夾

 

詳細說明可參考這篇用 Nested Set Model 建立巢狀資料表

裡面的內容我覺得講解的非常清楚

當然這篇不需要自己實作那麼複雜的資料結構

可使用一個叫Baum的套件

很方便的就能實作出Nested Set Model

 

怎麼讓圖片/資料夾放在資料夾內?

這是我一開始實作圖片庫這個需求遇到最大的瓶頸

最開始我是真的打算使用Laravel的FileSystem去讓User自己建立資料夾

結果一開始就遇到一個問題

就是當User資料夾名稱命名成中文的時候會出問題

後來才想到可以用Nested Set Model來解決這個問題

 

利用一個Nested Set的Table與Photo Table做一對多的關聯

當我們看到目前資料夾下的圖片

實際上是找與目前節點(資料夾)關聯的圖片

移動資料夾其實是移動Nested Set Model的其中一個節點

移動圖片則是更動Photo的關聯

讓User看起來好像真的是將圖片移動到一個資料夾內

實際上所有的圖片是放在同一個目錄下

資料夾(節點)Schema

主要結構還是依照baum文件建議的方式

不過因為希望這個Nested Set Model不只拿來當圖片庫資料夾

也能把其他樹拿來做其他用處

因此要使用baum的scoped功能

我們只使用其中一顆樹來做圖片資料夾

 

 <?php
 
 use Illuminate\Support\Facades\Schema;
 use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
 
 class CreateTreeSchema extends Migration
 {
     public function up()
     {
         Schema::create('tree_branch', function (Blueprint $table) {
             $table->engine = 'InnoDB';
             $table->string('id', 30);
             $table->string('branch', 50);
             $table->primary(['id']);
         });
 
         Schema::create('tree', function (Blueprint $table) {
             $table->engine = 'InnoDB';
             $table->string('id', 30);
             // 節點名稱
             $table->string('name', 50);
             $table->string('branch_id', 30);
             $table->foreign('branch_id')->references('id')->on('tree_branch')->onDelete('cascade');
 
             $table->string('parent_id', 30)->nullable();
             $table->integer('lft')->nullable();
             $table->integer('rgt')->nullable();
             $table->integer('depth')->nullable();
             $table->timestamps();
             $table->primary(['id', 'branch_id']);
         });
     }
 
     public function down()
     {
         Schema::disableForeignKeyConstraints();
         Schema::dropIfExists('tree');
         Schema::dropIfExists('tree_branch');
         Schema::enableForeignKeyConstraints();
     }
 }
 

 

圖片Schema

 <?php
 
 use Illuminate\Support\Facades\Schema;
 use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
 
 class CreatePhotoSchema extends Migration
 {
     public function up()
     {
         Schema::create('photo', function (Blueprint $table) {
             $table->engine = 'InnoDB';
             $table->string('id', 30);
             
             // User自訂圖片名稱
             $table->string('name', 50)->nullable();
             
             // 副檔名
             $table->string('extension', 10)->nullable();
             
             // 圖片uid
             $table->string('uid', 30)->nullable();
             
             // 圖片大小
             $table->integer('size')->default(0);
             
             // 資料夾(節點)id
             $table->string('folder_id', 30)->nullable();
             $table->foreign('folder_id')->references('id')->on('tree')->onDelete('cascade');
             $table->timestamps();
             $table->primary(['id', 'folder_id']);
         });
     }
 
     public function down()
     {
         Schema::disableForeignKeyConstraints();
         Schema::dropIfExists('photo');
         Schema::enableForeignKeyConstraints();
     }
 }
 

 

Tree Model

 <?php
 
 namespace Modules\Tree\Entities;
 
 class Tree extends \Baum\Node
 {
     protected $table = 'tree';
     public $incrementing = false;
     protected $scoped = ['branch_id'];
     protected $hidden = ['lft', 'rgt', 'parent_id', 'branch_id'];
     protected $fillable = [
         'id', 'name', 'branch_id', 'parent_id', 'lft', 'rgt', 'depth',
     ];
 }
 

 

Photo Model

這邊的env大概是這樣

version的用途主要是為了能夠一次清除所有圖片的cache

而帶在圖片url的後綴

 

PHOTO_STORE_PATHPHOTO_BASE_URL主要是要來組出圖片的url

利用env組出url是希望如果圖片有換domain能夠快速的搬移圖片

如我url寫死在DB會比較難搬

 

PHOTO_DISK則是要圖片儲存的FILE_DISK

 PHOTO_STORE_PATH=/photos/cube/
 PHOTO_BASE_URL=http://localhost:9000/storage
 PHOTO_DISK=public
 PHOTO_VERSION=v01

 

 <?php
 
 namespace Modules\Photo\Entities;
 use Illuminate\Database\Eloquent\Model;
 
 class Photo extends Model
 {
     protected $table = 'photo';
     public $incrementing = false;
     protected $appends = [
         'url',
     ];
 
     // 關聯的資料夾(節點)
     public function photo_folder() {
         return $this->belongsTo('Modules\Tree\Entities\Tree', 'folder_id', 'id');
     }
 
     // 組出圖片的url
     public function getUrlAttribute() {
         return env('PHOTO_BASE_URL').env('PHOTO_STORE_PATH').$this->attributes['uid'].'.'.$this->attributes['extension'].'?'.env('PHOTO_VERSION');
     }
 }
 

 

需要實做的API

圖片庫至少需要實做以下幾個API才算完整

下面會簡單說明API的前端使用時機以及後端原理

  1. 讀取目前資料夾內的圖片
  2. 讀取目前資料夾內的資料夾
  3. 新增圖片
  4. 新增資料夾
  5. 移動圖片
  6. 移動資料夾
  7. 刪除圖片
  8. 刪除資料夾

 

讀取目前資料夾內的圖片

前端

在第一次進入圖片庫頁

或是切換資料夾時須使用此API

 

後端

這邊算是搜尋圖片

就是帶著目前資料夾的id

取搜尋folder_id為該資料夾id的圖片

 $photo->select('*')->where('folder_id', '=', $request->folder_id);

 

讀取目前資料夾內的資料夾

前端

跟上一點一樣

在第一次進入圖片庫頁

或是切換到資料夾時須使用此API

 

後端

其實就是讀取目前節點的子節點

只要使用baum套件就能取得

 $node->children()->get();

 

新增圖片(上傳圖片)

前端

在一開始的Demo影片中

雖然是一次上傳多張圖片

但其實也是一張圖片發一次上傳的請求

不過為了要同時設定圖片的資料夾以及自訂的圖片名稱

因此除了檔案之外

目前資料夾的id及自訂的圖片名稱也會帶在上傳圖片請求中

 

後端

如果是Controller中簡單的流程

大概是這樣

photoRepository是我底層會去實際實做的資料以及儲存的機制

因為只是基本的FileSystem與DB操作

這邊就不另外說明

 public function upload(Request $request) {
     // 前面當然需要驗證request的資料, 但是不是本篇重點就不另外贅述
     
     $file = $request->file('file');
     $extension = strtolower($file->getClientOriginalExtension());
     $name = $request->name;
     $folder_id = $request->folder_id;
 
     // 儲存實體圖片
     $result = $this->photoRepository->store($file, $extension);
 
     // 建立圖片DB資料
     $photo = $this->photoRepository->create($file, $extension, $result['uid'], $folder_id, $name);
     $result['id'] = $photo->id;
 
     return $this->http200($result);
 }

 

新增資料夾

前端

當然是點選新增資料夾的時候

 

後端

一樣使用baum套件在目前節點上建立子節點

 $node->children()->create([ 'name' => '新資料夾' ]);

 

移動圖片

前端

拖曳圖片到資料夾上時

帶圖片id以及目標資料夾id

發出移動圖片請求

 

後端

更新圖片的folder_id關聯

 $photo = Photo::select('*')->where('id', '=', $request->photo_id)->first();
 $photo->folder_id = $request->folder_id;
 $photo->save();

 

移動資料夾

前端

拖曳資料夾到資料夾上時

帶資料夾id以及目標資料夾id

發出移動資料夾請求

 

後端

使用baum套件將節點移動到指定的目標節點上

 $node->makeFirstChildOf($target_node);

 

刪除圖片

這個比較簡單跟一般Equanent Model操作一樣

直接移除Photo Model就好

 $photo = Photo::where('id', '=', $request->photo_id)->first();
 $photo->delete();

 

刪除資料夾

這邊要注意下列幾個刪除流程

否則雖然表面會看不到刪除的資料夾

實際上會遺留一堆沒刪到的孤兒圖片

久了會造成系統空間的浪費

  1. 先取出要刪除圖片下的所有後代節點
  2. 將上述的節點關聯的圖片全部找出來
  3. 刪除檔案系統實體圖片
  4. 刪除DB中Photo Table中的圖片資料
  5. 刪除要刪除的節點(子節點baum會自動刪除)

 

 // 取出全部要刪除的節點(包含後代)array
 $nodes = $this->treeRepository->getDescendantsNodeByIds($folders);
 
 // 刪除與該節點關聯的圖片
 foreach ($nodes as $node) {
     // 取得每個資料夾內的圖片id array
     $photo_ids = $this->photoRepository->getPhotoByFolder($node);
     
     // 刪除圖片(包含DB Photo Table以及檔案系統實體圖片)
     $this->photoRepository->deleteMultiple($photo_ids);
 }
 
 // 刪除節點
 $this->treeRepository->batchDelete($nodes);

 

到這邊為止

我們已經可以利用Nested Set Model實做出圖片庫的功能

當然詳細的底層細節

要依照個人的需求來調整

因此上述程式碼的部份只做核心部份的說明

希望對想實作圖片庫的人有幫助

 

By The Way

如果圖片庫想串接Google Cloud Storage

可以參考我的另一篇文章

Laravel串接Google Cloud Storage