作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.
Igor Delac's profile image

Igor Delac

Igor是一位热情而熟练的专业人士,具有Java开发经验, system administration, and he loves to experiment.

Previously At

Ericsson
Share

In computing, 虚拟网络计算(VNC)是一种图形桌面共享系统,它使用远程帧缓冲区(RFB)协议远程控制另一台计算机. 它将键盘和鼠标事件从一台计算机传输到另一台计算机, 并通过网络向另一个方向传递图形屏幕更新.

RFB是一个用于远程访问图形用户界面的简单协议. Because it works at the frame buffer level, 它适用于包括Microsoft Windows在内的所有窗口系统和应用程序, Mac OS X, and X Window System.

用Java构建远程Framebuffer服务器端协议驱动的Swing应用程序

用Java构建远程Framebuffer服务器端协议驱动的Swing应用程序

在本文中,我将展示如何实现RFB服务器端协议,并使用一个小型Java Swing应用程序演示如何通过TCP连接将主窗口传输到VNC查看器. 其思想是演示协议的基本特性以及在Java中可能的实现.

读者应具备Java编程语言的基本知识,并熟悉TCP/IP网络的基本概念, client-server model, etc. Ideally, the reader is a Java developer 具有RealVNC、UltraVNC、TightVNC等知名VNC实现经验.

Remote Framebuffer Protocol Specification

RFB protocol specification is pretty well defined. 根据维基百科,RFB协议有几个版本. For this article, 我们的重点将放在大多数VNC实现(无论协议版本如何)都应该正确理解的常见消息上.

VNC查看器(客户端)与VNC服务器(RFB服务)建立TCP连接之后, 第一阶段涉及协议版本的交换:

RFB Service    -----------  "RFB 003.003\n"  -------> VNC viewer
RFB Service    <----------  "RFB 003.008\n"  -------- VNC viewer

它是一个简单的字节流,可以被解码成 ASCII characters, such as “RFB 003.008\n”.

完成后,下一步是身份验证. VNC服务器发送一个字节数组来指示它支持的身份验证类型. For example:

RFB Service    -----------  0x01 0x02 -----------> VNC viewer
RFB Service    <-----------  0x02  -----------     VNC viewer

这里VNC服务器只发送了1种可能的身份验证类型(0x02). 第一个字节0x01表示可用的身份验证类型的数量. VNC viewer has to reply with value 0x02, 因为这是本例中服务器支持的唯一可能类型.

Next, 服务器将发送身份验证挑战(取决于哪个算法), there are several), 客户端必须响应正确的质询响应消息,并等待服务器确认响应. 一旦客户端通过身份验证,他们就可以继续进行会话建立过程.

这里最简单的方法是选择不进行任何身份验证. 无论采用何种认证机制,RFB协议都是不安全的. 如果安全性很重要,正确的方法是通过VPN或SSH连接建立RFB会话隧道.

At this point, VNC查看器发送共享桌面消息,该消息告诉客户端是否将共享并允许其他VNC查看器连接到相同的桌面. 由RFB服务实现来考虑该消息并可能阻止多个VNC查看器共享同一屏幕. 该消息的长度只有1个字节,有效值为0x00或0x01.

最后,RFB服务器发送一个服务器初始化消息, which contains screen dimension, bits per pixel, depth, big endian flag and true color flags, maximum values for red, green and blue colors, bit positions in pixel for red, green and blue colors, and desktop string/title. 前两个字节表示屏幕宽度(以像素为单位),后两个字节表示屏幕高度. 在屏幕高度字节之后,应该在消息中显示每像素字节的位数. The value is usually 8, 16, or 32. 在大多数具有全彩色范围的现代系统上,每个像素字节的位数值为32 (0x20)。. 它告诉客户端它可以从服务器请求每个像素的全彩色. 只有当像素按大端序排列时,大端字节才是非零的. 如果true color byte非零(true),则接下来的六个字节指定如何提取红色, 绿色和蓝色的颜色强度来自像素值. 接下来的六个字节是像素的红、绿、蓝组件的最大允许值. 这在8位颜色模式中很重要,因为每个颜色组件只有很少的位可用. 红、绿、蓝移位决定了每种颜色的位位置. 最后三个字节是填充,应该被客户端忽略. 在像素格式之后,有一个字节用于定义桌面标题字符串的长度. 桌面标题是任意长度的字节数组中的ASCII编码字符串.

