模型-視圖-控制器模式
關注點分離(Separation of Concerns,SoC)原則是軟件工程相關的設計原則之一。SoC原則背后的思想是將一個應用切分成不同的部分,每個部分解決一個單獨的關注點。分層設計中的層次(數據訪問層、業務邏輯層和表示層等)即是關注點的例子。使用SoC原則能簡化軟件應用的開發和維護(請參考網頁[t.cn/RqrjewK])。
模型—視圖—控制器(Model-View-Controller,MVC)模式是應用到面向對象編程的Soc原則。模式的名稱來自用來切分軟件應用的三個主要部分,即模型部分、視圖部分和控制器部分。MVC被認為是一種架構模式而不是一種設計模式。架構模式與設計模式之間的區別在于前者比后者的范疇更廣。然而,MVC太重要了,不能僅因為這個原因就跳過不說。即使我們從不需要從頭實現它,也需要熟悉它,因為所有常見框架都使用了MVC或者是其略微不同的版本(之后會詳述)。
模型是核心的部分,代表著應用的信息本源,包含和管理(業務)邏輯、數據、狀態以及應用的規則。視圖是模型的可視化表現。視圖的例子有,計算機圖形用戶界面、計算機終端的文本輸出、智能手機的應用圖形界面、PDF文檔、餅圖和柱狀圖等。視圖只是展示數據,并不處理數據。控制器是模型與視圖之間的鏈接/粘附。模型與視圖之間的所有通信都通過控制器進行(請參考[GOF95,第14頁]、網頁[t.cn/RqrjF4G]和網頁[t.cn/RPrOUPr])。
對于將初始屏幕渲染給用戶之后使用MVC的應用,其典型使用方式如下所示:
- 用戶通過單擊(鍵入、觸摸等)某個按鈕觸發一個視圖
- 視圖把用戶操作告知控制器
- 控制器處理用戶輸入,并與模型交互
- 模型執行所有必要的校驗和狀態改變,并通知控制器應該做什么
- 控制器按照模型給出的指令,指導視圖適當地更新和顯示輸出
## 如下所示為來自于github的示例代碼:
#!/usr/bin/env python
# -*- coding: utf-8 -*-
class Model(object):
def __iter__(self):
raise NotImplementedError
def get(self, item):
"""Returns an object with a .items() call method
that iterates over key,value pairs of its information."""
raise NotImplementedError
@property
def item_type(self):
raise NotImplementedError
class ProductModel(Model):
class Price(float):
"""A polymorphic way to pass a float with a particular __str__ functionality."""
def __str__(self):
first_digits_str = str(round(self,2))
try:
dot_location = first_digits_str.index('.')
except ValueError:
return (first_digits_str + '.00')
else:
return (first_digits_str +
'0'*(3 + dot_location - len(first_digits_str)))
products = {
'milk': {'price': Price(1.50), 'quantity': 10},
'eggs': {'price': Price(0.20), 'quantity': 100},
'cheese': {'price': Price(2.00), 'quantity': 10}
}
item_type = 'product'
def __iter__(self):
for item in self.products:
yield item
def get(self, product):
try:
return self.products[product]
except KeyError as e:
raise KeyError((str(e) + " not in the model's item list."))
class View(object):
def show_item_list(self, item_type, item_list):
raise NotImplementedError
def show_item_information(self, item_type, item_name, item_info):
"""Will look for item information by iterating over key,value pairs
yielded by item_info.items()"""
raise NotImplementedError
def item_not_found(self, item_type, item_name):
raise NotImplementedError
class ConsoleView(View):
def show_item_list(self, item_type, item_list):
print(item_type.upper() + ' LIST:')
for item in item_list:
print(item)
print('')
@staticmethod
def capitalizer(string):
return string[0].upper() + string[1:].lower()
def show_item_information(self, item_type, item_name, item_info):
print(item_type.upper() + ' INFORMATION:')
printout = 'Name: %s' % item_name
for key, value in item_info.items():
printout += (', ' + self.capitalizer(str(key)) + ': ' + str(value))
printout += '\n'
print(printout)
def item_not_found(self, item_type, item_name):
print('That %s "%s" does not exist in the records' % (item_type, item_name))
class Controller(object):
def __init__(self, model, view):
self.model = model
self.view = view
def show_items(self):
items = list(self.model)
item_type = self.model.item_type
self.view.show_item_list(item_type, items)
def show_item_information(self, item_name):
try:
item_info = self.model.get(item_name)
except:
item_type = self.model.item_type
self.view.item_not_found(item_type, item_name)
else:
item_type = self.model.item_type
self.view.show_item_information(item_type, item_name, item_info)
if __name__ == '__main__':
model = ProductModel()
view = ConsoleView()
controller = Controller(model, view)
controller.show_items()
controller.show_item_information('cheese')
controller.show_item_information('eggs')
controller.show_item_information('milk')
controller.show_item_information('arepas')
### OUTPUT ###
# PRODUCT LIST:
# cheese
# eggs
# milk
#
# PRODUCT INFORMATION:
# Name: Cheese, Price: 2.00, Quantity: 10
#
# PRODUCT INFORMATION:
# Name: Eggs, Price: 0.20, Quantity: 100
#
# PRODUCT INFORMATION:
# Name: Milk, Price: 1.50, Quantity: 10
#
# That product "arepas" does not exist in the records
PRODUCT LIST:
cheese
milk
eggs
PRODUCT INFORMATION:
Name: cheese, Quantity: 10, Price: 2.00
PRODUCT INFORMATION:
Name: eggs, Quantity: 100, Price: 0.20
PRODUCT INFORMATION:
Name: milk, Quantity: 10, Price: 1.50
That product "arepas" does not exist in the records
你可能想知道為什么控制器部分是必要的?我們能跳過它嗎?能,但那樣我們將失去MVC提供的一大優勢:無需修改模型就能使用多個視圖的能力(甚至可以根據需要同時使用多個視圖)。為了實現模型與其表現之間的解耦,每個視圖通常都需要屬于它的控制器。如果模型直接與特定視圖通信,我們將無法對同一個模型使用多個視圖(或者至少無法以簡潔模塊化的方式實現)。
現實生活的例子
MVC是應用于面向對象編程的SoC原則。SoC原則在現實生活中的應用有很多。例如,如果你造一棟新房子,通常會請不同的專業人員來完成以下工作。
- 安裝管道和電路
- 粉刷房子
另一個例子是餐館。在一個餐館中,服務員接收點菜單并為顧客上菜,但是飯菜由廚師烹飪(請參考網頁[t.cn/RqrYh1I])。
軟件的例子
Web框架web2py(請參考網頁[t.cn/RqrYZwy])是一個支持MVC模式的輕量級Python框架。若你還未嘗試過web2py,我推薦你試用一下,安裝過程極其簡單,你要做的就是下載安裝包并執行一個Python文件(web2py.py)。在該項目的網頁上有很多例子演示了在web2py中如何使用MVC(請參考網頁[t.cn/RqrYADU])。
Django也是一個MVC框架,但是它使用了不同的命名約定。在此約定下,控制器被稱為視圖,視圖被稱為模板。Django使用名稱模型—模板—視圖(Model-Template-View,MTV)來替代MVC。依據Django的設計者所言,視圖是描述哪些數據對用戶可見。因此,Django把對應一個特定URL的Python回調函數稱為視圖。Django中的“模板”用于把內容與其展現分開,其描述的是用戶看到數據的方式而不是哪些數據可見(請參考網頁[t.cn/RwRJZ87])。
應用案例
MVC是一個非常通用且大有用處的設計模式。實際上,所有流行的Web框架(Django、Rails 和Yii)和應用框架(iPhone SDK、Android和QT)都使用了MVC或者其變種,其變種包括模式—視圖—適配器(Model-View-Adapter,MVA)、模型—視圖—演示者(Model-View-Presenter,MVP) 等。然而,即使我們不使用這些框架,憑自己實現這一模式也是有意義的,因為這一模式提供了以下這些好處。
- 視圖與模型的分離允許美工一心搞UI部分,程序員一心搞開發,不會相互干擾。
- 由于視圖與模型之間的松耦合,每個部分可以單獨修改/擴展,不會相互影響。例如,添加一個新視圖的成本很小,只要為其實現一個控制器就可以了。
- 因為職責明晰,維護每個部分也更簡單。
在從頭開始實現MVC時,請確保創建的模型很智能,控制器很瘦,視圖很傻瓜(請參考[Zlobin13,第9頁])。
可以將具有以下功能的模型視為智能模型。
- 包含所有的校驗/業務規則/邏輯
- 處理應用的狀態
- 訪問應用數據(數據庫、云或其他)
- 不依賴UI
可以將符合以下條件的控制器視為瘦控制器。
- 在用戶與視圖交互時,更新模型
- 在模型改變時,更新視圖
- 如果需要,在數據傳遞給模型/視圖之前進行處理不展示數據
- 不直接訪問應用數據
- 不包含校驗/業務規則/邏輯
可以將符合以下條件的視圖視為傻瓜視圖。
- 展示數據
- 允許用戶與其交互
- 僅做最小的數據處理,通常由一種模板語言提供處理能力(例如,使用簡單的變量和循環控制)
- 不存儲任何數據
- 不直接訪問應用數據
- 不包含校驗/業務規則/邏輯
如果你正在從頭實現MVC,并且想弄清自己做得對不對,可以嘗試回答以下兩個關鍵問題。
- 如果你的應用有GUI,那它可以換膚嗎?易于改變它的皮膚/外觀以及給人的感受嗎?可以為用戶提供運行期間改變應用皮膚的能力嗎?如果這做起來并不簡單,那就意味著你的MVC實現在某些地方存在問題(請參考網頁[t.cn/RqrjF4G])。
- 如果你的應用沒有GUI(例如,是一個終端應用),為其添加GUI支持有多難?或者,如果添加GUI沒什么用,那么是否易于添加視圖從而以圖表(餅圖、柱狀圖等)或文檔(PDF、電子表格等)形式展示結果?如果因此而作出的變更不小(小的變更是,在不變更模型的情況下創建控制器并綁定到視圖),那你的MVC實現就有些不對了。
如果你確信這兩個條件都已滿足,那么與未使用MVC模式的應用相比,你的應用會更靈活、更好維護。
實現
我可以使用任意常見框架來演示如何使用MVC,但覺得那樣的話,讀者對MVC的理解會不完整。因此我決定使用一個非常簡單的示例來展示如何從頭實現MVC,這個示例是名人名言打印機。想法極其簡單:用戶輸入一個數字,然后就能看到與這個數字相關的名人名言。名人名言存儲在一個quotes元組中。這種數據通常是存儲在數據庫、文件或其他地方,只有模型能夠直接訪問它。
我們從下面的代碼開始考慮這個例子。
quotes = ('A man is not complete until he is married. Then he is finished.',
'As I said before, I never repeat myself.',
'Behind a successful man is an exhausted woman.',
'Black holes really suck...', 'Facts are stubborn things.')
模型極為簡約,只有一個get_quote()方法,基于索引n從quotes元組中返回對應的名人名言(字符串)。注意,n可以小于等于0,因為這種索引方式在Python中是有效的。本節末尾準備了練習,供你改進這個方法的行為。
class QuoteModel:
def get_quote(self, n):
try:
value = quotes[n]
except IndexError as err:
value = 'Not found!'
return value
視圖有三個方法,分別是show()、error()和select_quote()。show()用于在屏幕上輸出一旬名人名言(或者輸出提示信息Not found!);error()用于在屏幕上輸出一條錯誤消息;select_quote()用于讀取用戶的選擇,如以下代碼所示。
class QuoteTerminalView:
def show(self, quote):
print('And the quote is: "{}"'.format(quote))
def error(self, msg):
print('Error: {}'.format(msg))
def select_quote(self):
return input('Which quote number would you like to see? ')
控制器負責協調。init()方法初始化模型和視圖。run()方法校驗用戶提供的名言索引,然后從模型中獲取名言,并返回給視圖展示,如以下代碼所示。
class QuoteTerminalController:
def init (self):
self.model = QuoteModel()
self.view = QuoteTerminalView()
def run(self):
valid_input = False
while not valid_input:
n = self.view.select_quote()
try:
n = int(n)
except ValueError as err:
self.view.error("Incorrect index '{}'".format(n)) else:
valid_input = True
quote = self.model.get_quote(n)
self.view.show(quote)
最后,但同樣重要的是,main()函數初始化并觸發控制器,如以下代碼所示。
def main():
controller = QuoteTerminalController()
while True:
controller.run()
以下是該示例的完整代碼(文件mvc.py)。
quotes = ('A man is not complete until he is married. Then he is finished.',
'As I said before, I never repeat myself.',
'Behind a successful man is an exhausted woman.',
'Black holes really suck...',
'Facts are stubborn things.')
class QuoteModel:
def get_quote(self, n):
try:
value = quotes[n]
except IndexError as err:
value = 'Not found!'
return value
class QuoteTerminalView:
def show(self, quote):
print('And the quote is: "{}"'.format(quote))
def error(self, msg):
print('Error: {}'.format(msg))
def select_quote(self):
return input('Which quote number would you like to see? ')
class QuoteTerminalController:
def __init__(self):
self.model = QuoteModel()
self.view = QuoteTerminalView()
def run(self):
valid_input = False
while not valid_input:
n = self.view.select_quote()
try:
n = int(n)
except ValueError as err:
self.view.error("Incorrect index '{}'".format(n))
else:
valid_input = True
quote = self.model.get_quote(n)
self.view.show(quote)
def main():
controller = QuoteTerminalController()
while True:
controller.run()
if __name__ == '__main__':
main()
---------------------------------------------------------------------------
KeyboardInterrupt Traceback (most recent call last)
/Users/hanlei/anaconda/lib/python3.5/site-packages/ipykernel/kernelbase.py in _input_request(self, prompt, ident, parent, password)
713 try:
--> 714 ident, reply = self.session.recv(self.stdin_socket, 0)
715 except Exception:
/Users/hanlei/anaconda/lib/python3.5/site-packages/jupyter_client/session.py in recv(self, socket, mode, content, copy)
738 try:
--> 739 msg_list = socket.recv_multipart(mode, copy=copy)
740 except zmq.ZMQError as e:
/Users/hanlei/anaconda/lib/python3.5/site-packages/zmq/sugar/socket.py in recv_multipart(self, flags, copy, track)
--> 358 parts = [self.recv(flags, copy=copy, track=track)]
359 # have first part already, only loop while more to receive
zmq/backend/cython/socket.pyx in zmq.backend.cython.socket.Socket.recv (zmq/backend/cython/socket.c:6971)()
zmq/backend/cython/socket.pyx in zmq.backend.cython.socket.Socket.recv (zmq/backend/cython/socket.c:6763)()
zmq/backend/cython/socket.pyx in zmq.backend.cython.socket._recv_copy (zmq/backend/cython/socket.c:1931)()
/Users/hanlei/anaconda/lib/python3.5/site-packages/zmq/backend/cython/checkrc.pxd in zmq.backend.cython.checkrc._check_rc (zmq/backend/cython/socket.c:7222)()
KeyboardInterrupt:
During handling of the above exception, another exception occurred:
KeyboardInterrupt Traceback (most recent call last)
<ipython-input-11-ea7696f29195> in <module>()
47
48 if __name__ == '__main__':
---> 49 main()
<ipython-input-11-ea7696f29195> in main()
44 controller = QuoteTerminalController()
45 while True:
---> 46 controller.run()
47
48 if __name__ == '__main__':
<ipython-input-11-ea7696f29195> in run(self)
31 valid_input = False
32 while not valid_input:
---> 33 n = self.view.select_quote()
34 try:
35 n = int(n)
<ipython-input-11-ea7696f29195> in select_quote(self)
21
22 def select_quote(self):
---> 23 return input('Which quote number would you like to see? ')
24
25 class QuoteTerminalController:
/Users/hanlei/anaconda/lib/python3.5/site-packages/ipykernel/kernelbase.py in raw_input(self, prompt)
687 self._parent_ident,
688 self._parent_header,
--> 689 password=False,
690 )
691
/Users/hanlei/anaconda/lib/python3.5/site-packages/ipykernel/kernelbase.py in _input_request(self, prompt, ident, parent, password)
717 except KeyboardInterrupt:
718 # re-raise KeyboardInterrupt, to truncate traceback
--> 719 raise KeyboardInterrupt
720 else:
721 break
KeyboardInterrupt:
當然,你不會(也不應該)就此止步。堅持多寫代碼,還有很多有意思的想法可以試驗,比如以下這些。
- 僅允許用戶使用大于或等于1的索引,程序會顯得更加友好。為此,你也需要修改get_quote()。
- 使用Tkinter、Pygame或Kivy之類的GUI框架來添加一個圖形化視圖。程序如何模塊化?可以在程序運行期間決定使用哪個視圖嗎?
- 讓用戶可以選擇鍵入某個鍵(例如,r鍵)隨機地看一旬名言。
- 索引校驗H前是在控制器中完成的。這個方式好嗎?如果你編寫了另一個視圖,需要它自己的控制器,那又該怎么辦呢?試想一下,為了讓索引校驗的代碼被所有控制/視圖復用,將索引校驗移到模型中進行,需要做哪些變更?
- 對這個例子進行擴展,使其變得像一個創建、讀取、更新、刪除(Create, Read, Update, Delete,CURD)應用。你應該能夠輸入新的名言,刪除已有的名言,以及修改名言。
小結
本章中,我們學習了MVC模式。MVC是一個非常重要的設計模式,用于將應用組織成三個部分:模型、視圖和控制器。
每個部分都有明確的職責。模型負責訪問數據,管理應用的狀態。視圖是模型的外在表現。視圖并非必須是圖形化的;文本輸出也是一種好視圖。控制器是模型與視圖之間的連接。MVC的恰當使用能確保最終產出的應用易于維護、易于擴展。
MVC模式是應用到面向對象編程的SoC原則。這一原則類似于一棟新房子如何建造,或一個餐館如何運營。
Python框架web2py使用MVC作為核心架構理念。即使是最簡單的web2py例子也使用了MVC來實現模塊化和可維護性。Django也是一個MVC框架,但它使用的名稱是MTV。
使用MVC時,請確保創建智能的模型(核心功能)、瘦控制器(實現視圖與模型之間通信的能力)以及傻瓜式的視圖(外在表現,最小化邏輯處理)。
在8.4節中,我們學習了如何從零開始實現MVC,為用戶展示有趣的名人名言。這與羅列一個RSS源的所有文章所要求的功能沒什么兩樣,如果你對其他推薦練習不感興趣,可以練習實現這個。