1. 程式人生 > >樂優商場開發第七天筆記

樂優商場開發第七天筆記

0.學習目標

  • 使用資料搭建後臺系統

  • 會使用nginx進行反向代理

  • 實現商品分類查詢功能

  • 掌握cors解決跨域

  • 實現品牌查詢功能

 

1.使用域名訪問本地專案

1.1.統一環境

我們現在訪問頁面使用的是:http://localhost:9001

有沒有什麼問題?

實際開發中,會有不同的環境:

  • 開發環境:自己的電腦

  • 測試環境:提供給測試人員使用的環境

  • 預釋出環境:資料是和生成環境的資料一致,執行最新的專案程式碼進去測試

  • 生產環境:專案最終釋出上線的環境

如果不同環境使用不同的ip去訪問,可能會出現一些問題。為了保證所有環境的一致,我們會在各種環境下都使用域名來訪問。

我們將使用以下域名:

  • 主域名是:www.leyou.com

  • 管理系統域名:manage.leyou.com

  • 閘道器域名:api.leyou.com

  • ...

但是最終,我們希望這些域名指向的還是我們本機的某個埠。

那麼,當我們在瀏覽器輸入一個域名時,瀏覽器是如何找到對應服務的ip和埠的呢?

1.2.域名解析

一個域名一定會被解析為一個或多個ip。這一般會包含兩步:

  • 本地域名解析

    瀏覽器會首先在本機的hosts檔案中查詢域名對映的IP地址,如果查詢到就返回IP ,沒找到則進行域名伺服器解析,一般本地解析都會失敗,因為預設這個檔案是空的。

    • Windows下的hosts檔案地址:C:/Windows/System32/drivers/etc/hosts

    • Linux下的hosts檔案所在路徑: /etc/hosts

    樣式:

    # My hosts
    127.0.0.1 localhost
    0.0.0.0 account.jetbrains.com
    127.0.0.1 www.xmind.net
  • 域名伺服器解析

    本地解析失敗,才會進行域名伺服器解析,域名伺服器就是網路中的一臺計算機,裡面記錄了所有註冊備案的域名和ip對映關係,一般只要域名是正確的,並且備案通過,一定能找到。

 

1.3.解決域名解析問題

我們不可能去購買一個域名,因此我們可以偽造本地的hosts檔案,實現對域名的解析。修改本地的host為:

127.0.0.1 api.leyou.com
127.0.0.1 manage.leyou.com

這樣就實現了域名的關係映射了。

每次在C盤尋找hosts檔案並修改是非常麻煩的,給大家推薦一個快捷修改host的工具,在課前資料中可以找到:

效果:

我們添加了兩個對映關係:

  • 127.0.0.1 api.leyou.com :我們的閘道器Zuul

  • 127.0.0.1 manage.leyou.com:我們的後臺系統地址

現在,ping一下域名試試是否暢通:

OK!

1.4.nginx解決埠問題

雖然域名解決了,但是現在如果我們要訪問,還得自己加上埠:http://manage.taotao.com:9001

這就不夠優雅了。我們希望的是直接域名訪問:http://manage.taotao.com。這種情況下埠預設是80,如何才能把請求轉移到9001埠呢?

這裡就要用到反向代理工具:Nginx

1.4.1.什麼是Nginx

NIO:not-blocking-io 非阻塞IO

BIO:blocking-IO 阻塞IO

nginx可以作為web伺服器,但更多的時候,我們把它作為閘道器,因為它具備閘道器必備的功能:

  • 反向代理

  • 負載均衡

  • 動態路由

  • 請求過濾

1.4.2.nginx作為web伺服器

Web伺服器分2類:

  • web應用伺服器,如:

    • tomcat

    • resin

    • jetty

  • web伺服器,如:

    • Apache 伺服器

    • Nginx

    • IIS

區分:web伺服器不能解析jsp等頁面,只能處理js、css、html等靜態資源。併發:web伺服器的併發能力遠高於web應用伺服器。

Nginx + tomcat

1.4.3.nginx作為反向代理

什麼是反向代理?

  • 代理:通過客戶機的配置,實現讓一臺伺服器代理客戶機,客戶的所有請求都交給代理伺服器處理。

  • 反向代理:用一臺伺服器,代理真實伺服器,使用者訪問時,不再是訪問真實伺服器,而是代理伺服器。

nginx可以當做反向代理伺服器來使用:

  • 我們需要提前在nginx中配置好反向代理的規則,不同的請求,交給不同的真實伺服器處理

  • 當請求到達nginx,nginx會根據已經定義的規則進行請求的轉發,從而實現路由功能

 

利用反向代理,就可以解決我們前面所說的埠問題,如圖

1.4.4.安裝和使用

安裝

安裝非常簡單,把課前資料提供的nginx直接解壓即可,綠色免安裝,舒服!

我們在本地安裝一臺nginx:

目錄結構:

 

使用

nginx可以通過命令列來啟動,操作命令:

  • 啟動:start nginx.exe

  • 停止:nginx.exe -s stop

  • 重新載入:nginx.exe -s reload

 

反向代理配置

示例:

nginx中的每個server就是一個反向代理配置,可以有多個server

 

完整配置:

​
#user  nobody;
worker_processes  1;
​
events {
    worker_connections  1024;
}
​
http {
    include       mime.types;
    default_type  application/octet-stream;
    sendfile        on;
   
    keepalive_timeout  65;
​
    gzip  on;
    server {
        listen       80;
        server_name  manage.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 / {
            proxy_pass http://127.0.0.1:9001;
            proxy_connect_timeout 600;
            proxy_read_timeout 600;
        }
    }
    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 / {
            proxy_pass http://127.0.0.1:10010;
            proxy_connect_timeout 600;
            proxy_read_timeout 600;
        }
    }
}

 

1.5.測試

