樂優商場開發第八天筆記
0.學習目標
-
獨立實現品牌新增
-
實現圖片上傳
-
瞭解FastDFS的安裝
-
使用FastDFS客戶端實現上傳
1.品牌的新增
昨天我們完成了品牌的查詢,接下來就是新增功能。
1.1.頁面實現
1.1.1.初步編寫彈窗
當我們點選新增按鈕,應該出現一個彈窗,然後在彈窗中出現一個表格,我們就可以填寫品牌資訊了。
我們檢視Vuetify官網,彈窗是如何實現:
另外,我們可以通過文件看到對話方塊的一些屬性:
-
value:控制視窗的可見性,true可見,false,不可見
-
max-width:控制對話方塊最大寬度
-
with:設定對話方塊的寬度
-
scrollable :是否可滾動,要配合v-card來使用,預設是false
-
persistent :點選彈窗以外的地方不會關閉彈窗,預設是false
現在,我們來使用一下。
首先,我們在data中定義一個dialog屬性,來控制對話方塊的顯示狀態:
然後,在頁面新增一個v-dialog
<v-card-title flat color="white"> <v-btn color="primary">新增</v-btn> <!--彈出的對話方塊--> <v-dialog persistent v-model="dialog" width="500"> <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> <!--空間隔離元件--> <v-spacer /> <!--搜尋框,與search屬性關聯--> <v-text-field label="輸入關鍵字搜尋" append-icon="search" v-model="search" hide-details/> </v-card-title>
說明:
我們給dialog指定了3個屬性,分別是
-
width:限制寬度
-
v-model:value值雙向繫結到dialog變數,用來控制視窗顯示
-
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:屬性,有兩種
padding
和margin
-
p
:對應padding
-
m
:對應margin
-
-
direction:只padding和margin的作用方向,
-
t
- 對應margin-top
或者padding-top
屬性 -
b
- 對應margin-bottom
orpadding-bottom
-
l
- 對應margin-left
orpadding-left
-
r
- 對應margin-right
orpadding-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.dialog = true; }
效果:
視窗關閉
現在,悲劇發生了,因為我們設定了persistent屬性,視窗無法被關閉了。除非把dialog屬性設定為false
因此我們需要給視窗新增一個關閉按鈕:
<!--對話方塊的標題--> <v-toolbar dense dark color="primary"> <v-toolbar-title>新增品牌</v-toolbar-title> <v-spacer/> <!--關閉視窗的按鈕--> <v-btn icon @click="close"><v-icon>close</v-icon></v-btn> </v-toolbar>
並且,我們還給按鈕綁定了點選事件,回撥函式為close。
接下來,編寫closeWindow函式:
close(){ // 關閉視窗 this.dialog = false; }
效果:
1.1.3.表單頁
接下來就是寫表單了。我們有兩種選擇:
-
直接在dialog對話方塊中編寫表單程式碼
-
另外編寫一個元件,元件內寫表單程式碼。然後在對話方塊引用元件
選第幾種?
我們選第二種方案,優點:
-
表單程式碼獨立元件,可拔插,方便後期的維護。
-
程式碼分離,可讀性更好。
我們新建一個MyBrandForm.vue
元件:
內容:
<template> <div>my brand form</div> </template> <script> export default { name: "MyBrandForm" } </script> <style scoped> </style>
將MyBrandForm引入到MyBrand中,這裡使用區域性元件的語法:
先匯入自定義元件:
// 匯入自定義的表單元件 import MyBrandForm from './MyBrandForm'
然後通過components屬性來指定區域性元件:
components:{ MyBrandForm }
然後在頁面中引用:
<v-card-text class="px-5"> 這是一個表單 <my-brand-form></my-brand-form> </v-card-text>
頁面效果:
1.1.3.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:顯示字尾
接下來,我們先新增兩個欄位:品牌名稱、品牌的首字母,校驗規則暫時不寫:
<template> <v-form v-model="valid"> <v-text-field v-model="brand.name" label="請輸入品牌名稱" required hint="例如:oppo"></v-text-field> <v-text-field v-model="brand.letter" label="請輸入品牌首字母" required hint="例如:O"></v-text-field> </v-form> </template>
-
千萬不要忘了通過
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-form v-model="valid"> <v-text-field v-model="brand.name" label="請輸入品牌名稱" required hint="例如:oppo"></v-text-field> <v-text-field v-model="brand.letter" label="請輸入品牌首字母" required hint="例如:O"></v-text-field> <v-cascader url="/item/category/list" multiple required v-model="brand.categories" label="請選擇商品分類"/> <v-layout row> <v-flex xs3><label style="color: rgba(0,0,0,.54); font-size: 16px">品牌LOG:</label></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-form>
注意:
-
檔案上傳元件本身沒有提供文字提示。因此我們需要自己新增一段文字說明
-
我們要實現文字和圖片元件左右放置,因此這裡使用了
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 row my-3> <v-spacer/> <v-btn @click="reset">重置</v-btn> <v-btn @click="submit" color="primary">提交</v-btn> </v-layout>
-
通過layout來進行佈局,
my-3
增大上下邊距 -
v-spacer
佔用一定空間,將按鈕都排擠到頁面右側 -
兩個按鈕分別綁定了submit和reset事件
我們先將方法定義出來:
methods: { reset(){ // 重置表單 }, submit(){ // 提交表單 } }
效果:
1.1.5.重置表單
重置表單相對簡單,因為v-form元件已經提供了reset方法,用來清空表單資料。只要我們拿到表單元件物件,就可以呼叫方法了。
我們可以通過$refs
內建物件來獲取表單元件。
首先,在表單上定義ref
屬性:
然後,在頁面檢視this.$refs
屬性:
reset(){ // 重置表單 console.log(this); }
檢視如下:
看到this.$refs
中只有一個屬性,就是myBrandForm
我們在clear中來獲取表單物件並呼叫reset方法:
methods: { reset() { // 重置表單 console.log(this); this.$refs.myBrandForm.reset(); // 需要手動清空商品分類 this.categories = []; }, submit() { // 提交表單 } }
要注意的是,這裡我們還手動把this.categories清空了,因為我寫的級聯選擇元件並沒有跟表單結合起來。需要手動清空。
1.1.5.表單校驗
1.1.5.1.校驗規則
Vuetify的表單校驗,是通過rules屬性來指定的:
校驗規則的寫法:
說明:
-
規則是一個數組
-
陣列中的元素是一個函式,該函式接收表單項的值作為引數,函式返回值兩種情況:
-
返回true,代表成功,
-
返回錯誤提示資訊,代表失敗
-
1.1.5.2.編寫校驗
我們有四個欄位:
-
name:做非空校驗和長度校驗,長度必須大於1
-
letter:首字母,校驗長度為1,非空。
-
image:圖片,不做校驗,圖片可以為空
-
categories:非空校驗,自定義元件已經幫我們完成,不用寫了
首先,我們定義規則:
data() { return { valid: false, brand: { name: '', // 品牌名稱 letter: '', // 品牌首字母 image: '',// 品牌logo categories: [] // 品牌所屬的商品分類陣列 }, rules: { required: value => !!value || "品牌名稱必須填寫", nameLength: value => value.length > 1 || "品牌名稱長度必須大於1", letter: value => /^[A-Z]{1}$/.test(value) || "品牌的首字母必須為1位大寫字母" } } }
然後,在頁面標籤中指定:
<v-text-field v-model="brand.name" label="請輸入品牌名稱" hint="例如:oppo" :rules="[rules.required, rules.nameLength]"></v-text-field> <v-text-field v-model="brand.letter" label="請輸入品牌首字母" hint="例如:O" :rules="[rules.letter]"></v-text-field>
效果:
1.1.6.表單提交
在submit方法中新增表單提交的邏輯:
submit() { // 1.表單校驗 if(this.$refs.myBrandForm.validate()){ // 2.定義一個請求引數物件,通過es6的解構表示式來獲取brand中的屬性 const {categories, ...params} = this.brand; // 3.資料庫中只需要儲存商品分類的id即可,因此需要對categories處理,只獲取id params.cids = categories.map(c=>c.id).join(","); // 4.提交資料 this.$http.post("/item/brand", params).then(()=>{ // 5.成功提示 this.$message.success("儲存成功!"); }).catch(()=>{ this.$message.error("儲存失敗!") }); } }
-
通過
this.$refs.myBrandForm
選中表單,然後呼叫表單的validate
方法,進行表單校驗。返回boolean值,true代表校驗通過 -
通過解構表示式來獲取brand中的值,categories需要處理,單獨獲取。其它的存入params物件中
-
品牌和商品分類的中間表只儲存兩者的id,而brand.categories中儲存的是物件陣列,裡面有id和name屬性,因此這裡通過陣列的map功能轉為id陣列,然後通過join方法拼接為字串
-
發起請求
-
彈窗提示成功還是失敗,這裡用到的是我們的自定義元件功能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) { // 新增品牌資訊 this.brandMapper.insertSelective(brand); // 新增品牌和分類中間表 for (Long cid : cids) { this.brandMapper.insertCategoryBrand(cid, brand.getId()); } }
這裡呼叫了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.2.4.測試
400:請求引數不合法
1.3.解決400
1.3.1.原因分析
我們填寫表單並提交,發現報錯了。檢視控制檯的請求詳情:
發現請求的資料格式是JSON格式。
原因分析:
axios處理請求體的原則會根據請求資料的格式來定:
-
如果請求體是物件:會轉為json傳送
-
如果請求體是String:會作為普通表單請求傳送,但需要我們自己保證String的格式是鍵值對。
如:name=jack&age=12
1.3.2.QS工具
QS是一個第三方庫,我們可以用npm install qs --save
來安裝。不過我們在專案中已經集成了,大家無需安裝:
這個工具的名字:QS,即Query String,請求引數字串。
什麼是請求引數字串?例如: name=jack&age=21
QS工具可以便捷的實現 JS的Object與QueryString的轉換。
在我們的專案中,將QS注入到了Vue的原型物件中,我們可以通過this.$qs
來獲取這個工具:
我們將this.$qs
物件列印到控制檯:
created(){ console.log(this.$qs); }
發現其中有3個方法:
這裡我們要使用的方法是stringify,它可以把Object轉為QueryString。
測試一下,使用瀏覽器工具,把qs物件儲存為一個臨時變數temp1,然後呼叫stringify方法:
成功將person物件變成了 name=zhangsan&age=30的字串了
1.3.3.解決問題
修改頁面,對引數處理後傳送:
然後再次發起請求,發現請求成功:
資料庫:
1.4.新增完成後關閉視窗
我們發現有一個問題:新增不管成功還是失敗,視窗都一致在這裡,不會關閉。
這樣很不友好,我們希望如果新增失敗,視窗保持;但是新增成功,視窗關閉才對。
因此,我們需要在新增的ajax請求完成以後,關閉視窗
但問題在於,控制視窗是否顯示的標記在父元件:MyBrand.vue中。子元件如何才能操作父元件的屬性?或者告訴父元件該關閉視窗了?
之前我們講過一個父子元件的通訊,有印象嗎?
-
第一步:在父元件中定義一個函式,用來關閉視窗,不過之前已經定義過了。父元件在使用子元件時,繫結事件,關聯到這個函式:
<!--對話方塊的內容,表單--> <v-card-text class="px-5"> <my-brand-form @close="closeWindow"/> </v-card-text>
-
第二步,子元件通過
this.$emit
呼叫父元件的函式:
測試一下,儲存成功:
我們優化一下,關閉的同時重新載入資料:
closeWindow(){ // 關閉視窗 this.show = false; // 重新載入資料 this.getDataFromServer(); }
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.upload</groupId> <artifactId>leyou-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> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</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:10086/eureka instance: lease-renewal-interval-in-seconds: 5 # 每隔5秒傳送一次心跳 lease-expiration-duration-in-seconds: 10 # 10秒不傳送就過期 prefer-ip-address: true ip-address: 127.0.0.1 instance-id: ${spring.application.name}:${server.port}
需要注意的是,我們應該添加了限制檔案大小的配置
2.1.4.啟動類
@SpringBootApplication @EnableDiscoveryClient public class LeyouUploadApplication { public static void main(String[] args) { SpringApplication.run(LeyouUploadApplication.class, args); } }
結構:
2.2.編寫上傳功能
2.2.1.controller
編寫controller需要知道4個內容:
-
請求方式:上傳肯定是POST
-
請求路徑:/upload/image
-
請求引數:檔案,引數名是file,SpringMVC會封裝為一個介面:MultipartFile
-
返回結果:上傳成功後得到的檔案的url路徑
程式碼如下:
@RestController @RequestMapping("upload") public class UploadController { @Autowired private UploadService uploadService; /** * 上傳圖片功能 * @param file * @return */ @PostMapping("image") public ResponseEntity<String> uploadImage(@RequestParam("file") MultipartFile file) { String url = this.uploadService.upload(file); if (StringUtils.isBlank(url)) { // url為空,證明上傳失敗 return new ResponseEntity<>(HttpStatus.BAD_REQUEST); } // 返回200,並且攜帶url路徑 return ResponseEntity.ok(url); } }
2.2.2.service
在上傳檔案過程中,我們需要對上傳的內容進行校驗:
-
校驗檔案大小
-
校驗檔案的媒體型別
-
校驗檔案的內容
檔案大小在Spring的配置檔案中設定,因此已經會被校驗,我們不用管。
具體程式碼:
@Service public class UploadService { private static final Logger logger = LoggerFactory.getLogger(UploadController.class); // 支援的檔案型別 private static final List<String> suffixes = Arrays.asList("image/png", "image/jpeg"); public String upload(MultipartFile file) { try { // 1、圖片資訊校驗 // 1)校驗檔案型別 String type = file.getContentType(); if (!suffixes.contains(type)) { logger.info("上傳失敗,檔案型別不匹配:{}", type); return null; } // 2)校驗圖片內容 BufferedImage image = ImageIO.read(file.getInputStream()); if (image == null) { logger.info("上傳失敗,檔案內容不符合要求"); return null; } // 2、儲存圖片 file.transferTo(new File("C:\\project\\images\\upload" + originalFilename)); // 2.3、拼接圖片地址 String url = "http://image.leyou.com/upload/" + file.getOriginalFilename(); return url; } catch (Exception e) { return null; } } }
這裡有一個問題:為什麼圖片地址需要使用另外的url?
-
圖片不能儲存在伺服器內部,這樣會對伺服器產生額外的載入負擔
-
一般靜態資源都應該使用獨立域名,這樣訪問靜態資源時不會攜帶一些不必要的cookie,減小請求的資料量
2.2.3.測試上傳
我們通過RestClient工具來測試:
結果:
去目錄下檢視:
上傳成功!
2.3.繞過閘道器
圖片上傳是檔案的傳輸,如果也經過Zuul閘道器的代理,檔案就會經過多次網路傳輸,造成不必要的網路負擔。在高併發時,可能導致網路阻塞,Zuul閘道器不可用。這樣我們的整個系統就癱瘓了。
所以,我們上傳檔案的請求就不經過閘道器來處理了。
2.3.1.Zuul的路由過濾
Zuul中提供了一個ignored-patterns屬性,用來忽略不希望路由的URL路徑,示例:
zuul.ignored-patterns: /upload/**
路徑過濾會對一切微服務進行判定。
Zuul還提供了ignored-services
屬性,進行服務過濾:
zuul.ignored-services: upload-servie
我們這裡採用忽略服務:
zuul: ignored-services: - upload-service # 忽略upload-service服務
上面的配置採用了集合語法,代表可以配置多個。
2.3.2.Nginx的rewrite指令
現在,我們修改頁面的訪問路徑:
<v-upload v-model="brand.image" url="/upload/image" :multiple="false" :pic-width="250" :pic-height="90" />
檢視頁面的請求路徑:
可以看到這個地址不對,依然是去找Zuul閘道器,因為我們的系統全域性配置了URL地址。怎麼辦?
有同學會想:修改頁面請求地址不就好了。
注意:原則上,我們是不能把除了閘道器以外的服務對外暴露的,不安全。
既然不能修改頁面請求,那麼就只能在Nginx反向代理上做文章了。
我們修改nginx配置,將以/api/upload開頭的請求攔截下來,轉交到真實的服務地址:
location /api/upload { proxy_pass http://127.0.0.1:8082; proxy_connect_timeout 600; proxy_read_timeout 600; }
這樣寫大家覺得對不對呢?
顯然是不對的,因為ip和埠雖然對了,但是路徑沒變,依然是:http://127.0.0.1:8002/api/upload/image
前面多了一個/api
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.4.跨域問題
重啟nginx,再次上傳,發現跟上次的狀態碼已經不一樣了,但是依然報錯:
不過慶幸的是,這個錯誤已經不是第一次見了,跨域問題。
我們在upload-service中新增一個CorsFilter即可:
@Configuration public class LeyouCorsConfiguration { @Bean public CorsFilter corsFilter() { //1.新增CORS配置資訊 CorsConfiguration config = new CorsConfiguration(); //1) 允許的域,不要寫*,否則cookie就無法使用了 config.addAllowedOrigin("http://manage.leyou.com"); //3) 允許的請求方式 config.addAllowedMethod("OPTIONS"); config.addAllowedMethod("POST"); // 4)允許的頭資訊 config.addAllowedHeader("*"); //2.新增對映路徑,我們攔截一切請求 UrlBasedCorsConfigurationSource configSource = new UrlBasedCorsConfigurationSource(); configSource.registerCorsConfiguration("/**", config); //3.返回新的CorsFilter. return new CorsFilter(configSource); } }
再次測試:
不過,非常遺憾的是,訪問圖片地址,卻沒有響應。
這是因為我們並沒有任何伺服器對應image.leyou.com這個域名。。
這個問題,我們暫時放下,回頭再來解決。
2.5.檔案上傳的缺陷
先思考一下,現在上傳的功能,有沒有什麼問題?
上傳本身沒有任何問題,問題出在儲存檔案的方式,我們是儲存在伺服器機器,就會有下面的問題:
-
單機器儲存,儲存能力有限
-
無法進行水平擴充套件,因為多臺機器的檔案無法共享,會出現訪問不到的情況
-
資料沒有備份,有單點故障風險
-
併發能力差
這個時候,最好使用分散式檔案儲存來代替本地檔案儲存。
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.上傳和下載流程
上傳
-
Client通過Tracker server查詢可用的Storage server。
-
Tracker server向Client返回一臺可用的Storage server的IP地址和埠號。
-
Client直接通過Tracker server返回的IP地址和埠與其中一臺Storage server建立連線並進行檔案上傳。
-
上傳完成,Storage server返回Client一個檔案ID,檔案上傳結束。
下載
-
Client通過Tracker server查詢要下載檔案所在的的Storage server。
-
Tracker server向Client返回包含指定檔案的某個Storage server的IP地址和埠號。
-
Client直接通過Tracker server返回的IP地址和埠與其中一臺Storage server建立連線並指定要下載檔案。
-
下載檔案成功。
3.4.安裝和使用
參考課前資料的:
3.5.java客戶端
餘慶先生提供了一個Java客戶端,但是作為一個C程式設計師,寫的java程式碼可想而知。而且已經很久不維護了。
這裡推薦一個開源的FastDFS客戶端,支援最新的SpringBoot2.0。
配置使用極為簡單,支援連線池,支援自動生成縮圖,狂拽酷炫吊炸天啊,有木有。
接下來,我們就用FastDFS改造leyou-upload工程。
3.5.1.引入依賴
在父工程中,我們已經管理了依賴,版本為:
<fastDFS.client.version>1.26.2</fastDFS.client.version>
因此,這裡我們直接在taotao-upload工程的pom.xml中引入座標即可:
<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屬性
在application.yml配置檔案中追加如下內容:
fdfs: so-timeout: 1501 # 超時時間 connect-timeout: 601 # 連線超時時間 thumb-image: # 縮圖 width: 60 height: 60 tracker-list: # tracker地址 - 192.168.56.101:22122
3.5.4.測試
建立測試類:
把以下內容copy進去:
@SpringBootTest @RunWith(SpringRunner.class) public class FastDFSTest { @Autowired private FastFileStorageClient storageClient; @Autowired private ThumbImageConfig thumbImageConfig; @Test public void testUpload() throws FileNotFoundException { // 要上傳的檔案 File file = new File("C:\\Users\\joedy\\Pictures\\xbx1.jpg"); // 上傳並儲存圖片,引數:1-上傳的檔案流 2-檔案的大小 3-檔案的字尾 4-可以不管他 StorePath storePath = this.storageClient.uploadFile( new FileInputStream(file), file.length(), "jpg", null); // 帶分組的路徑 System.out.println(storePath.getFullPath()); // 不帶分組的路徑 System.out.println(storePath.getPath()); } @Test public void testUploadAndCreateThumb() throws FileNotFoundException { File file = new File("C:\\Users\\joedy\\Pictures\\xbx1.jpg"); // 上傳並且生成縮圖 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/wKg4ZVsWl5eAdLNZAABAhya2V0c424.jpg M00/00/00/wKg4ZVsWl5eAdLNZAABAhya2V0c424.jpg
group1/M00/00/00/wKg4ZVsWmD-ARnWiAABAhya2V0c772.png M00/00/00/wKg4ZVsWmD-ARnWiAABAhya2V0c772.png M00/00/00/wKg4ZVsWmD-ARnWiAABAhya2V0c772_60x60.png
訪問第二組第一個路徑:
訪問最後一個路徑(縮圖路徑),注意加組名:
3.5.5.改造上傳邏輯
@Service public class UploadService { private static final Logger logger = LoggerFactory.getLogger(UploadController.class); // 支援的檔案型別 private static final List<String> suffixes = Arrays.asList("image/png", "image/jpeg"); @Autowired FastFileStorageClient storageClient; public String upload(MultipartFile file) { try { // 1、圖片資訊校驗 // 1)校驗檔案型別 String type = file.getContentType(); if (!suffixes.contains(type)) { logger.info("上傳失敗,檔案型別不匹配:{}", type); return null; } // 2)校驗圖片內容 BufferedImage image = ImageIO.read(file.getInputStream()); if (image == null) { logger.info("上傳失敗,檔案內容不符合要求"); return null; } // 2、將圖片上傳到FastDFS // 2.1、獲取檔案字尾名 String extension = StringUtils.substringAfterLast(file.getOriginalFilename(), "."); // 2.2、上傳 StorePath storePath = this.storageClient.uploadFile( file.getInputStream(), file.getSize(), extension, null); // 2.3、返回完整路徑 return "http://image.leyou.com/" + storePath.getFullPath(); } catch (Exception e) { return null; } } }
只需要把原來儲存檔案的邏輯去掉,然後上傳到FastDFS即可。
3.5.6.測試
通過RestClient測試:
3.6.頁面測試上傳
發現上傳成功:
4.修改品牌(作業)
修改的難點在於回顯。
當我們點選編輯按鈕,希望彈出視窗的同時,看到原來的資料:
4.1.點選編輯出現彈窗
這個比較簡單,修改show屬性為true即可實現,我們繫結一個點選事件:
<v-icon small class="mr-2" @click="editItem(props.item)"> edit </v-icon>
然後編寫事件,改變show 的狀態:
如果僅僅是這樣,編輯按鈕與新增按鈕將沒有任何區別,關鍵在於,如何回顯呢?
4.2.回顯資料
回顯資料,就是把當前點選的品牌資料傳遞到子元件(MyBrandForm)。而父元件給子元件傳遞資料,通過props屬性。
-
第一步:在編輯時獲取當前選中的品牌資訊,並且記錄到data中
先在data中定義屬性,用來接收用來編輯的brand資料:
我們在頁面觸發編輯事件時,把當前的brand傳遞給editBrand方法:
<v-btn color="info" @click="editBrand(props.item)">編輯</v-btn>
然後在editBrand中接收資料,賦值給oldBrand:
editItem(oldBrand){ // 使編輯視窗可見 this.dialog = true; // 初始化編輯的資料 this.oldBrand = oldBrand; }
-
第二步:把獲取的brand資料 傳遞給子元件
<!--對話方塊內容--> <v-card-text class="px-5"> <!--這是一個表單--> <my-brand-form @close="close" :oldBrand="oldBrand"></my-brand-form> </v-card-text>
-
第三步:在子元件(MyBrandForm.vue)中通過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.dialog = true; // 獲取要編輯的brand this.oldBrand = oldBrand // 回顯商品分類 this.oldBrand.categories = data; }) }
再次測試:資料成功回顯了
4.3.3.新增視窗資料干擾
但是,此時卻產生了新問題:新增視窗竟然也有資料?
原因:
如果之前開啟過編輯,那麼在父元件中記錄的oldBrand會保留。下次再開啟視窗,如果是編輯視窗到沒問題,但是新增的話,就會再次顯示上次開啟的品牌資訊了。
解決:
新增視窗開啟前,把資料置空。
addBrand() { // 控制彈窗可見: this.dialog = 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("儲存失敗!"); });