嘔心瀝血的java複雜專案(包括自定義應用層協議、CS多執行緒、多客戶端登入、上下線提醒等等)
首先來看下整個系統的檔案架構圖:
系統是個基於UDP的聊天室,因為不能保持所有使用者和聊天室的持續連線。同時為了保持資料傳輸的可靠性,就需要自定義應用層協議了。
程式大概的一個流程如下:
1.啟動伺服器,點選"start service",之後伺服器及開始監聽指定埠。
2.啟動客戶端,輸入使用者名稱,點選"connect"。如果伺服器端已經存在該使用者,則會提示使用者相應錯誤。如果使用者登入成功,則伺服器端會顯示當前線上的使用者列表,以及系統資訊。客戶端則會得到當前使用者列表,就可以開發資訊了。
3.再登入幾個客戶端,如2方法。此時其他使用者會收到上線提醒。
4.在客戶端隨便輸入點內容,點選"send",選擇要傳送的人,就會發現訊息傳送出去了。
5.使用者點選"quit",伺服器和其他使用者都會得到下線提醒,並重新整理使用者列表。
接下來分析具體的原始碼:
package common;
//分別表示訊息的作用
public enum CMD {
CMD_USERLOGIN, //使用者登入
CMD_UPDATEUSERLIST, //更新使用者列表
CMD_SENDMESSAGE, //傳送訊息
CMD_USERALREADYEXISTS, //當前使用者已經存在
CMD_SERVERSTOP, //伺服器停止
CMD_USERQUIT //客戶端退出
};
CMD類定義的都是一些應用層協議的命令程式碼,就好像是http協議中的404一樣,通過指定的命令程式碼會告訴客戶端和伺服器應該怎麼做。
Client類主要存放使用者的一些網路地址資訊,注意:因為我們傳輸和獲取的都是物件,所有需要實現Serializable介面,這樣才能直接從套接字中讀取物件(具體的請百度java序列化)。這裡重寫了equals方法,使得只要使用者名稱一樣,就可以認為Client是一樣的,這樣便於使用者列表的查詢。package common; import java.io.Serializable; import java.net.InetAddress; public class Client implements Serializable{ private static final long serialVersionUID = -4255412944764507834L; //使用者名稱 String clientName; //使用者IP地址 InetAddress clientIpAddress; //使用者埠 int clientPort; public Client(String clientName, InetAddress clientIpAddress, int clientPort) { super(); this.clientName = clientName; this.clientIpAddress = clientIpAddress; this.clientPort = clientPort; } public String getClientName() { return clientName; } public InetAddress getClientIp() { return clientIpAddress; } public int getClientPort() { return clientPort; } @Override public boolean equals(Object obj) { if(obj==null) return false; //如果使用者的姓名一樣,則它們相同 if(obj instanceof Client){ return clientName.equals(((Client)obj).clientName); } return false; } }
package common;
import java.io.Serializable;
//聊天訊息的格式:接收人、訊息內容、傳送時間
public class ChatText implements Serializable{
private static final long serialVersionUID = -1356206790228754726L;
private String receiver;
private String text;
private String sendTime;
public String getReceiver() {
return receiver;
}
public String getText() {
return text;
}
public String getSendTime(){
return sendTime;
}
public ChatText(String receiver, String text,String sendTime) {
super();
this.receiver = receiver;
this.text = text;
this.sendTime = sendTime;
}
}
ChatText封裝的是聊天訊息,包括訊息內容、傳送人、傳送時間等,它也實現了序列號介面。
package common;
import java.io.Serializable;
/*
* Socket傳輸中,設定訊息的格式,便於解析。
* 實現Serializable介面便於傳輸
* */
public class Message implements Serializable{
private static final long serialVersionUID = 1L;
//載體,不同的命令對應不同的載體
private Object carrier;
private CMD cmd;
private Client client;
public Message(Object carrier, CMD cmd, Client client) {
super();
this.carrier = carrier;
this.cmd = cmd;
this.client = client;
}
public Object getCarrier() {
return carrier;
}
public CMD getCmd() {
return cmd;
}
public Client getClient() {
return client;
}
}
Message用於網路套接字的傳輸,它的Object物件可以存String、Client、ChatText等等,它也實現了序列號介面。
package common;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.text.SimpleDateFormat;
import java.util.Date;
public class Utils {
public static String serverIP = "127.0.0.1";
public static int serverPort = 8765;
public static String getCurrentFormatTime(){
SimpleDateFormat df = new SimpleDateFormat("[MM-dd HH:mm:ss]: ");//設定日期格式
return df.format(new Date());
}
public static void sendMessage(Message msg,InetAddress address,int port) throws Exception{
byte[] data = new byte[1024*1024];
//設定物件輸入流
ByteArrayOutputStream bs = new ByteArrayOutputStream();
ObjectOutputStream bo = new ObjectOutputStream(bs);
bo.writeObject(msg);
//將流轉化為位元組陣列
data = bs.toByteArray();
//設定資料包套接字,併發送
DatagramSocket sendSocket = new DatagramSocket();
DatagramPacket sendPack = new DatagramPacket(data, data.length,address,port);
sendSocket.send(sendPack);
}
/*
* 傳送資料報時,注意不需要重複繫結埠
* */
public static Message receiveMessage(DatagramSocket receiveSocket) throws Exception{
//繫結隨機的埠,然後接受伺服器的資訊
byte[] data = new byte[1024*1024];
DatagramPacket receivePack = new DatagramPacket(data, data.length);
receiveSocket.receive(receivePack);
//解析資料包中的Message物件
ByteArrayInputStream bis = new ByteArrayInputStream(receivePack.getData());
ObjectInputStream os = new ObjectInputStream(bis);
return (Message)os.readObject();
}
}
Utils類定義了伺服器的Ip和埠號,實際執行中不一定是本地地址。還有獲取格式化時間函式,以及根據Message、埠等內容收發資料報的函式。
package server;
import java.awt.EventQueue;
public class ServerMainFrame extends JFrame{
private static final long serialVersionUID = 1L;
private JPanel contentPane;
private static Vector<Client> userList = new Vector<Client>(); //儲存登入使用者資訊
//用於接收的upd套接字
private static DatagramSocket receiveSocket = null;
private static boolean stopFlag = false;
public static void main(String[] args) {
EventQueue.invokeLater(new Runnable() {
public void run() {
try {
ServerMainFrame frame = new ServerMainFrame();
frame.setVisible(true);
} catch (Exception e) {
e.printStackTrace();
}
}
});
}
/**
* Create the frame.
*/
public ServerMainFrame() {
setTitle("Server Stop");
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
setBounds(100, 100, 450, 377);
contentPane = new JPanel();
contentPane.setBorder(new EmptyBorder(5, 5, 5, 5));
setContentPane(contentPane);
contentPane.setLayout(null);
JLabel lblNewLabel = new JLabel("Service");
lblNewLabel.setBounds(10, 10, 54, 15);
contentPane.add(lblNewLabel);
final JButton btnStartService = new JButton("start service");
btnStartService.setBounds(86, 6, 155, 23);
contentPane.add(btnStartService);
final JButton btnEndService = new JButton("stop service");
btnEndService.setEnabled(false);
btnEndService.setBounds(269, 6, 155, 23);
contentPane.add(btnEndService);
JLabel lblNewLabel_1 = new JLabel("User List");
lblNewLabel_1.setBounds(10, 35, 54, 15);
contentPane.add(lblNewLabel_1);
JLabel lblNewLabel_2 = new JLabel("System Records");
lblNewLabel_2.setBounds(10, 162, 131, 15);
contentPane.add(lblNewLabel_2);
//禁止最大最小化
setResizable(false);
JScrollPane scrollPane_2 = new JScrollPane();
scrollPane_2.setBounds(12, 187, 412, 140);
contentPane.add(scrollPane_2);
final JTextArea textAreaSystemRecords = new JTextArea();
scrollPane_2.setViewportView(textAreaSystemRecords);
textAreaSystemRecords.setEditable(false);
JScrollPane scrollPane = new JScrollPane();
scrollPane.setEnabled(false);
scrollPane.setBounds(10, 60, 412, 94);
contentPane.add(scrollPane);
final JTextArea textAreaUserList = new JTextArea();
scrollPane.setViewportView(textAreaUserList);
textAreaUserList.setEditable(false);
//只繫結一次埠,防止重複繫結
try {
receiveSocket = new DatagramSocket(Utils.serverPort);
} catch (SocketException e2) {
e2.printStackTrace();
}
btnStartService.addActionListener(new ActionListener() {
//啟動服務事件監聽器
public void actionPerformed(ActionEvent e) {
new Thread(new Runnable() {
@Override
public void run() {
//開始執行伺服器初始化過程
userList = new Vector<Client>();
//設定頁面上的提示
String tmp = textAreaSystemRecords.getText();
if(!tmp.equals(""))//防止重啟伺服器時出現的不協調顯示
tmp += "\n";
textAreaSystemRecords.setText(tmp+Utils.getCurrentFormatTime()+"the server start listening to port "+Utils.serverPort);
setTitle("Server : Started");
btnStartService.setEnabled(false);
btnEndService.setEnabled(true);
//將停止標誌設為false
stopFlag = false;
while(true){
try {
//繫結伺服器埠,然後接受來自客戶端的資訊
Message msg = Utils.receiveMessage(receiveSocket);
Client tc = msg.getClient();
//點選終止按鈕後,伺服器收到資料包後直接跳出
if(stopFlag)
break;
//根據不同的命令執行不同的過程
switch(msg.getCmd()){
case CMD_USERLOGIN:
//如果當前使用者名稱已經存在
Message tm = null;
if(userList.contains(tc)){
tm = new Message(null,CMD.CMD_USERALREADYEXISTS,null);
Utils.sendMessage(tm,tc.getClientIp(),tc.getClientPort());
}
else{
userList.add(tc);
//如果登入成功,則將最新的線上使用者列表傳送給所有客戶端(類似於好友上線提示)
textAreaSystemRecords.setText(textAreaSystemRecords.getText()+"\n"+Utils.getCurrentFormatTime()+"user [ "+tc.getClientName()+" ] login in");
updateListUserList(textAreaUserList, userList);
for(Client c : userList){
//tc表示當前上線的使用者,用於通知其他客戶端
tm = new Message(userList,CMD.CMD_UPDATEUSERLIST,tc);
Utils.sendMessage(tm,c.getClientIp(),c.getClientPort());
}
}
break;
//使用者退出的響應
case CMD_USERQUIT:
textAreaSystemRecords.setText(textAreaSystemRecords.getText()+"\n"+Utils.getCurrentFormatTime()+"user [ "+tc.getClientName()+" ] login out");
//從使用者列表中刪除該使用者
userList.remove(tc);
updateListUserList(textAreaUserList, userList);
//通知其他使用者,該使用者下線了
for(Client c : userList){
tm = new Message(userList,CMD.CMD_USERQUIT,tc);
Utils.sendMessage(tm,c.getClientIp(),c.getClientPort());
}
break;
//轉發使用者的訊息
case CMD_SENDMESSAGE:
//獲取接收人和聊天內容
ChatText chatText = (ChatText) msg.getCarrier();
String senderName = tc.getClientName();
String receiverName = chatText.getReceiver();
String chatMessage = chatText.getText();
tmp = "\n"+chatText.getSendTime()+":"+senderName+" said to "+receiverName+" :"+chatMessage;
textAreaSystemRecords.setText(textAreaSystemRecords.getText()+tmp);
//向接收人傳送訊息
msg = new Message(chatText,CMD.CMD_SENDMESSAGE,tc);
//找到接收人的ip地址和埠,由於只比較使用者名稱,所以不用設定ip地址和埠號
tc = userList.elementAt(userList.indexOf(new Client(receiverName,null,0)));
Utils.sendMessage(msg,tc.getClientIp(),tc.getClientPort());
break;
}
} catch (Exception e1) {
JOptionPane.showMessageDialog(textAreaSystemRecords,e1.getMessage());
e1.printStackTrace();
}
}}}).start();
}
});
//由於停止按鈕中不會發生阻塞,所以不用使用多執行緒
btnEndService.addActionListener(new ActionListener() {
//啟動服務事件監聽器
public void actionPerformed(ActionEvent e) {
//清空使用者列表及相關區域
textAreaSystemRecords.setText(textAreaSystemRecords.getText()+"\n"+Utils.getCurrentFormatTime()+"the server stop service");
textAreaUserList.setText("");
setTitle("Server : Stoped");
//設定停止標記
stopFlag = true;
//向線上的所有使用者傳送伺服器停止服務的訊息
if(!userList.isEmpty()){
for(Client c : userList){
Message tm = new Message(null,CMD.CMD_SERVERSTOP,null);
try {
Utils.sendMessage(tm,c.getClientIp(),c.getClientPort());
} catch (Exception e1) {
e1.printStackTrace();
}
}
}
btnStartService.setEnabled(true);
btnEndService.setEnabled(false);
}
});
}
//更新介面上的使用者列表區域
private static void updateListUserList(JTextArea jta,Vector<Client> v){
if(userList==null)
return;
String s = "UserName\t\tIP\t\tPort\n";
for(Client c : v){
s += c.getClientName()+"\t\t"+c.getClientIp().getHostAddress()+"\t\t"+c.getClientPort()+"\n";
}
jta.setText(s);
}
}
ServerMainFrame類,那些定義圖形化控制元件的程式碼可以不看,主要看訊息處理函式,這裡以接收使用者登入為例:
首先由於伺服器要監聽不同客戶端傳送的訊息,所以必須使用多執行緒並使用死迴圈,否則伺服器監聽時,介面上的按鈕都會卡死。
當伺服器接收到一個Message物件,分析它的CMD命令,然後執行對應操作。例如命令為CMD_USERLOGIN,表示使用者登入訊息。伺服器首先解析出使用者名稱,然後在userList中搜索,如果已經存在該使用者,則先客戶端傳送CMD_USERALREADYEXISTS表示使用者已經存在。否則,新增使用者到使用者列表,更新伺服器的使用者列表,最後告訴所有的線上使用者:“有新使用者登入了,需要更新使用者列表了”。
其他的都可以類似分析,至於一些控制元件的操作只是為了系統對使用者更加友好。
package client;
import java.awt.EventQueue;
public class ClientMainFrame extends JFrame {
private static final long serialVersionUID = 7952439640530949282L;
private JPanel contentPane;
private JTextField textFieldUserName;
//由於本地測試時,客戶端的埠號要不一致
private static int clientPort = new Random().nextInt(10000)+1024;
//每個客戶端只有一個接收資料包套接字
private static DatagramSocket receiveSocket = null;
private boolean connectFlag = false;
public static void main(String[] args) {
EventQueue.invokeLater(new Runnable() {
public void run() {
try {
ClientMainFrame frame = new ClientMainFrame();
frame.setVisible(true);
} catch (Exception e) {
e.printStackTrace();
}
}
});
}
/**
* Create the frame.
*/
public ClientMainFrame() {
setTitle("Client : Off");
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
setBounds(100, 100, 450, 371);
contentPane = new JPanel();
contentPane.setBorder(new EmptyBorder(5, 5, 5, 5));
setContentPane(contentPane);
contentPane.setLayout(null);
textFieldUserName = new JTextField();
textFieldUserName.setBounds(88, 10, 133, 21);
contentPane.add(textFieldUserName);
textFieldUserName.setColumns(10);
final JButton btnConnect = new JButton("connect");
btnConnect.setBounds(228, 9, 93, 23);
contentPane.add(btnConnect);
final JButton btnQuit = new JButton("quit");
btnQuit.setEnabled(false);
btnQuit.setBounds(331, 9, 93, 23);
contentPane.add(btnQuit);
JLabel lblNewLabel_1 = new JLabel("Message Records");
lblNewLabel_1.setBounds(10, 45, 113, 15);
contentPane.add(lblNewLabel_1);
JLabel lblNewLabel_2 = new JLabel("Sentence");
lblNewLabel_2.setBounds(10, 199, 73, 15);
contentPane.add(lblNewLabel_2);
JLabel lblNewLabel_3 = new JLabel("Receiver");
lblNewLabel_3.setBounds(331, 199, 54, 15);
contentPane.add(lblNewLabel_3);
final JButton btnSend = new JButton("Send");
btnSend.setEnabled(false);
btnSend.setBounds(331, 263, 93, 57);
contentPane.add(btnSend);
final JComboBox<String> comboBoxReceiver = new JComboBox<String>();
comboBoxReceiver.setBounds(331, 225, 93, 21);
contentPane.add(comboBoxReceiver);
JLabel lblNewLabel_4 = new JLabel("User Name");
lblNewLabel_4.setBounds(10, 10, 73, 15);
contentPane.add(lblNewLabel_4);
JScrollPane scrollPane = new JScrollPane();
scrollPane.setBounds(10, 70, 414, 119);
contentPane.add(scrollPane);
final JTextArea textAreaMsgRecords = new JTextArea();
textAreaMsgRecords.setEditable(false);
scrollPane.setViewportView(textAreaMsgRecords);
JScrollPane scrollPane_3 = new JScrollPane();
scrollPane_3.setBounds(10, 224, 298, 96);
contentPane.add(scrollPane_3);
final JTextArea textAreaSentence = new JTextArea();
scrollPane_3.setViewportView(textAreaSentence);
//禁止最大最小化
setResizable(false);
//只繫結一次埠,防止重複繫結
try {
receiveSocket = new DatagramSocket(clientPort);
} catch (SocketException e2) {
e2.printStackTrace();
}
btnConnect.addActionListener(new ActionListener() {
//啟動服務事件監聽器
public void actionPerformed(ActionEvent e) {
String userName = textFieldUserName.getText();
//未輸入使用者名稱
if(userName.equals("")){
JOptionPane.showMessageDialog(textAreaMsgRecords,"未輸入使用者名稱");
return;
}
/*點選連線伺服器伺服器按鈕,要做兩件事:
* 1.告訴伺服器當前使用者名稱,ip地址,埠號等資訊。如果有人給你發信息,伺服器就知道該往哪發。
* 2.根據伺服器傳送回的當前線上的使用者列表,重新整理客戶端的使用者列表
*/
try {
//將使用者名稱,命令,使用者的地址資訊綜合成msg
Message msg = new Message(userName, CMD.CMD_USERLOGIN, new Client(userName,InetAddress.getLocalHost(),clientPort));
Utils.sendMessage(msg,InetAddress.getByName(Utils.serverIP),Utils.serverPort);
}catch (Exception e1) {
JOptionPane.showMessageDialog(textAreaMsgRecords,e1.getMessage());
e1.printStackTrace();
}
//每次連線伺服器後,設定停止標籤
connectFlag = true;
//之後需要不停的等待伺服器端的訊息(伺服器停止,接收資訊等),所以用多執行緒
new Thread(new Runnable() {
@Override
public void run() {
while(connectFlag){
//獲取伺服器的回覆報文
Message msg = null;
try {
msg = Utils.receiveMessage(receiveSocket);
} catch (Exception e) {
e.printStackTrace();
}
//根據不同的訊息,作出不同的反應
switch(msg.getCmd()){
case CMD_USERALREADYEXISTS:
//使用者已經存在
JOptionPane.showMessageDialog(textAreaMsgRecords,"您的賬號已經在別處登入");
return;
case CMD_UPDATEUSERLIST:
btnSend.setEnabled(true);
btnConnect.setEnabled(false);
btnQuit.setEnabled(true);
textFieldUserName.setEditable(false);
String tmp = textAreaMsgRecords.getText();
if(!tmp.equals(""))
tmp += "\n";
textAreaMsgRecords.setText(tmp+Utils.getCurrentFormatTime()+msg.getClient().getClientName()+"成功登入伺服器");
setTitle("Client : ON");
//更新使用者列表下拉選單
@SuppressWarnings("unchecked")
Vector<Client> v = (Vector<Client>)msg.getCarrier();
//首先清空原來的項,然後進行更新
comboBoxReceiver.removeAllItems();
for(Client c : v){
comboBoxReceiver.addItem(c.getClientName());
}
//既然和伺服器連線了,那麼退出時必須告訴伺服器,取消Frame預設的退出功能
setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
break;
case CMD_SERVERSTOP:
btnSend.setEnabled(false);
textFieldUserName.setEditable(true);
btnConnect.setEnabled(true);
btnQuit.setEnabled(false);
setTitle("Client : Off");
comboBoxReceiver.removeAllItems();
textAreaMsgRecords.setText(textAreaMsgRecords.getText()+"\n"+Utils.getCurrentFormatTime()+"伺服器停止服務");
//設定連線標誌為停止false,表示不再接收報文,客戶端停止
connectFlag = false;
break;
//其他使用者下線,伺服器通知
case CMD_USERQUIT:
Client tc = msg.getClient();
textAreaMsgRecords.setText(textAreaMsgRecords.getText()+"\n"+Utils.getCurrentFormatTime()+tc.getClientName()+"退出登入");
//更新使用者列表下拉選單
@SuppressWarnings("unchecked")
Vector<Client> v1 = (Vector<Client>)msg.getCarrier();
//首先清空原來的項,然後進行更新
comboBoxReceiver.removeAllItems();
for(Client c : v1){
comboBoxReceiver.addItem(c.getClientName());
}
break;
//接收其他使用者發來的訊息
case CMD_SENDMESSAGE:
//獲取發件人,收件人,訊息內容
ChatText chatText = (ChatText) msg.getCarrier();
String chatMessage = chatText.getText();
String senderName = msg.getClient().getClientName();
tmp = "\n"+chatText.getSendTime()+":"+senderName+" said:"+chatMessage;
textAreaMsgRecords.setText(textAreaMsgRecords.getText()+tmp);
break;
}
}
}
}).start();
}});
//退出按鈕的響應函式
btnQuit.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
String userName = textFieldUserName.getText();
//向伺服器傳送退出訊息
try {
//將使用者名稱,命令,使用者的地址資訊綜合成msg
Message msg = new Message(null, CMD.CMD_USERQUIT, new Client(userName,InetAddress.getLocalHost(),clientPort));
Utils.sendMessage(msg,InetAddress.getByName(Utils.serverIP),Utils.serverPort);
}catch (Exception e1) {
JOptionPane.showMessageDialog(textAreaMsgRecords,e1.getMessage());
e1.printStackTrace();
}
//恢復相關控制元件的狀態
btnSend.setEnabled(false);
btnConnect.setEnabled(true);
btnQuit.setEnabled(false);
textAreaMsgRecords.setText(textAreaMsgRecords.getText()+"\n"+Utils.getCurrentFormatTime()+userName+"退出登入");
setTitle("Client : Off");
textFieldUserName.setEditable(true);
comboBoxReceiver.removeAllItems();
//退出程式
System.exit(0);
}
});
//傳送訊息的響應函式
btnSend.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
//獲取使用者輸入的訊息
String sentences = textAreaSentence.getText();
//使用者未輸入任何訊息
if(sentences.equals("")){
JOptionPane.showMessageDialog(textAreaMsgRecords,"文字框為空,不能傳送");
return;
}
String receiver = (String) comboBoxReceiver.getSelectedItem();
String userName = textFieldUserName.getText();
//向伺服器傳送“我要發訊息”的訊息
try {
//將訊息格式內容,命令,使用者的地址資訊綜合成msg
Message msg = new Message(new ChatText(receiver, sentences,Utils.getCurrentFormatTime()), CMD.CMD_SENDMESSAGE, new Client(userName,InetAddress.getLocalHost(),clientPort));
Utils.sendMessage(msg,InetAddress.getByName(Utils.serverIP),Utils.serverPort);
}catch (Exception e1) {
JOptionPane.showMessageDialog(textAreaMsgRecords,e1.getMessage());
e1.printStackTrace();
}
}
});
}
}
ClientMainFrame可以類似於伺服器進行分析,就不再贅述了。大家可以自己除錯或者分析下,有什麼意見可以留言與我進行交流。
剛開始做這個專案,我準備使用非阻塞的TCP套接字,結果發現介面容易卡死,或者是過於複雜。之後就使用UDP套接字,並把地址資訊加到資料報中,這樣接收端可以進行轉發或者恢復了。
另外,我並沒有打算使用自定義協議,而是根據連線的次數或者設定一些控制變數來判斷具體要進行什麼操作。最後過於複雜,把我自己都弄糊塗了。只好按照QQ類似的做個應用層協議,發現很好使,真是無心插柳柳成蔭呀。