啟動nginx,然後用域名訪問後臺管理系統:

現在實現了域名訪問網站了,中間的流程是怎樣的呢?

  1. 瀏覽器準備發起請求,訪問http://mamage.leyou.com,但需要進行域名解析

  2. 優先進行本地域名解析,因為我們修改了hosts,所以解析成功,得到地址:127.0.0.1

  3. 請求被髮往解析得到的ip,並且預設使用80埠:http://127.0.0.1:80

    本機的nginx一直監聽80埠,因此捕獲這個請求

  4. nginx中配置了反向代理規則,將manage.leyou.com代理到127.0.0.1:9001,因此請求被轉發

  5. 後臺系統的webpack server監聽的埠是9001,得到請求並處理,完成後將響應返回到nginx

  6. nginx將得到的結果返回到瀏覽器

 

2.實現商品分類查詢

商城的核心自然是商品,而商品多了以後,肯定要進行分類,並且不同的商品會有不同的品牌資訊,其關係如圖所示:

  • 一個商品分類下有很多商品

  • 一個商品分類下有很多品牌

  • 而一個品牌,可能屬於不同的分類

  • 一個品牌下也會有很多商品

 

因此,我們需要依次去完成:商品分類、品牌、商品的開發。

2.1.匯入資料

首先匯入課前資料提供的sql:

我們先看商品分類表:

