微服務架構(6):品牌新增&&使用FastDFS客戶端實現圖片上傳

學習目標

  • 獨立實現品牌新增
  • 實現圖片上傳
  • 瞭解FastDFS的安裝
  • 使用FastDFS客戶端實現上傳

1.品牌的新增

昨天我們完成了品牌的查詢,接下來就是新增功能。

1.1.頁面實現

1.1.1.初步編寫彈窗

當我們點擊新增按鈕,應該出現一個彈窗,然後在彈窗中出現一個表格,我們就可以填寫品牌信息了。

我們查看Vuetify官網,彈窗是如何實現:
在這裏插入圖片描述
另外,我們可以通過文檔看到對話框的一些屬性:

  • value:控制窗口的可見性,true可見,false,不可見
  • max-width:控制對話框最大寬度
  • scrollable :是否可滾動,要配合v-card來使用,默認是false
  • persistent :點擊彈窗以外的地方不會關閉彈窗,默認是false

現在,我們來使用一下。

首先,我們在data中定義一個show屬性,來控制對話框的顯示狀態:
在這裏插入圖片描述

然後,在頁面添加一個v-dialog

<!--彈出的對話框-->
<v-dialog max-width="500" v-model="show" persistent>
    <v-card>
        <!--對話框的標題-->
        <v-toolbar dense dark color="primary">
            <v-toolbar-title>新增品牌</v-toolbar-title>
        </v-toolbar>
        <!--對話框的內容,表單-->
        <v-card-text class="px-5">
            我是表單
        </v-card-text>
    </v-card>
</v-dialog>

說明:

  • 我們給dialog指定了3個屬性,分別是

    • max-width:限制寬度
    • v-model:value值雙向綁定到show變量,用來控制窗口顯示
    • persisitent:控制窗口不會被意外關閉
  • 因爲可滾動需要配合v-card使用,因此我們在對話框中加入了一個v-card

    • v-card的頭部添加了一個 v-toolbar,作爲窗口的頭部,並且寫了標題爲:新增品牌
      • dense:緊湊顯示
      • dark:黑暗主題
      • color:顏色,primary就是整個網站的主色調,藍色
    • v-card的內容部分,暫時空置,等會寫表單
  • class=“px-5":vuetify的內置樣式,含義是padding的x軸設置爲5,這樣表單內容會縮進一些,而不是頂着邊框

    基本語法:{property}{direction}-{size}

    • property:屬性,有兩種paddingmargin
      • p:對應padding
      • m:對應margin
    • direction:只padding和margin的作用方向,
      • t - 對應margin-top或者padding-top屬性
      • b - 對應margin-bottom or padding-bottom
      • l - 對應margin-left or padding-left
      • r - 對應margin-right or padding-right
      • x - 同時對應*-left*-right屬性
      • y - 同時對應*-top*-bottom屬性
    • size:控制空間大小,基於$spacer進行倍增,$spacer默認是16px
      • 0:將margin或padding的大小設置爲0
      • 1 - 將margin或者padding屬性設置爲$spacer * .25
      • 2 - 將margin或者padding屬性設置爲$spacer * .5
      • 3 - 將margin或者padding屬性設置爲$spacer
      • 4 - 將margin或者padding屬性設置爲$spacer * 1.5
      • 5 - 將margin或者padding屬性設置爲$spacer * 3

1.1.2.實現彈窗的可見和關閉

窗口可見

接下來,我們要在點擊新增品牌按鈕時,將窗口顯示,因此要給新增按鈕綁定事件。

<v-btn color="primary" @click="addBrand">新增品牌</v-btn>

然後定義一個addBrand方法:

addBrand(){
    // 控制彈窗可見:
    this.show = true;
}

效果:
在這裏插入圖片描述

窗口關閉

現在,悲劇發生了,因爲我們設置了persistent屬性,窗口無法被關閉了。除非把show屬性設置爲false

因此我們需要給窗口添加一個關閉按鈕:

<!--對話框的標題-->
<v-toolbar dense dark color="primary">
    <v-toolbar-title>新增品牌</v-toolbar-title>
    <v-spacer/>
    <!--關閉窗口的按鈕-->
    <v-btn icon @click="closeWindow"><v-icon>close</v-icon></v-btn>
</v-toolbar>

並且,我們還給按鈕綁定了點擊事件,回調函數爲closeWindow。

接下來,編寫closeWindow函數:

closeWindow(){
    // 關閉窗口
    this.show = false;
}

效果:
在這裏插入圖片描述

1.1.3.新增品牌的表單頁

接下來就是寫表單了。我們有兩種選擇:

  • 直接在dialog對話框中編寫表單代碼
  • 另外編寫一個組件,組件內寫表單代碼。然後在對話框引用組件

選第幾種?

我們選第二種方案,優點:

  • 表單代碼獨立組件,可拔插,方便後期的維護。
  • 代碼分離,可讀性更好。

我們新建一個MyBrandForm.vue組件:

在這裏插入圖片描述

將MyBrandForm引入到MyBrand中,這裏使用局部組件的語法:

先導入自定義組件:

  // 導入自定義的表單組件
  import MyBrandForm from './MyBrandForm'

然後通過components屬性來指定局部組件:

components:{
    MyBrandForm
}

然後在頁面中引用:

頁面效果:
在這裏插入圖片描述

1.1.4.編寫表單

1.1.4.1.表單

查看文檔,找到關於表單的部分:
在這裏插入圖片描述

v-form,表單組件,內部可以有許多輸入項。v-form有下面的屬性:

  • value:true,代表表單驗證通過;false,代表表單驗證失敗

v-form提供了兩個方法:

  • reset:重置表單數據
  • validate:校驗整個表單數據,前提是你寫好了校驗規則。返回Boolean表示校驗成功或失敗

