从零开始的Perl聊天 | P.I.C. 聊天系统
本文介绍了如何使用Perl脚本语言创建一个可运行的聊天系统。

引言
那么,让我们开始主题吧。首先,我从未对聊天或即时通讯软件感兴趣,事实上我也不知道它们是如何工作的。也许只是一点点。;) 我决定构建我自己的项目,以我自己的方式来实现它,看看它会如何运作。好的,P.I.C.(Private Internet Communication - 私人互联网通信)系统是用Perl编写的,它使用Perl gtk2模块来创建所有用户界面,并使用mysql数据库,它设计用于Linux系统,在Windows上无法运行。本文的主要目的是展示我们如何创建自己非常简单的聊天系统。
还有一件事,我不会过多地注释代码,因为Perl语法本身就能说明问题。
让我们看一下下面的图示来理解它的工作原理。

看,我很抱歉我的绘画技巧,我不是专业的。还有一件事,为什么是Perl?简单地说,因为我没有太多时间用C语言来玩构建它,而且它是一个开源项目。总之,我知道它应该做得更好,更安全等等。所以,现在你知道它是如何工作的了。所有发送到服务器的用户或管理员的数据都会被处理,并取决于请求中发送的信息。服务器执行一些操作,并将这些操作的结果发送给源发送者。所有数据都使用RC4和rot47算法的组合进行加密。是不是很简单?让我们看看代码。
Using the Code
解压存档后,您首先会看到2个文件:一个是icon,另一个是Installer。顺便说一句,请小心icon文件,如果删除了它,您将无法启动用户/管理员控制台。要摆脱这个问题,您需要查看生成文件中的代码,并从每个文件中删除一行。嘿,等等,伙计,什么生成的文件?就是这样,停,看看安装程序

您看到的这些字段都需要您填写数据。没有必要一一描述它们,只需点击“关于和帮助”按钮即可。我提到了这些生成的文件,所以,在每个文件都填入正确的数据后,通过点击“生成文件”按钮,您将得到3个文件:manager、client和server,已经配置好并准备运行。这些文件的内容是加密的,解密它们的函数如下所示
sub decry ($){ # base64 BTW
local($^W) = 0;
my $str = shift;my $res = "";
$str =~ tr|AZa-
z0-9+=/||cd;$str =~ s/=+$//;
$str =~ tr|A-Za-z0-9+/| -_|;
while ($str =~ /(.{1,60})/
gs) {my $len = chr(32 + length($1)*3/4);
$res .= unpack("u", $len . $1 ); }
$res;
}
所以,如果您担心我可能会在里面植入恶意shellcode,您可以这样检查代码
print(decry($part1));
好了,那么当您点击“生成文件”按钮时会发生什么?
$button->signal_connect(clicked=>sub {
$AutoRunFile = $ent->get_text;
$LogFile = $ent2->get_text;
$AdminIP = $ent3->get_text;
$MYSQLHost = $ent4->get_text;
$MYSQLLogin = $ent5->get_text;
$MYSQLPass = $ent6->get_text;
$MYSQLDatabase = $ent7->get_text;
$MYSQLTable = $ent8->get_text;
$ServerIP = $ent9->get_text;
$ServerPort = $ent10->get_text;
$ClientPort = $ent11->get_text;
$RC4Key = $ent12->get_text;
$ServerName = $ent13->get_text;
$Splitter = $ent14->get_text;
&GenerateUserConsole();
&GenerateServer();
&GenerateManagerConsole();
});
您的数据从每个Gtk2::Entry
小部件传递到相应的变量,然后使用您的数据生成文件。就这么简单,就像2+2一样。所以我们现在有了另外3个文件。首先,让我们检查用户端的应用程序。用户控制台