CREATE TABLE `tb_category` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '類目id',
  `name` varchar(20) NOT NULL COMMENT '類目名稱',
  `parent_id` bigint(20) NOT NULL COMMENT '父類目id,頂級類目填0',
  `is_parent` tinyint(1) NOT NULL COMMENT '是否為父節點,0為否,1為是',
  `sort` int(4) NOT NULL COMMENT '排序指數,越小越靠前',
  PRIMARY KEY (`id`),
  KEY `key_parent_id` (`parent_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1424 DEFAULT CHARSET=utf8 COMMENT='商品類目表,類目和商品(spu)是一對多關係,類目與品牌是多對多關係';

因為商品分類會有層級關係,因此這裡我們加入了parent_id欄位,對本表中的其它分類進行自關聯。

 

2.2.頁面實現

2.2.1.頁面分析

首先我們看下要實現的效果:

商品分類之間是會有層級關係的,採用樹結構去展示是最直觀的方式。

一起來看頁面,對應的是/pages/item/Category.vue:

頁面模板:

<v-card>
    <v-flex xs12 sm10>
        <v-tree url="/item/category/list"
                :treeData="treeData"
                :isEdit="isEdit"
                @handleAdd="handleAdd"
                @handleEdit="handleEdit"
                @handleDelete="handleDelete"
                @handleClick="handleClick"
                />
    </v-flex>
</v-card>
  • v-card:卡片,是vuetify中提供的元件,提供一個懸浮效果的面板,一般用來展示一組資料。

  • v-flex:佈局容器,用來控制響應式佈局。與BootStrap的柵格系統類似,整個螢幕被分為12格。我們可以控制所佔的格數來控制寬度:

    本例中,我們用sm10控制在小螢幕及以上時,顯示寬度為10格

  • v-tree:樹元件。Vuetify並沒有提供樹元件,這個是我們自己編寫的自定義元件:

裡面涉及一些vue的高階用法,大家暫時不要關注其原始碼,會用即可。

2.2.2.樹元件的用法

也可參考課前資料中的:《自定義Vue元件的用法.md》

 

這裡我貼出樹元件的用法指南。

屬性列表:

屬性名稱 說明 資料型別 預設值
url 用來載入資料的地址,即延遲載入 String -
isEdit 是否開啟樹的編輯功能 boolean false
treeData 整顆樹資料,這樣就不用遠端載入了 Array -

這裡推薦使用url進行延遲載入,每當點選父節點時,就會發起請求,根據父節點id查詢子節點資訊

當有treeData屬性時,就不會觸發url載入

遠端請求返回的結果格式:

[
    { 
        "id": 74,
        "name": "手機",
        "parentId": 0,
        "isParent": true,
        "sort": 2
    },
     { 
        "id": 75,
        "name": "家用電器",
        "parentId": 0,
        "isParent": true,
        "sort": 3
    }
]

 

事件:

事件名稱 說明 回撥引數
handleAdd 新增節點時觸發,isEdit為true時有效 新增節點node物件,包含屬性:name、parentId和sort
handleEdit 當某個節點被編輯後觸發,isEdit為true時有效 被編輯節點的id和name
handleDelete 當刪除節點時觸發,isEdit為true時有效 被刪除節點的id
handleClick 點選某節點時觸發 被點選節點的node物件,包含全部資訊

完整node的資訊

回撥函式中返回完整的node節點會包含以下資料:

{
    "id": 76, // 節點id
    "name": "手機", // 節點名稱
    "parentId": 75, // 父節點id
    "isParent": false, // 是否是父節點
    "sort": 1, // 順序
    "path": ["手機", "手機通訊", "手機"] // 所有父節點的名稱陣列
}

2.3.實現功能

2.3.1.url非同步請求

給大家的頁面中,treeData是假資料,我們刪除資料treeData屬性,只保留url看看會發生什麼:

<v-tree url="/item/category/list"
        :isEdit="isEdit"
        @handleAdd="handleAdd"
        @handleEdit="handleEdit"
        @handleDelete="handleDelete"
        @handleClick="handleClick"
        />

重新整理頁面,可以看到:

頁面中的樹沒有了,並且發起了一條請求:http://localhost/api/item/category/list?pid=0

 

大家可能會覺得很奇怪,我們明明是使用的相對路徑,講道理髮起的請求地址應該是:

http://manage.leyou.com/item/category/list

但實際卻是:

http://localhost/api/item/category/list?pid=0

這是因為,我們有一個全域性的配置檔案,對所有的請求路徑進行了約定:

路徑是localhost,並且預設加上了/api的字首,這恰好與我們的閘道器設定匹配,我們只需要把地址改成閘道器的地址即可,因為我們使用了nginx反向代理,這裡可以寫域名:

再次檢視頁面,發現地址已經變成了正確的地址了:

接下來,我們要做的事情就是編寫後臺介面,返回對應的資料即可。

 

2.3.2.實體類

ly-item-interface中新增category實體類:

@Table(name="tb_category")
public class Category {
    @Id
    @GeneratedValue(strategy=GenerationType.IDENTITY)
    private Long id;
    private String name;
    private Long parentId;
    private Boolean isParent;
    private Integer sort;
    // getter和setter略
    // 注意isParent的get和set方法
}

需要注意的是,這裡要用到jpa的註解,因此我們在ly-item-iterface中新增jpa依賴

<dependency>
    <groupId>javax.persistence</groupId>
    <artifactId>persistence-api</artifactId>
    <version>1.0</version>
</dependency>

結構:

2.3.3.controller

編寫一個controller一般需要知道四個內容:

  • 請求方式:決定我們用GetMapping還是PostMapping

  • 請求路徑:決定對映路徑

  • 請求引數:決定方法的引數

  • 返回值結果:決定方法的返回值

在剛才頁面發起的請求中,我們就能得到絕大多數資訊:

  • 請求方式:Get

  • 請求路徑:/api/item/category/list。其中/api是閘道器字首,/item是閘道器的路由對映,真實的路徑應該是/category/list

  • 請求引數:pid=0,根據tree元件的說明,應該是父節點的id,第一次查詢為0,那就是查詢一級類目

  • 返回結果:??

    根據前面tree元件的用法我們知道,返回的應該是json陣列:

    [
        { 
            "id": 74,
            "name": "手機",
            "parentId": 0,
            "isParent": true,
            "sort": 2
        },
         { 
            "id": 75,
            "name": "家用電器",
            "parentId": 0,
            "isParent": true,
            "sort": 3
        }
    ]

    對應的java型別可以是List集合,裡面的元素就是類目物件了。

 

controller程式碼:

@RestController
@RequestMapping("category")
public class CategoryController {
​
    @Autowired
    private CategoryService categoryService;
​
    /**
     * 根據父節點查詢商品類目
     * @param pid
     * @return
     */
    @GetMapping("list")
    public ResponseEntity<List<Category>> queryByParentId(
            @RequestParam(value = "pid", defaultValue = "0") Long pid) {
        List<Category> list = this.categoryService.queryListByParent(pid);
        if (list == null || list.size() < 1) {
            return new ResponseEntity<>(HttpStatus.NOT_FOUND);
        }
        return ResponseEntity.ok(list);
    }
}

 

2.3.4.service

一般service層我們會定義介面和實現類,不過這裡我們就偷懶一下,直接寫實現類了:

@Service
public class CategoryService {
​
    @Autowired
    private CategoryMapper categoryMapper;
​
    public List<Category> queryListByParent(Long pid) {
        Category category = new Category();
        category.setParentId(pid);
        return this.categoryMapper.select(category);
    }
}

 

2.3.5.mapper

我們使用通用mapper來簡化開發:

public interface CategoryMapper extends Mapper<Category> {
}

要注意,我們並沒有在mapper介面上宣告@Mapper註解,那麼mybatis如何才能找到介面呢?

我們在啟動類上新增一個掃描包功能:

@SpringBootApplication
@EnableDiscoveryClient
@MapperScan("com.leyou.item.mapper") // 掃描mapper包
public class LyItemService {
    public static void main(String[] args) {
        SpringApplication.run(LyItemService.class, args);
    }
}

 

專案結構:

 

2.3.6.啟動並測試

我們不經過閘道器,直接訪問:

然後試試閘道器是否暢通:

一切OK!

然後重新整理頁面檢視:

發現報錯了!

瀏覽器直接訪問沒事,但是這裡卻報錯,什麼原因?

 

2.4.跨域問題

2.4.1.什麼是跨域

跨域是指跨域名的訪問,以下情況都屬於跨域:

跨域原因說明 示例
域名不同 www.jd.comwww.taobao.com
域名相同,埠不同 www.jd.com:8080www.jd.com:8081
二級域名不同 item.jd.commiaosha.jd.com

如果域名和埠都相同,但是請求路徑不同,不屬於跨域,如:

www.jd.com/item

www.jd.com/goods

 

而我們剛才是從manage.leyou.com去訪問api.leyou.com,這屬於二級域名不同,跨域了。

 

2.4.2.為什麼有跨域問題?

跨域不一定會有跨域問題。

因為跨域問題是瀏覽器對於ajax請求的一種安全限制:一個頁面發起的ajax請求,只能是於當前頁同域名的路徑,這能有效的阻止跨站攻擊。

因此:跨域問題 是針對ajax的一種限制

但是這卻給我們的開發帶來了不變,而且在實際生成環境中,肯定會有很多臺伺服器之間互動,地址和埠都可能不同,怎麼辦?

 

2.4.3.解決跨域問題的方案

目前比較常用的跨域解決方案有3種:

  • Jsonp

    最早的解決方案,利用script標籤可以跨域的原理實現。

    限制:

    • 需要服務的支援

    • 只能發起GET請求

  • nginx反向代理

    思路是:利用nginx反向代理把跨域為不跨域,支援各種請求方式

    缺點:需要在nginx進行額外配置,語義不清晰

  • CORS

    規範化的跨域請求解決方案,安全可靠。

    優勢:

    • 在服務端進行控制是否允許跨域,可自定義規則

    • 支援各種請求方式

    缺點:

    • 會產生額外的請求

我們這裡會採用cors的跨域方案。

2.5.cors解決跨域

2.5.1.什麼是cors

CORS是一個W3C標準,全稱是"跨域資源共享"(Cross-origin resource sharing)。

它允許瀏覽器向跨源伺服器,發出XMLHttpRequest請求,從而克服了AJAX只能同源使用的限制。

CORS需要瀏覽器和伺服器同時支援。目前,所有瀏覽器都支援該功能,IE瀏覽器不能低於IE10。

  • 瀏覽器端:

    目前,所有瀏覽器都支援該功能(IE10以下不行)。整個CORS通訊過程,都是瀏覽器自動完成,不需要使用者參與。

  • 服務端:

    CORS通訊與AJAX沒有任何差別,因此你不需要改變以前的業務邏輯。只不過,瀏覽器會在請求中攜帶一些頭資訊,我們需要以此判斷是否執行其跨域,然後在響應頭中加入一些資訊即可。這一般通過過濾器完成即可。

2.5.2.原理有點複雜

瀏覽器會將ajax請求分為兩類,其處理方案略有差異:簡單請求、特殊請求。

簡單請求

只要同時滿足以下兩大條件,就屬於簡單請求。:

(1) 請求方法是以下三種方法之一:

  • HEAD

  • GET

  • POST

(2)HTTP的頭資訊不超出以下幾種欄位:

  • Accept

  • Accept-Language

  • Content-Language

  • Last-Event-ID

  • Content-Type:只限於三個值application/x-www-form-urlencodedmultipart/form-datatext/plain

 

當瀏覽器發現發現的ajax請求是簡單請求時,會在請求頭中攜帶一個欄位:Origin.

Origin中會指出當前請求屬於哪個域(協議+域名+埠)。服務會根據這個值決定是否允許其跨域。

如果伺服器允許跨域,需要在返回的響應頭中攜帶下面資訊:

Access-Control-Allow-Origin: http://manage.leyou.com
Access-Control-Allow-Credentials: true
Content-Type: text/html; charset=utf-8
  • Access-Control-Allow-Origin:可接受的域,是一個具體域名或者*,代表任意

  • Access-Control-Allow-Credentials:是否允許攜帶cookie,預設情況下,cors不會攜帶cookie,除非這個值是true

注意:

如果跨域請求要想操作cookie,需要滿足3個條件:

  • 服務的響應頭中需要攜帶Access-Control-Allow-Credentials並且為true。

  • 瀏覽器發起ajax需要指定withCredentials 為true

  • 響應頭中的Access-Control-Allow-Origin一定不能為*,必須是指定的域名

特殊請求

不符合簡單請求的條件,會被瀏覽器判定為特殊請求,,例如請求方式為PUT。

預檢請求

特殊請求會在正式通訊之前,增加一次HTTP查詢請求,稱為"預檢"請求(preflight)。

瀏覽器先詢問伺服器,當前網頁所在的域名是否在伺服器的許可名單之中,以及可以使用哪些HTTP動詞和頭資訊欄位。只有得到肯定答覆,瀏覽器才會發出正式的XMLHttpRequest請求,否則就報錯。

一個“預檢”請求的樣板:

OPTIONS /cors HTTP/1.1
Origin: http://manage.leyou.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header
Host: api.leyou.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

與簡單請求相比,除了Origin以外,多了兩個頭:

  • Access-Control-Request-Method:接下來會用到的請求方式,比如PUT

  • Access-Control-Request-Headers:會額外用到的頭資訊

預檢請求的響應

服務的收到預檢請求,如果許可跨域,會發出響應:

HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2.0.61 (Unix)
Access-Control-Allow-Origin: http://manage.leyou.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Access-Control-Max-Age: 1728000
Content-Type: text/html; charset=utf-8
Content-Encoding: gzip
Content-Length: 0
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain

除了Access-Control-Allow-OriginAccess-Control-Allow-Credentials以外,這裡又額外多出3個頭:

  • Access-Control-Allow-Methods:允許訪問的方式

  • Access-Control-Allow-Headers:允許攜帶的頭

  • Access-Control-Max-Age:本次許可的有效時長,單位是秒,過期之前的ajax請求就無需再次進行預檢了

 

如果瀏覽器得到上述響應,則認定為可以跨域,後續就跟簡單請求的處理是一樣的了。

2.5.3.實現非常簡單

雖然原理比較複雜,但是前面說過:

  • 瀏覽器端都有瀏覽器自動完成,我們無需操心

  • 服務端可以通過攔截器統一實現,不必每次都去進行跨域判定的編寫。

事實上,SpringMVC已經幫我們寫好了CORS的跨域過濾器:CorsFilter ,內部已經實現了剛才所講的判定邏輯,我們直接用就好了。

ly-api-gateway中編寫一個配置類,並且註冊CorsFilter:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.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(true);
        //3) 允許的請求方式
        config.addAllowedMethod("OPTIONS");
        config.addAllowedMethod("HEAD");
        config.addAllowedMethod("GET");
        config.addAllowedMethod("PUT");
        config.addAllowedMethod("POST");
        config.addAllowedMethod("DELETE");
        config.addAllowedMethod("PATCH");
        // 4)允許的頭資訊
        config.addAllowedHeader("*");
​
        //2.新增對映路徑,我們攔截一切請求
        UrlBasedCorsConfigurationSource configSource = new UrlBasedCorsConfigurationSource();
        configSource.registerCorsConfiguration("/**", config);
​
        //3.返回新的CorsFilter.
        return new CorsFilter(configSource);
    }
}

結構:

 

4.5.4.重啟測試:

訪問正常:

頁面也OK了:

 

分類的增刪改功能暫時就不做了,頁面已經預留好了事件介面,有興趣的同學可以完成一下。

 

3.品牌的查詢

商品分類完成以後,自然輪到了品牌功能了。

先看看我們要實現的效果:

 

接下來,我們從0開始,實現下從前端到後端的完整開發。

3.1.從0開始

為了方便看到效果,我們新建一個MyBrand.vue(注意先停掉伺服器),從0開始搭建。

內容初始化一下:

<template>
  <span>
    hello
  </span>
</template>
​
<script>
    export default {
        name: "my-brand"
    }
</script>
​
<style scoped>
​
</style>

改變router新的index.js,將路由地址指向MyBrand.vue

開啟伺服器,再次檢視頁面:

 

乾乾淨淨了。

 

3.2.品牌查詢頁面

3.2.1.data-tables元件

大家看到這個原型頁面肯定能看出,其主體就是一個table。我們去Vuetify檢視有關table的文件:

仔細閱讀,發現v-data-table中有以下核心屬性:

  • dark:是否使用黑暗色彩主題,預設是false

  • expand:表格的行是否可以展開,預設是false

  • headers:定義表頭的陣列,陣列的每個元素就是一個表頭資訊物件,結構:

    {
      text: string, // 表頭的顯示文字
      value: string, // 表頭對應的每行資料的key
      align: 'left' | 'center' | 'right', // 位置
      sortable: boolean, // 是否可排序
      class: string[] | string,// 樣式
      width: string,// 寬度
    }
  • items:表格的資料的陣列,陣列的每個元素是一行資料的物件,物件的key要與表頭的value一致

  • loading:是否顯示載入資料的進度條,預設是false

  • no-data-text:當沒有查詢到資料時顯示的提示資訊,string型別,無預設值

  • pagination.sync:包含分頁和排序資訊的物件,將其與vue例項中的屬性關聯,表格的分頁或排序按鈕被觸發時,會自動將最新的分頁和排序資訊更新。物件結構:

    {
        page: 1, // 當前頁
        rowsPerPage: 5, // 每頁大小
        sortBy: '', // 排序欄位
        descending:false, // 是否降序
    }
  • total-items:分頁的總條數資訊,number型別,無預設值

  • select-all :是否顯示每一行的複選框,Boolean型別,無預設值

  • value:當表格可選的時候,返回選中的行

 

我們向下翻,找找有沒有看起來牛逼的案例。

找到這樣一條:

其它的案例都是由Vuetify幫我們對查詢到的當前頁資料進行排序和分頁,這顯然不是我們想要的。我們希望能在服務端完成對整體品牌資料的排序和分頁,而這個案例恰好合適。

點選按鈕,我們直接檢視原始碼,然後直接複製到MyBrand.vue中

模板:

<template>
  <div>
    <v-data-table
      :headers="headers"
      :items="desserts"
      :search="search"
      :pagination.sync="pagination"
      :total-items="totalDesserts"
      :loading="loading"
      class="elevation-1"
    >
      <template slot="items" slot-scope="props">
        <td>{{ props.item.name }}</td>
        <td class="text-xs-right">{{ props.item.calories }}</td>
        <td class="text-xs-right">{{ props.item.fat }}</td>
        <td class="text-xs-right">{{ props.item.carbs }}</td>
        <td class="text-xs-right">{{ props.item.protein }}</td>
        <td class="text-xs-right">{{ props.item.iron }}</td>
      </template>
    </v-data-table>
  </div>
</template>

 

3.2.2.分析

接下來,就分析一下案例中每一部分是什麼意思,搞清楚了,我們也可以自己玩了。

先看模板中table上的一些屬性:

<v-data-table
              :headers="headers"
              :items="desserts"
              :search="search"
              :pagination.sync="pagination"
              :total-items="totalDesserts"
              :loading="loading"
              class="elevation-1"
              >
</v-data-table>
  • headers:表頭資訊,是一個數組

  • items:要在表格中展示的資料,陣列結構,每一個元素是一行

  • search:搜尋過濾欄位,用不到,暫時不管

  • pagination.sync:分頁資訊,包含了當前頁,每頁大小,排序欄位,排序方式等。加上.sync代表服務端排序,當用戶點選分頁條時,該物件的值會跟著變化。監控這個值,並在這個值變化時去服務端查詢,即可實現頁面資料動態載入了。

  • total-items:總條數

  • loading:boolean型別,true:代表資料正在載入,會有進度條。false:資料載入完畢。

 

另外,在v-data-tables中,我們還看到另一段程式碼:

<template slot="items" slot-scope="props">
        <td>{{ props.item.name }}</td>
        <td class="text-xs-right">{{ props.item.calories }}</td>
        <td class="text-xs-right">{{ props.item.fat }}</td>
        <td class="text-xs-right">{{ props.item.carbs }}</td>
        <td class="text-xs-right">{{ props.item.protein }}</td>
        <td class="text-xs-right">{{ props.item.iron }}</td>
</template>

這段就是在渲染每一行的資料。Vue會自動遍歷上面傳遞的items屬性,並把得到的物件傳遞給這段template中的props.item屬性。我們從中得到資料,渲染在頁面即可。

 

我們需要做的事情,主要有兩件:

  • 給items和totalItems賦值

  • 當pagination變化時,重新獲取資料,再次給items和totalItems賦值

 

3.2.3.初步實現

我們先弄點假品牌資料:

[
  {
    "id": 2032,
    "name": "OPPO",
    "image": "http://img10.360buyimg.com/popshop/jfs/t2119/133/2264148064/4303/b8ab3755/56b2f385N8e4eb051.jpg",
    "letter": "O"
  },
  {
    "id": 2033,
    "name": "飛利浦(PHILIPS)",
    "image": "http://img12.360buyimg.com/popshop/jfs/t18361/122/1318410299/1870/36fe70c9/5ac43a4dNa44a0ce0.jpg",
    "letter": "F"
  },
  {
    "id": 2034,
    "name": "華為(HUAWEI)",
    "image": "http://img10.360buyimg.com/popshop/jfs/t5662/36/8888655583/7806/1c629c01/598033b4Nd6055897.jpg",
    "letter": "H"
  },
  {
    "id": 2036,
    "name": "酷派(Coolpad)",
    "image": "http://img10.360buyimg.com/popshop/jfs/t2521/347/883897149/3732/91c917ec/5670cf96Ncffa2ae6.jpg",
    "letter": "K"
  },
  {
    "id": 2037,
    "name": "魅族(MEIZU)",
    "image": "http://img13.360buyimg.com/popshop/jfs/t3511/131/31887105/4943/48f83fa9/57fdf4b8N6e95624d.jpg",
    "letter": "M"
  }
]

品牌中有id,name,image,letter欄位。

 

修改模板

  <div>
    <v-data-table
      :headers="headers"
      :items="brands"
      :search="search"
      :pagination.sync="pagination"
      :total-items="totalBrands"
      :loading="loading"
      class="elevation-1"
    >
      <template slot="items" slot-scope="props">
        <td>{{ props.item.id }}</td>
        <td class="text-xs-center">{{ props.item.name }}</td>
        <td class="text-xs-center">
          <img v-if="props.item.image" :src="props.item.image" width="130" height="40">
          <span v-else>無</span>
        </td>
        <td class="text-xs-center">{{ props.item.letter }}</td>
      </template>
    </v-data-table>
  </div>

我們修改了以下部分:

  • items:指向一個brands變數,等下在js程式碼中定義

  • total-items:指向了totalBrands變數,等下在js程式碼中定義

  • template模板中,渲染了四個欄位:

    • id:

    • name

    • image,注意,我們不是以文字渲染,而是賦值到一個img標籤的src屬性中,並且做了非空判斷

    • letter

編寫資料

接下來編寫要用到的資料:

{
    data() {
      return {
        search: '', // 搜尋過濾欄位
        totalBrands: 0, // 總條數
        brands: [], // 當前頁品牌資料
        loading: true, // 是否在載入中
        pagination: {}, // 分頁資訊
        headers: [ // 頭資訊
          {text: 'id', align: 'center', value: 'id'},
          {text: '名稱', align: 'center', sortable: false, value: 'name'},
          {text: 'LOGO', align: 'center', sortable: false, value: 'image'},
          {text: '首字母', align: 'center', value: 'letter', sortable: true,}
        ]
      }
  }
}

 

編寫函式,初始化資料

接下來就是對brands和totalBrands完成賦值動作了。

我們編寫一個函式來完成賦值,提高複用性:

methods:{
      getDataFromServer(){ // 從服務的載入資料的方法。
        // 偽造假資料
        const brands = [
          {
            "id": 2032,
            "name": "OPPO",
            "image": "http://img10.360buyimg.com/popshop/jfs/t2119/133/2264148064/4303/b8ab3755/56b2f385N8e4eb051.jpg",
            "letter": "O",
            "categories": null
          },
          {
            "id": 2033,
            "name": "飛利浦(PHILIPS)",
            "image": "http://img12.360buyimg.com/popshop/jfs/t18361/122/1318410299/1870/36fe70c9/5ac43a4dNa44a0ce0.jpg",
            "letter": "F",
            "categories": null
          },
          {
            "id": 2034,
            "name": "華為(HUAWEI)",
            "image": "http://img10.360buyimg.com/popshop/jfs/t5662/36/8888655583/7806/1c629c01/598033b4Nd6055897.jpg",
            "letter": "H",
            "categories": null
          },
          {
            "id": 2036,
            "name": "酷派(Coolpad)",
            "image": "http://img10.360buyimg.com/popshop/jfs/t2521/347/883897149/3732/91c917ec/5670cf96Ncffa2ae6.jpg",
            "letter": "K",
            "categories": null
          },
          {
            "id": 2037,
            "name": "魅族(MEIZU)",
            "image": "http://img13.360buyimg.com/popshop/jfs/t3511/131/31887105/4943/48f83fa9/57fdf4b8N6e95624d.jpg",
            "letter": "M",
            "categories": null
          }
        ];
        // 模擬延遲一段時間,隨後進行賦值
        setTimeout(() => {
          // 然後賦值給brands
          this.brands = brands;
          this.totalBrands = brands.length;
          // 完成賦值後,把載入狀態賦值為false
          this.loading = false;
        },400)
      }
}

然後使用鉤子函式,在Vue例項初始化完畢後呼叫這個方法,這裡使用mounted(渲染後)函式:

mounted(){ // 渲染後執行
    // 查詢資料
    this.getDataFromServer();
}

 

完整程式碼

<template>
  <div>
    <v-data-table
      :headers="headers"
      :items="brands"
      :search="search"
      :pagination.sync="pagination"
      :total-items="totalBrands"
      :loading="loading"
      class="elevation-1"
    >
      <template slot="items" slot-scope="props">
        <td>{{ props.item.id }}</td>
        <td class="text-xs-center">{{ props.item.name }}</td>
        <td class="text-xs-center"><img :src="props.item.image"></td>
        <td class="text-xs-center">{{ props.item.letter }}</td>
      </template>
    </v-data-table>
  </div>
</template>
​
<script>
  export default {
    name: "my-brand",
    data() {
      return {
        search: '', // 搜尋過濾欄位
        totalBrands: 0, // 總條數
        brands: [], // 當前頁品牌資料
        loading: true, // 是否在載入中
        pagination: {}, // 分頁資訊
        headers: [
          {text: 'id', align: 'center', value: 'id'},
          {text: '名稱', align: 'center', sortable: false, value: 'name'},
          {text: 'LOGO', align: 'center', sortable: false, value: 'image'},
          {text: '首字母', align: 'center', value: 'letter', sortable: true,}
        ]
      }
    },
    mounted(){ // 渲染後執行
      // 查詢資料
      this.getDataFromServer();
    },
    methods:{
      getDataFromServer(){ // 從服務的載入數的方法。
        // 偽造假資料
        const brands = [
          {
            "id": 2032,
            "name": "OPPO",
            "image": "http://img10.360buyimg.com/popshop/jfs/t2119/133/2264148064/4303/b8ab3755/56b2f385N8e4eb051.jpg",
            "letter": "O",
            "categories": null
          },
          {
            "id": 2033,
            "name": "飛利浦(PHILIPS)",
            "image": "http://img12.360buyimg.com/popshop/jfs/t18361/122/1318410299/1870/36fe70c9/5ac43a4dNa44a0ce0.jpg",
            "letter": "F",
            "categories": null
          },
          {
            "id": 2034,
            "name": "華為(HUAWEI)",
            "image": "http://img10.360buyimg.com/popshop/jfs/t5662/36/8888655583/7806/1c629c01/598033b4Nd6055897.jpg",
            "letter": "H",
            "categories": null
          },
          {
            "id": 2036,
            "name": "酷派(Coolpad)",
            "image": "http://img10.360buyimg.com/popshop/jfs/t2521/347/883897149/3732/91c917ec/5670cf96Ncffa2ae6.jpg",
            "letter": "K",
            "categories": null
          },
          {
            "id": 2037,
            "name": "魅族(MEIZU)",
            "image": "http://img13.360buyimg.com/popshop/jfs/t3511/131/31887105/4943/48f83fa9/57fdf4b8N6e95624d.jpg",
            "letter": "M",
            "categories": null
          }
        ];
        // 模擬延遲一段時間,隨後進行賦值
        setTimeout(() => {
          // 然後賦值給brands
          this.brands = brands;
          this.totalBrands = brands.length;
          // 完成賦值後,把載入狀態賦值為false
          this.loading = false;
        },400)
      }
    }
  }
</script>
​
<style scoped>
​
</style>

 

重新整理頁面檢視:

 

3.2.4.優化頁面

編輯和刪除按鈕

我們將來要對品牌進行增刪改,需要給每一行資料新增 修改刪除的按鈕,一般放到改行的最後一列:

其實就是多了一列,只是這一列沒有資料,而是兩個按鈕而已。

我們先在頭(headers)中新增一列:

headers: [
    {text: 'id', align: 'center', value: 'id'},
    {text: '名稱', align: 'center', sortable: false, value: 'name'},
    {text: 'LOGO', align: 'center', sortable: false, value: 'image'},
    {text: '首字母', align: 'center', value: 'letter', sortable: true,},
    {text: '操作', align: 'center', value: 'id', sortable: false}
]

然後在模板中新增按鈕:

<template slot="items" slot-scope="props">
  <td>{{ props.item.id }}</td>
  <td class="text-xs-center">{{ props.item.name }}</td>
  <td class="text-xs-center"><img :src="props.item.image"></td>
  <td class="text-xs-center">{{ props.item.letter }}</td>
  <td class="justify-center">
    編輯/刪除
  </td>
</template>

因為不知道按鈕怎麼寫,先放個普通文字看看:

然後在官方文件中找到按鈕的用法:

修改我們的模板:

<template slot="items" slot-scope="props">
    <td>{{ props.item.id }}</td>
    <td class="text-xs-center">{{ props.item.name }}</td>
    <td class="text-xs-center"><img :src="props.item.image"></td>
    <td class="text-xs-center">{{ props.item.letter }}</td>
    <td class="justify-center layout">
        <v-btn color="info">編輯</v-btn>
        <v-btn color="warning">刪除</v-btn>
    </td>
</template>

新增按鈕

因為新增根某個品牌無關,是獨立的,因此我們可以放到表格的外面:

效果:

 

卡片(card)

為了不讓按鈕顯得過於孤立,我們可以將按新增按鈕表格放到一張卡片(card)中。

我們去官網檢視卡片的用法:

卡片v-card包含四個基本元件:

  • v-card-media:一般放圖片或視訊

  • v-card-title:卡片的標題,一般位於卡片頂部

  • v-card-text:卡片的文字(主體內容),一般位於卡片正中

  • v-card-action:卡片的按鈕,一般位於卡片底部

我們可以把新增的按鈕放到v-card-title位置,把table放到下面,這樣就成一個上下關係。

  <v-card>
    <!-- 卡片的頭部 -->
    <v-card-title>
      <v-btn color="primary">新增</v-btn>
    </v-card-title>
    <!-- 分割線 -->
    <v-divider/>
    <!--卡片的中部-->
    <v-data-table
      :headers="headers"
      :items="brands"
      :search="search"
      :pagination.sync="pagination"
      :total-items="totalBrands"
      :loading="loading"
      class="elevation-1"
    >
      <template slot="items" slot-scope="props">
        <td>{{ props.item.id }}</td>
        <td class="text-xs-center">{{ props.item.name }}</td>
        <td class="text-xs-center"><img :src="props.item.image"></td>
        <td class="text-xs-center">{{ props.item.letter }}</td>
        <td class="justify-center layout">
          <v-btn color="info">編輯</v-btn>
          <v-btn color="warning">刪除</v-btn>
        </td>
      </template>
    </v-data-table>
  </v-card>

效果:

新增搜尋框

我們還可以在卡片頭部新增一個搜尋框,其實就是一個文字輸入框。

檢視官網中,文字框的用法:

  • name:欄位名,表單中會用到

  • label:提示文字

  • value:值。可以用v-model代替,實現雙向繫結

 

修改模板,新增輸入框:

<v-card-title>
    <v-btn color="primary">新增品牌</v-btn>
    <!--搜尋框,與search屬性關聯-->
    <v-text-field label="輸入關鍵字搜尋" v-model="search"/>
</v-card-title>

效果:

發現輸入框變的超級長!!!

這個時候,我們可以使用Vuetify提供的一個空間隔離工具:

修改程式碼:

    <v-card-title>
      <v-btn color="primary">新增品牌</v-btn>
      <!--空間隔離元件-->
      <v-spacer />
      <!--搜尋框,與search屬性關聯-->
      <v-text-field label="輸入關鍵字搜尋" v-model="search"/>
    </v-card-title>

 

 

給搜尋框新增搜尋圖示

檢視textfiled的文件,發現:

通過append-icon屬性可以為 輸入框新增後置圖示,所有可用圖示名稱可以到 material-icons官網去檢視。

修改我們的程式碼:

<v-text-field label="輸入關鍵字搜尋" v-model="search" append-icon="search"/>

 

把文字框變緊湊

搜尋框看起來高度比較高,頁面不夠緊湊。這其實是因為預設在文字框下面預留有錯誤提示空間。通過下面的屬性可以取消提示:

修改程式碼:

<v-text-field label="輸入關鍵字搜尋" v-model="search" append-icon="search" hide-details/>

效果:

幾乎已經達到了原來一樣的效果了吧!

 

3.3.後臺提供查詢介面

前臺頁面已經準備好,接下來就是後臺提供資料介面了。

3.3.1.資料庫表

CREATE TABLE `tb_brand` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '品牌id',
  `name` varchar(50) NOT NULL COMMENT '品牌名稱',
  `image` varchar(200) DEFAULT '' COMMENT '品牌圖片地址',
  `letter` char(1) DEFAULT '' COMMENT '品牌的首字母',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=325400 DEFAULT CHARSET=utf8 COMMENT='品牌表,一個品牌下有多個商品(spu),一對多關係';
​

簡單的四個欄位,不多解釋。

這裡需要注意的是,品牌和商品分類之間是多對多關係。因此我們有一張中間表,來維護兩者間關係:

CREATE TABLE `tb_category_brand` (
  `category_id` bigint(20) NOT NULL COMMENT '商品類目id',
  `brand_id` bigint(20) NOT NULL COMMENT '品牌id',
  PRIMARY KEY (`category_id`,`brand_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='商品分類和品牌的中間表,兩者是多對多關係';
​

但是,你可能會發現,這張表中並沒有設定外來鍵約束,似乎與資料庫的設計正規化不符。為什麼這麼做?

  • 外來鍵會嚴重影響資料庫讀寫的效率

  • 資料刪除時會比較麻煩

在電商行業,效能是非常重要的。我們寧可在程式碼中通過邏輯來維護表關係,也不設定外來鍵。

 

3.3.2.實體類

@Table(name = "tb_brand")
public class Brand {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;// 品牌名稱
    private String image;// 品牌圖片
    private Character letter;
    // getter setter 略
}

 

3.3.3.mapper

通用mapper來簡化開發:

public interface BrandMapper extends Mapper<Brand> {
}

3.3.4.controller

編寫controller先思考四個問題,這次沒有前端程式碼,需要我們自己來設定

  • 請求方式:查詢,肯定是Get

  • 請求路徑:分頁查詢,/brand/page

  • 請求引數:根據我們剛才編寫的頁面,有分頁功能,有排序功能,有搜尋過濾功能,因此至少要有5個引數:

    • page:當前頁,int

    • rows:每頁大小,int

    • sortBy:排序欄位,String

    • desc:是否為降序,boolean

    • key:搜尋關鍵詞,String

  • 響應結果:分頁結果一般至少需要兩個資料

    • total:總條數

    • items:當前頁資料

    • totalPage:有些還需要總頁數

    這裡我們封裝一個類,來表示分頁結果:

    public class PageResult<T> {
        private Long total;// 總條數
        private Long totalPage;// 總頁數
        private List<T> items;// 當前頁資料
    ​
        public PageResult() {
        }
    ​
        public PageResult(Long total, List<T> items) {
            this.total = total;
            this.items = items;
        }
    ​
        public PageResult(Long total, Long totalPage, List<T> items) {
            this.total = total;
            this.totalPage = totalPage;
            this.items = items;
        }
    ​
        public Long getTotal() {
            return total;
        }
    ​
        public void setTotal(Long total) {
            this.total = total;
        }
    ​
        public List<T> getItems() {
            return items;
        }
    ​
        public void setItems(List<T> items) {
            this.items = items;
        }
    ​
        public Long getTotalPage() {
            return totalPage;
        }
    ​
        public void setTotalPage(Long totalPage) {
            this.totalPage = totalPage;
        }
    }

    另外,這個PageResult以後可能在其它專案中也有需求,因此我們將其抽取到ly-common中,提高複用性:

接下來,我們編寫Controller

@RestController
@RequestMapping("brand")
public class BrandController {
​
    @Autowired
    private BrandService brandService;
​
    @GetMapping("page")
    public ResponseEntity<PageResult<Brand>> queryBrandByPage(
            @RequestParam(value = "page", defaultValue = "1") Integer page,
            @RequestParam(value = "rows", defaultValue = "5") Integer rows,
            @RequestParam(value = "sortBy", required = false) String sortBy,
            @RequestParam(value = "desc", defaultValue = "false") Boolean desc,
            @RequestParam(value = "key", required = false) String key) {
        PageResult<Brand> result = this.brandService.queryBrandByPageAndSort(page,rows,sortBy,desc, key);
        if (result == null || result.getItems().size() == 0) {
            return new ResponseEntity<>(HttpStatus.NOT_FOUND);
        }
        return ResponseEntity.ok(result);
    }
}
​

 

3.3.5.Service

@Service
public class BrandService {
​
    @Autowired
    private BrandMapper brandMapper;
​
    public PageResult<Brand> queryBrandByPageAndSort(
            Integer page, Integer rows, String sortBy, Boolean desc, S