我們在data中定義一個valid屬性,跟表單的value進行雙向綁定,觀察表單是否通過校驗,同時把等會要跟表單關聯的品牌brand對象聲明出來:

  export default {
    name: "my-brand-form",
    data() {
      return {
        valid:false, // 表單校驗結果標記
        brand:{
          name:'', // 品牌名稱
          letter:'', // 品牌首字母
          image:'',// 品牌logo
          categories:[], // 品牌所屬的商品分類數組
        }
      }
    }
  }

然後,在頁面先寫一個表單:

<v-form v-model="valid">

</v-form>

1.1.4.2.文本框

我們的品牌總共需要這些字段:

  • 名稱
  • 首字母
  • 商品分類,有很多個
  • LOGO

表單項主要包括文本框、密碼框、多選框、單選框、文本域、下拉選框、文件上傳等。思考下我們的品牌需要哪些?

  • 文本框:品牌名稱、品牌首字母都屬於文本框
  • 文件上傳:品牌需要圖片,這個是文件上傳框
  • 下拉選框:商品分類提前已經定義好,這裏需要通過下拉選框展示,提供給用戶選擇。

先看文本框,昨天已經用過的,叫做v-text-field

在這裏插入圖片描述

查看文檔,v-text-field有以下關鍵屬性:

  • append-icon:文本框後追加圖標,需要填寫圖標名稱。無默認值
  • clearable:是否添加一個清空圖標,點擊會清空文本框。默認是false
  • color:顏色
  • counter:是否添加一個文本計數器,在角落顯示文本長度,指定true或允許的組大長度。無默認值
  • dark:是否應用黑暗色調,默認是false
  • disable:是否禁用,默認是false
  • flat:是否移除默認的動畫效果,默認是false
  • full-width:指定寬度爲全屏,默認是false
  • hide-details:是否因此錯誤提示,默認是false
  • hint:輸入框的提示文本
  • label:輸入框的標籤
  • multi-line:是否轉爲文本域,默認是false。文本框和文本域可以自由切換
  • placeholder:輸入框佔位符文本,focus後消失
  • required:是否爲必填項,如果是,會在label後加*,不具備校驗功能。默認是false
  • rows:文本域的行數,multi-line爲true時纔有效
  • rules:指定校驗規則及錯誤提示信息,數組結構。默認[]
  • single-line:是否單行文本顯示,默認是false
  • suffix:顯示後綴

接下來,我們先添加兩個字段:品牌名稱、品牌的首字母,校驗規則暫時不寫:

  <v-form v-model="valid">
    <v-text-field v-model="brand.name" label="請輸入品牌名稱" required />
    <v-text-field v-model="brand.letter" label="請輸入品牌首字母" required />
  </v-form>
  • 千萬不要忘了通過v-model把表單項與brand的屬性關聯起來。

效果:

在這裏插入圖片描述

1.1.4.3.級聯下拉選框

接下來就是商品分類了,按照剛纔的分析,商品分類應該是下拉選框。

但是大家仔細思考,商品分類包含三級。在展示的時候,應該是先由用戶選中1級,才顯示2級;選擇了2級,才顯示3級。形成一個多級分類的三級聯動效果。

這個時候,就不是普通的下拉選框,而是三級聯動的下拉選框!

這樣的選框,在Vuetify中並沒有提供(它提供的是基本的下拉框)。因此我已經給大家編寫了一個無限級聯動的下拉選框,能夠滿足我們的需求。

在這裏插入圖片描述

具體請參考課前資料的《自定義組件用法指南.md》

我們在代碼中使用:

    <v-cascader
      url="/item/category/list"
      multiple 
      required
      v-model="brand.categories"
      label="請選擇商品分類"/>
  • url:加載商品分類選項的接口路徑
  • multiple:是否多選,這裏設置爲true,因爲一個品牌可能有多個分類
  • requried:是否是必須的,這裏爲true,會在提示上加*,提醒用戶
  • v-model:關聯我們brand對象的categories屬性
  • label:文字說明

效果:

在這裏插入圖片描述

data中獲取的結果:
在這裏插入圖片描述

1.1.4.4.文件上傳項

在Vuetify中,也沒有文件上傳的組件。

還好,我已經給大家寫好了一個文件上傳的組件:
在這裏插入圖片描述

詳細用法,參考《自定義組件使用指南.md》

我們添加上傳的組件:

<v-layout row>
    <v-flex xs3>
        <span style="font-size: 16px; color: #444">品牌LOGO:</span>
    </v-flex>
    <v-flex>
        <v-upload
             v-model="brand.image"
             url="/upload" 
             :multiple="false" 
             :pic-width="250" 
             :pic-height="90"
                  />
    </v-flex>
</v-layout>

注意:

  • 文件上傳組件本身沒有提供文字提示。因此我們需要自己添加一段文字說明
  • 我們要實現文字和圖片組件左右放置,因此這裏使用了v-layout佈局組件:
    • layout添加了row屬性,代表這是一行,如果是column,代表是多行
    • layout下面有v-flex組件,是這一行的單元,我們有2個單元
      • <v-flex xs3> :顯示文字說明,xs3是響應式佈局,代表佔12格中的3格
      • 剩下的部分就是圖片上傳組件了
  • v-upload:圖片上傳組件,包含以下屬性:
    • v-model:將上傳的結果綁定到brand的image屬性
    • url:上傳的路徑,我們先隨便寫一個。
    • multiple:是否運行多圖片上傳,這裏是false。因爲品牌LOGO只有一個
    • pic-width和pic-height:可以控制l圖片上傳後展示的寬高

最終結果:
在這裏插入圖片描述

1.1.4.5.按鈕

上面已經把所有的表單項寫完。最後就差提交和清空的按鈕了。

