Chain of Responsibility

December 31, 2021

你想要設計日誌程式庫,先從簡單的開始,只有日誌層次、Logger,進行日誌時,若 Logger 的日誌層級等於或小於 log 方法指定的層級,才會輸出日誌:

enum Level {INFO, WARNING, SEVERE};

class Logger {
	private Level level;
	
	Logger setLevel(Level level) {
		this.level = level;
		return this;
	}
	
	void log(Level level, String msg) {
		if(this.level.compareTo(level) <= 0) {
			out.printf("%s - %s: %s%n", level, LocalDateTime.now(), msg);
		}
	}
}

使用上很簡單,就是建個 Logger 後設定層級,在必要的地方使用 log

var logger = new Logger().setLevel(Level.WARNING);
logger.log(Level.WARNING, "警訊日誌");

訊息傳播/各自職責

接著新的需求加入了,日誌會有階層關係,例如,在 cc.openhome 套件的 Logger 物件,希望會是 cc.openhome.patternLogger 實例之父日誌物件,這時你修改了 Logger

class Logger {
	private String name;
	private Logger parent;
	private Level level;
	
	Logger(String name) {
		this.name = name;
	}
	
	Logger setParent(Logger logger) {
		this.parent = logger;
		return this;
	}
	
	Logger getParent() {
		return this.parent;
	}
	
	Logger setLevel(Level level) {
		this.level = level;
		return this;
	}
	
	void log(Level level, String msg) {
		if(this.level.compareTo(level) <= 0) {
			out.printf("%s %s - %s: %s%n", 
				name,
				level, 
				LocalDateTime.now(), 
				msg
			);
		}
	}
}

在需求中,具有父子階層關係的 Logger,子 Logger 處理完日誌訊息後,日誌訊息要傳給父 Logger,看看需不需要也輸出日誌訊息,為此,你先寫了個簡單的程式,設定父子關係並傳播日誌訊息:

var parent = new Logger("cc.openhome").setLevel(Level.WARNING);
var child = new Logger("cc.openhome.pattern")
                    .setLevel(Level.SEVERE)
                    .setParent(parent);

var msg = "嚴重訊息";
var level = Level.SEVERE;
child.log(level, msg);
if(child.getParent() != null) {
    child.getParent().log(level, msg);
}

當然,Logger 絕對不會只有兩層,這時要考慮的是,該如何進行日誌傳播,以及在哪判斷日誌層級,你想了一下,既然 Logger 物件本身知道自身層級以及父 Logger,相關邏輯放入 Logger 不就好了:

class Logger {
	private String name;
	private Logger parent;
	private Level level;
	
	Logger(String name) {
		this.name = name;
	}
	
	Logger setParent(Logger logger) {
		this.parent = logger;
		return this;
	}
	
	Logger getParent() {
		return this.parent;
	}
	
	Logger setLevel(Level level) {
		this.level = level;
		return this;
	}
	
	void log(Level level, String msg) {
		if(this.level.compareTo(level) <= 0) {
			out.printf("%s %s - %s: %s%n", 
				name,
				level, 
				LocalDateTime.now(), 
				msg
			);

            if(getParent() != null) {
    		    getParent().log(level, msg);
    	    }
		}
	}
}

這麼一來,Logger 的日誌訊息,自然就可以依設定的關係進行傳播了:

var parent = new Logger("cc.openhome").setLevel(Level.WARNING);
var child = new Logger("cc.openhome.pattern")
                    .setLevel(Level.SEVERE)
                    .setParent(parent);

child.log(Level.SEVERE, "嚴重訊息");

當然,你還可以繼續完善這個 Logger,例如設計一個 Logger.getLogger,可以自動判斷指定的名稱,建立對應的 Logger、設定父子關係什麼的,這是另一個故事了…

Java 的實現

Java 標準 API 的 java.util.logging,其中就包含了 Chain of Responsibility 的實現,以完成日誌訊息的傳播與處理,只不過它的 Logger 要更複雜一些,可允許使用者設定 HandlerFormatterFilter,實現各種日誌的輸入輸出、格式化,以及更複雜的訊息過濾。

如果你有寫過 Java 的 Web 容器應用程式,應該會聯想到 Filter,確實地,它也是 Chain of Responsibility 的實現,而且 FilterdoFilter 傳入的 FilterChain,允許自訂 Filter 時,決定要不要將請求傳播下去:

public class PerformanceFilter extends HttpFilter {
    @Override
    protected void doFilter(
         HttpServletRequest request, HttpServletResponse response, FilterChain chain)
                throws IOException, ServletException {
        long begin = System.currentTimeMillis();
        
        chain.doFilter(request, response);
        
        getServletContext().log("Request process in " +
                (System.currentTimeMillis() - begin) + " milliseconds");
    }
}

這是因為 FilterChaindoFilter 實作概念類似以下:

Filter filter = filterIterator.next();
if(filter != null) {
    filter.doFilter(request, response, this);
} 
else {
    targetServlet.service(request, response);
}

像這類物件彼此會有連結關係,有訊息時就依連結關係傳播給各物件,讓各物件自行決定處理方式的概念,Gof 稱為 Chain of Responsibility。

彼此之間的連結關係,是視各自需求而定,有可能像是 Logger 的樹狀階層,也有可能是類似 Filter 的線性關係,這影響的只是走訪各物件時的方式,重要的是,各物件只處理份內之事,要說原則的話,大概是以 single responsibility 的角度來思考。

這類設計可讓客戶端在使用 Logger、自訂日誌層級、Handler 等時,或者在 Web 容器中自定 Filter 這類物件時,可以有機會自掃門前雪;相對地,這也表示,自訂這類物件時,不要讓他們有什麼相依性,像是處理日誌時設定 Handler 時,別去管父 Logger 怎麼處理,或在 Web 容器中的 Filter,最好設計上各自獨立,別去考慮什麼前後順序關係之類的。