Observer Pattern

深入淺出設計模式 讀書會

第二章 觀察者模式

 

講者:Eugene

課前準備

程式碼:

https://github.com/yuyueugene84/weatherapp​

 

可用 git 複製至本機端:

 

 

 

 

git clone https://github.com/yuyueugene84/weatherapp.git

回顧

  • 設計守則
    • 將變動部份封裝
    • 多用合成,少用繼承
    • 針對介面寫程式(多型),不要針對實踐寫程式(知道太多細節)
  • 策略模式把個別演算法(行為)封裝起來,讓它們可以互換,而不影響到使用

 

氣象觀測 APP

1. 使用 WeatherData 物件追蹤天氣狀況

2. 三個布告板

需求
天氣狀況 溫度(C)、濕度(%)、氣壓(kPa)
氣象統計 最低溫度、最高溫度
天氣預報 文字顯示天氣狀態

打開 WeatherData 類別...

class WeatherData
  
  def get_temperature
    #取得最新氣溫的方法
  end
  
  def get_humidity
    #取得最新溼度的方法
  end

  def get_pressure
    #取得最新氣壓的方法
  end

  def measurement_changed
    #一旦氣象資料有更新,此方法會被呼叫
  end
end

實作 MeasurementChanged 方法

class WeatherData
  
  def measurement_changed
    #呼叫方法取得最新資料
    temp = get_temperature
    humidity = get_humidity
    pressure = get_pressure
    
    #更新所有佈告板
    @current_conditions_display.update(temp, humidity, pressure)
    @statistics_display.update(temp, humidity, pressure)
    @forecast_conditions_display.update(temp, humidity, pressure)
  end

end

直覺的寫出方法...

但是...

class WeatherData
  
  def measurement_changed
    #呼叫方法取得最新資料
    temp = get_temperature
    humidity = get_humidity
    pressure = get_pressure
    
    #更新所有佈告板
    @current_conditions_display.update(temp, humidity, pressure)
    @statistics_display.update(temp, humidity, pressure)
    @forecast_conditions_display.update(temp, humidity, pressure)
  end

end

1. 針對實踐寫程式

2. 若有新的佈告板,就需要修改這裡的程式碼

3. 無法動態增加或刪除布告板

4. 尚未封裝改變的部分

觀察者模式 Observer Pattern

當物件之間有一對多的關係,並且當一個物件的狀態或資料發生變化時,希望相依的所有物件都可自動收到更改的狀態或資料

用白話一點的說法...

就是出版者和訂閱者的關係!

出版者 + 訂閱者 = 觀察者模式

相關名詞中英對照

中文 English
主題 / 出版者 Subject / Publisher
觀察者 / 訂閱者 Observer / Subscriber

概念

1. 當 Subject 有內容改變,會通知所有關注該 Subject 的 Observer 並更新

2. Observer 可以訂閱 或 取消訂閱 Subject

3. 取消訂閱就不會收到 Subject 通知

4. 一個 Subject 可被多個 Observer 訂閱

類別圖說明

觀察者模式 類別圖

鬆綁 Loose Coupling

 

  • 盡量讓需要互動的物件鬆綁
  • 好處是讓物件間的相依性降到最低 (彼此不用知道對方太多細節)
  • 由上圖可得知 觀察者模式 可以讓 Subject 和Observer 之間鬆綁

觀察者模式如何鬆綁

  • Subject 只知道 Observer 有實作特定介面
    • 但不知道細節
  • 任何時後都能加入/移除 Observer
  • 新型態的 Observer 加入,Subject 不用修改程式碼
    • Subject 只在乎 Observer 有沒有實作它的介面
  • 兩者不必綁在一起使用,可被單獨使用
    • 各自實作各自的介面
  • 只要介面被遵守,改變任一個都不會影響另一個

觀察者模式 類別圖

觀察者模式 方法整裡

public interface Subject {
    public void registerObserver(Observer o); //註冊觀察者
    public void removeObserver(Observer o); //移除觀察者
    public void notifyObserver(); //若狀態更新,通知所有觀察者
}

public interface Observer {
    public void update(float temp, float humidity, float pressure); //被呼叫時,把物件的資料更新,然後呼叫 display 顯示資料
}

public interface DisplayElement {
    public void display(); //顯示資料
}

實作 Subject 模組

module Subject
  def initialize
    @observers = []
  end

  def register_observer(observer)
    @observers << observer
  end

  def remove_observer(observer)
    @observers.delete observer
  end

  def notify_observers
    @observers.each do |observer|
      observer.update(self.temp, self.humidity, self.pressure)
    end
  end
end

實作 WeatherData 類別

