CefSharp請求資源攔截及自定義處理
前言
在CefSharp中,我們不僅可以使用Chromium瀏覽器內核,還可以通過Cef暴露出來的各種Handler來實現我們自己的資源請求處理。
什么是資源請求呢?簡單來說,就是前端頁面在加載的過程中,請求的各種文本(js、css以及html)。在以Chromium內核的瀏覽器上,我們可以使用瀏覽器為我們提供的開發者工具來檢查每一次頁面加載發生的請求。
准備
鑒於本文的重心是了解CefSharp的資源攔截處理,所以我們不討論前端的開發以及客戶端嵌入CefSharp組件的細節。我們首先完成一個基本的嵌入CefSharp的WinForm程序:該程序界面如下,擁有一個地址輸入欄和一個顯示網頁的Panel:
並且編寫一個極其簡單的頁面,該頁面會請求1個js資源和1個css資源:
demo:
- index.html
- test1.js
- test1.css
這幾個文件的代碼都十分簡單:
body
{
background-color: aqua
}
function myFunc() {
return 'test1 js file';
}
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8" />
<title>Home</title>
<!-- 如下記載js、css資源 -->
<script type="text/javascript" src="test1.js"></script>
<link type="text/css" rel="stylesheet" href="test1.css"/>
</head>
<body>
<h1>Resource Intercept Example</h1>
<h2 id="result"></h2>
<script>
// 調用test1.js中的myFunc
document.getElementById('result').innerHTML = myFunc();
</script>
</body>
</html>
代碼很簡單,效果也很容易知道,頁面加載后,頁面背景色為aqua,頁面上會顯示文本“test1 js file”。同時,當我們使用開發工具,刷新頁面,能夠看到對應的資源加載:
CefSharp資源攔截及自定義處理
完成上述准備后,我們進入正文:資源攔截及自定義處理。首先我們需要對目標的理解達成一致,資源攔截是指我們能夠檢測到上圖中的html、js還有css的資源請求事件,在接下來的Example中,因為我們是使用的客戶端程序,所以會在請求的過程中彈出提示;自定義處理是指,在完成攔截提示后,我們還能夠替換這些資源,這里我們設定完成攔截后,可以把js和css換為我們想要另外的文件:test2.js和test2.css:
function myFunc() {
return 'test2 js file';
}
body
{
background-color: beige
}
即我們希望攔截並替換后,頁面上的文字不再是之前的,而是“test2 js file”,頁面的背景色是beige。
IRequestHandler
在CefSharp中,要想對請求進行攔截處理,最為核心的Handler就是IRequestHandler這個接口,查看官方的源碼,會發現里面有數個方法的定義,通過閱讀官方的summary,我們可以聚焦到如下的兩個定義(注釋本人進行了刪減):
/// <summary>
/// Called before browser navigation.
/// 譯:在瀏覽器導航前調用
/// If the navigation is allowed <see cref="E:CefSharp.IWebBrowser.FrameLoadStart" /> and <see cref="E:CefSharp.IWebBrowser.FrameLoadEnd" />
/// will be called. If the navigation is canceled <see cref="E:CefSharp.IWebBrowser.LoadError" /> will be called with an ErrorCode
/// value of <see cref="F:CefSharp.CefErrorCode.Aborted" />.
/// </summary>
bool OnBeforeBrowse(
IWebBrowser chromiumWebBrowser,
IBrowser browser,
IFrame frame,
IRequest request,
bool userGesture,
bool isRedirect);
/// <summary>
/// Called on the CEF IO thread before a resource request is initiated.
/// 在一個資源請求初始化前在CEF IO線程上調用
/// </summary>
IResourceRequestHandler GetResourceRequestHandler(
IWebBrowser chromiumWebBrowser,
IBrowser browser,
IFrame frame,
IRequest request,
bool isNavigation,
bool isDownload,
string requestInitiator,
ref bool disableDefaultHandling);
於是,我們繼承一個默認的名為RequestHandler的類(請區分DefaultRequestHandler),只重寫上述的兩個方法。
public class MyRequestHandler : RequestHandler
{
protected override bool OnBeforeBrowse(IWebBrowser chromiumWebBrowser, IBrowser browser, IFrame frame, IRequest request, bool userGesture,
bool isRedirect)
{
// 先調用基類的實現,斷點調試
return base.OnBeforeBrowse(chromiumWebBrowser, browser, frame, request, userGesture, isRedirect);
}
protected override IResourceRequestHandler GetResourceRequestHandler(IWebBrowser chromiumWebBrowser, IBrowser browser, IFrame frame,
IRequest request, bool isNavigation, bool isDownload, string requestInitiator, ref bool disableDefaultHandling)
{
// 先調用基類的實現,斷點調試
return base.GetResourceRequestHandler(
chromiumWebBrowser, browser, frame, request, isNavigation,
isDownload, requestInitiator, ref disableDefaultHandling);
}
}
然后完成對該Handler的注冊:
this._webBrowser = new ChromiumWebBrowser(string.Empty)
{
RequestHandler = new MyRequestHandler()
};
打上斷點,開始訪問我們的Example:index.html。這里會發現,OnBeforeBrowse調用了一次,而GetResourceRequestHandler會調用3次。檢查OnBeforeBrowse中的request參數內容,是一次主頁的請求,而GetResourceRequestHandler中的3次分別是:主頁html資源、test1.js和test1.css。
結合官方注釋和調試的結果,我們可以得出結論:要進行導航的攔截,我們可以重寫OnBeforeBrowse方法,要想進行資源的攔截,我們需要實現自己的ResourceRequestHandler。
IResourceRequestHandler
查看IResourceRequestHandler的定義,我們再次聚焦一個函數定義:
/// <summary>
/// Called on the CEF IO thread before a resource is loaded. To specify a handler for the resource return a <see cref="T:CefSharp.IResourceHandler" /> object
/// </summary>
/// <returns>To allow the resource to load using the default network loader return null otherwise return an instance of <see cref="T:CefSharp.IResourceHandler" /> with a valid stream</returns>
IResourceHandler GetResourceHandler(
IWebBrowser chromiumWebBrowser,
IBrowser browser,
IFrame frame,
IRequest request);
該定義從注釋可以看出,如果實現返回null,那么Cef會使用默認的網絡加載器來發起請求,或者我們可以返回一個自定義的資源處理器ResourceHandler來處理一個合法的數據流(Stream)。也就是說,對於資源的處理,要想實現自定義的處理(不是攔截,攔截到目前為止我們可以在上述的兩個Handler中進行處理)我們還需要實現一個IResourceHandler接口的實例,並在GetResourceHandler處進行返回,Cef才會在進行處理的時候使用我們的Handler。所以在GetResourceHandler
中,我們進行資源的判斷,如果是想要替換的資源,我們就使用WinForm提供的OpenFileDialog來選擇本地的js或是css文件,並傳給我們自定義的ResourceHandler,如果不是想要攔截的資源或是用戶未選擇任何的文件就走默認的:
public class MyResourceRequestHandler : ResourceRequestHandler
{
protected override IResourceHandler GetResourceHandler(IWebBrowser chromiumWebBrowser, IBrowser browser, IFrame frame, IRequest request)
{
if (request.Url.EndsWith("test1.js") || request.Url.EndsWith("test1.css"))
{
MessageBox.Show($@"資源攔截:{request.Url}");
string type = request.Url.EndsWith(".js") ? "js" : "css"; // 這里簡單判斷js還是css,不過多編寫
string fileName = null;
using (OpenFileDialog openFileDialog = new OpenFileDialog())
{
openFileDialog.Filter = $@"{type}文件|*.{type}"; // 過濾
openFileDialog.Multiselect = true;
if (openFileDialog.ShowDialog() == DialogResult.OK)
{
fileName = openFileDialog.FileName;
}
}
if (string.IsNullOrWhiteSpace(fileName))
{
// 沒有選擇文件,還是走默認的Handler
return base.GetResourceHandler(chromiumWebBrowser, browser, frame, request);
}
// 否則使用選擇的資源返回
return new MyResourceHandler(fileName);
}
return base.GetResourceHandler(chromiumWebBrowser, browser, frame, request);
}
}
IResourceHandler
根據上文,我們進一步探究IResourceHandler,對該Handler,官方有一個默認的實現:RequestHandler,該Handler通過閱讀源碼可以知道是網絡加載的Handler,這里為了實現我們自定義攔截策略,我們最好單獨實現自己的IResourceHandler。對於該接口,有如下的注釋:
/// <summary>
/// Class used to implement a custom resource handler. The methods of this class will always be called on the CEF IO thread.
/// Blocking the CEF IO thread will adversely affect browser performance. We suggest you execute your code in a Task (or similar).
/// To implement async handling, spawn a new Task (or similar), keep a reference to the callback. When you have a
/// fully populated stream, execute the callback. Once the callback Executes, GetResponseHeaders will be called where you
/// can modify the response including headers, or even redirect to a new Url. Set your responseLength and headers
/// Populate the dataOut stream in ReadResponse. For those looking for a sample implementation or upgrading from
/// a previous version <see cref="T:CefSharp.ResourceHandler" />. For those upgrading, inherit from ResourceHandler instead of IResourceHandler
/// add the override keywoard to existing methods e.g. ProcessRequestAsync.
/// </summary>
public interface IResourceHandler : IDisposable
{ ... }
該類的注釋意思大致為:我們可以通過實現該接口來實現自定義資源的處理類。該類中的方法總是在CEF的IO線程中調用。然而,阻塞CEF IO線程將會不利於瀏覽器的性能。所以官方建議開發者通過把自己的處理代碼放在Task(或是類似的異步編程框架)中異步執行,然后在完成或取消(失敗)時,在異步中調用callback對應的操作函數(continue、cancel等方法)。當你擁有一個完全填充(fully populated)好了的Stream的時候,再執行callback(這一步對應Open方法)。一旦callback執行了,GetResponseHeaders這個方法將會調用,於是你可以在這個方法里面對Reponse的內容包括headers進行修改,或者甚至是重定向到一個新的Url。設置你自己的reponseLength和headers。接下來,通過在ReadResponse(實際上即將作廢,而是Read)函數中,實現並填充dataOut這個Stream。最終CEF會對該Stream進行讀取數據,獲得資源數據。
事實上,該Handler的實現可以有很多花樣,這里我們實現一個最簡單的。
Dispose
對於通常進行資源釋放的Dispose,因為我們這里只是一個Demo,所以暫時留空。
Open(ProcessRequest)
官方注釋指出,ProcessRequest將會在不久的將來棄用,改為Open。所以ProcessRequest我們直接返回true。對於Open方法,其注釋告訴我們:
- 要想要立刻進行資源處理(同步),請設置handleRequest參數為true,並返回true
- 決定稍后再進行資源的處理(異步),設置handleRequest為false,並調用callback對應的continue和cancel方法來讓請求處理繼續還是取消,並且當前Open返回false。
- 要立刻取消資源的處理,設置handleRequest為true,並返回false。
也就是說,handleRequest的true或false決定是同步還是異步處理。若同步,則Cef會立刻通過Open的返回值true或false來決定后續繼續進行還是取消。若為異步,則Cef會通過異步的方式來檢查callback的調用情況(這里的callback實際上是要我們創建Task回調觸發的)。這里我們選擇同步的方式(選擇異步也沒有問題)編寫如下的代碼:
public bool Open(IRequest request, out bool handleRequest, ICallback callback)
{
handleRequest = true;
return true;
}
GetResponseHeaders
在上小節中我們已經完成了對資源數據的入口(Open)的分析。既然我們已經告訴了Cef我們准備開始進行資源請求的處理了,那么接下來我們顯然需要着手進行資源的處理。根據前面的概要注釋,我們需要實現GetResponseHeaders方法,因為這是資源處理的第二步。該方法的注釋如下:
/// <summary>
/// Retrieve response header information. If the response length is not known
/// set <paramref name="responseLength" /> to -1 and ReadResponse() will be called until it
/// returns false. If the response length is known set <paramref name="responseLength" />
/// to a positive value and ReadResponse() will be called until it returns
/// false or the specified number of bytes have been read.
///
/// It is also possible to set <paramref name="response" /> to a redirect http status code
/// and pass the new URL via a Location header. Likewise with <paramref name="redirectUrl" /> it
/// is valid to set a relative or fully qualified URL as the Location header
/// value. If an error occured while setting up the request you can call
/// <see cref="P:CefSharp.IResponse.ErrorCode" /> on <paramref name="response" /> to indicate the error condition.
/// </summary>
void GetResponseHeaders(IResponse response, out long responseLength, out string redirectUrl);
Summary翻譯解釋如下:獲取響應頭信息。如果響應的數據長度未知,則設置responseLength
為-1
,然后CEF會一直調用ReadResponse
(即將廢除,實際上是Read
方法)直到該Read方法返回false
。如果響應數據的長度是已知的,可以直接設置responseLength
長度為一個正數,然后ReadResponse
(Read
)將會一直調用,直到該Read方法返回false或者在已經讀取的數據的字節長度達到了設置的responseLength的值。當然你也可以通過設置response.StatusCode值為重定向的值(30x)以及redirectUrl為對應的重定向Url來實現資源重定向。
在本文中,我們采取簡單的方式:直接返回資源的長度,然后交給下一步的Read
方法來進行真正的資源處理。在該步驟中,我們編寫獲取本地文件字節數據來實現js和css文件的本地加載,並且將該數據保存在該ResourceHanlder實例私有變量中。
public void GetResponseHeaders(IResponse response, out long responseLength, out string redirectUrl)
{
using (FileStream fileStream = new FileStream(this._localResourceFileName, FileMode.Open, FileAccess.Read))
{
using (BinaryReader binaryReader = new BinaryReader(fileStream))
{
long length = fileStream.Length;
this._localResourceData = new byte[length];
// 讀取文件中的內容並保存到私有變量字節數組中
binaryReader.Read(this._localResourceData, 0, this._localResourceData.Length);
}
}
responseLength = this._localResourceData.LongLength;
redirectUrl = null;
}
Read
該方法的定義和注釋如下:
/// <summary>
/// Read response data. If data is available immediately copy up to
/// dataOut.Length bytes into dataOut, set bytesRead to the number of
/// bytes copied, and return true. To read the data at a later time keep a
/// pointer to dataOut, set bytesRead to 0, return true and execute
/// callback when the data is available (dataOut will remain valid until
/// the callback is executed). To indicate response completion set bytesRead
/// to 0 and return false. To indicate failure set bytesRead to < 0 (e.g. -2
/// for ERR_FAILED) and return false. This method will be called in sequence
/// but not from a dedicated thread.
///
/// For backwards compatibility set bytesRead to -1 and return false and the ReadResponse method will be called.
/// </summary>
bool Read(Stream dataOut, out int bytesRead, IResourceReadCallback callback);
Summary的翻譯大致為:讀取響應數據。如果數據是可以立即獲得的,那么可以直接將dataOut.Length
長度的字節數據拷貝到dataOut這個流中,然后設置bytesRead的值為拷貝的數據字節長度值,最后再返回true
。如果開發者希望繼續持有dataOut的引用(注釋是pointer指針,但是個人覺得這里寫為指向該dataOut的引用更好)然后在稍后填充該數據流,那么可以設置bytesRead
為0
,通過異步方式在數據准備好的時候執行callback的操作函數,然后立刻返回true
。(dataOut這個流會一直保持不被釋放直到callback被調用為止)。為了讓CEF知道當前的響應數據已經填充完畢,需要設置bytesRead
為0
然后返回false
。要想讓CEF知道響應失敗,需要設置bytesRead
為一個小於零的數(例如ERR_FAILED: -2),然后返回false
。這個方法將會依次調用但不是在一個專有線程。
根據上述的注釋,總結如下:
- bytesRead > 0,return true:填充了數據,但Read還會被調用
- bytesRead = 0,return false:數據填充完畢,當前為最后一次調用
- bytesRead < 0,return false:出錯,當前為最后一次調用
- bytesRead = 0,return true:CEF不會釋放dataOut流,在異步調用中准備好數據后調用callback
針對本例,我們增加一個該類的私有變量_dataReadCount
用於標識已讀的資源數據字節量並在構造函數中初始化為0。
每次在Read中進行讀取的時候,首先檢查剩余待讀取字節數this._localResourceData.LongLength - this._dataReadCount
,如果該值為零,則表明已經將所有的數據通過dataOut拷貝給了外圍,此時設置bytesRead為0,直接返回false;若剩余值大於0,則需要繼續進行拷貝操作,但需要注意的是dataOut並不是一個無限大的流,而是一個類似於緩存的流,它的Length值為2^16 = 65536
,所以我們需要設置bytesRead來讓外圍知道我們實際在這個流中放了多少字節的數據。同時在使用Stream.Write
API的時候,需要設置正確的offset和count。
最終,Read
的實現如下:
public bool Read(Stream dataOut, out int bytesRead, IResourceReadCallback callback)
{
int leftToRead = this._localResourceData.Length - this._dataReadCount;
if (leftToRead == 0)
{
bytesRead = 0;
return false;
}
int needRead = Math.Min((int)dataOut.Length, leftToRead); // 最大為dataOut.Lenght
dataOut.Write(this._localResourceData, this._dataReadCount, needRead);
this._dataReadCount += needRead;
bytesRead = needRead;
return true;
}
其他的幾個方法
對於Cancel和Skip方法,在本例不會調用,所以這里使用默認實現,不進行討論,感興趣的伙伴可以自己去研究。
最終效果
通過上文的代碼設計和編寫,我們最終完成了一個簡單的資源攔截及自定義處理的Example。首先我們在不進行資源攔截的情況下,加載我們的web頁面:
可以看到界面中呈現“test1 js file”的字樣以及背景色為海藍色。接下來我們開啟資源攔截,再次加載頁面,在加載過程中會有對應資源的攔截時的彈窗以及我們需要選擇我們自定義的資源文件:
完成處理后,得到如下的顯示頁面:
源碼
本Example的源碼已經開源在Github上,整個Demo較為簡單,主要是對本文的驗證