在表單的最下面添加兩個按鈕:

    <v-layout class="my-4" row>
      <v-spacer/>
      <v-btn @click="submit" color="primary">提交</v-btn>
      <v-btn @click="clear" >重置</v-btn>
    </v-layout>
  • 通過layout來進行佈局,my-4增大上下邊距
  • v-spacer佔用一定空間,將按鈕都排擠到頁面右側
  • 兩個按鈕分別綁定了submit和clear事件

我們先將方法定義出來:

methods:{
    submit(){
        // 提交表單
    },
    clear(){
        // 重置表單
    }
}

重置表單相對簡單,因爲v-form組件已經提供了reset方法,用來清空表單數據。只要我們拿到表單組件對象,就可以調用方法了。

我們可以通過$refs內置對象來獲取表單組件。

首先,在表單上定義ref屬性:
在這裏插入圖片描述

然後,在頁面查看this.$refs屬性:

在這裏插入圖片描述

看到this.$refs中只有一個屬性,就是myBrandForm

我們在clear中來獲取表單對象並調用reset方法:

    methods:{
      submit(){
        // 提交表單
        console.log(this);
      },
      clear(){
        // 重置表單
        this.$refs.myBrandForm.reset();
        // 需要手動清空商品分類
        this.categories = [];
      }
    }

要注意的是,這裏我們還手動把this.categories清空了,因爲我寫的級聯選擇組件並沒有跟表單結合起來。需要手動清空。

1.1.5.表單校驗

1.1.5.1.校驗規則

Vuetify的表單校驗,是通過rules屬性來指定的:
在這裏插入圖片描述
校驗規則的寫法:
在這裏插入圖片描述

說明:

  • 規則是一個數組
  • 數組中的元素是一個函數,該函數接收表單項的值作爲參數,函數返回值兩種情況:
    • 返回true,代表成功,
    • 返回錯誤提示信息,代表失敗

1.1.5.2.項目中代碼

我們有四個字段:

  • name:做非空校驗和長度校驗,長度必須大於1
  • letter:首字母,校驗長度爲1,非空。
  • image:圖片,不做校驗,圖片可以爲空
  • categories:非空校驗,自定義組件已經幫我們完成,不用寫了

首先,我們定義規則:

nameRules:[
    v => !!v || "品牌名稱不能爲空",
    v => v.length > 1 || "品牌名稱至少2位"
],
letterRules:[
    v => !!v || "首字母不能爲空",
    v => /^[A-Z]{1}$/.test(v) || "品牌字母只能是A~Z的大寫字母"
]

然後,在頁面標籤中指定:

<v-text-field v-model="brand.name" label="請輸入品牌名稱" required :rules="nameRules" />
<v-text-field v-model="brand.letter" label="請輸入品牌首字母" required :rules="letterRules" />

效果:

在這裏插入圖片描述

1.1.6.表單提交

在submit方法中添加表單提交的邏輯:

submit() {
    // 1、表單校驗
    if (this.$refs.myBrandForm.validate()) {
        // 2、定義一個請求參數對象,通過解構表達式來獲取brand中的屬性
        const {categories ,letter ,...params} = this.brand;
        // 3、數據庫中只要保存分類的id即可,因此我們對categories的值進行處理,只保留id,並轉爲字符串
        params.cids = categories.map(c => c.id).join(",");
        // 4、將字母都處理爲大寫
        params.letter = letter.toUpperCase();
        // 5、將數據提交到後臺
        this.$http.post('/item/brand', params)
            .then(() => {
            // 6、彈出提示
            this.$message.success("保存成功!");
        })
            .catch(() => {
            this.$message.error("保存失敗!");
        });
    }
}
  • 1、通過this.$refs.myBrandForm選中表單,然後調用表單的validate方法,進行表單校驗。返回boolean值,true代表校驗通過

  • 2、通過解構表達式來獲取brand中的值,categories和letter需要處理,單獨獲取。其它的存入params對象中

  • 3、品牌和商品分類的中間表只保存兩者的id,而brand.categories中保存的數對象數組,裏面有id和name屬性,因此這裏通過數組的map功能轉爲id數組,然後通過join方法拼接爲字符串

  • 4、首字母都處理爲大寫保存

  • 5、發起請求

  • 6、彈窗提示成功還是失敗,這裏用到的是我們的自定義組件功能message組件:

在這裏插入圖片描述

這個插件把$message對象綁定到了Vue的原型上,因此我們可以通過this.$message來直接調用。

包含以下常用方法:

  • info、error、success、warning等,彈出一個帶有提示信息的窗口,色調與爲普通(灰)、錯誤(紅色)、成功(綠色)和警告(黃色)。使用方法:this.$message.info(“msg”)
  • confirm:確認框。用法:this.$message.confirm("確認框的提示信息"),返回一個Promise

1.2.後臺實現新增

1.2.1.controller

還是一樣,先分析四個內容:

  • 請求方式:剛纔看到了是POST
  • 請求路徑:/brand
  • 請求參數:brand對象,外加商品分類的id數組cids
  • 返回值:無

代碼:

/**
 * 新增品牌
 * @param brand
 * @return
 */
@PostMapping
public ResponseEntity<Void> saveBrand(Brand brand, @RequestParam("cids") List<Long> cids) {
    this.brandService.saveBrand(brand, cids);
    return new ResponseEntity<>(HttpStatus.CREATED);
}

1.2.2.Service

這裏要注意,我們不僅要新增品牌,還要維護品牌和商品分類的中間表。

@Transactional
    public void saveBrand(Brand brand, List<Long> cids) {
        //新增品牌,count爲1則新增成功,反之爲0
        int count = brandMapper.insert(brand);
        if (count != 1) {
            throw new LyException(ExceptionEnum.BRAND_SAVE_ERROR);
        }
        
        //新增中間表(品牌與分類)
        for (Long cid :
                cids) {
            int count1 = brandMapper.insertCategoryBrand(cid, brand.getId());
            if (count != 1) {
                throw new LyException(ExceptionEnum.BRAND_SAVE_ERROR);
            }
        }
    }

