微服務架構(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:屬性,有兩種
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
默認是16px0
:將margin
或padding的大小設置爲01
- 將margin
或者padding
屬性設置爲$spacer * .25
2
- 將margin
或者padding
屬性設置爲$spacer * .5
3
- 將margin
或者padding
屬性設置爲$spacer
4
- 將margin
或者padding
屬性設置爲$spacer * 1.5
5
- 將margin
或者padding
屬性設置爲$spacer * 3
- property:屬性,有兩種
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
在上傳文件過程中,我們需要對上傳的內容進行校驗:
- 校驗文件的媒體類型
- 校驗文件的內容
文件大小在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.上傳和下載流程
上傳
- 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.安裝和使用
參考課前資料的:《centos安裝FastDFS.md》
3.5.java客戶端
餘慶先生提供了一個Java客戶端,但是作爲一個C程序員,寫的java代碼可想而知。而且已經很久不維護了。
這裏推薦一個開源的FastDFS客戶端,支持最新的SpringBoot2.0。
配置使用極爲簡單,支持連接池,支持自動生成縮略圖,狂拽酷炫吊炸天啊,有木有。
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("保存失敗!");
});