class WeatherData
  include Subject
  attr_accessor :temp, :humidity, :pressure

  def measurements_changed
    notify_observers
  end

  def set_measurements(temp, humidity, pressure)
    # 更新資料
    self.temp     = temp
    self.humidity = humidity
    self.pressure = pressure
    measurements_changed() # 通知所有觀察者更新並顯示最新資料
  end
end

實作  Observer 相關模組

module Observer
  def update(temp, humidity, pressure)
    self.temp     = temp
    self.humidity = humidity
    self.pressure = pressure
    display
  end
end

module DisplayElement
  def display
    puts "目前情況 : 溫度 = #{temp}度C, 濕度 = #{humidity}%, 壓力 = #{pressure}kPa"
  end
end

實作 CurrentConditionDisplay 類別

class CurrentConditionsDisplay
  include Observer, DisplayElement
  attr_accessor :temp, :humidity, :pressure, :subject

  def initialize(weatherData)
    self.subject = weatherData
    self.subject.registerObserver(self)
  end

  def update(temp, humidity, pressure)
    self.temp     = temp
    self.humidity = humidity
    self.pressure = pressure
    display
  end

  def display
    puts "目前情況 : 溫度 = #{temp}度C, 濕度 = #{humidity}%, 壓力 = #{pressure}"
  end
end

實作 WeatherStation 類別

class WeatherStation
  def initialize
    # 主程式,new 出 WeatherData 和 CurrentConditionsDisplay 物件
    puts 'WeatherStation Start'
    w = WeatherData.new
    c = CurrentConditionsDisplay.new(w)
    
    # 丟測試用資料
    w.setMeasurements 80, 65, 30.4
    w.setMeasurements 82, 70, 29.2
    w.setMeasurements 78, 90, 29.2
  end
end

用假資料太無聊了...

用 Yahoo API 取得真的氣象資料

1. 參考台北在 Yahoo! 氣象的網址,最後面的數字是城市代碼

2. 到 Yahoo 開發者網頁

3. 將下面的 YQL Query 字串貼入並測試:

 

select item.forecast, item.condition, atmosphere   from weather.forecast where woeid = 2306179 and u="c" limit 1

改寫 WeatherData Class

class WeatherData
  include Subject
  attr_accessor :temp, :humidity, :pressure

  def measurements_changed
    notifyObservers
  end

  def setMeasurements(temp=nil, humidity=nil, pressure=nil)
    url = 'https://query.yahooapis.com/v1/public/yql?q=select%20item.forecast%2C%20item.condition%2C%20atmosphere%20%20%20from%20weather.forecast%20where%20woeid%20%3D%202306179%20and%20u%3D%22c%22%20limit%201&format=json&env=store%3A%2F%2Fdatatables.org%2Falltableswithkeys'
    uri = URI(url)
    response = Net::HTTP.get(uri)
    obj = JSON.parse(response, object_class: OpenStruct)

    self.temp     = ( temp != nil ) ? temp : obj.query.results.channel.item.condition.temp
    self.humidity = ( humidity != nil ) ? humidity : obj.query.results.channel.atmosphere.humidity
    self.pressure = ( pressure != nil ) ? pressure : obj.query.results.channel.atmosphere.pressure
    measurements_changed()
  end

end

Ruby 內建的 Observable 模組

class WeatherData
  # 只要加入這一行
  include Observable
  
  attr_accessor :temp, :humidity, :pressure

  def setMeasurements(temp=nil, humidity=nil, pressure=nil)
    url = 'https://query.yahooapis.com/v1/public/yql?q=select%20item.forecast%2C%20item.condition%2C%20atmosphere%20%20%20from%20weather.forecast%20where%20woeid%20%3D%202306179%20and%20u%3D%22c%22%20limit%201&format=json&env=store%3A%2F%2Fdatatables.org%2Falltableswithkeys'
    uri = URI(url)
    response = Net::HTTP.get(uri)
    obj = JSON.parse(response, object_class: OpenStruct)

    changed

    self.temp     = ( temp != nil ) ? temp : obj.query.results.channel.item.condition.temp
    self.humidity = ( humidity != nil ) ? humidity : obj.query.results.channel.atmosphere.humidity
    self.pressure = ( pressure != nil ) ? pressure : obj.query.results.channel.atmosphere.pressure

    notify_observers(self)
    # measurements_changed()
  end

end

至於原本的 Subject 模組呢?

# module Subject
#   def initialize
#     @observers = []
#   end

#   def register_observer(observer)
#     @observers << observer
#   end

#   def remove_observer(observer)
#     @observers.delete observer
#   end

#   def notify_observers
#     @observers.each do |observer|
#       observer.update(self.temp, self.humidity, self.pressure)
#     end
#   end
# end