远程Framebuffer服务器-客户端协议:版本交换、身份验证和服务器初始化消息

远程Framebuffer服务器-客户端协议:版本交换、身份验证和服务器初始化消息

服务器初始化消息后,RFB服务从套接字读取客户端消息并解码. There are 6 types of messages:

  • SetPixelFormat
  • SetEncodings
  • FramebufferUpdateRequest
  • KeyEvent
  • PointerEvent
  • ClientCutText

协议文档非常精确,并解释了每条消息. For each message, every byte is explained. For example, server init message:

No of bytesTypeDescription
2U16framebuffer-width
2U16framebuffer-height
16PIXEL_FORMATserver-pixel-format
4U32name-length
name-lengthU8 arrayname-string

Here, PIXEL_FORMAT is:

No of bytesTypeDescription
1U8bits-per-pixel
1U8depth
1U8big-endian-flag
1U8true-colour-flag
2U16red-max
2U16green-max
2U16blue-max
1U8red-shift
1U8green-shift
1U8blue-shift
3padding

U16 means unsigned 16-bit integer (two bytes), U32 is unsigned 32-bit integer, U8 array is array of bytes, etc.

Protocol Implementation in Java

典型的Java服务器应用程序由一个线程组成,该线程侦听客户机连接, 还有几个线程处理客户端连接.

/*
 *以TCP端口5902 (display:2)为例进行监听.
 */
int port = 5902;
ServerSocket serverSocket;
serverSocket = new ServerSocket(port);

/*
 * Limit sessions to 100. This is lazy way, if 
 有人真的打开了100个会话,服务器套接字
 *将停止侦听,没有新的VNC查看器将 
 * 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 (display:2), while循环等待客户端连接. Method ServerSocket.accept() 是阻塞,它使线程等待一个新的客户端连接. Once the client connects, 创建了一个新的线程RFBService来处理从客户端接收到的RFB协议消息.

Class RFBService implements Runnable interface. It’s full of methods to read bytes from socket. Method run() 很重要,它在线程在循环结束时启动时立即执行:

@Override
public void run() {
	
	try {

		/*
		 * RFB服务器必须先发送协议版本字符串.
		 * And wait for VNC viewer to replay with 
* protocol version string.
		 */
		sendProtocolVersion();
		String protocolVer = readProtocolVersion();
		if (!protocolVer.startsWith("RFB")) {
			throw new IOException();
		}

Here method sendProtocolVersion() 发送RFB字符串到客户端(VNC查看器),然后从客户端读取协议版本字符串. 客户应该回复类似“RFB 003.008\n”. Method readProtocolVersion() 当然是阻塞,就像任何名字以read开头的方法一样.

readProtocolVersion()抛出IOException {
	byte[] buffer = readU8Array(12);
	return new String(buffer);
}

readProtocolVersion()方法很简单:它从socket读取12个字节,并返回一个字符串值. readU8Array(int)读取指定字节数,在本例中为12字节. 如果套接字上没有足够的字节可读,它会等待:

private byte[] readU8Array(int len)抛出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;		
}

Similar to readU8Array(int), methods readU16int() and readU32int() 存在从套接字读取字节并返回整数值.

发送协议版本并读取响应后, RFB service should send security message:

/*
 * RFB服务器发送可能请求的安全类型字节 
 * a user to type password.
 *在这个实现中,设置为simples 
 * possible option: no authentication at all.
 */
sendSecurityType();

在这个实现中,选择了最简单的方法:不需要VNC客户端提供任何密码.

sendSecurityType()抛出IOException {
	out.write(SECURITY_TYPE);
	out.flush();
}

where SECURITY_TYPE is byte array:

private final byte[] SECURITY_TYPE = {0x00, 0x00, 0x00, 0x01};

This array of bytes by RFB protocol version 3.3表示VNC查看器不需要发送任何密码.

接下来RFB服务应该从客户端获得的是共享桌面标志. It’s one byte on socket.

/*
 * RFB server reads shared desktop flag. It's a single 
 * byte that tells RFB server
 *是否支持多个VNC查看器连接在 
 * same time or not. 
 */
byte sharedDesktop = readSharedDesktop();