這裏調用了brandMapper中的一個自定義方法,來實現中間表的數據新增

1.2.3.Mapper

通用Mapper只能處理單表,也就是Brand的數據,因此我們手動編寫一個方法及sql,實現中間表的新增:

public interface BrandMapper extends Mapper<Brand> {
    /**
     * 新增商品分類和品牌中間表數據
     * @param cid 商品分類id
     * @param bid 品牌id
     * @return
     */
    @Insert("INSERT INTO tb_category_brand (category_id, brand_id) VALUES (#{cid},#{bid})")
    int insertCategoryBrand(@Param("cid") Long cid, @Param("bid") Long bid);
}

1.3.請求參數格式錯誤

1.3.1.原因分析

我們填寫表單並提交,發現報錯了:

在這裏插入圖片描述

查看控制檯的請求詳情:

在這裏插入圖片描述

發現請求的數據格式是JSON格式。

原因分析:

axios處理請求體的原則會根據請求數據的格式來定:

  • 如果請求體是對象:會轉爲json發送

  • 如果請求體是String:會作爲普通表單請求發送,但需要我們自己保證String的格式是鍵值對。

    如:name=jack&age=12

所以必須將person對象變成了 name=jack&age=21的字符串

1.3.2.QS工具

QS是一個第三方庫,我們可以用npm install qs --save來安裝。不過我們在項目中已經集成了,大家無需安裝:

在這裏插入圖片描述

這個工具的名字:QS,即Query String,請求參數字符串。

什麼是請求參數字符串?例如: name=jack&age=21

QS工具可以便捷的實現 JS的Object與QueryString的轉換,將請求中的對象轉換成字符串而並不是拿到的json數據。

在我們的項目中,將QS注入到了Vue的原型對象中,我們可以通過this.$qs來獲取這個工具:

我們將this.$qs對象打印到控制檯:

created(){
    console.log(this.$qs);
}

發現其中有3個方法:

在這裏插入圖片描述

這裏我們要使用的方法是stringify,它可以把Object轉爲QueryString。

測試一下,使用瀏覽器工具,把qs對象保存爲一個臨時變量:

在這裏插入圖片描述

然後調用stringify方法:
在這裏插入圖片描述

成功將person對象變成了 name=jack&age=21的字符串了

1.3.3.解決問題

修改頁面,對參數處理後發送:

在這裏插入圖片描述

然後再次發起請求:

在這裏插入圖片描述

發現請求成功:

在這裏插入圖片描述

參數格式:

在這裏插入圖片描述

數據庫:
在這裏插入圖片描述

1.4.新增完成後關閉窗口

我們發現有一個問題:新增不管成功還是失敗,窗口都一致在這裏,不會關閉。

這樣很不友好,我們希望如果新增失敗,窗口保持;但是新增成功,窗口關閉纔對。

因此,我們需要在新增的ajax請求完成以後,關閉窗口

但問題在於,控制窗口是否顯示的標記在父組件:MyBrand.vue中。子組件如何才能操作父組件的屬性?或者告訴父組件該關閉窗口了?

之前我們講過一個父子組件的通信,有印象嗎?

  • 第一步,在父組件中定義一個函數,用來關閉窗口,不過之前已經定義過了,我們優化一下,關閉的同時重新加載數據:
closeWindow(){
    // 關閉窗口
    this.show = false;
    // 重新加載數據
    this.getDataFromServer();
}
  • 第二步,父組件在使用子組件時,綁定事件,關聯到這個函數:
<!--對話框的內容,表單-->
<v-card-text class="px-5">
    <my-brand-form @close="closeWindow"/>
</v-card-text>
  • 第三步,子組件通過this.$emit調用父組件的函數:

在這裏插入圖片描述
測試一下

2.實現圖片上傳

剛纔的新增實現中,我們並沒有上傳圖片,接下來我們一起完成圖片上傳邏輯。

文件的上傳並不只是在品牌管理中有需求,以後的其它服務也可能需要,因此我們創建一個獨立的微服務,專門處理各種上傳。

2.1.搭建項目

2.1.1.創建module

在這裏插入圖片描述

2.1.2.依賴

我們需要EurekaClient和web依賴:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>leyou</artifactId>
        <groupId>com.leyou.parent</groupId>
        <version>1.0.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.leyou.service</groupId>
    <artifactId>ly-upload</artifactId>
    <version>1.0.0-SNAPSHOT</version>

    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    </dependencies>
</project>

2.1.3.編寫配置

server:
  port: 8082
spring:
  application:
    name: upload-service
  servlet:
    multipart:
      max-file-size: 5MB # 限制文件上傳的大小
# Eureka
eureka:
  client:
    service-url:
      defaultZone: http://127.0.0.1:10087/eureka
  instance:
    prefer-ip-address: true
    ip-address: 127.0.0.1

需要注意的是,我們應該添加了限制文件大小的配置

2.1.4.啓動類

@SpringBootApplication
@EnableDiscoveryClient
public class LyUploadService {
    public static void main(String[] args) {
        SpringApplication.run(LyUploadService.class, args);
    }
}

結構:

在這裏插入圖片描述

2.2.編寫上傳功能

2.2.1.controller

編寫controller需要知道4個內容:

  • 請求方式:上傳肯定是POST
  • 請求路徑:/upload/image
  • 請求參數:文件,參數名是file,SpringMVC會封裝爲一個接口:MultipleFile
  • 返回結果:上傳成功後得到的文件的url路徑

代碼如下:

@RestController
@RequestMapping("upload")
public class UploadController {

    @Autowired
    private UploadService uploadService;

