over 1 year ago

這一篇主講Sails中Config和如何將Database Model與RESTful API串連一塊
首先有兩個觀念要認識 BlueprintsORM/ODM Waterline

Blueprints

Sails為了提供快速的RESTful API搭建與減少使用者coding負擔,提出了Blueprints API的概念,提供快速產生ActionRoute的方法
Action中,Sails預設會提供基本的操作,如find、findOne、create、update、delete等等,當然可以在controller中自行覆寫或是擴充功能
Route中,Sails會提供3種路由解析並對應Action,像是

1. RESTful routes
POST /user -> 新增使用者
DELETE /user/123 -> 刪除123這位使用者
GET /user?name=hello -> 取得名字為hello的使用者

2. Shortcut routes
將Action描述放入URL中
/user/update/1?name=mike -> 更新使用者#1

3. Action routes
路由會自動將Controller對應路由,例如說FooController.js中有一個bar的function,那/foo/bar就會自動對應到該bar function
ORM/ODM Waterline

Sails內建ORM/ODM,希望可以提供多一層抽象層與資料庫API隔離,在Waterline中提供find/create/update/delete等高階API,不管底層連結的資料庫是用MySQL還是MongoDB使用者都可以不用理會(95%正確,後續補充),甚至Waterline還提供populate()方式提供跨資料庫的關聯方式,相當強大!
如果不滿於Waterline內建的函式,也可以透過native()、query()等方式直接呼叫底層資料庫API,這點Sails保留適當的彈性。

以使用者管理為例子,產生了User.js與UserController.js,前者定義Databse的Model欄位,後者定義應用程式如何操作Model的業務邏輯,並用Blueprints API直接用POSTman調用API

使用者管理
  1. $ npm install sails-mongo
    我使用Mongo DB當作目前的資料庫實驗,另外建議安裝GUI Client方便查看操作是否成功
  2. 修改/confing/connection.js
    在Sails中,想要使用任何的資料庫都必須先設定連線,記得adapter要先安裝喔
    localMongo: {
       adapter: 'sails-mongo',
       host: 'localhost', // defaults to localhost if omitted
    
       port: 27017, // defaults to 27017 if omitted
    
       database: 'blog' // or omit if not relevant
    
    }
    
  3. 修改/config/models.js
    /config/models.js主要定義每個/api/models/{Model}.js的預設值,也可以在個別{Model}.js中覆寫設定
    通常這裡就放預定的連線資料庫connection,記得名稱必須是剛才設定在connection.js中的資料庫名稱
    connection: 'localMongo',
    migrate:'alter',
    autoCreatedAt: true,
    autoUpdatedAt: true
    
  4. $ sails generate api user
    此時會自動生成/api/controllers/UserController.js和/api/models/User.js
  5. 修改/api/models/User.js
    這個檔案就是放Schema設定,Sails有自訂的資料型別,此時SQL vs NoSQL有些許不同,如在MySQL中有VARCHAR(255)的型別但在MongoDB是沒有的,在Sails中統稱為string型別,但如果是用MySQL可以加入size限定字串長度
    module.exports = {
    attributes: {
        id:{
            type: 'objectid',
            primaryKey: true
        },
        username:{
            type: 'string',
            required: true
        },
        account:{
            type: 'string',
            size: 20,     //對SQL有用
    
            required: true,
            unique: true
        },
        password:{
            type: 'string',
            required: true,
            size: 20
        }
    }
    };
    
  6. 此時/api/controllers/UserController.js尚未修改,執行$ sails lift並用Postman測試Blueprints API 先前提過,Blueprints會自動產生Action並對應3種不同的路由方式,此時就先直接測試
    執行$sails lift時如果出現error orm()...的字樣,可能是model中的connection與connection.js設定的名稱不同或是資料庫連線出問題,記得先排除;
    接著使用Postman或其他相關工具
    1. POST localhost:1337/user  { "username": "helloworld", "account": "1243@g.com", "password": "1234asd"}
    此時會回傳成功創建的物件,同時多了idcreateAtupdateAt欄位,後兩者是因為在/config/models.js中有設定並套用到User.js
    2. GET localhost:1337/user
    [{....}]
    回傳目前所有的使用者
    3. GET localhost:1337/user?username=helloworld
    回傳使用者名稱為helloworld的使用者
    4. DELETE localhost:1337/user/58d4933a45b08933be14b84c
    刪除時第二個參數只能是id喔!
    // 先前account有限定要unique,如果重複創建相同account會噴錯誤
    