一旦从套接字读取共享桌面标志,我们在实现中忽略它.

RFB service has to send server init message:

/*
 * RFB服务器发送的ServerInit消息包括 
 * 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服务线程通过从套接字读取六种类型的消息来循环:

/*
 *从套接字读取客户端消息的主循环.
 */
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();
	
	/*
	 根据消息类型,读取套接字上的完整消息.
	 */
	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);
	}
}

Each method readSetPixelFormat(), readSetEncoding(), readFrameBufferUpdateRequest(), … readClientCutText() is blocking and triggers some action.

For example, readClientCutText() 方法读取在消息中编码的文本,当用户在客户端切割文本,然后VNC查看器通过RFB协议发送文本到服务器. Text is then placed on server-side in Clipboard.

Client Messages

所有六个消息都必须由RFB服务支持, 至少在字节级别:当客户端发送消息时, a full byte length has to be read. 这是因为RFB协议是面向字节的,两条消息之间没有边界.

最重要的消息是帧缓冲区更新请求. 客户可要求全面更新或逐步更新屏幕.

readframebufferedaterequest()抛出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,宽度,高度,0,RobotScreen.robo.getColorImageBuffer());					
			
			
		}
		else if (incremental == 0x01) {
			
			incrementalFrameBufferUpdate = true;
			
		}
		else {
			throw new IOException();
		}
	}
	else {
		throw new IOException();
	}

}

帧缓冲区请求消息的第一个字节是消息类型. Value is always 0x03. 下一个字节是增量标志,它告诉服务器发送完整帧或只是一个差异. In case of full update request, RFB服务将使用RobotScreen类截取主窗口的截图并将其发送给客户端.

If it is incremental request, a flag incrementalFrameBufferUpdate will be set to true. Swing组件将使用此标志来检查它们是否需要发送已更改的屏幕部分. Usually JMenu, JMenuItem, JTextArea, etc. 当用户移动鼠标指针,点击,发送击键等时,需要对屏幕进行增量更新.

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();
}

Method checks that (x, Y)坐标不要与图像缓冲区的宽度x高度一起离开屏幕. 帧缓冲区更新的消息类型值为0x00. 填充值通常为0x00,应该被VNC查看器忽略. 矩形数是两个字节值,定义了消息中有多少个矩形.

每个矩形具有左上角坐标、宽度和高度、编码类型和像素数据. 有一些有效的编码格式可以使用,如zrle、hextile和tight. However, to keep things simple and easy to understand, we will use raw encoding in our implementation.

原始编码是指像素颜色作为RGB分量传输. 如果客户端设置像素编码为32位,则每个像素传输4个字节. 如果客户端使用8位颜色模式,则每个像素作为1字节传输. Code is shown in for-loop. 注意,对于8位模式,颜色映射用于从截图/图像缓冲区中找到每个像素的最佳匹配. For 32-bit pixel mode, image buffer contains array of integers, each value has RGB components multiplexed.

Swing Demo Application

Swing演示应用程序包含触发动作监听器 sendFrameBufferUpdate(int, int, int, int, int[]) method. Usually application elements, like Swing components, 应该有监听器并将屏幕更改发送给客户端吗. 例如,当用户在JTextArea中键入某些内容时,应该将其传输到VNC查看器.