    /**
     * 上傳圖片
     * @param file SpringMVC封裝成MultipartFile
     * @return 返回的是個url路徑
     */
    @PostMapping("image")
    public ResponseEntity<String> uploadImage(@RequestParam("file")MultipartFile file) {
        String url = uploadService.uploadImage(file);
        return ResponseEntity.ok(url);
    }
}

2.2.2.service

在上傳文件過程中,我們需要對上傳的內容進行校驗:

  1. 校驗文件的媒體類型
  2. 校驗文件的內容

文件大小在Spring的配置文件中設置,因此已經會被校驗,我們不用管。

具體代碼:

@Service
@Slf4j
public class UploadService {

    private static final List<String> ALLOW_TYPES = Arrays.asList("image/jpeg","image/png","image/bmp");

    public String uploadImage(MultipartFile file) {

        try {
            //校驗文件類型
            String contentType = file.getContentType();
            if (!ALLOW_TYPES.contains(contentType)) {
                throw new LyException(ExceptionEnum.FILE_TYPE_NOT_MATCH);
            }

            //校驗文件內容
            BufferedImage image = ImageIO.read(file.getInputStream());
            if (image == null) {
                throw new LyException(ExceptionEnum.FILE_TYPE_NOT_MATCH);
            }

            //準備目標路徑
            File dest = new File("E:\\Maven工程\\upload",file.getOriginalFilename());
            //保存文件到本地
            file.transferTo(dest);
            //返回路徑
            return "http://image.leyou.com/" + file.getOriginalFilename();
        } catch (IOException e) {
            //上傳失敗
            log.error("上傳文件失敗",e);
            throw new LyException(ExceptionEnum.UPLOAD_FILE_ERROR);
        }
    }
}

這裏有一個問題:爲什麼圖片地址需要使用另外的url?

  • 圖片不能保存在服務器內部,這樣會對服務器產生額外的加載負擔
  • 一般靜態資源都應該使用獨立域名,這樣訪問靜態資源時不會攜帶一些不必要的cookie,減小請求的數據量

2.2.3.測試上傳

我們通過RestClient工具來測試:
在這裏插入圖片描述
結果:
在這裏插入圖片描述
去目錄下查看:

在這裏插入圖片描述

上傳成功!

2.2.4.忽略路由前綴

Zuul的理由功能,會忽略路由匹配的路徑前綴,不過我們的Controller中有/upload路徑。但是配置中因爲路由匹配的前綴、/upload在請求轉發過程中被自動忽略。

這樣地址看起來非常臃腫,因此我們可以禁止忽略路由前綴