此時還沒寫任何的業務邏輯就可以有基本的CRUD與RESTful API操作,這正是Sails的強大與方便之處!
另外對於Blueprints提供的三種路由方式,可以在/config/blueprints.js設定是否開啟,當然也可以選擇全部關閉並以/config/routes.js為主,這後續再補充。

Model Lifecycle、Validation與Associations
  1. Validation
    Sails內建提供多種欄位驗證的方式,在每個create()、update()Sails都會確保資料格式符合才寫入資料庫中!
    除了unique是採用database-level的驗證方式,其他如maxLength、email等都是JS functin,文件中指出unique是廣泛資料庫都有提供的欄位驗證,如果要在Sails應用層做到檢查欄位不重複的開銷太大了,所以都是透過database adapter去做unique檢查。
    另外要記得,在每次創建與更新都執行驗證程序是有開銷的,如果不適用於前者情況可以在Controller、Service或是自定義Model函式自己手動驗證,例如說 假設使用者在註冊階段想要開通支付的服務,但是要開通服務就必須要設定Email反之不用,這種判定狀況最好就自己手動驗證。
    最後,如果要自己設定錯誤訊息可以透過$ npm install sails-hook-validation並加入validationMessages欄位,這部分可以參考文件
    / 修正</span><span class="o">/</span><span class="nx">api</span><span class="o">/</span><span class="nx">models</span><span class="o">/</span><span class="nx">User</span><span class="p">.</span><span class="nx">js</span><span class="err">寫法
    module.exports = {
      attributes: {
          id:{
              type: 'objectid',
              primaryKey: true
          },
          username:{
              type: 'string',
              required: true,
              minLength: 5,
              maxLength: 15
          },
          account:{
              type: 'string',
              size: 20,
              required: true,
              unique: true,
              email: true
          },
          password:{
              type: 'string',
              required: true,
              size: 20,
              password: true / << 定義於下方
          }
      },
      / 自定義驗證方式
          types:{
          password: function(value) {
              / • be a string
                  /  be at least 6 characters long
              /  contain at least one number
              /  contain at least one letter
              return _.isString(value) && value.length >= 6 && value.match(/[a-z]/i) && value.match(/[0-9]/);
          }
      },
      / 自訂錯誤訊息
          validationMessages: {
          username: {
              required: 'username is required',
              minLength: 'Provide valid email address',
              maxLength: 'Email address is already taken'
          },
          account: {
              required: 'Username is required',
              email: 'Account format is email',
              unique: 'Account should be unique'
          }
      }
    };
    
  2. Lifecyclecreate()、update()有支援beforeValidate()->afterValidate()->before{Action}()->after{Action}(),detroy()則只有後面兩個函式,最常使用到的便是在beforeCreate()中將密碼做Hash的動作
    / 以下加入/api/models/User.js
    / Lifecycle Callbacks
    beforeCreate: function (values, cb) {
      bcrypt.hash(values.password, 10, function(err, hash) {
        if(err) return cb(err);
        values.password = hash;
        / calling cb() with an argument returns an error. Useful for canceling the entire operation if some criteria fails.
            cb();
      });
    }
    
  3. Associations
    如果有使用過其他的ORM如Sequelize的話,對於1-1、1-多、多-多關聯的實作方式就很容易理解
    a. 多對多
    Sails會預設會創建兩個中間Table記錄雙方的key,兩者都可以透過populate()打印出關聯的資料,透過定義collectionvia指定關聯的Table與欄位;
    比較特別的是Sails考量到跨資料庫關聯而有了dominant欄位設定,這個設定表示關聯產生的中間Table放在哪個資料庫中,原則上如果一方式關聯式資料庫則放這在那一邊;
    如果想要自定義中間Table可以透過through,這樣就只會有一個中間Table而已
    b. 一對多
    多方為了表示僅針對一方,使用model顯示關聯的Table,而一方則使用collectionvia表示隱式關聯。
    c. 一對一
    將一對多中多方的model加上unique的限制,這樣就成了同步的一對一
  4. 補充Model設定
    在/config/models.js中要特別注意一個屬性migrate,這個屬性決定了每次重啟Sails對於資料庫中原有資料的處置方式,尤其是在開發階段,可能會在table/collection中新增欄位、增加屬性等等,此時會產生重大的改變,可以選擇手動或是自動更新資料庫Schema格式;
    migrate共有三個屬性值
    a. safe:
    Sails什麼事都不會幫你做,你必須手動下SQL改變Table屬性
    b. alter:
    Sails會嘗試保留原資料,並自動改變現有Table屬性(實驗中)
    c. drop:
    在每次$ sails lift時捨棄所有資料並重新建Table

