文章目录
- 一、介绍
- 二、程序设计第一步:创建客户端远程文件对话程序
- 三、程序设计第二步:创建客户端数据传送进程FileDataClient.java
- 四、程序设计第三步:文件对话服务器程序FileDialogServer.java
- 五、程序设计第四步:文件数据服务器程序FileDataServer.java
- 扩展一
- 扩展二
- FileDataClient.java中的getFile方法代码
- 文件对话服务器程序中的文件列表服务模块(供参考)
- 文件数据服务器程序部分代码
- 项目结构
- 完整代码
- chapter04/client/FileClientFX.java
- chapter04/client/FileDataClient.java
- chapter04/client/FileDialogClient.java
教学与实践目的:学会基本的文件传输FTP程序设计技术
一、介绍
程序设计知识点:字节流(网络字节流和文件字节流)的读写技术。
前两讲我们学会了使用TCP套接字(Socket),能实现字符串的发送和接收功能,简单地做到了客户机和服务器的对话。
这次课,我们进一步学习TCP套接字,利用它的字节传输技术,实现网络文件传输。
文件传输协议规定(RFC 959),网络文件传输中用两个TCP端口来实现:
- 一个端口(21号)用来对话,传递控制信息,总是开启;
- 一个端口(20号)实现文件数据传递服务,有数据传输服务时开启。
网络对话和网络文件传输,使用TCP的socket编程,本质还是一样,对话过程,使用字符流来包装;而网络文件传输过程,则应该使用字节流来进行处理。
本讲实现一个简单的远程文件传输系统,实现基本的下载功能。
我们用2021端口实现对话服务,如身份验证、文件列表信息浏览等,用2020端口传递数据文件(即文件下载)。
基于C/S的主要程序结构如下:
客户端程序:
- 主界面客户端程序FileClientFX.java;
- 文件对话客户端程序(控制进程)FileDialogClient.java;
- 文件数据客户端程序(数据传输进程)FileDataClient.java。
服务端程序:
- 文件对话服务器程序FileDialogServer.java,开启2021端口;
主要功能:身份验证、文件目录传送。 - 文件数据服务器程序FileDataServer.java,开启2020端口。
主要功能:传送文件名,接收文件。
二、程序设计第一步:创建客户端远程文件对话程序
该客户端程序主要功能:发送用户信息、实现和文件服务器的基本对话,文件浏览和下载。
注意:客户端发送信息后,能够看到什么内容,如何交互,这就和服务端的程序编写有关,客户端无法控制。例如,这一讲服务器就提供了文件列表浏览、传输文件等功能,也就有了更多约定:发送dir 显示文件列表、发送 help显示帮助信息、bye表示退出;发送文件名,服务器会回馈文件名是否正确、能否下载,再根据提示使用“下载”按钮完成下载动作。
- 新建一个程序包:chapter04,这个包下面再创建client和server两个程序包,本讲所有的客户端程序都创建在client包中,如图4.3所示:
- 发送用户信息等功能和之前学习的程序要求基本一致,可以将上一讲的TCPClient.java程序借用过来,复制、重构命名为FileDialogClient.java,原有的方法保持不变。
- 创建FileClientFX窗体程序。该窗体界面如图4.4所示。
(1)程序界面和之前的任务大致相似,同样策略,可以复制、重构上一讲的TCPClientThreadFX程序,重命名为FileClientFX,并在代码中增加“下载”按钮。
注意,使用idea重构此类以及前面的类,可能会提示有多处需要重构重命名,这是idea比较智能的地方,因为窗体类多处使用了TCPClient,这个类重命名后,窗体中对应的变量名也需要修改,这样比自己手动修改方便很多(重构时会将TCPClient的调用改为FileDialogClient的调用,TCPClient类型的实例变量名也会自动重命名为fileDialogClient,如果有报错,说明有地方没有修改完全,则自行仔细检查,手动完成);
(2)在程序中增加ip和port两个String类型的成员变量,用来保存对应文本框的输入信息;
三、程序设计第二步:创建客户端数据传送进程FileDataClient.java
主要功能:连接服务器数据端口、发送文件名、保存下载的文件,文件传输完成后关闭数据连接。
该程序有2个方法:
(1)构造方法,FileDataClient(String ip, String port),主要功能是向服务器的数据端口请求连接;
(2)文件下载方法getFile(fileName)[见附录代码]。
该方法主要功能是先在本地新建一个空文件,并向服务器发送其文件名(基于字符串的字节流包装操作),然后接收网络文件数据并保存为本地的这个文件(基于文件的字节流包装操作),关闭数据套接字。
可以在窗体界面的“下载”按钮中调用getFile方法,例如:
btnDownload.setOnAction(event -> {
if(tfSend.getText().equals("")) //没有输入文件名则返回
return;
String fName = tfSend.getText().trim();
tfSend.clear();
FileChooser fileChooser = new FileChooser();
fileChooser.setInitialFileName(fName);
File saveFile = fileChooser.showSaveDialog(null);
if (saveFile == null) {
return;//用户放弃操作则返回
}
try {
//数据端口是2020
new FileDataClient(ip, "2020").getFile(saveFile);
Alert alert = new Alert(Alert.AlertType.INFORMATION);
alert.setContentText(saveFile.getName() + " 下载完毕!");
alert.showAndWait();
//通知服务器已经完成了下载动作,不发送的话,服务器不能提供有效反馈信息
fileDialogClient.send("客户端开启下载");
} catch (IOException e) {
e.printStackTrace();
}
});
注意,若下载文件为0字节,首先就要检查是否做了flush和关闭文件的操作。一些逻辑判断、ip地址、端口号等各种错误都可能造成错误,这种情况就善用idea的断点调试功能,慢慢观察各个变量值,找出bug。另外,为了避免不小心带入空白符,建议程序中获取文本信息都使用trim()方法消除空白。
下载完成后,第20行代码给服务器发送了“客户端开启下载”,这就类似bye一样都是服务端和客户端的约定,服务端的代码已经对这个字符串做了判断,接收到这个字符串就知道客户端开始了下载,这样就可以给客户端发送一些反馈信息。
四、程序设计第三步:文件对话服务器程序FileDialogServer.java
文件对话服务器程序开启2021端口,用来传送双方的对话信息,如文件列表等信息,本质上和第二讲的TCPServer类似,但要额外提供发送文件列表的功能,而且服务器要使用多线程模式,本讲不强制要求同学制作该程序(该程序运行在教师机上,同学们也可自行制作类似的服务器,提升程序设计能力),附录中有部分代码。
五、程序设计第四步:文件数据服务器程序FileDataServer.java
文件数据服务器程序专门提供文件下载服务,开启2020端口,提供文件下载服务,不强制要求同学制作该程序(该程序运行在教师机上,同学们也可自行制作类似的服务器,提升程序设计能力),附录中有部分下载文件的代码,和客户端的getFile方法配合使用提供下载功能。
请认真对照客户端getFile方法和下载文件的代码,思考文件下载和之前章节的网络对话有本质的区别吗?
扩展一
增加一个便捷功能:鼠标拖动加亮信息显示区的文字,拖动的同时把加亮的文字同步复制到信息输入框中,这个功能是为了方便下载文件(实现点击下载按钮则读取信息输入框中的文件名,并下载该文件,手动输入显然不如鼠标操作方便);
这个功能的实现比较特别,这种情况一般会处理鼠标拖动事件来解决(mouse dragged),例如,把文本框TextField的内容复制到文本域TextArea,只要setOnMouseDragged 处理鼠标拖动事件就可以。但从TextArea复制内容到TextField,这种方式却不适用。文本域是多行文本,鼠标拖动加亮文字不会触发鼠标拖动事件。解决方案是为文本域的选择属性selectionProperty添加监听器(相当于是为输入文本框和显示文本域做了有条件的属性绑定):
//信息显示区鼠标拖动高亮文字直接复制到信息输入框,方便选择文件名
//taDispaly为信息选择区的TextArea,tfSend为信息输入区的TextField
//为taDisplay的选择范围属性添加监听器,当该属性值变化(选择文字时),会触发监听器中的代码
taDisplay.selectionProperty().addListener((observable, oldValue, newValue) -> {
//只有当鼠标拖动选中了文字才复制内容
if(!taDisplay.getSelectedText().equals(""))
tfSend.setText(taDisplay.getSelectedText());
});
扩展二
对于新开线程,使用lambda表达式比较方便,但如果新线程的代码较多,则新线程中的任务和其它代码混在一起比较混乱,不方便阅读和查错(例如连接按钮中新开线程用于读取信息的相关代码)。
设计一个成员内部类ReceiveHandler,用于封装新线程读取信息的功能,类似:
class ReceiveHandler implements Runnable{……}
连接按钮中新开线程的代码就可以简写为:
//用于接收服务器信息的单独线程
receiveThread = new Thread(new ReceiveHandler(), "my-receiveThread");
这样代码就比较清晰易读。其它部分代码大家也可以优化,例如下载按钮动作事件的代码也较多,也可以封装为一个方法,再在下载按钮动作事件处理中调用。
FileDataClient.java中的getFile方法代码
public void getFile(File saveFile) throws IOException {
if (dataSocket != null) { // dataSocket是Socket类型的成员变量
FileOutputStream fileOut = new FileOutputStream(saveFile);//新建本地空文件
byte[] buf = new byte[1024]; // 用来缓存接收的字节数据
//网络字节输入流
InputStream socketIn = dataSocket.getInputStream();
//网络字节输出流
OutputStream socketOut = dataSocket.getOutputStream();
//(2)向服务器发送请求的文件名,字符串读写功能
PrintWriter pw = new PrintWriter(new OutputStreamWriter(socketOut, "utf-8"), true);
pw.println(saveFile.getName());
//(3)接收服务器的数据文件,字节读写功能
int size = 0;
while ((size = socketIn.read(buf)) != -1) {//读一块到缓存,读取结束返回-1
fileOut.write(buf, 0, size); //写一块到文件
}
fileOut.flush();//关闭前将缓存的数据全部推出
//文件传输完毕,关闭流
fileOut.close();
if (dataSocket != null) {
dataSocket.close();
}
} else {
System.err.println("连接ftp数据服务器失败");
}
}
文件对话服务器程序中的文件列表服务模块(供参考)
public void fileListPushToClient(PrintWriter pw) {
String path = "d:/ftpserver"; //给出服务器下载目录路径
File filePath = new File(path);
if (!filePath.exists()) { //路径不存在则返回
System.out.println("ftp下载目录不存在");
return;
}
//如果不是一个目录就返回
if (!filePath.isDirectory()) {
System.out.println("不是一个目录");
return;
}
//开始显示目录下的文件,不包括子目录
fileNames = filePath.list();
File tempFile;
// 格式化文件大小输出,不保留小数,不用四舍五入,有小数位就进1
DecimalFormat formater = new DecimalFormat();
formater.setMaximumFractionDigits(0);
formater.setRoundingMode(RoundingMode.CEILING);
for (String fileName : fileNames) {
tempFile = new File(filePath + "/" + fileName);
if (tempFile.isFile()) {
pw.println(" " +
fileName + " " + formater.format(tempFile.length() / 1024.0) + "KB");
}
}
}
文件数据服务器程序部分代码
try {
InputStream socketIn = socket.getInputStream();
BufferedReader br = new BufferedReader(new InputStreamReader(socketIn, "utf-8"));
String downFileName = br.readLine().trim();
System.out.println("要下载的文件为:" + downFileName);
String filePath = "d:/ftpserver";
if (downFileName == null || !isValidFileName(downFileName, filePath)) {
socket.close(); //文件名无效,关闭连接
return;
}
downFileName = filePath + "/" + downFileName;
//读取ftp服务器上的文件,写出到网络字节流
OutputStream socketOut = socket.getOutputStream();
FileInputStream fileIn = new FileInputStream(downFileName);
byte[] buf = new byte[1024]; //用来缓存字节数据
int size;
while ((size = fileIn.read(buf)) != -1) { //读取结束返回-1
socketOut.write(buf, 0, size);
}
socketOut.flush();
socketOut.close();
fileIn.close();
System.out.println(downFileName + " 文件传输结束");
} catch (IOException e) {
e.printStackTrace();
} finally {
if (socket != null) {
try {
socket.close(); //关闭socket及其关联的输入输出流
} catch (IOException e) {
e.printStackTrace();
}
}
}
项目结构
完整代码
chapter04/client/FileClientFX.java
package chapter04.client;
import chapter01.TextFileIO;
//import chapter03.TCPClient;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import java.io.File;
import java.io.IOException;
/**
* @projectName: NetworkApp
* @package: chapter02
* @className: TCPClientFX
* @author: GCT
* @description: TODO
* @date: 2022/9/4 18:08
* @version: 1.0
*/
public class FileClientFX extends Application {
private Button btnExit = new Button("退出");
private Button btnSend = new Button("发送");
private Button btnConnect = new Button("连接");
private Button btnDownload = new Button("下载");
Thread receiveThread; //定义成员变量,读取服务器信息的线程
// private Button btnOpen = new Button("加载");
// private Button btnSave = new Button("保存");
//待发送信息的文本框
private TextField tfip = new TextField();
private TextField tfport = new TextField();
private TextField tfSend = new TextField();
//显示信息的文本区域
private TextArea taDisplay = new TextArea();
// private TCPClient tcpClient;
private String ip;
private String port;
private FileDialogClient fileDialogClient;
public static void main(String[] args) {
launch(args);
}
@Override
public void start(Stage primaryStage) {
// 将TextFileIO类实例化为textFileIO
TextFileIO textFileIO = new TextFileIO();
BorderPane mainPane = new BorderPane();
// 顶部的ip和端口输入框区域
HBox topHBox = new HBox();
topHBox.setSpacing(10);
topHBox.setPadding(new Insets(10,20,10,20));
topHBox.setAlignment(Pos.CENTER);
topHBox.getChildren().addAll(new Label("IP: "),tfip,new Label("端口号:"),tfport,btnConnect);
mainPane.setTop(topHBox);
//内容显示区域
VBox vBox = new VBox();
vBox.setSpacing(10);//各控件之间的间隔
//VBox面板中的内容距离四周的留空区域
vBox.setPadding(new Insets(10,20,10,20));
vBox.getChildren().addAll(new Label("信息显示区:"),
taDisplay,new Label("信息输入区:"), tfSend);
//设置显示信息区的文本区域可以纵向自动扩充范围
VBox.setVgrow(taDisplay, Priority.ALWAYS);
mainPane.setCenter(vBox);
//底部按钮区域
HBox hBox = new HBox();
hBox.setSpacing(10);
hBox.setPadding(new Insets(10,20,10,20));
hBox.setAlignment(Pos.CENTER_RIGHT);
// hBox.getChildren().addAll(btnSend,btnSave,btnOpen,btnExit);
hBox.getChildren().addAll(btnSend,btnDownload,btnExit);
mainPane.setBottom(hBox);
Scene scene = new Scene(mainPane,700,400);
primaryStage.setScene(scene);
primaryStage.show();
//……
--------事件处理代码部分--------
//……
// 连接
btnConnect.setOnAction(event -> {
ip = tfip.getText().trim();
port = tfport.getText().trim();
try {
//tcpClient不是局部变量,是本程序定义的一个TCPClient类型的成员变量
fileDialogClient = new FileDialogClient(ip,port);
//成功连接服务器,接收服务器发来的第一条欢迎信息
// String firstMsg = tcpClient.receive();
// taDisplay.appendText(firstMsg + "\n");
//用于接收服务器信息的单独线程
receiveThread = new Thread(()->{
String msg = null;
//不知道服务器有多少回传信息,就持续不断接收
//由于在另外一个线程,不会阻塞主线程的正常运行
while ((msg = fileDialogClient.receive()) != null) {
//runLater中的lambda表达式不能直接访问外部非final类型局部变量
//所以这里使用了一个临时常量,可以省略final,但本质还是会作为常量使用
final String msgTemp = msg; //msgTemp实质是final类型
Platform.runLater(()->{
taDisplay.appendText( msgTemp + "\n");
});
}
//跳出了循环,说明服务器已关闭,读取为null,提示对话关闭
Platform.runLater(()->{
taDisplay.appendText("对话已关闭!\n" );
});
}, "my-readServerThread"); //给新线程取别名,方便识别
receiveThread.start(); //启动线程
} catch (Exception e) {
taDisplay.appendText("服务器连接失败!" + e.getMessage() + "\n");
}
});
// 退出
btnExit.setOnAction(event -> {
// if(tcpClient != null){
// //向服务器发送关闭连接的约定信息
// tcpClient.send("bye");
// tcpClient.close();
// }
// System.exit(0);
endSystem();
});
// 设置taDisplay自动换行
taDisplay.setWrapText(true);
// 设置taDisplay只读
taDisplay.setEditable(false);
// 退出按钮事件
// btnExit.setOnAction(event -> {System.exit(0);});
btnExit.setOnAction(event -> endSystem());
// 发送按钮事件
btnSend.setOnAction(event -> {
String sendMsg = tfSend.getText();
if (sendMsg.equals("bye")){
btnConnect.setDisable(false);
btnSend.setDisable(true);
}
//9.20
fileDialogClient.send(sendMsg);//向服务器发送一串字符
// tcpClient.send(sendMsg);//向服务器发送一串字符
taDisplay.appendText("客户端发送:" + sendMsg + "\n");
// String receiveMsg = tcpClient.receive();//从服务器接收一行字符
// taDisplay.appendText(receiveMsg + "\n");
});
taDisplay.selectionProperty().addListener((observable,oldValue,newValue)->{
if(!taDisplay.getSelectedText().equals(""))
tfSend.setText(taDisplay.getSelectedText());
});
// 下载事件
btnDownload.setOnAction(event -> {
if(tfSend.getText().equals("")) //没有输入文件名则返回
return;
String fName = tfSend.getText().trim();
tfSend.clear();
FileChooser fileChooser = new FileChooser();
fileChooser.setInitialFileName(fName);
File saveFile = fileChooser.showSaveDialog(null);
if (saveFile == null) {
return;//用户放弃操作则返回
}
try {
//数据端口是2020
new FileDataClient(ip, "2020").getFile(saveFile);
Alert alert = new Alert(Alert.AlertType.INFORMATION);
alert.setContentText(saveFile.getName() + " 下载完毕!");
alert.showAndWait();
//通知服务器已经完成了下载动作,不发送的话,服务器不能提供有效反馈信息
fileDialogClient.send("客户端开启下载");
} catch (IOException e) {
e.printStackTrace();
}
});
// tfSend.setOnKeyPressed(event -> {
// if (event.getCode() == KeyCode.ENTER){
// if (event.isShiftDown()){
// String msg = tfSend.getText();
// taDisplay.appendText("echo: "+ msg + "\n");
// tfSend.clear();
// }
// else{
// String msg = tfSend.getText();
// taDisplay.appendText(msg + "\n");
// tfSend.clear();
// }
// }
//
// });
// btnSave.setOnAction(event -> {
// //添加当前时间信息进行保存
// textFileIO.append(
// LocalDateTime.now().withNano(0) +" "+ taDisplay.getText());
// });
//
// btnOpen.setOnAction(event -> {
// String msg = textFileIO.load();
// if(msg != null){
// taDisplay.clear();
// taDisplay.setText(msg);
// }
// });
}
private void endSystem(){
if (fileDialogClient!=null){
fileDialogClient.send("bye");
fileDialogClient.close();
}
System.exit(0);
}
}
chapter04/client/FileDataClient.java
package chapter04.client;
import java.io.*;
import java.net.Socket;
/**
* @projectName: NetworkApp
* @package: chapter04.client
* @className: FileDataClient
* @author: GCT
* @description: TODO
* @date: 2022/9/20 19:17
* @version: 1.0
*/
public class FileDataClient {
Socket dataSocket;
FileDataClient(String ip,String port) throws IOException {
dataSocket = new Socket(ip, Integer.parseInt(port));
//得到网络输出字节流地址,并封装成网络输出字符流
}
public void getFile(File saveFile) throws IOException {
if (dataSocket != null) { // dataSocket是Socket类型的成员变量
FileOutputStream fileOut = new FileOutputStream(saveFile);//新建本地空文件
byte[] buf = new byte[1024]; // 用来缓存接收的字节数据
//网络字节输入流
InputStream socketIn = dataSocket.getInputStream();
//网络字节输出流
OutputStream socketOut = dataSocket.getOutputStream();
//(2)向服务器发送请求的文件名,字符串读写功能
PrintWriter pw = new PrintWriter(new OutputStreamWriter(socketOut, "utf-8"), true);
pw.println(saveFile.getName());
//(3)接收服务器的数据文件,字节读写功能
int size = 0;
while ((size = socketIn.read(buf)) != -1) {//读一块到缓存,读取结束返回-1
fileOut.write(buf, 0, size); //写一块到文件
}
fileOut.flush();//关闭前将缓存的数据全部推出
//文件传输完毕,关闭流
fileOut.close();
if (dataSocket != null) {
dataSocket.close();
}
} else {
System.err.println("连接ftp数据服务器失败");
}
}
}
chapter04/client/FileDialogClient.java
package chapter04.client;
import java.io.*;
import java.net.Socket;
/**
* @projectName: NetworkApp
* @package: chapter02
* @className: TCPCilent
* @author: GCT
* @description: TODO
* @date: 2022/9/4 18:06
* @version: 1.0
*/
public class FileDialogClient {
private Socket socket; //定义套接字
//定义字符输入流和输出流
private PrintWriter pw;
private BufferedReader br;
public FileDialogClient(String ip, String port) throws IOException {
//主动向服务器发起连接,实现TCP的三次握手过程
//如果不成功,则抛出错误信息,其错误信息交由调用者处理
socket = new Socket(ip, Integer.parseInt(port));
//得到网络输出字节流地址,并封装成网络输出字符流
OutputStream socketOut = socket.getOutputStream();
pw = new PrintWriter( // 设置最后一个参数为true,表示自动flush数据
new OutputStreamWriter(//设置utf-8编码
socketOut, "utf-8"), true);
//得到网络输入字节流地址,并封装成网络输入字符流
InputStream socketIn = socket.getInputStream();
br = new BufferedReader(
new InputStreamReader(socketIn, "utf-8"));
}
public void send(String msg) {
//输出字符流,由Socket调用系统底层函数,经网卡发送字节流
pw.println(msg);
}
public String receive() {
String msg = null;
try {
//从网络输入字符流中读信息,每次只能接受一行信息
//如果不够一行(无行结束符),则该语句阻塞等待,
// 直到条件满足,程序才往下运行
msg = br.readLine();
} catch (IOException e) {
e.printStackTrace();
}
return msg;
}
public void close() {
try {
if (socket != null) {
//关闭socket连接及相关的输入输出流,实现四次握手断开
socket.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws IOException{
FileDialogClient fileDialogClient = new FileDialogClient("127.0.0.1", "8008");
fileDialogClient.send("hello");
System.out.println(fileDialogClient.receive());
}
}