完全不需要了~

Observable 會自動實作以下方法:
1. add_observer

2. delete_observer

3. notify_observers

改寫 Observer 模組

module Observer
  #def update(temp, humidity, pressure)
  def update(obj) #改成傳入物件,改動彈性比較大
    raise NotImplementedError, 'Implement this method!'
  end
end

重構 CurrentConditionDisplay 類別

class CurrentConditionsDisplay
  include Observer, DisplayElement
  attr_accessor :temp, :humidity, :pressure, :subject

  def initialize(weatherData)
    self.subject = weatherData
    # self.subject.registerObserver(self)
    self.subject.add_observer(self) #物件產生時就把自己加入 weatherdata 的觀察者
  end

  #def update(temp, humidity, pressure)
  def update(obj)
    self.temp     = obj.temp
    self.humidity = obj.humidity
    self.pressure = obj.pressure

    display
  end

  def display
    puts "目前情況 : 溫度 = #{temp}度C, 濕度 = #{humidity}%, 壓力 = #{pressure}kPa"
  end
end

還沒結束!

實作 StatisticsDisplay 類別

class StatisticsDisplay
  include Observer, DisplayElement
  attr_accessor :high_temp, :low_temp, :subject

  def initialize(weatherData)
    self.subject = weatherData
    self.subject.add_observer(self)
  end

  def update(obj) #到 obj 裡取得自己需要的資料即可,介面不用和其他顯示一樣
    self.high_temp = obj.high_temp
    self.low_temp = obj.low_temp

    display
  end

  def display
    puts "天氣統計 : 最高溫度 = #{high_temp}度C, 最低溫度 = #{low_temp}度C"
  end
end

實作 ForecastDisplay 類別

class ForecastDisplay
  include Observer, DisplayElement
  attr_accessor :forecast, :subject

  def initialize(weatherData)
    self.subject = weatherData
    self.subject.add_observer(self)
  end

  def update(obj)
    self.forecast = obj.forecast

    display
  end

  def display
    puts "天氣預報 : #{forecast}"
  end
end

重構 WeatherData 類別

class WeatherData
  include Observable
  attr_accessor :temp, :humidity, :pressure, :high_temp, :low_temp, :forecast

  def set_measurements(temp=nil, humidity=nil, pressure=nil, high_temp=nil, low_temp=nil, forecast=nil)
    obj = get_yahoo_data # 去 yahoo API 取得資料,存入 obj 變數中

    changed # 一個 flag,告訴 Ruby 該物件的狀態/資料已更改,接下來 notify_observer 才會通知所有觀察該物件的觀察者已改變

    self.temp     = ( temp != nil ) ? temp : obj.query.results.channel.item.condition.temp
    self.humidity = ( humidity != nil ) ? humidity  : obj.query.results.channel.atmosphere.humidity
    self.pressure = ( pressure != nil ) ? pressure  : obj.query.results.channel.atmosphere.pressure
    self.high_temp = (high_temp != nil) ? high_temp : obj.query.results.channel.item.forecast.high
    self.low_temp = (low_temp != nil) ?   low_temp  : obj.query.results.channel.item.forecast.low
    self.forecast = ( forecast != nil ) ? forecast  : obj.query.results.channel.item.forecast.text
    # 更新所有的狀態,若傳入的參數有值,就用參數的值,若沒有,就用 yahoo 的資料

    notify_observers(self)
    # measurements_changed()
  end

  def get_yahoo_data
    url = 'https://query.yahooapis.com/v1/public/yql?q=select%20item.forecast%2C%20item.condition%2C%20atmosphere%20%20%20from%20weather.forecast%20where%20woeid%20%3D%202306179%20and%20u%3D%22c%22%20limit%201&format=json&env=store%3A%2F%2Fdatatables.org%2Falltableswithkeys'
    uri = URI(url)
    response = Net::HTTP.get(uri)
    obj = JSON.parse(response, object_class: OpenStruct)
  end
end

大功告成!

class WeatherStation
  def initialize
    puts 'WeatherStation Start'
    w = WeatherData.new
    CurrentConditionsDisplay.new(w)
    StatisticsDisplay.new(w)
    ForecastDisplay.new(w)

    w.set_measurements

    # now = Time.now
    # end_time = now + 30
    
    # 在 30 秒內,不斷丟假資料給 WeatherData,讓它自動顯示新資料
    # begin 
    #   w.set_measurements rand(1..40), rand(1..100), rand(10000..20000), rand(20..40), rand(1..20), ['sunny', 'rain'].sample
    #   now += 1
    # end while now < end_time

    puts 'WeatherStation Ends'
  end

end

實用層面:MVC 架構

Q&A

The End

deck

By Eugene Chang