毫無目的的編碼就像不帶購物清單到商店購物一樣。當然,您可能過得很愉快,而且買到了一些東西(某個人在將來的某一天會用到的東西)但卻不能解決今天的晚飯問題。這就是我撰寫這一專欄的初衷(當然是為了編碼,而不是為了購物)。但后來我卻把編碼的事拋在腦后,寫了一些很酷的東西(您不得不照我的思路讀下去),因此當最終期限越來越近時,我還沒有任何東西可以發表。幸運的是,當我為我的妻子 Laura 設置一臺運行 Microsoft? Windows? XP Home 的“新”計算機(其實是我的舊計算機)后,我從中獲得了一些靈感。 迄今為止,我從未運行過安裝了 XP Home 的計算機(我的計算機通常運行的是連接到域中的 Windows xpPRo),因此 XP Home 的歡迎屏幕(請參見圖 1)給我留下了深刻的印象。Laura 也是一樣,她甚至還注重到一個我從未發現的功能:假如登錄后長時間不使用系統,系統將切換回登錄屏幕(或者按 Windows 鍵 + L),這時屏幕將顯示未讀的電子郵件數目!這對她來說真是太妙了,因為她可以在經過計算機時快速掃一眼,看看是否有新的電子郵件,而不必輸入密碼。啊!原本平淡無奇的計算機或電子解決方案卻給我的妻子帶來這么大的幫助,這種感覺真是好極了!
圖 1:歡迎使用 Windows XP! 令人沮喪的是,屏幕上總是顯示同樣的內容:7 封未讀電子郵件,即使 Laura 根本沒有任何未讀的電子郵件時也是如此。這種設置可太叫人失望了:本來,系統通知您有七封可能會讓您非常激動的新郵件,登錄后卻發現沒有任何新郵件。原本欣喜的感覺一掃而光,我要盡快研究研究解決方案。 那七封未讀郵件原來在她的 hotmail 收件箱中,而登錄屏幕上顯示的值是通過具有明確名稱的 SHSetUnreadMailCount(英文)API 調用設置的。Laura 現在使用 hotmail 只是接收 MSN? Messenger(英文),因此知道她有多少封未讀的 hotmail 郵件并不是很有用。在這個現實面前,我腦子里的那個布滿 Microsoft 觀念的思維機器馬上飛速運轉起來,而且我相信我的朋友們也會如此。忽然我有了靈感:我可以創建一個小應用程序,使登錄屏幕顯示更有用的值。 現在,我要澄清幾個細節:Microsoft? Outlook? Express 也可以設置這個值,因此假如使用此應用程序接收電子郵件,則可以設置值,但 Outlook 卻不行。我要構建的應用程序應該能夠處理幾個操作:連接一組 POP3 服務器、獲取新郵件的數量并填充相應的注冊表值。但是,在應用程序的開發完成之后,我才意識到假如忽略 Outlook,一切努力都將付之東流,因為 Laura 的郵件客戶端就是 Outlook,所以我把 Outlook 作為一種附加功能添加到程序中。 根據我的習慣,我將整個工作劃分成一系列可以獨立完成的任務: 連接 POP3 服務器,并檢查當前的郵件數量。 答應您使用主機和用戶 ID/密碼信息來配置一組 POP3 服務器。 以可靠的方式保存服務器配置信息(包括用戶 ID/密碼)。
編寫在后臺運行的應用程序,并使該應用程序每 n 分鐘對已配置的每臺服務器進行檢查。 只要郵件數量發生改變,就更新 Windows XP 登錄屏幕設置。 另外,為了更有意思一些,可以使用 Outlook 2000 或 XP 來執行所有相同的任務。 您可以獲得全部代碼,但我會按順序介紹各個項目并說明代碼是如何在各種情況下工作的。 連接 POP3 服務器
我并非靠編寫套接字代碼謀生,因此作為 Microsoft 軟件的用戶,您應對此感到興奮。假如我的目的就是完成這項工作,那么我會尋找其他人的 POP3 組件(盡管我必須要購買它)。這會更有效,也更簡單。 但我的目的是向您展示一些 Microsoft? .NET 代碼,因此我認為對您來說,觀看我圍繞 System.NET 命名空間和 POP3 spec(英文)編寫自己的組件會給您帶來樂趣和啟發。 第一件事是創建一個小應用程序,用于在一個 Button1_Click 例程中完成所有 POP3 工作,并以一種非常程序化的方式(所有代碼都在一個大的過程中)進行工作,直到我讓它再次執行任務。要在應用程序中使用此代碼,還需要執行一些整理工作。我將它重組為適當的類,并提取一些有關 POP3 命令和服務器信息的特定內容,從而方便您在需要時進行更改。我當時自我感覺很好,想把這個過程稱為“refactoring(再分解)”階段,但是 Microsoft? Word 認為這個詞拼錯了,我也同意這個結論。 假如您從未使用過 .NET 中的套接字類,您也不必擔心,這些類非常簡單直觀。我只用了幾分鐘就實現了連接并發送數據。我使用 System.Net.Sockets.TcpClient(英文)的一個實例來創建與 POP3 服務器的連接,在建立連接以后抓取 NetworkStream(英文)對象。Public Sub New(ByVal server As String, _ ByVal port As Integer, _ ByVal delay As Integer) Try m_Client = New TcpClient() m_Client.Connect(server, port) m_NS = m_Client.GetStream() m_Delay = delay Dim sResponse As String = GetResponse().Trim If sResponse.Substring(0, 3) <> "+OK" Then Throw New Exception("連接失敗") End If Catch se As SocketException MsgBox(se.Message & vbCrLf & vbCrLf & se.ToString, _ MsgBoxStyle.Exclamation, "套接字異常!") Throw New Exception("連接失敗", se) Catch ex As Exception MsgBox(ex.Message & vbCrLf & vbCrLf & ex.ToString, _ MsgBoxStyle.Exclamation, "異常!") Throw New Exception("連接失敗", ex) End Try End Sub 創建開放式連接和數據流之后,接下來要做的就是發送和接收文本。我提取了 NetworkStream 代碼,以便讀取字節數組并將其寫入到幾個高級函數中:SendCommand 和 GetResponse。POP3 規范介紹了兩種類型的服務器響應,即單行和多行,因此我在兩個函數的參數列表中加入了 MultiLine 標記。Private Overloads Function GetResponse() As String Return GetResponse(False) End FunctionPrivate Overloads Function GetResponse( _ ByVal multiLine As Boolean) As String 'GetResponse 的任務是等待 '服務器響應,以便以不同方式完成 '單行和多行響應端, '因此它們的結束條件 '也應該稍有不同。 Dim sOutput As String = "" Dim input As Integer Dim str(4096) As Byte Dim startTime As Date = Now Dim endCondition As StringIf multiLine Then endCondition = vbCrLf & vbCrLf & "." Else endCondition = vbCrLf End IfDo While m_NS.DataAvailable() startTime = Now input = m_NS.Read(str, 0, 4096) sOutput &= ASCIIEncoding.ASCII.GetChars( _ str, 0, input) End While Loop Until sOutput.IndexOf(endCondition) >= 0 _ Or Now.SuBTract(startTime).TotalMilliseconds > Me.m_DelayIf sOutput.IndexOf(endCondition) < 0 Then
Return sOutput Else Return sOutput End If End Function'SendCommand 用于發送字符串 '并接收響應 Public Overloads Function SendCommand( _ ByVal command As String) As String Return SendCommand(command, False) End FunctionPublic Overloads Function SendCommand( _ ByVal command As String, _ ByVal multiLine As Boolean) As String Dim user As Byte() user = ASCIIEncoding.ASCII.GetBytes(command) m_NS.Write(user, 0, user.GetLength(0)) Return GetResponse(multiLine) End Function 注重:我本可以在 NetworkStream 的頂部打開一對友好的流對象,例如 StreamReader(英文)和 StreamWriter(英文),但我還是堅持使用字節數組。假如您要查看有關在 NetworkStream 中使用 StreamReader/StreamWriter 的示例,請參閱由 Andrew Duthiecheck 撰寫的這篇 MSDN Magazine 文章(英文)。
成功發送和接收信息后,我需要使用更高級別的套接字代碼(就如同這些代碼是其他人編寫的,而我并不關心其工作原理一樣),因此我決定將該代碼提取到實用程序類中。然后,在主 POP3Server 類中,我實現了簡單的 POP3 命令集,用于檢索郵件列表。我使用 USER 和 PASS 命令登錄到服務器,獲取郵件數量(使用 STAT),然后列出每個郵件的標題(使用 TOP)。我從這些標題中分析出三條重要信息:發件人、主題和郵件 ID。獲取郵件 ID 的目的是獲得每個郵件的唯一標識符,借助這個標識符,我的程序就能了解郵件何時是最新的。假如發現了新郵件,就會引發一個事件并傳遞主題和發件人信息。以下代碼片段顯示了郵件標題的檢索,以及新電子郵件事件的觸發。假如將這些代碼放在完整的源代碼環境中查看,您的印象會更加深刻。Dim msg As POP3.Message If msgCount > 0 Then Dim i As Integer For i = 1 To msgCount '使用 TOP 命令 '獲取郵件的標題 response = p3.SendCommand( _ String.Format(Me.TopCmd, i), True) msg = ParseResponse(response)Dim msgIndex As Integer = 0 Dim found As Boolean = False '檢查此郵件是否為新郵件 Do While msgIndex < Me.m_Messages.Count And Not found If Me.m_Messages(msgIndex).ID = msg.ID Then found = True End If msgIndex += 1 Loop If Not found Then Me.m_Messages.Add(msg) '假如是新郵件,則引發 NewEmail '事件,傳遞發件人和主題 RaiseEvent NewEmail(CObj(Me), _ New NewEmailEventArgs( _ msg.From, msg.Subject, msg.ID)) End If Next End If 該事件由我的主應用程序捕捉,現在我們來看一下中心應用程序代碼。 編寫用于系統任務欄的應用程序
我在編程時,把幾乎整個應用程序都作為一個 Microsoft? Visual Basic? 模塊(對于 C# 類型,這個模塊相當于一個所有成員都為靜態的類),并且僅使用了一個選項對話框窗體。使用 Visual Basic 6.0 完成編碼是非常有趣的。我編寫的應用程序使用了系統任務欄圖標、計時器和上下文菜單,而不需要使用不可見的窗體。我確實不喜歡創建不可見的窗體,尤其是那些僅在啟動的瞬見可以看到的窗體。因此,當我發現并不需要不可見的窗體時,我很興奮。在這個主模塊中,我創建了一個 Hans Blomme's wonderful NotifyIconXP(英文)的實例、上下文菜單和一些菜單項,以及一個計時器。然后,我為菜單項、計時器編寫了事件處理程序,甚至為通知圖標的 BalloonClick 事件(盡管我并不用這個事件,但我認為您可能需要它)也編寫了一個事件處理程序。'服務器列表 Dim servers As New POP3ServerCollection()'輪詢電子郵件服務器的計時器 Dim checkTimer As New Timer()'notifyIcon 提供給用程序的主要用戶界面 '通過它可以彈出氣泡式信息等。 Dim ni As HansBlomme.Windows.Forms.NotifyIcon'常規的應用程序首選項
Dim appSettings As Settings'我將此窗體保留為模塊級 '變量,因此使用 ViewOptions 菜單可以避免 '一次打開多個實例 Dim myFrm As frmOptionsSub Main() '從序列化的 xml 文件中 '加載服務器列表和設置信息 Reload()'遍歷所有加載的 '服務器,并為 NewE-mail 事件 '附加一個處理程序。 Dim srv As POP3Server For Each srv In servers AddHandler srv.NewE-mail, AddressOf NewE-mail Next'appSettings 以秒為單位存儲間隔, '計時器以毫秒為單位進行工作,因此,為使它們協同工作, '必須進行一定的轉換。 checkTimer.Interval = appSettings.CheckInterval * 1000 checkTimer.Enabled = True'為計時器的 Tick 事件添加處理程序 AddHandler checkTimer.Tick, AddressOf CheckE-mail_Tick'創建新圖標,刪除 HansBlomme 部分,以使用 '標準的 NotifyIcon。此外,還需要對其他代碼 '進行更改 ni = New HansBlomme.Windows.Forms.NotifyIcon() ni.Icon = New Icon(GetType(Main), "mail.ico") ni.Visible = True'當用戶單擊由通知圖標彈出的氣泡式信息時, '添加事件處理程序。標準的 NotifyIcon '不提供此事件,因此假如不使用 HansBlomme 圖標, '則需要刪除此行。 AddHandler ni.BalloonClick, AddressOf NotificationBalloonClicked'設置上下文菜單,包括事件處理程序 Dim ctxMenu As New ContextMenu() ctxMenu.MenuItems.Add("查看選項", AddressOf ViewOptionsForm) ctxMenu.MenuItems.Add("退出", AddressOf Endapplication) '并將其指定給 NotifyIcon,以便在右擊該圖標時, '它可以彈出。 ni.ContextMenu = ctxMenu'運行應用程序,使用 Application.Run '來創建新的消息循環 Application.Run()'當退出應用程序時 '隱藏通知圖標并 ni.Visible = False'將服務器信息和設置保存到 xml 文件中 Persist() End Sub 保存設置
我在 Main() 例程的開頭調用了 Reload(),在結尾調用了 Persist()。這兩個過程將我的設置和 POP3 服務器信息保存到磁盤(在 Persist 中),并在啟動時又加載它們(在 Reload 中)。當您要將對象(甚至是結構復雜的對象)保存到磁盤時,最好執行序列化。這段代碼引用的 POP3ServerCollection 類是使用 GotDotNet(英文)中的 Collection Generator(英文)生成的,可以從可下載的源代碼中獲得。Public Sub Persist() '使用序列化將 Settings 對象和 'POP3 服務器的集合保存到 XML 文件中 '請注重,與 Background Copying 一文不同, '這些文件并未保存到孤立的存儲區中。 '雖然可以將它們保存在孤立的存儲區中,但我想 '這一次應該有所變化。'指出 xml 文件的路徑,注重 '使用 IO.Path.Combine... 要比僅 '將它們連接起來好得多。 Dim serverSettingsPath As String _ = IO.Path.Combine( _ Application.LocalUserAppDataPath, _ "servers.xml") Dim settingsPath As String _ = IO.Path.Combine( _ Application.LocalUserAppDataPath, _ "settings.xml")Dim myXMLSerializer As Xml.Serialization.XmlSerializer Dim settingsFile As IO.StreamWriter'保存服務器列表 myXMLSerializer _ = New Xml.Serialization.XmlSerializer( _ GetType(POP3ServerCollection)) settingsFile _ = New IO.StreamWriter(serverSettingsPath) myXMLSerializer.Serialize(settingsFile, servers) settingsFile.Close()'保存 Settings 類 myXMLSerializer _ = New Xml.Serialization.XmlSerializer( _ GetType(Settings)) settingsFile = New IO.StreamWriter(settingsPath) myXMLSerializer.Serialize( _ settingsFile, appSettings) settingsFile.Close()
End SubPublic Sub Reload() '與 Persist() 正好相反,此例程 '從類的 XML 文件中加載兩個類,或 '創建新的實例(假如 XML 文件不存在) Dim serverSettingsPath As String _ = IO.Path.Combine( _ Application.LocalUserAppDataPath, "servers.xml") Dim settingsPath As String _ = IO.Path.Combine( _ Application.LocalUserAppDataPath, "settings.xml")If IO.File.Exists(serverSettingsPath) Then '加載服務器 Dim settingsFile As IO.StreamReader _ = New IO.StreamReader(serverSettingsPath) Dim myXMLSerializer As _ New Xml.Serialization.XmlSerializer( _ GetType(POP3ServerCollection)) servers = DirectCast( _ myXMLSerializer.Deserialize(settingsFile), _ POP3ServerCollection) settingsFile.Close() Else servers = New POP3ServerCollection() End IfIf IO.File.Exists(settingsPath) Then '加載設置 Dim settingsFile As IO.StreamReader _ = New IO.StreamReader(settingsPath) Dim myXMLSerializer As _ New Xml.Serialization.XmlSerializer( _ GetType(Settings)) appSettings = DirectCast( _ myXMLSerializer.Deserialize(settingsFile), _ Settings) settingsFile.Close() Else appSettings = New Settings() End If End Sub 存儲用戶ID/密碼
正如您在 Persist/Reload 代碼中看到的,我將設置保存到本地應用程序數據路徑中。這樣可以將信息作為 XML 文件保存到 /Documents and Settings/<userid>/Local Settings/Application Data/POP3/POP3/<version>/ 中。該區域應該受到限制,使得只有您可以訪問。但是,這并不能阻止那些具有治理員訪問權限的用戶,所以我認為最好對信息中的敏感部分再采取一些措施。為了提高安全性,在將用戶 ID 和密碼保存到磁盤之前,我使用 DPAPI(英文,受這個簡單示例 [英文] 的啟發)對它們進行了加密。Function EncryptText(ByVal source As String) As String '我使用 GotDotNet 中的 DPAPI 組件 '請查看鏈接的相關文章。 Dim dp As _ New Dpapi.DataProtector(Dpapi.Store.UserStore) Dim sourceBytes As Byte() = _ System.Text.Encoding.Unicode.GetBytes(source) Dim encryptedBytes As Byte() encryptedBytes = dp.Encrypt(sourceBytes) '我可以存儲二進制值,但是由于使用 'XML 作為存儲機制,因此我更愿意使用 '字符串。ToBase64String 處理我的字符串 Return Convert.ToBase64String(encryptedBytes) End FunctionFunction DecryptText(ByVal source As String) As String '我使用 GotDotNet 中的 DPAPI 組件 '請查看鏈接的相關文章。 Dim dp As _ New Dpapi.DataProtector(Dpapi.Store.UserStore) Dim sourceBytes As Byte() = _ Convert.FromBase64String(source) Dim decryptedBytes As Byte() decryptedBytes = dp.Decrypt(sourceBytes) Return System.Text.Encoding.Unicode.GetString(decryptedBytes) End Function 當然,對這些字符串進行加密之后,我必須做一些額外的工作,才能通過選項窗體查看和/或編輯這些值。 通過選項對話框更改設置
我得承認,在為自己編寫代碼時,我通常習慣手動更改代碼中的值,例如 .config 文件或數據庫中的值。這并不是一個好習慣,因為有時候需要將代碼提供給其他人,然后必須解釋如何更改設置、處理項目符號和創建選項對話框。在這個示例中,我決定事先做好預備,因此我創建了一個小的對話框,在通知圖標以外提供了“查看選項”菜單項。Sub ViewOptionsForm( _ ByVal sender As Object, _ ByVal e As EventArgs) '彈出“選項”對話框 Try '檢查對話框是否已啟動, '假如已啟動,將焦點設置到對話框 If Not myFrm Is Nothing AndAlso myFrm.Visible Then myFrm.Focus()
Else '假如尚未啟動,則創建一個新的副本 myFrm = New frmOptions()'在窗體中導入數據 myFrm.servers = servers myFrm.appSettings = appSettingsIf myFrm.ShowDialog() = DialogResult.OK Then '我在窗體上“克隆”了 appSettings '因此在窗體關閉時, '您需要彈出一個副本 appSettings = myFrm.appSettings '嗨,您已經更改了所有設置 '最好是保存它們! '這樣,即使應用程序以錯誤的方式結束, '您所做的更改仍會被保存。 Persist() End If myFrm.Dispose() myFrm = Nothing End If Catch ex As System.Exception Debug.WriteLine(ex.ToString) Debug.WriteLine(ex.StackTrace) End Try End Sub 為了能夠編輯服務器集合,我將數據綁定到一組控件并提供了“新建”和“刪除”按鈕。這個小的導航控件是我為方便自己使用而編寫的,您可能也會用到。假如我不打算使用此控件,本來使用兩個按鈕來處理 CurrencyManager(英文)的 Position 屬性就可以了。
圖 3:可以使用“選項”對話框設置 POP3 服務器信息。 由于就像我在前面的“存儲用戶 ID/密碼”一節提到的,已經對用戶 ID 和密碼進行了加密,因此需要做一些工作以便在數據綁定方案中利用這些值。我為 UserID 和 Password 綁定對象的 Parse(英文)和 Format(英文)事件添加了事件處理程序,以使這些值在顯示時自動解密,在存儲到服務器集合之前自動加密。'設置例程格式并對其進行分析,以解密/加密值 Private Sub encryptedBinding_Format( _ ByVal sender As Object, _ ByVal e As System.Windows.Forms.ConvertEventArgs) If e.Value <> String.Empty Then e.Value = Main.DecryptText(CStr(e.Value)) End If End SubPrivate Sub encryptedBinding_Parse( _ ByVal sender As Object, _ ByVal e As System.Windows.Forms.ConvertEventArgs) If e.Value <> String.Empty Then e.Value = Main.EncryptText(CStr(e.Value)) End If End Sub 常規的設置并不是數據綁定的,我將它們從 Settings 類中彈出,然后根據需要推入新值。'未對常規設置進行數據綁定 '我只是在控件之間手動 '彈出/推入這些值。 Private Sub PopulateControls() If m_appSettings.CheckInterval _ > Me.checkInterval.Maximum Then m_appSettings.CheckInterval _ = Me.checkInterval.Maximum ElseIf m_appSettings.CheckInterval _ < Me.checkInterval.Minimum Then m_appSettings.CheckInterval _ = Me.checkInterval.Minimum End If Me.checkInterval.Value = _ Me.m_appSettings.CheckInterval Me.checkOutlook.Checked = _ Me.m_appSettings.CheckOutlook Me.displayPopups.Checked = _ Me.m_appSettings.DisplayNewMailPopup End SubPrivate Sub PullControlValues() Me.m_appSettings.CheckInterval = _ Me.checkInterval.Value Me.m_appSettings.CheckOutlook = _ Me.checkOutlook.Checked Me.m_appSettings.DisplayNewMailPopup = _ Me.displayPopups.Checked End Sub Outlook
我差點忘記了這個應用程序。我說過我要檢查 Outlook 的未讀郵件和 POP3 帳戶。我不想引用 Outlook 庫的原因有兩個: 我不希望您使用 Outlook 來編譯或運行此代碼。 我不希望創建與 Outlook 特定版本相關的依靠關系。 假如不使用對 Outlook 的引用,則必須使用最新綁定,這意味著 Microsoft? IntelliSense? 和其他一 切都不能視為對象。但積極的一面是,結果代碼可以在 Microsoft? Office 2000、Office XP 和 Office 11 上運行。Public Shared Function GetUnreadMessages() As Integer Dim OutlookApp As Object Try OutlookApp = _ GetObject(, "Outlook.Application")
Catch OutlookApp = Nothing End Try '取消注釋這段內容,以便在 '需要時打開 Outlook 'If OutlookApp Is Nothing Then ' OutlookApp = _ ' CreateObject("Outlook.Application") 'End IfIf Not OutlookApp Is Nothing Then '6 為常數(對于收件箱) '由 Outlook 庫提供 '沒有引用就沒有庫 '也沒有常數 Dim inbox As Object = _ OutlookApp.session.GetDefaultFolder(6) Return inbox.UnReadItemCount() Else Return -1 End If End Function 請注重,假如 Outlook 未打開,此代碼將無法工作。假如我使用 CreateObject(英文),此代碼就可以工作,但是我并不想讓這個小小的系統任務欄應用程序強制 Outlook 每 n 秒就打開一次。假如該程序未打開,那么它將無法讀取 Outlook 收件箱中的未讀郵件數。假如無法與 Outlook 相連接,就必須保留未讀郵件的值,這一點很重要。將其設置為零將清除上一次的有效數字。 編碼問題