zuul: 
    upload-service:
      path: /upload/**
      serviceId: upload-service
      strip-prefix: false

2.2.5.繞過網關

圖片上傳是文件的傳輸,如果也經過Zuul網關的代理,文件就會經過多次網路傳輸,造成不必要的網絡負擔。在高併發時,可能導致網絡阻塞,Zuul網關不可用。這樣我們的整個系統就癱瘓了。

所以,我們上傳文件的請求就不經過網關來處理了。

2.2.5.1.Nginx的rewrite指令

現在,查看頁面的請求路徑:
在這裏插入圖片描述
我們需要修改到此/zuul爲前綴,可以通過nginx的rewrite指令實現這一需求:

Nginx提供了rewrite指令,用於對地址進行重寫,語法規則:

rewrite "用來匹配路徑的正則" 重寫後的路徑 [指令];

我們的案例:

	server {
        listen       80;
        server_name  api.leyou.com;

        proxy_set_header X-Forwarded-Host $host;
        proxy_set_header X-Forwarded-Server $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    	# 上傳路徑的映射
		location /api/upload {	
			proxy_pass http://127.0.0.1:8082;
			proxy_connect_timeout 600;
			proxy_read_timeout 600;
			
			rewrite "^/api/(.*)$" /$1 break; 
        }
		
        location / {
			proxy_pass http://127.0.0.1:10010;
			proxy_connect_timeout 600;
			proxy_read_timeout 600;
        }
    }
  • 首先,我們映射路徑是/api/upload,而下面一個映射路徑是 / ,根據最長路徑匹配原則,/api/upload優先級更高。也就是說,凡是以/api/upload開頭的路徑,都會被第一個配置處理

  • proxy_pass:反向代理,這次我們代理到8082端口,也就是upload-service服務

  • rewrite "^/api/(.*)$" /$1 break,路徑重寫:

    • "^/api/(.*)$":匹配路徑的正則表達式,用了分組語法,把/api/以後的所有部分當做1組

    • /$1:重寫的目標路徑,這裏用$1引用前面正則表達式匹配到的分組(組編號從1開始),即/api/後面的所有。這樣新的路徑就是除去/api/以外的所有,就達到了去除/api前綴的目的

    • break:指令,常用的有2個,分別是:last、break

      • last:重寫路徑結束後,將得到的路徑重新進行一次路徑匹配
      • break:重寫路徑結束後,不再重新匹配路徑。

      我們這裏不能選擇last,否則以新的路徑/upload/image來匹配,就不會被正確的匹配到8082端口了

修改完成,輸入nginx -s reload命令重新加載配置。然後再次上傳試試。

2.2.6.跨域問題

重啓nginx,再次上傳,發現報錯了:
在這裏插入圖片描述

不過慶幸的是,這個錯誤已經不是第一次見了,跨域問題。

我們在upload-service中添加一個CorsFilter即可:

@Configuration
public class GlobalCorsConfig {
    @Bean
    public CorsFilter corsFilter() {
        //1.添加CORS配置信息
        CorsConfiguration config = new CorsConfiguration();
        //1) 允許的域,不要寫*,否則cookie就無法使用了
        config.addAllowedOrigin("http://manage.leyou.com");
        //2) 是否發送Cookie信息
        config.setAllowCredentials(false);
        //3) 允許的請求方式
        config.addAllowedMethod("OPTIONS");
        config.addAllowedMethod("POST");
        config.addAllowedHeader("*");

        //2.添加映射路徑,我們攔截一切請求
        UrlBasedCorsConfigurationSource configSource = new UrlBasedCorsConfigurationSource();
        configSource.registerCorsConfiguration("/**", config);

        //3.返回新的CorsFilter.
        return new CorsFilter(configSource);
    }
}

再次測試:

不過,非常遺憾的是,訪問圖片地址,卻沒有響應。
在這裏插入圖片描述
這是因爲我們並沒有任何服務器對應image.leyou.com這個域名。。

這個問題,我們暫時放下,回頭再來解決。

2.2.7.之前上傳的缺陷

先思考一下,之前上傳的功能,有沒有什麼問題?

上傳本身沒有任何問題,問題出在保存文件的方式,我們是保存在服務器機器,就會有下面的問題:

  • 單機器存儲,存儲能力有限
  • 無法進行水平擴展,因爲多臺機器的文件無法共享,會出現訪問不到的情況
  • 數據沒有備份,有單點故障風險
  • 併發能力差

這個時候,最好使用分佈式文件存儲來代替本地文件存儲。

3.FastDFS

3.1.什麼是分佈式文件系統

分佈式文件系統(Distributed File System)是指文件系統管理的物理存儲資源不一定直接連接在本地節點上,而是通過計算機網絡與節點相連。

通俗來講:

  • 傳統文件系統管理的文件就存儲在本機。
  • 分佈式文件系統管理的文件存儲在很多機器,這些機器通過網絡連接,要被統一管理。無論是上傳或者訪問文件,**
  • **

3.2.什麼是FastDFS

FastDFS是由淘寶的餘慶先生所開發的一個輕量級、高性能的開源分佈式文件系統。用純C語言開發,功能豐富:

  • 文件存儲
  • 文件同步
  • 文件訪問(上傳、下載)
  • 存取負載均衡
  • 在線擴容

適合有大容量存儲需求的應用或系統。同類的分佈式文件系統有谷歌的GFS、HDFS(Hadoop)、TFS(淘寶)等。

3.3.FastDFS的架構

3.3.1.架構圖

先上圖:

在這裏插入圖片描述

FastDFS兩個主要的角色:Tracker Server 和 Storage Server 。

  • Tracker Server:跟蹤服務器,主要負責調度storage節點與client通信,在訪問上起負載均衡的作用,和記錄storage節點的運行狀態,是連接client和storage節點的樞紐。
  • Storage Server:存儲服務器,保存文件和文件的meta data(元數據),每個storage server會啓動一個單獨的線程主動向Tracker cluster中每個tracker server報告其狀態信息,包括磁盤使用情況,文件同步情況及文件上傳下載次數統計等信息
  • Group:文件組,多臺Storage Server的集羣。上傳一個文件到同組內的一臺機器上後,FastDFS會將該文件即時同步到同組內的其它所有機器上,起到備份的作用。不同組的服務器,保存的數據不同,而且相互獨立,不進行通信。
  • Tracker Cluster:跟蹤服務器的集羣,有一組Tracker Server(跟蹤服務器)組成。
  • Storage Cluster :存儲集羣,有多個Group組成。

3.3.2.上傳和下載流程

上傳

在這裏插入圖片描述

  1. Client通過Tracker server查找可用的Storage server。
  2. Tracker server向Client返回一臺可用的Storage server的IP地址和端口號。
  3. Client直接通過Tracker server返回的IP地址和端口與其中一臺Storage server建立連接並進行文件上傳。
  4. 上傳完成,Storage server返回Client一個文件ID,文件上傳結束。

下載
在這裏插入圖片描述

  1. Client通過Tracker server查找要下載文件所在的的Storage server。
  2. Tracker server向Client返回包含指定文件的某個Storage server的IP地址和端口號。
  3. Client直接通過Tracker server返回的IP地址和端口與其中一臺Storage server建立連接並指定要下載文件。
  4. 下載文件成功。

3.4.安裝和使用

參考課前資料的:《centos安裝FastDFS.md》
在這裏插入圖片描述

3.5.java客戶端

餘慶先生提供了一個Java客戶端,但是作爲一個C程序員,寫的java代碼可想而知。而且已經很久不維護了。

這裏推薦一個開源的FastDFS客戶端,支持最新的SpringBoot2.0。

配置使用極爲簡單,支持連接池,支持自動生成縮略圖,狂拽酷炫吊炸天啊,有木有。

地址:tobato/FastDFS_client

在這裏插入圖片描述

3.5.1.引入依賴

在父工程中,我們已經管理了依賴,版本爲:

<fastDFS.client.version>1.26.2</fastDFS.client.version>

因此,這裏我們直接引入座標即可:

<dependency>
    <groupId>com.github.tobato</groupId>
    <artifactId>fastdfs-client</artifactId>
</dependency>

3.5.2.引入配置類

純java配置:

@Configuration
@Import(FdfsClientConfig.class)
// 解決jmx重複註冊bean的問題
@EnableMBeanExport(registration = RegistrationPolicy.IGNORE_EXISTING)
public class FastClientImporter {
}

3.5.3.編寫FastDFS屬性

fdfs:
  so-timeout: 2500
  connect-timeout: 600
  thumb-image: # 縮略圖
    width: 60
    height: 60
  tracker-list: # tracker地址
    - 192.168.56.101:22122

3.5.4.測試

@RunWith(SpringRunner.class)
@SpringBootTest(classes = LyUploadService.class)
public class FdfsTest {

    @Autowired
    private FastFileStorageClient storageClient;

    @Autowired
    private ThumbImageConfig thumbImageConfig;

    @Test
    public void testUpload() throws FileNotFoundException {
        File file = new File("D:\\test\\baby.png");
        // 上傳並且生成縮略圖
        StorePath storePath = this.storageClient.uploadFile(
                new FileInputStream(file), file.length(), "png", null);
        // 帶分組的路徑
        System.out.println(storePath.getFullPath());
        // 不帶分組的路徑
        System.out.println(storePath.getPath());
    }

    @Test
    public void testUploadAndCreateThumb() throws FileNotFoundException {
        File file = new File("D:\\test\\baby.png");
        // 上傳並且生成縮略圖
        StorePath storePath = this.storageClient.uploadImageAndCrtThumbImage(
                new FileInputStream(file), file.length(), "png", null);
        // 帶分組的路徑
        System.out.println(storePath.getFullPath());
        // 不帶分組的路徑
        System.out.println(storePath.getPath());
        // 獲取縮略圖路徑
        String path = thumbImageConfig.getThumbImagePath(storePath.getPath());
        System.out.println(path);
    }
}

結果:

group1/M00/00/00/wKg4ZVro5eCAZEMVABfYcN8vzII630.png
M00/00/00/wKg4ZVro5eCAZEMVABfYcN8vzII630.png
M00/00/00/wKg4ZVro5eCAZEMVABfYcN8vzII630_60x60.png

訪問第一個路徑:

在這裏插入圖片描述
訪問最後一個路徑(縮略圖路徑),注意加組名:

在這裏插入圖片描述

3.5.5.改造上傳邏輯

@Service
@Slf4j
public class UploadService {

    @Autowired
    private FastFileStorageClient storageClient;
    
    private static final List<String> ALLOW_TYPES = Arrays.asList("image/jpeg","image/png","image/bmp","image/gif");

    public String uploadImage(MultipartFile file) {

        try {
            //校驗文件類型
            String contentType = file.getContentType();
            if (!ALLOW_TYPES.contains(contentType)) {
                throw new LyException(ExceptionEnum.FILE_TYPE_NOT_MATCH);
            }

            //校驗文件內容
            BufferedImage image = ImageIO.read(file.getInputStream());
            if (image == null) {
                throw new LyException(ExceptionEnum.FILE_TYPE_NOT_MATCH);
            }

//            //準備目標路徑
//            File dest = new File("E:\\Maven工程\\upload",file.getOriginalFilename());
//            //保存文件到本地
//            file.transferTo(dest);
            
            //上傳到fastdfs
            String extension = StringUtils.substringAfterLast(file.getOriginalFilename(),".");
            StorePath storePath = storageClient.uploadFile(file.getInputStream(), file.getSize(), extension, null);

            //返回路徑
            return "http://image.leyou.com/" + storePath.getFullPath();
        } catch (IOException e) {
            //上傳失敗
            log.error("上傳文件失敗",e);
            throw new LyException(ExceptionEnum.UPLOAD_FILE_ERROR);
        }
    }
}

只需要把原來保存文件的邏輯去掉,然後上傳到FastDFS即可。

書寫配置文件來注入baseUrl和allowTypes:
在application.yml中:

Ly:
  upload:
    baseUrl: "http://image.leyou.com/"
    allowTypes:
      - image/jpeg
      - image/png
      - image/bmp
      - image/gif

新建一個UploadProperties配置類,用@ConfigurationProperties(prefix = "ly.upload")來注入

/**
 * @author Mango
 */