所以,第一件事是,它会打开一个端口并开始通过执行一个简单的函数来接受传入数据
sub makeConnection {
$sockMain = IO::Socket::INET->new(LocalPort => $LISTEN_PORT, Proto => $proto) or
$buffEx->insert ($buffEx->get_end_iter, "sockMain: $@! \n");
Gtk2::Helper->add_watch ( fileno $sockMain, 'in',sub{
($fd,$condition,$fh) = @_;\&watch_callback($fh,$tview);},$sockMain);
}
&makeConnection();
我应该指出这里的一些元素
$buffEx->insert ($buffEx->get_end_iter, "sockMain: $@! \n");
这负责记录任何事件,例如,如果发生套接字错误,此函数将在主窗口中打印有关错误的详细信息。非错误事件也是如此,例如成功的登录等。下一个元素:watch_callback
负责处理来自聊天服务器的任何传入数据,其外观如下
sub watch_callback {
my ($fh,$tview) = @_; my $msg;
int $i; $fh->recv($msg, $MAXLEN) or
$buffEx->insert ($buffEx->get_end_iter, "recv: $!\n");
my $buffer = $tview->get_buffer();
my $NewBuff = $NewTView2->get_buffer();
my $decRC4 = RC4( $pass, $msg );
my $decoded = rot47($decRC4);
my @words = split(/$J/, $decoded);
if($decoded eq "auth_nouser"){
$buffEx->insert ($buffEx->get_end_iter,
"you are not registered, please register to be able to use our chat :)\n");
}elsif($decoded eq "auth_notok") {
$buffEx->insert ($buffEx->get_end_iter,
"username or password incorrect, please try again!\n");
}elsif($decoded eq "auth_ok"){
$buffEx->insert ($buffEx->get_end_iter,
"Logged In! Please hit 'Update Users' Button to see user list ;)\n");
}elsif($decoded eq "reg_ok"){
$buffEx->insert ($buffEx->get_end_iter,
"Logged In and registered! Please hit 'Update Users' Button to see user
list ;)\n");
open (F5, ">>/etc/.P.I.C_DATA.txt"); print F5 cry($msg_send); close (F5);
}elsif($decoded eq "reg_notok") {
$buffEx->insert ($buffEx->get_end_iter,
"Account already in use! Try something different.\n");
}elsif($words[0] eq "users") {
$number_of_users = $words[1];
$buffEx->insert ($buffEx->get_end_iter, "Nr of users: $number_of_users\n");
foreach(1..2+$number_of_users){
$users_container .= $words[$i++];
$users_container .= "\n";
} @word = ($users_container =~ /(\w+)/g);
$xaxa = join "$J", @word[2..$number_of_users+2];
} elsif($words[0] eq "not_logged") {
print "not logged!\n";
$buffEx->insert ($buffEx->get_end_iter,
"You are not logged to the system. Please Log in.\n");
} elsif($words[0] eq "from_user") {
my $SourceUser = $words[1];
my $SourceMSG = $words[2];
my $Ready = "<$SourceUser> $SourceMSG";
&update_buffer($NewBuff,$Ready,0);
}elsif($words[0] eq "banned_user") {
$buffEx->insert ($buffEx->get_end_iter, "You are banned!\n"); }else
{ &update_buffer($buffer,$decoded,0);
} return 1;
}
从服务器返回的数据主要有两种类型。带有策略信息的,例如检查您是否已登录,是否未被禁止,是否已授权,用户列表等 - 这些数据会被格式化、拆分、操作、处理。还有简单的消息,它们将被简单地传递到update_buffer
函数,在主窗口中打印它们。如您所见,传入数据与传出数据一样,都使用RC4和rot47这两种算法进行加密。好吧,我仍然在谈论主窗口,但这并不是我们拥有的唯一窗口。还有一个窗口……您可能会问,是做什么用的?仅用于与一个选定的用户通信!发送消息给所有用户的函数如下所示
my $sock = IO::Socket::INET->new(Proto => $proto,PeerPort =>
$SEND_PORT, PeerAddr => $server_host)
or $buffEx->insert ($buffEx->get_end_iter, "Creating socket: $!\n");
my $encoded0 = rot47("<$tag_send> $msg_send");
my $encRC4 = RC4( $pass, $encoded0 );
$sock->send($encRC4) or $buffEx->insert ($buffEx->get_end_iter, "send: $!\n");
现在,发送数据给所有人与发送给一个人有什么区别?来了
my $ReadyValue = substr($value, 0, -4);
# username, ex. john [+], where [+] means status online, we cut the last 4
#characters and got john
my $sock = IO::Socket::INET->new(Proto => $proto,PeerPort =>
$SEND_PORT, PeerAddr => $server_host)or
$buffEx->insert ($buffEx->get_end_iter, "Creating socket: $!\n"); # socket stuff
my $encoded0 = rot47("to_user$J$ReadyValue$J$tag_send$J$msg_send"); # encryption
my $encRC4 = RC4( $pass, $encoded0 ); # and again
$sock->send($encRC4) or $buffEx->insert ($buffEx->get_end_iter, "send: $!\n"); # send!
现在,我们的服务器在这两种情况下都做什么?给所有用户
my $test;
my $dbh = DBI->connect("dbi:mysql:$db:$dbhost",$dbuser,$dbpass) ; # connect to mysql
my $sql_check = "select logged from $table where ip = '$remoteaddress'"; # sql query
my $sth_check = $dbh->prepare($sql_check); # prepare sql
$sth_check->execute or &SendStatus("Cannot check if user is logged!"); # execute sql
@row = $sth_check->fetchrow_array;
my $rowcheck = @row[$test]; # get data
if($rowcheck eq "true") { # if user is logged
my $dbh = DBI->connect("dbi:mysql:$db:$dbhost",$dbuser,$dbpass) ;
print "starting to send to everyone\n";
my $global_data;
my $data_int;
my $container_next;
my $int;
my $sql_fetch_ip = "select ip from $table";
my $sth_fetch_ip = $dbh->prepare($sql_fetch_ip);
$sth_fetch_ip->execute or &SendStatus("Problem while getting IPs list!");
while(@rowz = $sth_fetch_ip->fetchrow_array){
print @rowz; print "\n";
$global_data .= $rowz[$data_int];
$global_data .= "$J";
} @glob = split(/$J/, $global_data);
print @glob; print "\n";
my $sql_count_ip = "select count(*) from $table";
my $st_count_ip = $dbh->prepare($sql_count_ip);
$st_count_ip->execute;
while (@row_next = $st_count_ip->fetchrow_array){
$container_next .= $row_next[$int++];
} print "container_next: ".$container_next."\n";
foreach(1..$container_next){
my $UseIP = $glob[$data_int++];
if($UseIP == $remoteaddress) {goto nosend;}
my $sock3 = IO::Socket::INET->new(Proto => $proto, PeerPort =>
$SEND_PORT, PeerAddr => $UseIP) ;
my $encoded0 = rot47("$decoded"); my $encRC4 = RC4( $RC4pass, $encoded0 );
$sock3->send("$encRC4") or &SendStatus ( "send $!");
print "send to: ".$UseIP."\n"; nosend: print "Sender's IP omitted\n"; }
} else {
&SendUserStatus("not_logged", $remoteaddress); print "not logged..\n";
}
实际上,正如您所见(我不会注释行 - 太简单了),服务器会从数据库中获取所有IP,并将收到的消息发送给每个IP。现在,关于一个人通信的情况,您可能已经知道它会是什么样子了?
elsif ($words[0] eq "to_user"){
my $dbh = DBI->connect("dbi:mysql:$db:$dbhost",$dbuser,$dbpass) ;
my $UserName = $words[1]; my $DestAddr; my $test;
print "$UserName\n"; if(length($UserName) < 2){
&SendUserStatus("UserName too small!", $remoteaddress);
goto Endof;
}
my $sql_check = "select logged from $table where ip = '$remoteaddress'";
my $sth_check = $dbh->prepare($sql_check);
$sth_check->execute or &SendStatus ( "cant check if user ($remoteaddress) is logged");
@row = $sth_check->fetchrow_array; my $rowcheck = @row[$test];
if($rowcheck eq "true") {
my $tag_send = $words[2]; my $OrigMsg = $words[3];
my $sql_fetch_ip = "select ip from $table where name = '$UserName'";
my $sth_fetch_ip = $dbh->prepare($sql_fetch_ip);
$sth_fetch_ip->execute; my $data;
@row = $sth_fetch_ip->fetchrow_array;
$DestAddr = @row[$data];
my $sock3 = IO::Socket::INET->new(Proto => $proto, PeerPort =>
$SEND_PORT, PeerAddr => $DestAddr) ;
my $encoded0 = rot47("from_user$J$tag_send$J$OrigMsg");
my $encRC4 = RC4( $RC4pass, $encoded0 );
$sock3->send("$encRC4") or &SendStatus ( "send $!");
print "Message sent to: $DestAddr\n";
} else {
&SendUserStatus("not_logged", $remoteaddress);
} }
而用户端的应用程序,当收到这样的行:from_user$J$tag_send$J$OrigMsg
时,会执行以下操作
} elsif($words[0] eq "from_user") {
my $SourceUser = $words[1];
my $SourceMSG = $words[2];
my $Ready = "<$SourceUser> $SourceMSG";
&update_buffer($NewBuff,$Ready,0);
}
它会在所谓的“私人窗口”中打印一条消息。要只与一个选定的用户交谈,我们只需从用户列表中选择他即可。无需双击,无需额外的窗口,也无需无用的东西。也没有“发送消息”按钮,因为我认为现在没有人实际使用它们了,只需在消息输入框中键入消息,然后按回车键即可。这就是用户端的运作方式,没什么特别的。现在让我们检查管理员端。管理员控制台
我们有4个选项卡。第一个就像用户端的程序一样,所以无需解释。接下来是日志窗口。您可以在此处看到任何日志:来自管理员控制台和来自服务器的日志。控制台的日志系统使用与用户控制台相同的技术,现在关于服务器是如何完成的。服务器具有以下函数
sub SendStatus {
my($message) = @_;
my $master_sock = IO::Socket::INET->new(Proto =>
$proto, PeerPort => $SEND_PORT, PeerAddr =>
$MASTER_IP) ;
my $encoded0 = rot47("l0g$J$message"); # $J in our case is 'splitter' -
':', so: 'l0g:Message'
my $encRC4 = RC4( $RC4pass, $encoded0 );
$master_sock->send("$encRC4");
}
让我们看看任何东西是如何执行的
$sth->execute or &SendStatus
( "cannot execute mysql query while checking if user($login) exists!\n");
服务器尝试执行mysql查询,例如,它会检查用户是否存在。如果一切正常,那就没问题,无需担心。但是,如果出现问题,它会通知管理员。事情就是这样运作的。现在管理员控制台的watch_callback
函数会收到消息
}elsif ($words[0] eq "l0g"){
$buffEx->insert ($buffEx->get_end_iter, $words[1]."\n");
}
下图展示了聊天管理员如何查看详细统计信息。一些代码示例说明了它的工作原理。好了,我们想知道我们有多少用户,谁在线,谁不在线,谁被禁止了,等等。我们点击“更新统计信息”按钮,然后享受结果,同时脚本以以下方式处理我们的命令
$mainbut->signal_connect("clicked" =>sub { # button clicked!
my $clearbuffer = $mainview->get_buffer; # clear each column
$clearbuffer->delete($clearbuffer->get_start_iter, $clearbuffer->get_end_iter);
# actually our statistics window is
#splitted
my $clearbuffer2 = $mainview2->get_buffer;
# in the same number of columns we got in database
$clearbuffer2->delete($clearbuffer2->get_start_iter, $clearbuffer2->get_end_iter);
# excluding first one, primary
#column
my $clearbuffer3 = $mainview3->get_buffer;
# it is done so to avoid the mess in the window
$clearbuffer3->delete($clearbuffer3->get_start_iter, $clearbuffer3->get_end_iter);
# anyway it is not perfect now either
my $clearbuffer4 = $mainview4->get_buffer;
$clearbuffer4->delete($clearbuffer4->get_start_iter, $clearbuffer4->get_end_iter);
my $clearbuffer5 = $mainview5->get_buffer;
$clearbuffer5->delete($clearbuffer5->get_start_iter, $clearbuffer5->get_end_iter);
# after space has been prepared for incoming data we send the request to the server
my $sock3 = IO::Socket::INET->new(Proto => $proto, PeerPort => $SEND_PORT, PeerAddr =>
$server_host) ; #
#initialize socket
my $encoded0 = rot47("the_stats".$J."gimme"); # rot47 encryption
my $encRC4 = RC4( $pass, $encoded0 ); # RC4 encryption
$sock3->send("$encRC4"); # transmit request!
});
服务器端
elsif ($words[0] eq "the_stats"){ # first word of the command
if($remoteaddress eq $MASTER_IP) { # check if the request is done by admin
my $dbh = DBI->connect("dbi:mysql:$db:$dbhost",$dbuser,$dbpass) or
&SendStatus (" Detailed stats error - $DBI::errstr"); # connect to database
my $sql = "select * from $table"; # get them all!
my $sth = $dbh->prepare($sql); # prepare sql request....
$sth->execute or print "SQL Error: $DBI::errstr\n"; # execute!
while (@row = $sth->fetchrow_array) { #while getting data from DB
my $status; my $logged; my $banned; # some constants
if($row[5] eq "false"){$status = "OffLine"} elsif($row[5] eq "true")
{$status = "OnLine"} # assign online value
if($row[4] eq "false"){$logged = "Logged OFF"} elsif($row[4] eq "true")
{$logged = "Logged IN"} # assign logged
#value
if($row[7] eq "false"){$banned = "Not Banned"} elsif($row[7] eq "true")
{$banned = "Banned!"} # assign banned value
my $sock3 = IO::Socket::INET->new(Proto => $proto, PeerPort =>
$SEND_PORT, PeerAddr => $MASTER_IP) ; #
socket init
my $encoded0 = rot47("the_stats$J".$row[1]."$J".$status."$J".$row[6]."$J".$logged.
"$J".$banned); # encode data..
my $encRC4 = RC4( $RC4pass, $encoded0 ); # the same
$sock3->send("$encRC4") or &SendStatus ( "send $!");
#send! actually sending data unless last user is fetched...[]
} } else { # if request came not from admin, notify admin about issue
&SendStatus ("Unprivileged user ($remoteaddress) tried to
execute admin command! ".$words[0]);
} }
回到源代码
elsif ($words[0] eq "the_stats"){
my $buffExMain = $mainview->get_buffer(); # get buffer of every text widget / column
my $buffExMain2 = $mainview2->get_buffer();
my $buffExMain3 = $mainview3->get_buffer();
my $buffExMain4 = $mainview4->get_buffer();
my $buffExMain5 = $mainview5->get_buffer();
my $buffExMain6 = $mainview6->get_buffer();
$buffExMain->insert ($buffExMain->get_end_iter, "\t\t".$words[1]."\n");
# insert appropriate value into each column...
$buffExMain2->insert ($buffExMain2->get_end_iter, "\t\t".$words[2]."\n");
$buffExMain3->insert ($buffExMain3->get_end_iter, "\t\t".$words[3]."\n");
$buffExMain4->insert ($buffExMain4->get_end_iter, "\t\t".$words[4]."\n");
$buffExMain5->insert ($buffExMain5->get_end_iter, "\t\t".$words[5]."\n");
}
呼……简单吧?我就是喜欢Perl语言。还能说什么呢,希望一切都足够清楚,这样我们就可以继续看下一张图了。我们的最后一个窗口实际上是……看看这个“选择命令”标签?没错,我们的最后一个窗口是我们控制聊天服务器的命令中心,所以,命令不多,我们可以让聊天服务器休眠一段时间,禁止某个用户,重启远程机器,关闭远程机器,从远程机器删除聊天服务器等等。让我们看看按下按钮后会发生什么。但首先,内部视图
my $ccbox = Gtk2::HBox->new;
my $ccframe = Gtk2::Frame->new("ARGV[1] (It can be Username): "); # that is our
my $ccentry = Gtk2::Entry->new(); # entry, where arguments are passed
$ccentry->set_text("");
$ccframe->add($ccentry);
$ccbox->pack_start($ccframe,1,1,1);
$ccentry->set_width_chars(30);
my $ccframe2 = Gtk2::Frame->new("Select Command: "); # we are selecting command here
my @commands = qw/sUs_p3nd_eXecut1on b4n_thE_Imp0stEr p1c0fF re_sT4rT p0w3_R0fF dE_4Th/;
#commands
list,
# you can change it the way you want, i have wrote it this way to
# make it not interfered with any other commands
$combobox = Gtk2::ComboBox->new_text;
for ($commands[0]) {
$combobox->append_text ($_."-| Sleep |- [time(minutes)] - [Reason]");}
# appending command to combobox
for ($commands[1]) {
$combobox->append_text ($_."-| Ban |- [UserName] - [Reason]");}
for ($commands[2]) {$combobox->append_text (
$_."-| ShutDown P.I.C. Server | - [Reason]");}
for ($commands[3]) {$combobox->append_text ($_."-| Reboot Remote Machine |- [Reason]");}
for ($commands[4]) {$combobox->append_text
($_."-| Shutdown Remote Machine |- [Reason]");}
for ($commands[5]) {$combobox->append_text ($_."-| Delete P.I.C server | - [Reason]");}
$combobox->set_active(0); $ccframe2->add($combobox);$ccbox->pack_start($ccframe2,1,1,1);
my $ccbox_next = Gtk2::HBox->new; # another 2 arguments entries...
my $ccframe_next = Gtk2::Frame->new("ARGV[2]: ");
my $ccentry_next = Gtk2::Entry->new();
$ccframe_next->add($ccentry_next);
$ccbox_next->pack_start($ccframe_next,1,1,1);
$ccentry_next->set_width_chars(30);
my $ccframe_next2 = Gtk2::Frame->new("ARGV[3]: ");
my $ccentry_next2 = Gtk2::Entry->new();
$ccframe_next2->add($ccentry_next2);
$ccbox_next->pack_start($ccframe_next2,1,1,1);
$ccentry_next2->set_width_chars(30);
……按钮已按下……
my $transmition = Gtk2::Button->new_from_stock
("[ Transmit Command to P.I.C. Server ]");
$transmition->signal_connect("clicked" =>sub {
my $username = $ccentry->get_text(); # get text from each entry widget
my $argument1 = $ccentry_next->get_text();
my $argument2 = $ccentry_next2->get_text();
$cmdone = $combobox->get_active_text; # get command from combobox
@w = split(/-/, $cmdone); #ged rid of splitter '-'
my $joincmd = $w[0].$J.$username.$J.$argument1.$J.$argument2;
# join command with arguments separated with our
splitter
my $sock3 = IO::Socket::INET->new(Proto => $proto, PeerPort => $SEND_PORT,
PeerAddr => $server_host) ;
my $encoded0 = rot47("$joincmd");
my $encRC4 = RC4( $pass, $encoded0 );
$sock3->send("$encRC4"); # Transmit!
});
服务器端
elsif ($words[0] eq "b4n_thE_Imp0stEr"){
# if admin want to ban user ... and so on all the way down...
if($remoteaddress eq $MASTER_IP) {
my $User2Ban = $words[1];
my $Reason = $words[2];
my $dbh = DBI->connect("dbi:mysql:$db:$dbhost",$dbuser,$dbpass) or
&SendStatus ("Ban error - $DBI::errstr");
my $sql = "update $table set banned = 'true', logged = 'false',
online = 'false' where name = '$User2Ban'";
my $sth = $dbh->prepare($sql);
$sth->execute or &SendStatus ("Ban error - $DBI::errstr");
&NotifyAllUsers("User $User2Ban has been banned. Reason: $Reason");
# send message from server to each user
} else {
&SendStatus ("Unprivileged user ($remoteaddress)
tried to execute admin command! ".$words[0]);
}
}elsif ($words[0] eq "re_sT4rT"){
if($remoteaddress eq $MASTER_IP) {
my $Reason = $words[1];
&NotifyAllUsers("Server machine is going to reboot. Reason: $Reason");
system ("reboot"); die("Rebooting Machine!\n");
} else {
&SendStatus ( "Unprivileged user ($remoteaddress)
tried to execute admin command! ".$words[0]);
}
} elsif ($words[0] eq "p0w3_R0fF"){
if($remoteaddress eq $MASTER_IP) {
my $Reason = $words[1];
&NotifyAllUsers("Server machine is going offline. Reason: $Reason");
system("poweroff"); die("Machine OFFLINE!\n");
} else {
&SendStatus ( "Unprivileged user ($remoteaddress)
tried to execute admin command! ".$words[0]);
}
等等。所以,清楚了吗?我认为是。这可能是最简单的聊天系统示例了。我希望您会觉得玩这个很有趣,也许您会有自己的想法,添加自己的功能。实际上有很多东西可以添加。这种聊天的一个主要缺点是它需要一个外部IP。但是,无论如何,我的目的也不是在这里创建一个功能齐全且专业的聊天系统。如果您有任何关于扩展项目的想法,请告诉我。附注:还有一件事我没提到。为了让用户和管理员控制台在您的系统上看起来像您想要的那样,有一行代码可以编辑
Gtk2::Rc->parse ('/usr/share/themes/Qt/gtk-2.0/gtkrc');
只需在此处放置您的主题路径,聊天就会看起来像您想要的那样。:)
历史
- 2009年4月30日:初始版本