前言: 上一篇博客已经提到了我自己买了一个树莓派摄像头,搭建了一个局域网监控系统。但是这个东西不能在外网访问的话,它是实际应用价值就很低了。因为当时选择的钉钉内网穿透方式不能使用(指令集不兼容),所以我就暂时放弃了,转而思考了使用自己的网络编程知识来模拟实现一下内网穿透的效果。然后,我的同学给我提出了建议,要是可以使用流媒体就更好了(实际上我也不会流媒体,不过我倒是有一点兴趣)。我于是在当时的代码基础上,又进行了改进,专门针对motion实现的局域网监控方式,重新写了一个代码,希望可以实现外网访问motion中的内网。
一、项目
1.演示视频
内网穿透实现基于树莓派的网络摄像头
2.代码
代码已经上传到了github,通过下面链接可以访问到。
raspberry_monitor
二、结构设计
说明:这里我的PC部分可以不需要,因为我是在PC上运行的客户端代码,所以就捎带上了。
三、代码设计
设计思路
主要设计思路参考上一篇博客:黑盒方式模拟实现内网穿透
这里只做一个简要介绍了:
在阿里云和树莓派之间维持两个长连接,其中一个用来进行通信控制,另一个用来进行数据传输。对于访问阿里云的请求 ,通过该通信控制长连接进行中转,将请求发给树莓派,同时将响应数据通过数据传输长连接返回给阿里云服务器,再由阿里云服务器响应给用户。
内网穿透思路
这里将控制通信的长连接称为中间人,进行数据传输的长连接称为管道。并且由中间人控制管道的开启和关闭。由于管道只有一条,所以这里只能允许一个用户进行访问。对于其它的用户,要禁止其进行访问。因为多个中间人和管道设计比较麻烦(我不会,哈哈!),我这里只能实现一个中间人和一个管道的方式,并且多个用户访问,对树莓派和服务器的压力都比较大,也不是很适合(这是一个借口,主要还是能力不够)。
启动方式
1.首先启动服务器,注意此时不能访问服务器,否则项目无法工作。
2.然后启动客户端,客户端会立刻和服务器建立上面说的两个连接,等待服务器打印出建立成功的消息后,用户才可以进行正常访问。
注意:之所以必须严格这种顺序是因为我这里其实是混合使用了几种协议(长连接算是自己自定义的协议),但是我并没有去分辨这几种协议,只是默认先连接的是树莓派和服务器的长连接,后连接的才是和服务器的HTTP连接。总之,就是因为太复杂了,所以我使用了简单的方式,毕竟能运行才是真道理!
四、代码实现
工程目录
服务器端代码
NetServer
package org.dragon;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class NetServer {
private static final int THREAD = 3;
public static void main(String[] args) {
int port = 10000; // 默认端口号
try {
port = Integer.parseInt(args[0]);
} catch (Exception e) {
// e1.printStackTrace();
}
System.out.println("服务器启动中,占用端口为:" + port);
ExecutorService threadPool = Executors.newFixedThreadPool(THREAD);
Lock lock = new ReentrantLock();
try (ServerSocket server = new ServerSocket(port)) {
// 传输数据的管道
Socket pipeline = server.accept();
System.out.println("数据传输管道建立成功。。。");
// 控制管道的中间人
Socket middleman = server.accept();
System.out.println("中间人建立成功。。。");
System.out.println("系统启动。。。");
// 下面是作为一个Http服务器的部分了。
while (true) {
Socket client = server.accept();
System.out.println("一个请求进入系统。。。" + client);
threadPool.submit(new UserConnection(client,
new PipelineServer(pipeline),
new MiddlemanServer(middleman),
lock));
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
MiddlemanServer
package org.dragon;
import java.io.BufferedOutputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;
/**
* 用于协调双方通信的中间人
* */
public class MiddlemanServer {
private DataOutputStream output;
public MiddlemanServer(Socket middleman) throws IOException {
this.output = new DataOutputStream(new BufferedOutputStream(middleman.getOutputStream()));
}
/**
* 建立一个连接
* @throws IOException
* */
public void open() throws IOException {
output.writeChar('Y');
output.flush();
}
/**
* 终止一个连接
* @throws IOException
* */
public void terminate() throws IOException {
output.writeChar('N');
output.flush();
}
}
PipelineServer
package org.dragon;
import java.io.BufferedInputStream;
import java.io.DataInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
public class PipelineServer {
private byte[] responseHeader = ("HTTP/1.0 200 OK\r\n" +
"Server: Motion/4.1.1\r\n" +
"Connection: close\r\n" +
"Max-Age: 0\r\n" +
"Expires: 0\r\n" +
"Cache-Control: no-cache, private\r\n" +
"Pragma: no-cache\r\n" +
"Content-Type: multipart/x-mixed-replace; boundary=BoundaryString" +
"\r\n\r\n") // 注意拼接最后的两个分隔符。
.getBytes(StandardCharsets.UTF_8);
/**
* 内网客户端的输入
* */
private DataInputStream input;
public PipelineServer(Socket socket) throws IOException {
input = new DataInputStream(new BufferedInputStream(socket.getInputStream()));
}
/**
* 同步方法
* 将内网服务的输出,转发给外网服务器作为其输入。
* @throws IOException
* */
synchronized public void transfer(OutputStream output) throws IOException {
// 因为我客户端抓取的是HTTP的报文,所以报头被过滤了,只有数据体部分了
// 但是我是直接响应客户端的请求,因此需要额外补充一个报文头
output.write(responseHeader);
// 持续传输数据(数据体部分)
while (true) {
byte[] data = new byte[5*1024];
input.readFully(data); // 每次从内网穿透客户端读取数据大小为5KB
output.write(data); // 转发数据包到外网客户端
output.flush(); // 手动刷新缓存
}
}
/**
* 清空管道的方法
* 客户端输出端,清空输出缓存;服务端输入端,清空输入缓存。
* */
public void clear() throws IOException {
byte[] data = new byte[10*1024]; // 设置一个尽量大一点的数组,然后一次性读取,清空缓冲区,不知道是否可以成功。
int len = input.read(data); // 读取之后,不做处理,即丢弃,目的是为了清空管道。
System.out.println("执行清空管道输入端的命令,读取大小为:" + len + " 字节");
}
}
UserConnection
package org.dragon;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.locks.Lock;
public class UserConnection implements Runnable {
public Socket client;
private InputStream input;
private OutputStream output;
private PipelineServer pipeline;
private MiddlemanServer middleman;
private Lock lock; // 同步锁
// 信道被占用时,直接返回该报文
private byte[] errorResponse = ("HTTP/1.1 444\r\n" +
"Content-Type: text/html;charset=UTF-8\r\n" +
"Content-Length: 48\r\n" +
"Connection: close\r\n" +
"\r\n" +
"<h1>当前信道被占用,无法访问!</h1>").getBytes(StandardCharsets.UTF_8);
// 信道被占用时,直接返回该报文
private byte[] unknownResponse = ("HTTP/1.1 404\r\n" +
"Content-Type: text/html;charset=UTF-8\r\n" +
"Content-Length: 36\r\n" +
"Connection: close\r\n" +
"\r\n" +
"<h1>这里是未知的世界!</h1>").getBytes(StandardCharsets.UTF_8);
public UserConnection(Socket client, PipelineServer pipeline, MiddlemanServer middleman, Lock lock) throws IOException {
this.client = client;
input = new BufferedInputStream(client.getInputStream());
output = new BufferedOutputStream(client.getOutputStream());
this.pipeline = pipeline;
this.middleman = middleman;
this.lock = lock;
}
@Override
public void run() {
execute();
}
/**
* 用于接力传输数据的方法
* */
public void execute() {
// 只读取请求头
try {
String[] params = getRequestLine(input);
if (params.length != 3) {
throw new Exception("错误请求!");
}
System.out.println("Request method: " + params[0] +
" Reqeust path: " + params[1] +
" Protocol version: " + params[2]);
byte[] response = null;
// 只处理对于根路径的访问,其它的拒绝掉
if ("/".equals(params[1])) {
System.out.println("准备获取同步锁");
if (lock.tryLock()) { // 尝试获取同步锁,获取不到则返回信道被占用。
try {
System.out.println("获取到同步锁");
// 请求客户端建立连接
middleman.open();
// 开始数据接力
pipeline.transfer(output);
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock(); // 释放同步锁
// 如果锁被释放了,说明用户结束了页面。
middleman.terminate();
Thread.sleep(2000); // 暂停两秒
pipeline.clear();
System.out.println("释放同步锁");
}
} else {
System.out.println("没有获取到同步锁,信道被占用");
response = errorResponse;
output.write(response);
output.close(); // 正常关闭该连接
}
} else {
response = unknownResponse;
output.write(response);
output.close(); // 正常关闭该连接
}
System.out.println("执行结束。。。");
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
if (client != null) {
client.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
private String[] getRequestLine(InputStream in) throws IOException {
// 只读取第一行,这是我们需要的全部内容
StringBuilder requestBuilder = new StringBuilder();
while (true) {
int c = in.read();
if (c == '\r' || c == '\n' || c == -1) break;
requestBuilder.append((char)c);
}
String requestLine = requestBuilder.toString();
return requestLine.split(" ");
}
}
客户端代码
NetClient
package org.dragon;
import java.io.IOException;
import java.net.Socket;
import java.net.UnknownHostException;
public class NetClient {
private final static String REMOTE_HOST = "127.0.0.1";
private final static String LOCAL_HOST = "192.168.187.144";
private final static int REMOTE_PORT = 10000;
private final static int LOCAL_PORT = 8081;
private final static String URL = "http://" + LOCAL_HOST + ":" + LOCAL_PORT;
public static void main(String[] args) {
try {
// 建立用于协调双方的中间人
PipelineClient pipeline = new PipelineClient(new Socket(REMOTE_HOST, REMOTE_PORT));
MiddlemanClient middleman = new MiddlemanClient(new Socket(REMOTE_HOST, REMOTE_PORT), pipeline, URL);
Thread client = new Thread(middleman);
client.start(); // 启动中间人线程。
} catch (UnknownHostException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
}
**MiddlemanClient **
package org.dragon;
import java.io.BufferedInputStream;
import java.io.DataInputStream;
import java.io.IOException;
import java.net.Socket;
import java.net.URL;
public class MiddlemanClient implements Runnable {
private DataInputStream input;
private PipelineClient pipeline;
private String urlStr;
public MiddlemanClient(Socket middleman, PipelineClient pipeline, String urlStr) throws IOException {
this.input = new DataInputStream(new BufferedInputStream(middleman.getInputStream()));
this.pipeline = pipeline;
this.urlStr = urlStr;
}
@Override
public void run() {
System.out.println("客户端启动了。。。");
while (true) { // 中间人需要一直保持同服务器端的通信。
try {
this.receive();
} catch (IOException e) {
e.printStackTrace();
}
}
}
public void receive() throws IOException {
char ch = input.readChar();
System.out.println("接收到服务端发送的命令:" + ch);
if (ch == 'Y') {
pipeline.setFlag(true);
new Thread(()-> {
try {
execute();
} catch (IOException e) {
e.printStackTrace();
}
}).start();
} else if (ch == 'N') {
pipeline.setFlag(false);
} else {
// 不做处理
}
}
public void execute() throws IOException {
// 建立正常的连接请求,获取到内网服务器的响应数据
URL url = new URL(urlStr);
DataInputStream httpInput = new DataInputStream(new BufferedInputStream(url.openStream()));
pipeline.transfer(httpInput);
}
}
PipelineClient
package org.dragon;
import java.io.BufferedOutputStream;
import java.io.DataInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
/**
* 用于接力数据的管道类
*
* 每次只能有一个线程获取到管道类,如果被使用就继续等待或者直接放弃。
* */
public class PipelineClient {
private boolean flag = false; // 标志变量,为true 管道开启,false 管道关闭,默认关闭
private OutputStream output;
public PipelineClient(Socket socket) throws IOException {
output = new BufferedOutputStream(socket.getOutputStream());
}
/**
* 同步方法
* 将内网服务的输出,转发给外网服务器作为其输入。
* @throws IOException
* */
public void transfer(InputStream in) throws IOException {
DataInputStream httpInput = new DataInputStream(in);
// 持续传输数据
while (flag) {
byte[] data = new byte[5*1024];
httpInput.readFully(data); // 每次读取数据大小为5KB
output.write(data); // 转发数据包
output.flush(); // 手动刷新缓存
System.out.println("向服务端发送数据大小为:" + data.length + " 字节。");
}
// 如果管道传输退出的话,就关闭 inputStream 对象,in
in.close();
}
/**
* 清空管道的方法
* 客户端输出端,清空输出缓存;服务端输入端,清空输入缓存。
* */
public void clear() throws IOException {
output.flush();
}
/**
* 设置flag变量的值
* */
public void setFlag(boolean flag) {
this.flag = flag;
if (flag) {
System.out.println("管道开启");
} else {
System.out.println("管道关闭");
try {
this.clear();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
五、总结
实现这个东西还是需要很多知识的,与别人的交流也很重要。当然了,最重要的是扎实的工程实现能力,我发现自己在这一块还是很薄弱,很多东西有了想法都无法实现下去。学习的过程中,还是要注重多实践才行。例如,我在这里实现只有一个用户可以访问,使用了一个同步锁。这是我第一次在实际的代码中使用同步锁来解决问题,以前只是照着书本上的案例,感觉效果不是很好。因为顺着别人的思路总是很容易走下去的,当自己思考时,却不一定可以很快想出来。多线程和网络编程这一块,确实是需要加强学习,特别是多线程通信这一块,感觉都不怎么会,线程同步学习了很久也就偶尔使用一个synchronized
关键字。
所以,下一步需要加强学习,同时注重理论和实际的结合。这个小的项目,所有代码都是自己完成的,也确实是解决了一个问题,这种从想法到实践的过程让我得到了很多的收获。