所以在Production務必使用safe否則現有資料會不見或是有不可預期的變化!

此時我們加入Article與Tag,一名User可以創建多篇Article,而Article則只能有一個User為作者,Article與Tag為多對多關聯:
首先看User和Article

/* User中屬性加入 */
articleList: {
    collection: 'article',
    via: 'author'
}
/* Article中屬性加入 */
author: {
    model: 'user',
    required: true
},

觀察DB實際儲存的資料格式,User中欄位沒有增加articleList(隱式關聯),而Article中有author欄位(顯示關聯,表對應一位User),但是如果使用GET /user或是GET /article會發現Sails會自動將資料關聯後回傳!

接著是Tag與Article多對多,記得先創建中間Table $ sails generate model articleTagMap,中間Table不需要Controller

/* Article屬性*/
tagList: {
    collection: 'tag',
    via: 'id',
    through: 'articletagmap'
}
/* Tag屬性 */
articleList:{
    collection: 'article',
    via: '_id',
    through: 'articletagmap'
}
/* 中間Table ArticleTagMap.js */
attributes: {
  tag:{
    model:'tag'
  },
  article: {
    model: 'article'
  }
}

因為多對多關係必須先個別創建Artilce和Tag,接著才是在Artilce中加入已經存在的Tag,所以我們必須自己建立一個新增Tag的方法並加入路由!

自建Model方法、加入Controller與路由控制

首先是自建Model的方法,在官方文件中提到共有兩種方式,一種是屬性方法(Attribute Method)模型方法(Model Method)
前者主要是可以自定義回傳屬性值判斷,例如說 判斷是否符合老人資格

isEligibleForSocialSecurity: function (){
    return this.age >= 65;
},

後者是我們這次要加入的是後者,主要是將方法定義在Model上,可以做更進一步的非同步處理,要特別記住官網指出不要再Model Methods使用req、res,而是要透過Controller去處理,這裡直接看範例

        /* 在Article.js中,加入自定義 將文章加入Tag */
    addTag: function(opts, cb){
        "use strict";
        if(!opts.articleId && opts.tagId){
            cb("missing params");
        }

        Article.findOne(opts.articleId).exec((err, article)=>{
            if(err || !article) return cb("article not exist");

            Tag.findOne(opts.tagId).exec((err, tag)=>{
                if(err || !tag) return cb("tag not exist");
                article.tagList.add(tag.id);
                article.save(cb);
            });
        })
    },
    
    /* ArticleController.js */
    addTag: function(req, res){
        "use strict";
        let articleId = req.body.articleId;
        let tagId = req.body.tagId;
        if(!articleId || !tagId){
            return res.badRequest("Missing Params");
        }
        Article.addTag({articleId, tagId}, function(err, article){
            if(err) {
                console.log("controller",err);
                return res.badRequest("Params Error");
            }

            res.created();
        })
    }
    
    /* /config/routes.js中加入 */
    'POST /article/tag': 'ArticleController.addTag'

Article.js中,加入自定義的addTag方法,裡頭就只是先確認Article和Tag存在,接著透過原先內建的add()、save()儲存,另外,在Sails中Model是Global的,所以我都不需要另外注入Model就可以直接使用;
ArticleController.js中,因為我們預設路徑是使用POST,所以參數用body去接,這點和Express是一樣的,另外req.badRequest()和req.create()都是Sails內建的Response方法,可以參考/api/response底下,註解非常仔細可以直接閱讀,當然也可以自定義,後續有需要再提;
最後是在/config/routes.js加入我們希望對應的路由方式與路徑!

錯誤修正,id is not valid

這是sails-mongo的bug,我們使用查詢得到的article中id屬性被轉為string,但在Mongo DB儲存的格式為ObjectId,型別不同在使用article.save()就會噴出錯誤,後來的解決方式就將article中的id屬性拿掉,之後都改用MongoDB自動創建的_id當作唯一的辨別碼即可。

結論

這一章主要探討Sails Model的配置與設定,但是目前操作權限都是對外開啟,沒有認證與條件判斷的機制,下一章持續探討Sails的中間件設定與路由機制

← Sails.js - 從Express到Sails,打造部落格系統一 : 專案架構 Sails.js - 從Express到Sails,打造部落格系統三 : 路由判斷與驗證機制 →
 
comments powered by Disqus