一、引论
程序开发中存在三种不同的应用方式:传统方法、C/S方法和基于构件(中间件)的方法。
传统方法中,表示逻辑、业务逻辑和数据库连接都混合到了单个程序代码中。这种基于整块应用(monolithic applications)的设计方法缺点很多:比如应用中出现很小的变动,整个应用都必须重新编译、再次集成,这将直接导致代码更新和应用再发布的代价攀升。
考虑到传统方法的缺点,后来引入了客户/服务器(C/S)构件,在这种架构中,数据从客户端分离出来,存储在服务器的集结处(数据库、文件、XML等)。由于这种架构并没有将业务逻辑和表示逻辑分离出来,因此又称两层构架。在这种架构中,数据库连接代码的程序在服务器端,鉴于业务逻辑和表示逻辑存放位置的不同,又分为:胖客户端和瘦客户端;如果业务逻辑和表示逻辑被放置在客户端,则为胖客户端,如果在服务器端,则为瘦客户端。两层架构的缺点主要有以下两点:首先,业务逻辑的任何变动都可能影响表示逻辑或者数据库连接代码的变更;其次,使用两层架构的应用很难放大。由于客户连接直接面对数据库,而服务器端客户可用的数据库连接数目又受诸多因素(驱动程序、系统资源)限制,连接请求经常会因为超过这个限制而被拒绝。为了改变这个缺点,引入了三层或者N层架构设计。
三层架构中,表示逻辑驻留在客户端,数据库访问代码驻留在服务器端。业务逻辑单独分离出来作为中间件,这样,用户连接和数据库访问面对的就是业务逻辑,对用户而言,不需要直接操作数据库,对服务器而言,访问数据库的不是难以应付的多客户连接,而是可以使用缓冲池控制的有限的几个可用连接。这种架构模式使得程序设计者可以把精力集中转移到业务逻辑的实现上,业务策略的变动只需要更改表示逻辑就可以了。这种构件的集中特性使得开发、维护、部署都非常容易。
表示逻辑、业务逻辑和数据库驻留在多台机器上的应用称为分布式应用。开发分布式应用有很多种方法:一种是使用SUN公司提供的远程方法调用(RMI),使用这种方法程序员不需要知道Socket编程,不需要知道多线程管理,不需要知道网络传输可靠性控制和安全控制,只要集中考虑如何开发业务逻辑就可以了。另外一种方法就是自己编写Socket程序。使用套接字来处理跨边界的由一个宿主机到另外一个宿主机的数据传递。由于使用SUN公司的RMI需要在服务器端启动RMI远程注册表,给程序分发带来很多障碍。为了解除这种障碍,同时也为了引明RMI程序的模拟实现机制,使程序员在开发基于分布式应用的三层架构的程序中更得心用手,特为B/S模式设计和C/S模式设计引入了实现这种架构的具体程序框架与实例代码。该篇讲的是C/S模式下架构的实现,B/S模式下架构的实现请参看下一期的相应文章。
二、Socket与多线程
1.Socket
Socket接口介于应用程序与硬件之间,并可以提供标准函数以符合不同的网络硬件规格。对Socket的理解可以简化为:它是封装了数据流(Stream)的从机器到机器的一条软接线,通过这条软接线,并借助于线两端的收发程序,网络上的机器间实现了信息的交流与互通。分离在软线两端的应用程序(服务器端程序和客户端程序)可以通过调用Socket接口来开发具有TCP/IP网络功能的程序应用。所以,Socket接口的介入使得开发分布式应用程序变得更为简单。
如果把机器比作房间,那么每个在房间里创建了Socket(套接字)的程序都是这个房间的一个后门。只要了解了该Socket接收数据的协议(后门的钥匙),就可以从房间外轻松地打开房间的门而进入该房间。因特网的极度膨胀和自发扩张使得程序的分发不再受时间和地域的限制。为了在网络上部署安全的信息服务,有必要在服务器端控制来自客户的对信息的访问行为。Socket正是迎合这种需求而被广泛应用的一种网络信息访问技术。
Java提供了包java.net支持Socket编程,在应用程序中引入该包。代码如下:
import java.net.*;
服务器端要做的就是为提供服务的程序(后门)固定一个位置,便于进入房间的人能够找到,这个位置实际就是一个数字,Socket中叫端口。端口从1025开始(其他的已经被系统作为保留端口使用),代码如下:
ServerSocket serverListener=new ServerSocket(1234);
需要注意的是该例程的任务只是用来负责接收监听,也就是记录访问该后门的所有客户,真正与客户交流的应该是另外一个例程:
Socket server=serverListener.accept();
作为客户端,既要知道服务程序所在房间的房间号(IP地址),又要知道后门的位置(端口),这样才能正确地进入房间。
Socket client =new Socket(InetAddress.getByName(“25.100.0.1”),1234);
类InetAddress的静态方法getByName()取得特定的Java对象,该对象包含了服务程序所在机器的有效IP地址(或DNS域名),最关键的是该对象是创建客户端Socket所必须的。通过这样一个对接过程,Socket server和client就连通了。
2.数据编发
软线建立完成后,还必须有数据传输的规则,这就是服务端和客户端商量着来的事情了。数据使用什么封装,传送时遵循什么规则(收发协议)、传输是否采用压缩技术和加解密技术等。将数据封装起来,可以让程序的设计者像操作本地文件一样来操作流动在网络上的信息。不同的数据封装方式将决定客户端和服务器端数据的访问方式。这就是为什么在使用RMI生成代码存根时必须指定版本号的原因了。
rmic –v1.1 AddTwoNumberServer
在1.1版本中,使用DataInputStream和DataOutputStream封装数据,因此编发类型仅限于标量(基本型别)和字符串对象,而1.2中,使用ObjectInputStream和ObjectOutputStream封装数据,其编发类型扩展到所有实现了java.io.Serializable的对象。
鉴于考虑对低版本的支持,一并演示对象在网络上的串行化机制,这里列出了本例中使用的数据封装代码:
DataOutputStream out=new DataOutputStream(server.getOutputStream());
DataInputStream in=new DataInputStream(server.getInputStream());
可以使用同样的代码来封装客户端Socket数据流,这里就不列出了。
在网络上使用DataOutputStream和DataInputStream封装数据流并不特别方便,需要考虑有关对象到字节的串行化以及字节流到对象的还原问题。以下是两种操作的代码及简单说明:
(1)从对象到字节流
//首先将对象转为字节流
ByteArrayOutputStream buf=new ByteArrayOutputStream();
ObjectOutputStream oos=new ObjectOutputStream(buf);
oos.writeObject(obj);
byte[] bytes=buf.toByteArray();
int l=bytes.length;
//写对象长度到DataOutputStream
out.writeInt(l);
//写对象字节流到DataOutputStream
out.write(buf.toByteArray());
out.flush();
(2)从字节流到对象
//从DataInputStream中读入对象的长度
int l=in.readInt();
//从DataInputStream中读入对象字节
byte[] bytes=new byte[l];
in.readFully(bytes);
//返回对象本身
ByteArrayInputStream buf=new ByteArrayInputStream(bytes);
ObjectInputStream ois=new ObjectInputStream(buf);
return ois.readObject();
3.多线程
作为一种例程服务,必须要考虑其同时服务于多用户的问题。这个问题的解决可以通过Java线程技术来实现,而且相当简单。基本思路是:在服务器内产生单一的监听Socket,并循环调用accept()等待新的软线连接。Accept()每返回一次,就会带回与特定客户有关的Socket,将该Socket的运作安排到一个独立的线程中,这样其服务就是针对特定客户的了。为了维持与客户的通信状态,在程序中引入了一个类专门存放客户session,并将所有用户的session放到了哈希表Hashtable中。具体代码及说明请参照TunnelServer中的startServer()方法。
考虑到多线程对共享资源访问时的同步问题,如果处理不当就会造成线程的不安全,必须为临界区设置线程锁。由于多个用户会同时访问哈希表并更改其中内容,所以,使用关键字Synchronized来加锁访问共享信息的区段(即临界区)。主要代码如下:
synchronized (ht){
count++;
Object serverBusObject=getRealObject();
cs=new ClientSession(count,clientSocket,serverBusObject);
ht.put(Integer.toString(count),cs);
}
三、架构
这部分将创建一个分布式的业务逻辑实现的例子:编写一个程序,用户可以在客户端通过网络直接浏览到服务器的磁盘文件结构。
1.架构组成类的结构图
架构组成类的结构如图1所示:
图1 架构类示意图
2.基于TCP的收发协议
使用Socket编程,必须明确客户端与服务器端的收发协议。以下是针对该实例定义的收发协议。
(1) 初始化(9999)
客户端:发送报头9999,发送用户名(String),发送密码(String)。
服务端:接收报头9999,接收用户名和密码,验证登录权限。
是合法用户:发送报头9999,发送欢迎信息(String)。
是非法用户:发送报头-1,发送警告信息(UTF)。
客户端:接收报头9999,获取欢迎信息(String)。
(2) 获取文件名和目录名
客户端:发送报头1,发送路径(String)。
服务端:接收报头1,执行获取操作。
发送报头1,发送结果(FileAndDirectory)。
客户端:接收报头1,接收结果(FileAndDirectory),处理结果。
(3) 其他业务逻辑方法(n)
客户端:发送报头n,发送参数。
服务端:接收报头n,执行相应操作。
发送报头n,发送结果。
客户端:接收报头n,接收结果,处理结果。
备注:如果客户端返回报头-1,则表示出错,紧跟着的就是一个UTF携带相应的出错信息。
3.架构的代码
第一步:编写业务逻辑的接口BusInterface.java。
该接口必须同时部署到客户端和服务器端。在服务器端,类BusObject实现该接口,并且该类是业务逻辑真正意义上的实现。接口中定义的所有业务操作都在该类被一一实现。
在客户端,类BusObjectProxy实现该接口,以便在客户端模拟服务器端的BusObject类,不过所有的业务逻辑方法只是实现了一个网络连接,并没有从真正意义上实现BusObject,所以称BusObjectProxy是服务器端BusObject类的一个代理。
业务逻辑在服务器端被具体实现、在客户端只做代理的这种特性称胖服务器或瘦客户端程序设计模式。定义接口不仅可以明确约束所有实现该接口类的协议,而且由于其本身并不实现具体细节,因此程序设计时便于阅读和参考。实现的核心代码如下:
import java.util.Vector;
public interface BusInterface{
/**
*获取指定路径下的所有文件,传入参数举例:path="c:\aa"
*返回一个FileAndDirectory对象,包含两个Vector,一个是所有的文件名集合,一个是所有的目录名
集合*/
public FileAndDirectory getAllFileName(String path);
/**
*仿照上面的定义,你可以实现程序中所有其他可用的业务逻辑
*
*/
}
第二步:编写可以被网络串行化的助益类Helper Class(服务器)。
业务逻辑方法的入口参数、出口参数很可能因为比较复杂而被单独封装为一个类,像上面定义接口中的方法getAllFileName,其出口参数就是一个助益类FileAndDirectory。因为该参数会被编发并从服务器端传送到客户端,因此,该类必须可以被串行化Serializable。实现的核心代码如下:
import java.io.*;
import java.util.*;
public class FileAndDirectory implements Serializable{
Vector fileArray; //文件名集合
Vector directoryArray; //子文件夹集合
public FileAndDirectory(Vector files,Vector dirs){
fileArray=files;
directoryArray=dirs;
}
public Vector getFileArray(){
return fileArray;
}
public void setFileArray(Vector files){
fileArray=files;
}
public Vector getDirectoryArray(){
return directoryArray;
}
public void setDirectoryArray(Vector dirs){
directoryArray=dirs;
}
}
第三步:编写业务逻辑实现类BusObject.java(服务器)。
该类实现了BusInterface接口,之所以设计使服务器端类BusObject和客户端代理BusObjectProxy类都实现同一个接口,主要目的就是使客户端程序调用服务器业务逻辑方法时能够透明化。对设计客户端的程序员来讲,它操作的就是服务器类本身,而屏蔽了代理与主类间网络层复杂的连接代码。简化客户端程序设计,实现的核心代码如下:
import java.io.*;
import java.util.Vector;
import java.lang.*;
/**
*商业对象(在服务器端)
*实现接口BusInterface中定义的所有业务逻辑方法
*/
public class BusObject implements BusInterface{
/**
*获取指定PATH的所有文件名称和文件夹名称
*/
public FileAndDirectory getAllFileName(String path){
Vector files=new Vector();
Vector dirs=new Vector();
FileAndDirectory fd;
//获取path中所有的文件名和目录名
File pathFile=new File(path);
String[] list;
list=pathFile.list();
for(int i=0;i<list.length;i++){
String strTmp=list[i];
strTmp=path+"\\"+strTmp;
File fileTmp=new File(strTmp);
if(fileTmp.isFile()){
files.addElement(list[i]);
}
else{
dirs.addElement(list[i]);
}
}
fd=new FileAndDirectory(files,dirs);
return fd;
}
//将单元测试程序放到该单元下是一个好习惯!!
public static void main(String[] args){
BusObject bo=new BusObject();
//单元测试:获取指定目录下的所有文件名和文件夹名
String path="d:\\s";
FileAndDirectory fd=bo.getAllFileName(path);
Vector v=new Vector();
v=fd.getFileArray();
System.out.println("输出所有的文件名:");
for(int i=0;i<v.size();i++){
System.out.println(v.elementAt(i));
}
v=fd.getDirectoryArray();
System.out.println("输出所有的目录名:");
for(int i=0;i<v.size();i++){
System.out.println(v.elementAt(i));
}
}
}
第四步:编写商业对象代理的网络层TunnelClient.java(客户端)
由于商业对象代理一边要负责假装实现商业对象的业务逻辑(所谓假装就是并不真正实现,只是做了以下两个操作:接收入口参数,将入口参数串行化成字节流传送出去;把网络上接收回的信息解释为从业务逻辑方法中运行返回的结果,对客户端程序员而言,他并看不到内部的这个工作),另一边又要实现对数据的网络封装、传送和接收。为了实现程序网络层与业务逻辑层的分离,使用了继承机制。把封装了网络功能的代码单独作为一个类TunnelClient,把公用的东西和非公用的东西分开处理是一个很好的习惯。
其实该代理的网络层代码对服务器只是简单地做了数据的封装和网络协议的简单制定,对客户端程序员则透明地提供了远程方法调用。由于最终所有的参数都要转换成字节流进行传输,因此需要涉及到对象流化的一些方法,比如如何将一个实现了串化的对象构造为字节流,又如何还原。这些技巧在代码中都有体现。实现的核心代码如下:
import java.io.*;
import java.net.*;
/**
*抽象类,是客户端代理的网络层实现,主要包含初始化方法:
*方法:initialize()用来进行初始化。如果登录成功,则返回服务器的欢迎信息。
*客户端程序可以通过getWelcome方法获取它。
*/
public abstract class TunnelClient{
Socket socket;
String host="127.0.0.1";
int port=7484;
DataOutputStream out;
DataInputStream in;
private String userName; //用户名
private String userPass; //密码
private String welcome; //如果登录成功,则返回服务器的欢迎信息
/**
*写入报头,9999表示初始化。其后紧跟着用户名和密码
*数据报协议:
*服务器接收到flag=9999,则验证用户名和密码,如果正确,返回报头9999表示连接成功
*其后跟随服务器的欢迎信息
*否则返回-1表示连接失败,紧跟着字符串说明失败的原因
*/
public void initialize() throws TunnelException{
//首先与服务器建立连接
try{
connectServer(host,port);
}
catch(Exception ex){
ex.printStackTrace();
}
try{
//写报头
writeHeader(9999);
//传入入口参数
writeObject(userName);
writeObject(userPass);
//读报头
readHeader();
//读出口参数
welcome=(String)readObject();
}
catch(Exception ex){
ex.printStackTrace();
}
}
//向输出流写入报头
public void writeHeader(int flag) throws TunnelException{
try{
out.writeInt(flag);
out.flush();
}catch(IOException ex){
ex.printStackTrace();
throw new TunnelException(ex.getMessage());
}
}
//读取报头,如果报头为-1则报错
public int readHeader() throws TunnelException{
int f=-1;
try{
//获取从服务器返回的信息
//先读报头信息
f=in.readInt();
//如果出现错误,则返回
if(f==-1){
String msg=in.readUTF();
throw new TunnelException(msg);
}
}catch(IOException ex){
f=-1;
ex.printStackTrace();
throw new TunnelException(ex.getMessage());
}
return f;
}
//连接到服务器,并获取输入流和输出流
public void connectServer(String host,int port) throws Exception{
try{
socket=new Socket(InetAddress.getByName(host),port); //与指定服务器建立TCP Socket
out=new DataOutputStream(socket.getOutputStream());
in=new DataInputStream(socket.getInputStream());
}catch(IOException ex){}
}
/**
* 将对象写入字节流,先写入一个整型数,表示对象的长度,然后再写对象本身
* 调用该函数前应该先创建DataOutputStream对象out
*/
public void writeObject(Object obj) throws Exception{
try{
//首先将对象转为字节流
ByteArrayOutputStream buf=new ByteArrayOutputStream();
ObjectOutputStream oos=new ObjectOutputStream(buf);
oos.writeObject(obj);
byte[] bytes=buf.toByteArray();
int l=bytes.length;
//写入对象长度
out.writeInt(l);
//写入对象字节流
out.write(buf.toByteArray());
out.flush();
}
catch(Exception ex){
ex.printStackTrace();
}
}
/**
* 从输入流中读取一个对象,先读出该对象占用字节数,然后读对象本身
* 调用该函数前应该存在DataInputStream对象in
*/
public Object readObject() throws Exception{
try{
//读入对象的长度
int l=in.readInt();
//构造对象字节流数组
byte[] bytes=new byte[l];
in.readFully(bytes);
//返回对象本身
ByteArrayInputStream buf=new ByteArrayInputStream(bytes);
ObjectInputStream ois=new ObjectInputStream(buf);
return ois.readObject();
}
catch(Exception ex){
ex.printStackTrace();
return null;
}
}
public String getWelcome(){
return welcome;
}
public String getUserName(){
return userName;
}
public void setUserName(String name){
userName=name;
}
public String getUserPass(){
return userPass;
}
public void setUserPass(String pass){
userPass=pass;
}
}
第五步:定义自己的网络异常类TunnelException.java(客户端和服务器端共用)。
|