@Data
@ConfigurationProperties(prefix = "ly.upload")
public class UploadProperties {
    
    private String baseUrl;
    private List<String> allowTypes;
}

所以service類即爲

package com.leyou.upload.service;

import com.github.tobato.fastdfs.domain.StorePath;
import com.github.tobato.fastdfs.service.FastFileStorageClient;
import com.leyou.commons.enums.ExceptionEnum;
import com.leyou.commons.exception.LyException;
import com.leyou.upload.config.UploadProperties;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.IOException;

@Service
@Slf4j
@EnableConfigurationProperties(UploadProperties.class)
public class UploadService {

    @Autowired
    private FastFileStorageClient storageClient;
    
    @Autowired
    private UploadProperties prop;
    
    //private static final List<String> ALLOW_TYPES = Arrays.asList("image/jpeg","image/png","image/bmp","image/gif");

    public String uploadImage(MultipartFile file) {

        try {
            //校驗文件類型
            String contentType = file.getContentType();
            if (!prop.getAllowTypes().contains(contentType)) {
                throw new LyException(ExceptionEnum.FILE_TYPE_NOT_MATCH);
            }

            //校驗文件內容
            BufferedImage image = ImageIO.read(file.getInputStream());
            if (image == null) {
                throw new LyException(ExceptionEnum.FILE_TYPE_NOT_MATCH);
            }

//            //準備目標路徑
//            File dest = new File("E:\\Maven工程\\upload",file.getOriginalFilename());
//            //保存文件到本地
//            file.transferTo(dest);
            
            //上傳到fastdfs
            String extension = StringUtils.substringAfterLast(file.getOriginalFilename(),".");
            StorePath storePath = storageClient.uploadFile(file.getInputStream(), file.getSize(), extension, null);

            //返回路徑
            return prop.getBaseUrl() + storePath.getFullPath();
        } catch (IOException e) {
            //上傳失敗
            log.error("上傳文件失敗",e);
            throw new LyException(ExceptionEnum.UPLOAD_FILE_ERROR);
        }
    }
}

3.5.6.測試

通過RestClient測試:

在這裏插入圖片描述

3.6.頁面測試上傳

發現上傳成功:

在這裏插入圖片描述

不過,當我們訪問頁面時:

在這裏插入圖片描述

這是因爲我們圖片是上傳到虛擬機的,ip爲:192.168.56.101

因此,我們需要將image.leyou.com映射到192.168.56.101

修改我們的hosts:
在這裏插入圖片描述

再次上傳:

在這裏插入圖片描述

4.修改品牌(作業)

修改的難點在於回顯。

當我們點擊編輯按鈕,希望彈出窗口的同時,看到原來的數據:
在這裏插入圖片描述