public void actionPerformed(ActionEvent arg0) {

	/*
	 *获取JFrame主窗口的尺寸和位置.
	 */
	int offsetX = JFrameMainWindow.jFrameMainWindow.getX();
	int offsetY = JFrameMainWindow.jFrameMainWindow.getY();

	int width  = JFrameMainWindow.jFrameMainWindow.getWidth();
	int height = JFrameMainWindow.jFrameMainWindow.getHeight();

	/*
	 *不要更新屏幕,如果主窗口的尺寸已经改变.
	 *在主窗口调整大小,另一个动作监听器将
	 * 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 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 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的屏幕截图, 然后确定是否需要部分更新屏幕. Variable diffUpdateOfScreen is used as flag for partial update. 最后将完整的图像缓冲区或仅将不同的行传输到客户端. This code also considers more clients connected, 这就是为什么要使用迭代器并在中维护客户端列表 RFBDemo.rfbClientList member.

Framebuffer更新动作监听器可以在Timer中使用,它可以通过任何JComponent的改变来启动:

/*
 为帧缓冲区更新定义定时器,延迟400毫秒 
* 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()方法:

公共类DocumentListenerChange实现DocumentListener

	@Override
	public void changedUpdate(DocumentEvent e) {
		JFrameMainWindow = JFrameMainWindow.jFrameMainWindow;
		jFrameMainWindow.doIncrementalFrameBufferUpdate();		
	}

	// ...

}

This way should be simple and easy to follow. 只需要对JFrameMainWindow实例的引用和对 doIncrementalFrameBufferUpdate() method. 方法将检查是否有客户端连接,如果有,则检查计时器 timerUpdateFrameBuffer will be started. 一旦计时器启动,动作监听器将实际截取屏幕截图和 sendFrameBufferUpdate() is executed.

上图显示了监听器与帧缓冲区更新过程的关系. 大多数侦听器是在用户执行操作:单击时触发的, selects text, types something in text area, etc. Then member function doIncrementalFramebufferUpdate() is executed which starts the timer timerUpdateFrameBuffer. The timer will eventually call sendFrameBufferUpdate() 方法,它将导致客户端(VNC查看器)的屏幕更新。.

捕捉屏幕,播放击键和移动鼠标指针在屏幕上

Java有一个内置的Robot类,它使开发人员能够编写抓取屏幕截图的应用程序, send keys, manipulate mouse pointer, produce clicks, etc.

为了抓取显示JFrame窗口的屏幕区域,使用了RobotScreen. Main method is getScreenshot(int, int, int, int) which captures a region of screen. 每个像素的RGB值存储在int[]数组中:

   	public void getscreen (int x, int y, int width, int height) {
         	
         	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;
   		
   	}

Method stores pixels in colorImageBuffer array. To get pixel data, getColorImageBuffer() method can be used.

Method also saves previous image buffer. 有可能只得到已经改变的像素. 为了得到图像面积的唯一差值,使用方法 getDeltaImageBuffer().

发送按键到系统是很容易的机器人类. 但是,必须首先正确翻译从VNC查看器接收到的一些特殊密钥代码. Class RobotKeyboard has method 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) 将键值传递给机器人,效果与本地用户敲击键盘上的键相同:

private void doType(int keyCode, int state) {
   	if (state == 0) {
	  	robot.keyRelease(keyCode);
	}
	else {
		robot.keyPress(keyCode);
	}
}

与RobotKeyboard类似的是处理指针事件的RobotMouse类, and causes mouse pointer to move and click.

public void mouseMove(int x, int y) {
	robot.mouseMove(x, y);
}

All three classes RobotScreen, RobotMouse, 和RobotKeyboard在构造函数中分配新的Robot实例:

this.robot = new Robot();

We have only one instance of each, 因为在应用程序级别上不需要有多个RobotScreen实例, RobotMouse, or RobotKeyboard class.

public static void main(String[] args) {
	...
	/*
	*为屏幕、键盘和鼠标初始化静态机器人对象.
	*/
	 RobotScreen.robo = new RobotScreen();
	 RobotKeyboard.robo = new RobotKeyboard();
	 RobotMouse.robo = new RobotMouse();
	 ...
}	

在这个演示应用程序中,这些实例是用 main() function.

The result, 是一个基于Java的Swing应用程序,它作为RFB服务提供者,并允许标准VNC查看器连接到它:

Conclusion

RFB protocol is widely used and accepted. VNC查看器形式的客户机实现几乎适用于所有平台和设备. 其主要目的是远程显示桌面,但也可以有其他应用程序. For example, 您可以创建漂亮的图形工具,并远程访问它们,以增强您现有的 remote workflows.

This article covers the basics of RFB protocol, message format, how to send part of screen, and how to deal with keyboard and mouse. 完整的源代码与Swing演示应用程序是 available on GitHub.

Hire a Toptal expert on this topic.
Hire Now
Igor Delac's profile image
Igor Delac

Located in Split, Croatia

Member since November 3, 2014

About the author

Igor是一位热情而熟练的专业人士,具有Java开发经验, system administration, and he loves to experiment.

Toptal作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.

Previously At

Ericsson

World-class articles, delivered weekly.

Subscription implies consent to our privacy policy

World-class articles, delivered weekly.

Subscription implies consent to our privacy policy

Toptal Developers

Join the Toptal® community.