用 Java 實現遠程幀緩沖服務器
已發表: 2022-03-11在計算中,虛擬網絡計算 (VNC) 是一種圖形桌面共享系統,它使用遠程幀緩衝區 (RFB) 協議來遠程控制另一台計算機。 它將鍵盤和鼠標事件從一台計算機傳輸到另一台計算機,並通過網絡將圖形屏幕更新轉發回另一個方向。
RFB 是一種用於遠程訪問圖形用戶界面的簡單協議。 因為它工作在幀緩衝區級別,所以它適用於所有窗口系統和應用程序,包括 Microsoft Windows、Mac OS X 和 X Window System。
在本文中,我將展示如何實現 RFB 服務器端協議,並通過一個小型 Java Swing 應用程序演示如何通過 TCP 連接將主窗口傳輸到 VNC 查看器。 這個想法是為了演示協議的基本特性和 Java 中的可能實現。
讀者應具備 Java 編程語言的基本知識,並應熟悉 TCP/IP 網絡、客戶端-服務器模型等基本概念。理想情況下,讀者是 Java 開發人員,並且對 RealVNC 等知名 VNC 實現有一定的經驗、UltraVNC、TightVNC等
遠程幀緩衝協議規範
RFB 協議規範定義得非常好。 根據維基百科,RFB 協議有幾個版本。 對於本文,我們的重點將放在大多數 VNC 實現應該正確理解的常見消息,而不管協議版本如何。
在 VNC 查看器(客戶端)與 VNC 服務器(RFB 服務)建立 TCP 連接後,第一階段涉及協議版本的交換:
RFB Service ----------- "RFB 003.003\n" -------> VNC viewer RFB Service <---------- "RFB 003.008\n" -------- VNC viewer
它是一個簡單的字節流,可以解碼為 ASCII 字符,例如“RFB 003.008\n”。
完成後,下一步就是身份驗證。 VNC 服務器發送一個字節數組來指示它支持的身份驗證類型。 例如:
RFB Service ----------- 0x01 0x02 -----------> VNC viewer RFB Service <----------- 0x02 ----------- VNC viewer
這裡 VNC 服務器只發送了 1 種可能的身份驗證類型 (0x02)。 第一個字節 0x01 表示可用的身份驗證類型的數量。 VNC 查看器必須回复值 0x02,因為這是本示例中服務器支持的唯一可能類型。
接下來,服務器將發送身份驗證質詢(取決於哪種算法,有幾種),客戶端必須以正確的質詢響應消息進行響應,並等待服務器確認響應。 一旦客戶端通過身份驗證,他們就可以繼續會話建立過程。
這裡最簡單的方法是完全不選擇身份驗證。 不管身份驗證機制如何,RFB 協議都是不安全的。 如果安全很重要,正確的方法是通過 VPN 或 SSH 連接來隧道 RFB 會話。
此時,VNC 查看器會發送一條共享桌面消息,告知客戶端是否將共享並允許其他 VNC 查看器連接到同一桌面。 由 RFB 服務實施來考慮該消息並可能阻止多個 VNC 查看器共享同一屏幕。 該消息只有 1 個字節長,有效值為 0x00 或 0x01。
最後,RFB 服務器發送一個服務器初始化消息,其中包含屏幕尺寸、每像素位數、深度、大端標誌和真彩色標誌、紅色、綠色和藍色的最大值、紅色、綠色和藍色的像素位位置, 和桌面字符串/標題。 前兩個字節以像素為單位表示屏幕寬度,接下來的兩個字節是屏幕高度。 在屏幕高度字節之後,每像素字節的位數應該出現在消息中。 該值通常為 8、16 或 32。在大多數具有全色範圍的現代系統上,每像素字節的位數為 32 (0x20)。 它告訴客戶端它可以從服務器請求每個像素的全彩色。 僅當像素按大端順序排列時,大端字節才非零。 如果真彩色字節不為零(真),那麼接下來的六個字節指定如何從像素值中提取紅色、綠色和藍色強度。 接下來的六個字節是像素的紅色、綠色和藍色分量的最大允許值。 這在 8 位顏色模式中很重要,其中每個顏色分量只有幾個位可用。 紅色、綠色和藍色移位確定每種顏色的位位置。 最後三個字節是填充,應該被客戶端忽略。 在像素格式之後,有一個字節定義了桌面標題的字符串長度。 桌面標題是任意長度的字節數組中的 ASCII 編碼字符串。
在服務器初始化消息之後,RFB 服務應該從套接字讀取客戶端消息並對其進行解碼。 有 6 種類型的消息:
- 設置像素格式
- 設置編碼
- 幀緩衝更新請求
- 按鍵事件
- 指針事件
- 客戶端剪切文本
協議文檔非常準確,並解釋了每條消息。 對於每條消息,每一個字節都會被解釋。 例如,服務器初始化消息:
字節數 | 類型 | 描述 |
---|---|---|
2 | U16 | 幀緩衝寬度 |
2 | U16 | 幀緩衝高度 |
16 | PIXEL_FORMAT | 服務器像素格式 |
4 | U32 | 名稱長度 |
名稱長度 | U8陣列 | 名稱字符串 |
在這裡,PIXEL_FORMAT 是:
字節數 | 類型 | 描述 |
---|---|---|
1 | U8 | 每像素位數 |
1 | U8 | 深度 |
1 | U8 | 大端標誌 |
1 | U8 | 真彩旗 |
2 | U16 | 紅最大 |
2 | U16 | 綠色最大 |
2 | U16 | 藍最大 |
1 | U8 | 紅移 |
1 | U8 | 綠移 |
1 | U8 | 藍移 |
3 | 填充 |
U16 表示無符號 16 位整數(兩個字節),U32 表示無符號 32 位整數,U8 數組是字節數組等。
Java中的協議實現
一個典型的 Java 服務器應用程序由一個監聽客戶端連接的線程和幾個處理客戶端連接的線程組成。
/* * Use TCP port 5902 (display :2) as an example to listen. */ int port = 5902; ServerSocket serverSocket; serverSocket = new ServerSocket(port); /* * Limit sessions to 100. This is lazy way, if * somebody really open 100 sessions, server socket * will stop listening and no new VNC viewers will be * able to connect. */ while (rfbClientList.size() < 100) { /* * Wait and accept new client. */ Socket client = serverSocket.accept(); /* * Create new object for each client. */ RFBService rfbService = new RFBService(client); /* * Add it to list. */ rfbClientList.add(rfbService); /* * Handle new client session in separate thread. */ (new Thread(rfbService, "RFBService" + rfbClientList.size())).start(); }
這裡選擇了 TCP 端口 5902(顯示:2),while 循環等待客戶端連接。 方法ServerSocket.accept()是阻塞的,它使線程等待新的客戶端連接。 客戶端連接後,會創建一個新線程 RFBService,用於處理從客戶端接收到的 RFB 協議消息。
RFBService 類實現了 Runnable 接口。 它充滿了從套接字讀取字節的方法。 方法run()很重要,當線程在循環結束時啟動時立即執行:
@Override public void run() { try { /* * RFB server has to send protocol version string first. * And wait for VNC viewer to replay with * protocol version string. */ sendProtocolVersion(); String protocolVer = readProtocolVersion(); if (!protocolVer.startsWith("RFB")) { throw new IOException(); }
這裡方法sendProtocolVersion()將 RFB 字符串發送到客戶端(VNC 查看器),然後從客戶端讀取協議版本字符串。 客戶應回复“RFB 003.008\n”之類的內容。 方法readProtocolVersion()當然是阻塞的,就像任何名稱以 read 開頭的方法一樣。
private String readProtocolVersion() throws IOException { byte[] buffer = readU8Array(12); return new String(buffer); }
方法 readProtocolVersion() 很簡單:它從套接字讀取 12 個字節,並返回一個字符串值。 函數 readU8Array(int) 讀取指定數量的字節,在本例中為 12 個字節。 如果在套接字上沒有足夠的字節讀取,它會等待:
private byte[] readU8Array(int len) throws IOException { byte[] buffer = new byte[len]; int offset = 0, left = buffer.length; while (offset < buffer.length) { int numOfBytesRead = 0; numOfBytesRead = in.read(buffer, offset, left); offset = offset + numOfBytesRead; left = left - numOfBytesRead; } return buffer; }
與readU8Array(int)類似,存在readU16int()和readU32int()方法,它們從套接字讀取字節並返回整數值。
發送協議版本並讀取響應後,RFB 服務應發送安全消息:
/* * RFB server sends security type bytes that may request * a user to type password. * In this implementation, this is set to simples * possible option: no authentication at all. */ sendSecurityType();
在這個實現中,選擇了最簡單的方法:不需要來自 VNC 客戶端的任何密碼。
private void sendSecurityType() throws IOException { out.write(SECURITY_TYPE); out.flush(); }
其中 SECURITY_TYPE 是字節數組:
private final byte[] SECURITY_TYPE = {0x00, 0x00, 0x00, 0x01};
RFB 協議版本 3.3 的這個字節數組意味著 VNC 查看器不需要發送任何密碼。
接下來 RFB 服務應該從客戶端獲取共享桌面標誌。 它是套接字上的一個字節。
/* * RFB server reads shared desktop flag. It's a single * byte that tells RFB server * should it support multiple VNC viewers connected at * same time or not. */ byte sharedDesktop = readSharedDesktop();
從套接字讀取共享桌面標誌後,我們在實現中忽略它。
RFB 服務必鬚髮送服務器初始化消息:
/* * RFB server sends ServerInit message that includes * screen resolution, * number of colors, depth, screen title, etc. */ screenWidth = JFrameMainWindow.jFrameMainWindow.getWidth(); screenHeight = JFrameMainWindow.jFrameMainWindow.getHeight(); String windowTitle = JFrameMainWindow.jFrameMainWindow.getTitle(); sendServerInit(screenWidth, screenHeight, windowTitle);
JFrameMainWindow 類是 JFrame,在這裡作為圖形源用於演示目的。 服務器初始化消息具有以像素為單位的強制性屏幕寬度和高度,以及桌面標題。 在這個例子中,它是通過 getTitle() 方法獲得的 JFrame 的標題。
在服務器初始化消息之後,RFB 服務線程通過從套接字讀取六種類型的消息進行循環:
/* * Main loop where clients messages are read from socket. */ while (true) { /* * Mark first byte and read it. */ in.mark(1); int messageType = in.read(); if (messageType == -1) { break; } /* * Go one byte back. */ in.reset(); /* * Depending on message type, read complete message on socket. */ if (messageType == 0) { /* * Set Pixel Format */ readSetPixelFormat(); } else if (messageType == 2) { /* * Set Encodings */ readSetEncoding(); } else if (messageType == 3) { /* * Frame Buffer Update Request */ readFrameBufferUpdateRequest(); } else if (messageType == 4) { /* * Key Event */ readKeyEvent(); } else if (messageType == 5) { /* * Pointer Event */ readPointerEvent(); } else if (messageType == 6) { /* * Client Cut Text */ readClientCutText(); } else { err("Unknown message type. Received message type = " + messageType); } }
每個方法readSetPixelFormat() , readSetEncoding() , readFrameBufferUpdateRequest() , ... readClientCutText()都是阻塞的並觸發一些動作。
例如,當用戶在客戶端剪切文本時, readClientCutText()方法讀取消息中編碼的文本,然後 VNC 查看器通過 RFB 協議將文本發送到服務器。 然後將文本放置在剪貼板的服務器端。
客戶端消息
RFB 服務必須支持所有六個消息,至少在字節級別:當客戶端發送消息時,必須讀取完整的字節長度。 這是因為 RFB 協議是面向字節的,兩個消息之間沒有邊界。
最重要的消息是幀緩衝區更新請求。 客戶端可以請求屏幕的完全更新或增量更新。
private void readFrameBufferUpdateRequest() throws IOException { int messageType = in.read(); int incremental = in.read(); if (messageType == 0x03) { int x_pos = readU16int(); int y_pos = readU16int(); int width = readU16int(); int height = readU16int(); screenWidth = width; screenHeight = height; if (incremental == 0x00) { incrementalFrameBufferUpdate = false; int x = JFrameMainWindow.jFrameMainWindow.getX(); int y = JFrameMainWindow.jFrameMainWindow.getY(); RobotScreen.robo.getScreenshot(x, y, width, height); sendFrameBufferUpdate(x_pos, y_pos, width, height, 0, RobotScreen.robo.getColorImageBuffer()); } else if (incremental == 0x01) { incrementalFrameBufferUpdate = true; } else { throw new IOException(); } } else { throw new IOException(); } }
幀緩衝請求消息的第一個字節是消息類型。 值始終為 0x03。 下一個字節是增量標誌,它告訴服務器發送全幀或只是一個差異。 在完整更新請求的情況下,RFB 服務將使用 RobotScreen 類截取主窗口並將其發送給客戶端。

如果是增量請求,一個標誌增量FrameBufferUpdate將被設置為true。 Swing 組件將使用此標誌來檢查它們是否需要發送已更改的屏幕部分。 通常 JMenu、JMenuItem、JTextArea 等需要在用戶移動鼠標指針、點擊、發送按鍵等時對屏幕進行增量更新。
方法 sendFrameBufferUpdate(int, int, int, int, int[]) 將圖像緩衝區刷新到套接字。
public void sendFrameBufferUpdate(int x, int y, int width, int height, int encodingType, int[] screen) throws IOException { if (x + width > screenWidth || y + height > screenHeight) { err ("Invalid frame update size:"); err (" x = " + x + ", y = " + y); err (" width = " + width + ", height = " + height); return; } byte messageType = 0x00; byte padding = 0x00; out.write(messageType); out.write(padding); int numberOfRectangles = 1; writeU16int(numberOfRectangles); writeU16int(x); writeU16int(y); writeU16int(width); writeU16int(height); writeS32int(encodingType); for (int rgbValue : screen) { int red = (rgbValue & 0x000000FF); int green = (rgbValue & 0x0000FF00) >> 8; int blue = (rgbValue & 0x00FF0000) >> 16; if (bits_per_pixel == 8) { out.write((byte) colorMap.get8bitPixelValue(red, green, blue)); } else { out.write(red); out.write(green); out.write(blue); out.write(0); } } out.flush(); }
方法檢查 (x, y) 坐標是否與圖像緩衝區的寬度 x 高度一起離開屏幕。 幀緩衝區更新的消息類型值為 0x00。 填充值通常為 0x00,應被 VNC 查看器忽略。 矩形數量是兩個字節的值,定義了消息中跟隨的矩形數量。
每個矩形都有左上角坐標、寬度和高度、編碼類型和像素數據。 有一些高效的編碼格式可以使用,例如zrle、hextile和tight。 然而,為了讓事情簡單易懂,我們將在我們的實現中使用原始編碼。
原始編碼意味著像素顏色作為 RGB 分量傳輸。 如果客戶端已將像素編碼設置為 32 位,則每個像素傳輸 4 個字節。 如果客戶端使用 8 位顏色模式,則每個像素作為 1 個字節傳輸。 代碼顯示在 for 循環中。 請注意,對於 8 位模式,顏色映射用於從屏幕截圖/圖像緩衝區中找到每個像素的最佳匹配。 對於 32 位像素模式,圖像緩衝區包含整數數組,每個值都具有多路復用的 RGB 分量。
Swing 演示應用程序
Swing 演示應用程序包含觸發sendFrameBufferUpdate(int, int, int, int, int[])方法的動作偵聽器。 通常應用程序元素,如 Swing 組件,應該有監聽器並向客戶端發送屏幕變化。 例如當用戶在 JTextArea 中鍵入內容時,它應該被傳輸到 VNC 查看器。
public void actionPerformed(ActionEvent arg0) { /* * Get dimensions and location of main JFrame window. */ int offsetX = JFrameMainWindow.jFrameMainWindow.getX(); int offsetY = JFrameMainWindow.jFrameMainWindow.getY(); int width = JFrameMainWindow.jFrameMainWindow.getWidth(); int height = JFrameMainWindow.jFrameMainWindow.getHeight(); /* * Do not update screen if main window dimension has changed. * Upon main window resize, another action listener will * take action. */ int screenWidth = RFBDemo.rfbClientList.get(0).screenWidth; int screenHeight = RFBDemo.rfbClientList.get(0).screenHeight; if (width != screenWidth || height != screenHeight) { return; } /* * Capture new screenshot into image buffer. */ RobotScreen.robo.getScreenshot(offsetX, offsetY, width, height); int[] delta = RobotScreen.robo.getDeltaImageBuffer(); if (delta == null) { offsetX = 0; offsetY = 0; Iterator<RFBService> it = RFBDemo.rfbClientList.iterator(); while (it.hasNext()) { RFBService rfbClient = it.next(); if (rfbClient.incrementalFrameBufferUpdate) { try { /* * Send complete window. */ rfbClient.sendFrameBufferUpdate( offsetX, offsetY, width, height, 0, RobotScreen.robo.getColorImageBuffer()); } catch (SocketException ex) { it.remove(); } catch (IOException ex) { ex.printStackTrace(); it.remove(); } rfbClient.incrementalFrameBufferUpdate = false; } } } else { offsetX = RobotScreen.robo.getDeltaX(); offsetY = RobotScreen.robo.getDeltaY(); width = RobotScreen.robo.getDeltaWidth(); height = RobotScreen.robo.getDeltaHeight(); Iterator<RFBService> it = RFBDemo.rfbClientList.iterator(); while (it.hasNext()) { RFBService rfbClient = it.next(); if (rfbClient.incrementalFrameBufferUpdate) { try { /* * Send only delta rectangle. */ rfbClient.sendFrameBufferUpdate( offsetX, offsetY, width, height, 0, delta); } catch (SocketException ex) { it.remove(); } catch (IOException ex) { ex.printStackTrace(); it.remove(); } rfbClient.incrementalFrameBufferUpdate = false; } } } }
這個動作監聽器的代碼非常簡單:它使用 RobotScreen 類截取主窗口 JFrameMain 的屏幕截圖,然後確定是否需要部分更新屏幕。 變量diffUpdateOfScreen用作部分更新的標誌。 最後完成圖像緩衝區或僅將不同的行傳輸給客戶端。 這段代碼還考慮了更多的客戶端連接,這就是為什麼使用迭代器並且在RFBDemo.rfbClientList<RFBService>成員中維護客戶端列表的原因。
Framebuffer 更新動作監聽器可以在 Timer 中使用,它可以由任何 JComponent 更改啟動:
/* * Define timer for frame buffer update with 400 ms delay and * no repeat. */ timerUpdateFrameBuffer = new Timer(400, new ActionListenerFrameBufferUpdate()); timerUpdateFrameBuffer.setRepeats(false);
此代碼在 JFrameMainWindow 類的構造函數中。 計時器在 doIncrementalFrameBufferUpdate() 方法中啟動:
public void doIncrementalFrameBufferUpdate() { if (RFBDemo.rfbClientList.size() == 0) { return; } if (!timerUpdateFrameBuffer.isRunning()) { timerUpdateFrameBuffer.start(); } }
其他動作監聽器通常調用 doIncrementalFrameBufferUpdate() 方法:
public class DocumentListenerChange implements DocumentListener { @Override public void changedUpdate(DocumentEvent e) { JFrameMainWindow jFrameMainWindow = JFrameMainWindow.jFrameMainWindow; jFrameMainWindow.doIncrementalFrameBufferUpdate(); } // ... }
這種方式應該簡單易行。 只需要對 JFrameMainWindow 實例的引用和一次調用doIncrementalFrameBufferUpdate()方法。 方法將檢查是否有客戶端連接,如果有,定時器timerUpdateFrameBuffer將被啟動。 一旦計時器啟動,動作監聽器將實際截取屏幕截圖並執行sendFrameBufferUpdate() 。
上圖顯示了偵聽器與幀緩衝區更新過程的關係。 大多數偵聽器在用戶執行操作時觸發:單擊、選擇文本、在文本區域中鍵入內容等。然後執行成員函數doIncrementalFramebufferUpdate()來啟動計時器timerUpdateFrameBuffer 。 計時器最終將調用RFBService 類中的 sendFrameBufferUpdate()方法,它會導致客戶端(VNC 查看器)上的屏幕更新。
捕獲屏幕、播放擊鍵和在屏幕上移動鼠標指針
Java 有一個內置的 Robot 類,使開發人員能夠編寫一個應用程序來抓取屏幕截圖、發送鍵、操作鼠標指針、產生點擊等。
要抓取顯示 JFrame 窗口的屏幕區域,使用 RobotScreen。 主要方法是獲取屏幕區域的getScreenshot(int, int, int, int) 。 每個像素的 RGB 值存儲在 int[] 數組中:
public void getScreenshot(int x, int y, int width, int height) { Rectangle screenRect = new Rectangle(x, y, width, height); BufferedImage colorImage = robot.createScreenCapture(screenRect); previousImageBuffer = colorImageBuffer; colorImageBuffer = ((DataBufferInt) colorImage.getRaster().getDataBuffer()).getData(); if (previousImageBuffer == null || previousImageBuffer.length != colorImageBuffer.length) { previousImageBuffer = colorImageBuffer; } this.width = width; this.height = height; }
方法將像素存儲在 colorImageBuffer 數組中。 要獲取像素數據,可以使用getColorImageBuffer()方法。
方法還保存以前的圖像緩衝區。 可以僅獲取已更改的像素。 要僅獲取圖像區域的差異,請使用方法getDeltaImageBuffer() 。
使用 Robot 類很容易將擊鍵發送到系統。 但是,必須首先正確翻譯從 VNC 查看器收到的一些特殊鍵碼。 RobotKeyboard 類具有處理特殊鍵和字母數字鍵的方法sendKey(int, int) :
public void sendKey(int keyCode, int state) { switch (keyCode) { case 0xff08: doType(VK_BACK_SPACE, state); break; case 0xff09: doType(VK_TAB, state); break; case 0xff0d: case 0xff8d: doType(VK_ENTER, state); break; case 0xff1b: doType(VK_ESCAPE, state); break; … case 0xffe1: case 0xffe2: doType(VK_SHIFT, state); break; case 0xffe3: case 0xffe4: doType(VK_CONTROL, state); break; case 0xffe9: case 0xffea: doType(VK_ALT, state); break; default: /* * Translation of a..z keys. */ if (keyCode >= 97 && keyCode <= 122) { /* * Turn lower-case a..z key codes into upper-case A..Z key codes. */ keyCode = keyCode - 32; } doType(keyCode, state); } }
參數狀態確定是按下還是釋放鍵。 將鍵碼正確轉換為 VT 常量後,方法doType(int, int)將鍵值傳遞給 Robot,效果與本地用戶按鍵盤上的鍵相同:
private void doType(int keyCode, int state) { if (state == 0) { robot.keyRelease(keyCode); } else { robot.keyPress(keyCode); } }
與 RobotKeyboard 類似的是 RobotMouse 類,它處理指針事件,並導致鼠標指針移動和單擊。
public void mouseMove(int x, int y) { robot.mouseMove(x, y); }
RobotScreen、RobotMouse 和 RobotKeyboard 這三個類都在構造函數中分配新的 Robot 實例:
this.robot = new Robot();
我們只有一個實例,因為在應用程序級別不需要有多個 RobotScreen、RobotMouse 或 RobotKeyboard 類的實例。
public static void main(String[] args) { ... /* * Initialize static Robot objects for screen, keyboard and mouse. */ RobotScreen.robo = new RobotScreen(); RobotKeyboard.robo = new RobotKeyboard(); RobotMouse.robo = new RobotMouse(); ... }
在這個演示應用程序中,這些實例是在main()函數中創建的。
結果是一個基於 Swing 的 Java 應用程序,它充當 RFB 服務提供者並允許標準 VNC 查看器連接到它:
結論
RFB 協議被廣泛使用和接受。 幾乎所有平台和設備都存在 VNC 查看器形式的客戶端實現。 主要目的是遠程顯示桌面,但也可以有其他應用程序。 例如,您可以創建漂亮的圖形工具並遠程訪問它們以增強您現有的遠程工作流程。
本文介紹了 RFB 協議的基礎知識、消息格式、如何發送部分屏幕以及如何處理鍵盤和鼠標。 GitHub 上提供了 Swing 演示應用程序的完整源代碼。