4.1.點擊編輯出現彈窗

這個比較簡單,修改show屬性爲true即可實現,我們綁定一個點擊事件:

<v-btn color="info" @click="editBrand">編輯</v-btn>

然後編寫事件,改變show 的狀態:

在這裏插入圖片描述

如果僅僅是這樣,編輯按鈕與新增按鈕將沒有任何區別,關鍵在於,如何回顯呢?

4.2.回顯數據

回顯數據,就是把當前點擊的品牌數據傳遞到子組件(MyBrandForm)。而父組件給子組件傳遞數據,通過props屬性。

  • 第一步:在編輯時獲取當前選中的品牌信息,並且記錄到data中

    先在data中定義屬性,用來接收用來編輯的brand數據:

在這裏插入圖片描述

我們在頁面觸發編輯事件時,把當前的brand傳遞給editBrand方法:

<v-btn color="info" @click="editBrand(props.item)">編輯</v-btn>

然後在editBrand中接收數據,賦值給oldBrand:

editBrand(oldBrand){
  // 控制彈窗可見:
  this.show = true;
  // 獲取要編輯的brand
  this.oldBrand = oldBrand;
},
  • 第二步:把獲取的brand數據 傳遞給子組件

    <!--對話框的內容,表單-->
    <v-card-text class="px-5">
        <my-brand-form @close="closeWindow" :oldBrand="oldBrand"/>
    </v-card-text>
    
  • 第三步:在子組件中通過props接收要編輯的brand數據,Vue會自動完成回顯

    接收數據:

在這裏插入圖片描述

通過watch函數監控oldBrand的變化,把值copy到本地的brand:

watch: {
    oldBrand: {// 監控oldBrand的變化
        handler(val) {
            if(val){
                // 注意不要直接複製,否則這邊的修改會影響到父組件的數據,copy屬性即可
                this.brand =  Object.deepCopy(val)
            }else{
                // 爲空,初始化brand
                this.brand = {
                    name: '',
                    letter: '',
                    image: '',
                    categories: [],
                }
            }
        },
            deep: true
    }
}
  • Object.deepCopy 自定義的對對象進行深度複製的方法。
  • 需要判斷監聽到的是否爲空,如果爲空,應該進行初始化

測試:發現數據回顯了,除了商品分類以外:

在這裏插入圖片描述

4.3.商品分類回顯

爲什麼商品分類沒有回顯?

因爲品牌中並沒有商品分類數據。我們需要在進入編輯頁面之前,查詢商品分類信息:

4.3.1.後臺提供接口

controller

/**
     * 通過品牌id查詢商品分類
     * @param bid
     * @return
     */
@GetMapping("bid/{bid}")
public ResponseEntity<List<Category>> queryByBrandId(@PathVariable("bid") Long bid) {
    List<Category> list = this.categoryService.queryByBrandId(bid);
    if (list == null || list.size() < 1) {
        return new ResponseEntity<>(HttpStatus.NOT_FOUND);
    }
    return ResponseEntity.ok(list);
}

Service

public List<Category> queryByBrandId(Long bid) {
    return this.categoryMapper.queryByBrandId(bid);
}

mapper

因爲需要通過中間表進行子查詢,所以這裏要手寫Sql:

/**
     * 根據品牌id查詢商品分類
     * @param bid
     * @return
     */
@Select("SELECT * FROM tb_category WHERE id IN (SELECT category_id FROM tb_category_brand WHERE brand_id = #{bid})")
List<Category> queryByBrandId(Long bid);

4.3.2.前臺查詢分類並渲染

我們在編輯頁面打開之前,先把數據查詢完畢:

editBrand(oldBrand){
    // 根據品牌信息查詢商品分類
    this.$http.get("/item/category/bid/" + oldBrand.id)
        .then(({data}) => {
        // 控制彈窗可見:
        this.show = true;
        // 獲取要編輯的brand
        this.oldBrand = oldBrand
        // 回顯商品分類
        this.oldBrand.categories = data;
    })
}

再次測試:數據成功回顯了

在這裏插入圖片描述

4.3.3.新增窗口數據干擾

但是,此時卻產生了新問題:新增窗口竟然也有數據?

原因:

​ 如果之前打開過編輯,那麼在父組件中記錄的oldBrand會保留。下次再打開窗口,如果是編輯窗口到沒問題,但是新增的話,就會再次顯示上次打開的品牌信息了。

解決:

​ 新增窗口打開前,把數據置空。

addBrand() {
    // 控制彈窗可見:
    this.show = true;
    // 把oldBrand變爲null
    this.oldBrand = null;
}

4.3.4.提交表單時判斷是新增還是修改

新增和修改是同一個頁面,我們該如何判斷?

父組件中點擊按鈕彈出新增或修改的窗口,因此父組件非常清楚接下來是新增還是修改。

因此,最簡單的方案就是,在父組件中定義變量,記錄新增或修改狀態,當彈出頁面時,把這個狀態也傳遞給子組件。

第一步:在父組件中記錄狀態:
在這裏插入圖片描述

第二步:在新增和修改前,更改狀態:

在這裏插入圖片描述

第三步:傳遞給子組件

在這裏插入圖片描述

第四步,子組件接收標記:

在這裏插入圖片描述

標題的動態化:
在這裏插入圖片描述

表單提交動態:

axios除了除了get和post外,還有一個通用的請求方式:

// 將數據提交到後臺
// this.$http.post('/item/brand', this.$qs.stringify(params))
this.$http({
    method: this.isEdit ? 'put' : 'post', // 動態判斷是POST還是PUT
    url: '/item/brand',
    data: this.$qs.stringify(this.brand)
}).then(() => {
    // 關閉窗口
    this.$emit("close");
    this.$message.success("保存成功!");
})
    .catch(() => {
    this.$message.error("保存失敗!");
});
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章