php/socket.io实现扫码登录
来源:互联网 发布:windows视觉样式文件 编辑:程序博客网 时间:2024/04/30 06:59
首先先给大家道个歉,由于上次写东西不认真,自己也没有测试,不仅没能帮到大家,还害的不少人走了弯路,所以原文章代码我删掉了
扫码登陆的原理 我上面的图上已经说的很清楚 实际上就是手机端的token传递到web端的过程 关于token我不多说,这个大家应该都懂
修改过后的代码可以在下面的链接中下载到 代码注释非常清楚 可以直接运行 请先看reedme!
首先是编写服务端
需要的node moudle如下
winston 日志模块 可以用log4替代 做日志记录用 需安装 npm installwinston 下同
express web服务器兼框架 主要利用里面一些现成的东西
socket.io 长链接的服务模块
request 网络请求 如果你的node server需要与后端web server进行通信 需要这个 选装
定义日志记录
var winston = require('winston');var logger = new (winston.Logger)({ transports: [ new (winston.transports.Console)({level: "info", timestamp: true}),//设置日志级别 new (winston.transports.File)({ filename: 'access.log',json: false})//设置日志记录的文件及各式 ]});
编写http服务
由于手机端没有必要做长链接 一般是以短链接的形式 当然你使用长链接也可以 可以省掉这一段
var http = require('http');//加载url模块 解析get参数 var url = require('url');//加载query模块 解析post参数var query = require("querystring"); //开启http服务var server = http.createServer(function (request,response) { //获取url访问参数 var pathUrl = url.parse(request.url).pathname; //我这里不实用rount模块来实现路由 直接使用switch来做一个简单的路由 switch (pathUrl){ //手机端链接成功 case '/connection': var postdata = ''; //当监测到post数据后 将参数追加到postdata中 如果数据量大 这里可以使用buffer request.on("data",function(postchunk){ postdata += postchunk; }) //接受完post数据后 request.on("end",function(){ var data = query.parse(postdata.toString('utf-8')); if(!isNull(data.uuid)){ errorHead(response,'没传递uuid参数'); response.end(); } replayToDisplayer(data.uuid,{"status":0,"message":"手机端已链接成功"},'/appconnect') successHead(response,'链接成功'); }) break; //手机端确认登陆 case '/confrim': var postdata = ''; request.on("data",function(postchunk){ postdata += postchunk; }) request.on("end",function(){ var data = query.parse(postdata.toString('utf-8')); if(!isNull(data.token)){ errorHead(response,'没传递token参数'); response.end(); } if(!isNull(data.uuid)){ errorHead(response,'没传递uuid参数'); response.end(); } replayToDisplayer(data.uuid,{"status":0,"data":{"token":data.token},"message":"手机端已确认登陆"},'/appconfirm') successHead(response,"手机端已确定登陆",data.token); }) break; default: errorHead(response); break; }}).listen(8889, "127.0.0.1");//http服务状态报告server.once('listening', function() { logger.info('tcp服务开启 监听端口 8889'); });
然后是socket的服务
//定义一个list存放uuid与socket.id的对应关系//同一时间会有多个客户端链接在服务器上 所以要知道手机端到底要将token给哪一个客户端 //当socket链接建立成功后 每一个链接都有一个独一无二的socket.id 服务端会根据socket.id来决定要给哪个客户端发送消息//而客户端链接的时候会提供一个唯一参数uuid //而手机端扫码完成后 就可以解析道这个参数 从而得知到底要响应那一个客户端//我们要做的就是将uuid与socket.id进行绑定 这里比较难懂一点 多看几遍 //可以理解成坐飞机 首先你得有张飞机票(socket.id) 而能上飞机的人都有飞机票 但你总得知道你坐在哪里//这时候你的座位号就有用了(uuid) 通过你的座位号你才知道 你究竟做哪 而根据座位号 可以反着计算出你的票是那张 这里要实现的就是票和座位号的绑定关系<span style="color:#FF0000;">var UUIDMap = {}</span>;//后续请主要关注这个集合的变化/** * 开启socket.io服务 */var request = require('request');var io = require('socket.io').listen(8888);logger.info('socket服务开启,监听端口:', 8888);/** * socket.io事件 连接成功 * @param {string} event名称 * @param {function} 连接成功的回调函数 */io.sockets.on('connection', function (socket) {logger.info('web端链接成功,socket_id为:', socket.id); var UUID; //客户端进行uuid与socket.id绑定 socket.on('/register', function(data){ UUID = data['uuid']; UUIDMap[UUID] = socket.id; logger.info('web端注册,uuid为', UUID); }); //客户端断开链接 socket.on('/disconnect', function () { if (UUID != null) { logger.info('客户端断开链接,从连接池中删除', "uuid 为"+UUID+",socket.id为"+socket.id); delete UUIDMap[UUID]; } }); });
公共函数
/** * http请求成功应答 */function successHead(response,notice,token){ response.writeHead(200,{"Content-Type":"text/plain","Content-Type":"text/html; charset=utf-8"}); var message = {"status":"0","data": isNull(token) ? token : "","message" :isNull(notice) ? notice : "请求成功"}; response.write(JSON.stringify(message)); response.end();}/** * http请求失败应答 */function errorHead(response,notice){ response.writeHead(200,{"Content-Type":"text/plain","Content-Type":"text/html; charset=utf-8"}); var message = {"status":"1","data":"","message": isNull(notice) ? notice : "请求地址不存在"}; response.write(JSON.stringify(message)); response.end();}/** * 向指定web端发送信息 * * @param {json} data 要返回的数据 * @param {string} event 要回调客户端的监听事件 * @returns {undefined} */function replayToDisplayer(uuid, data, event) { var submitUUID = uuid; var displayerSocket = findSocketByUUID(submitUUID); if (displayerSocket != null) { logger.info('根据uuid:'+uuid+"找到socket.id:"); displayerSocket.emit(event, data); } }/** * 通过uuid查找socket connection id * @param {uuid} data 要返回的数据 */function findSocketByUUID(UUID) { var targetSocketID = UUIDMap[UUID]; if (targetSocketID != null) { var targetSocket = io.sockets.connected[targetSocketID]; if (targetSocket != null) { return targetSocket; }else{ logger.info('不能根据uuid找到socketid,uuid为', UUID); } } return null;}/** * 判断是否null * @param {string} data * @return bool */function isNull(data){ return (data == "" || data == undefined || data == null) ? false : true; }
php后端
php后端主要用来生成二维码和校验token 校验部分请根据自身业务编写 在node server中使用request模块做一个网络请求发送到php端来验证token
include 'qrcode/phpqrcode.php';//uuid 唯一的标示符 用于指定客户端收发信息$uuid = 'abc123';//生成二维码文件$filename = 'qrcode'.time().mt_rand(1000,9999).'.png';//二维码中包含的数据$data = [ "ip"=>'127.0.0.1', "port"=>'8888', 'exprise'=>time()+60, 'uuid'=>$uuid];try{ QRcode::png($data,'temp/'.$filename,'L',15); echo json_encode(['code'=>1,'message'=>'temp/'.$filename,'uuid'=>$uuid]);}catch(\Exception $e) { echo json_encode(['code'=>0,'message'=>$e->getMessage()]);}
客户端(web)
web端主要加载二维码 然后即等待服务器响应 代码如下
<!Doctype html><html> <head> <title>扫码登录demo</title> <meta charset="utf-8"></meta></head> <body> <div style='margin:100px auto;width:80%;text-align:center'> <img src="" class="qrcode" style="display:none;margin:0 auto;"/><br /> <p></p><br /> <button class='button' onclick="getQrcode()" style='font-size:16px'>获取二维码登陆</button> </div><script type="text/javascript" src="js/socket.io.js"></script><script type="text/javascript" src="js/jquery.min.js"></script><script type="text/javascript"> var timeLimit = 60; var uuid; /** * 获取二维码 */ function getQrcode(){ $.ajax({ type: 'POST', url: '../php/index.php', data: {}, dataType: 'json', success: function(data){ if(data.code === 1){ $('.qrcode').attr('src','../php/'+data.message); $('.qrcode').show(); $('.button').attr('disabled',true); uuid = data.uuid; countDown(); init(data.uuid); console.log('生成二维码成功,正在建立链接...'); }else{ console.log(data.message); } } }); } /** * 刷新计时器 */ function countDown(){ var id = setInterval(function (){ var str = '二维码有效期剩余:'+timeLimit+'秒'; $('.button').html(str); if(timeLimit >0){ timeLimit--; }else{ timeLimit = 60; clearInterval(id); $('.button').html('获取二维码登陆'); $('.button').attr('disabled',false); } },1000); } /* * 初始化链接 */ function init(uuid) { var socket = io.connect('http://127.0.0.1:8888'); //向服务器发送uuid绑定socket.id socket.emit('/register',{uuid:uuid}); console.log("链接成功"); //手机端扫码成功 socket.on('/appconnect',function(data){ console.log(data); //后续操作 页面显示扫码成功啦等等 }); //手机端确认登陆 socket.on('/appconfirm',function(data){ //实际上就是要手机的token 扫码登陆实际上就是把手机的token传递到web端上 console.log(data); //后续操作 比如跳转页面 }); } </script> </body> </html>
手机端(ios swift)
扫码解析
import UIKitimport AVFoundationclass WKQrCodeViewController: UIViewController,AVCaptureMetadataOutputObjectsDelegate { fileprivate let sWidth = UIScreen.main.bounds.size.width fileprivate let sHeight = UIScreen.main.bounds.size.height fileprivate let maskViewColor = UIColor.black fileprivate let maskViewAlpha : CGFloat = 0.3 deinit { print("二维码界面被销毁了") } //比例 let scaleWidth : CGFloat = 0.6 var session:AVCaptureSession? var lineView:UIImageView? = UIImageView.init(imageName: "qrscan_line") var timer = Timer() fileprivate var isSent: Bool = false override func viewWillAppear(_ animated: Bool) { //即将进入时对状态条进行隐藏 UIApplication.shared.setStatusBarHidden(true, with: UIStatusBarAnimation.none) } override func viewWillDisappear(_ animated: Bool) { self.timer.invalidate() } override func viewDidLoad() { super.viewDidLoad() //二维码框上的动画计时器 self.timer = Timer.scheduledTimer(timeInterval: 2, target: self, selector: #selector(configLine), userInfo: nil, repeats: true) //获取摄像设备,注意是Video而不是Audio let device = AVCaptureDevice.defaultDevice(withMediaType: AVMediaTypeVideo) //初始化AV Session来协调和处理AV的输入和输出流 let session = AVCaptureSession() //创建输入流 let input:AVCaptureDeviceInput? = try! AVCaptureDeviceInput(device: device) if session.canAddInput(input){ session.addInput(input) } //创建输出流 let output:AVCaptureMetadataOutput = AVCaptureMetadataOutput() if session.canAddOutput(output){ session.addOutput(output) //设置输出流代理,从接收端收到的所有元数据都会被传送到delegate方法,所有delegate方法均在queue中执行 output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) //设置元数据的类型,这里是二维码QRCode output.metadataObjectTypes = [AVMetadataObjectTypeQRCode] // //固定宽度// let gdWidth : CGFloat = 200// let scaleWidth : CGFloat = 200 / sWidth //比例// let scaleWidth : CGFloat = 0.6 /*! 这个是手机横着的时候的 x,y,w,h */ output.rectOfInterest = CGRect(x : (1 - scaleWidth * sHeight / sWidth) / 2, y : (1 - scaleWidth) / 2,width : scaleWidth * sHeight / sWidth, height : scaleWidth) print(output.rectOfInterest) } //创建视频设备拍摄视频区域 let layer:AVCaptureVideoPreviewLayer = AVCaptureVideoPreviewLayer.init(session: session) layer.videoGravity = AVLayerVideoGravityResizeAspectFill layer.frame = CGRect(x : 0, y : 0, width : UIScreen.main.bounds.size.height,height : UIScreen.main.bounds.size.width); self.view.layer.addSublayer(layer) //上 let topView = UIView() print((sHeight - sWidth * scaleWidth) / 2) print(sHeight)//320 print(sWidth)//640 topView.frame = CGRect(x:0, y:0, width:sHeight, height:(sWidth - sHeight * scaleWidth) / 2) topView.backgroundColor = maskViewColor topView.alpha = maskViewAlpha //下 let downView = UIView() downView.frame = CGRect(x:0, y:sWidth - topView.frame.size.height, width:topView.frame.size.width, height:topView.frame.size.height) downView.backgroundColor = maskViewColor downView.alpha = maskViewAlpha //左 let leftView = UIView() leftView.frame = CGRect(x:0,y:topView.frame.size.height,width:(sHeight - (sWidth - 2*topView.frame.size.height)) / 2, height:sWidth - 2*topView.frame.size.height) leftView.backgroundColor = maskViewColor leftView.alpha = maskViewAlpha //右 let rightView = UIView() rightView.frame = CGRect(x:sWidth - 2*topView.frame.size.height + leftView.frame.size.width, y:topView.frame.size.height, width:(sHeight - (sWidth - 2*topView.frame.size.height)) / 2, height:sWidth - 2*topView.frame.size.height) rightView.backgroundColor = maskViewColor rightView.alpha = maskViewAlpha //温馨提示(上) var tmpview = UIView() tmpview = tmpview.configOnPrompt(center: rightView.center) view.addSubview(tmpview) //温馨提示(下) var lab = UILabel() lab = lab.configDownPrompt(frame: leftView.frame) view.addSubview(lab) self.view.layer.addSublayer(topView.layer) self.view.layer.addSublayer(downView.layer) self.view.layer.addSublayer(leftView.layer) self.view.layer.addSublayer(rightView.layer) //线 configLine() //框 configborder() //取消 configBack() //开始采集视频数据 session.startRunning() } func configBack() -> Void { let backButton = UIButton() backButton.setTitle("取消", for: .normal) backButton.sizeToFit() backButton.frame = CGRect(x:sHeight - 37.5, y:15, width:40, height:20) backButton.transform = CGAffineTransform(rotationAngle: CGFloat(M_PI_2)); backButton.backgroundColor = UIColor.clear backButton.addTarget(self, action: #selector(backEvent), for: UIControlEvents.touchUpInside) view.addSubview(backButton) } func backEvent() -> Void { print("二维码界面的返回被点击") guard (self.presentingViewController? .isKind(of: WKQrConfirmViewController.classForCoder()))! else { self.presentingViewController?.dismiss(animated: true, completion: nil) return } self.presentingViewController?.presentingViewController?.dismiss(animated: true, completion: nil) } //线(存在问题是图片不能够放在UIImageView上) func configLine() -> Void { /* imageView.contentScaleFactor = [[UIScreen mainScreen] scale]; 5 imageView.contentMode = UIViewContentModeScaleAspectFill; 6 imageView.autoresizingMask = UIViewAutoresizingFlexibleHeight; 7 imageView.clipsToBounds = YES; */// lineView?.contentScaleFactor = UIScreen.main.scale// lineView?.autoresizingMask = .flexibleHeight// lineView?.contentMode = .scaleAspectFill lineView!.frame = CGRect(x: (sHeight - (sWidth - 2*(sWidth - sHeight * scaleWidth) / 2)) / 2 + self.sWidth - 2*(self.sWidth - self.sHeight * self.scaleWidth) / 2,y: (sWidth - sHeight * scaleWidth) / 2, width: 2, height: (sWidth - sHeight * scaleWidth) / 2 + 2) UIView.animate(withDuration: 2) { self.lineView!.frame = CGRect(x: (self.sHeight - (self.sWidth - 2*(self.sWidth - self.sHeight * self.scaleWidth) / 2)) / 2,y: (self.sWidth - self.sHeight * self.scaleWidth) / 2,width: 2, height: (self.sWidth - self.sHeight * self.scaleWidth) / 2 + 2) self.view.addSubview(self.lineView!) } } func configborder() -> Void { let qrCodeFrameView = UIImageView(image: UIImage(named: "qrscan_frame")) qrCodeFrameView.frame = CGRect(x:(sHeight - (sWidth - (sWidth - sHeight * scaleWidth))) / 2, y:(sWidth - sHeight * scaleWidth) / 2, width:sWidth - (sWidth - sHeight * scaleWidth), height:sWidth - (sWidth - sHeight * scaleWidth)) view.addSubview(qrCodeFrameView) self.view.layer.addSublayer(qrCodeFrameView.layer); } //实现AVCaptureMetadataOutputObjectsDelegate的成员方法来处理二维码信息 @objc(captureOutput:didOutputMetadataObjects:fromConnection:) func captureOutput(_ captureOutput: AVCaptureOutput!, didOutputMetadataObjects metadataObjects: [Any]!, from: AVCaptureConnection!) { session?.stopRunning() //获取二维码信息元数据 guard let metadataObject = metadataObjects.first else { return } //让扫描只执行一次 if isSent == true { return } isSent = true let readableObject = metadataObject as! AVMetadataMachineReadableCodeObject //添加震动 AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate)) // MARK: - 拿到加密串base64 解码 && 反序列化 let decodedData = NSData(base64Encoded : readableObject.stringValue!, options:.ignoreUnknownCharacters ) let decodedString = String(data: decodedData! as Data, encoding: String.Encoding.utf8) let UTF8Data = decodedString?.data(using: String.Encoding.utf8) let oResult = try! JSONSerialization.jsonObject(with: UTF8Data!, options: [JSONSerialization.ReadingOptions.mutableContainers, JSONSerialization.ReadingOptions.mutableLeaves]) print(oResult) guard let result = oResult as? [String: AnyObject] else { print("result没解析出来!!") return } let host = result["host"] as! String let port = String(describing: result["port"]) let uuid = result["uuid"] as! String let viewModel = WKQrCodeViewModel() viewModel.qrCodeUpData(host: host, port : port, UUID: uuid, success: { let userToken = UserAccountViewModel.sharedUserAccount let para = ["uuid" : uuid, "token" : userToken.accessToken!] as [String : Any] print(para) viewModel.qrCodeUpDataAgain(para: para as! Dictionary<String, String>, success: { //第二次网络请求成功,触发去除二维码界面,返回跳进刷新界面 //完成跳转后对二维码界面进行销毁 self.dismiss(animated: false, completion: nil) let confirmVC = WKQrConfirmViewController() self.presentingViewController?.present(confirmVC, animated: true, completion: { }) }, failure: { (errMsg) in print("打印第二次失败信息:\(errMsg)") let alert = UIAlertController(title: "温馨提示", message: "服务器故障,请取消扫码", preferredStyle: .alert) alert.addAction(UIAlertAction(title: "知道了", style: .default){(action)->() in alert.view.isHidden = true }) alert.view.isHidden = true self.present(alert, animated: true, completion: {() -> Void in alert.view.transform = CGAffineTransform(rotationAngle: CGFloat(M_PI_2)) alert.view.isHidden = false }) }) }) { (errMsg) in print("打印失败信息\(errMsg)"); let alert = UIAlertController(title: "温馨提示", message: "服务器故障,请取消扫码", preferredStyle: .alert) alert.addAction(UIAlertAction(title: "知道了", style: .default){(action)->() in alert.view.isHidden = true }) alert.view.isHidden = true self.present(alert, animated: true, completion: {() -> Void in alert.view.transform = CGAffineTransform(rotationAngle: CGFloat(M_PI_2)) alert.view.isHidden = false }) } } override var shouldAutorotate : Bool { return false } override var supportedInterfaceOrientations : UIInterfaceOrientationMask { return UIInterfaceOrientationMask.portrait } override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() // Dispose of any resources that can be recreated. } }//MARK: 二维码界面提示extension UIView { func configOnPrompt(center:CGPoint) -> UIView { let promptLab1 = UILabel() let promptLab2 = UILabel() let backboard = UIView() backboard.backgroundColor = UIColor.clear backboard.bounds = CGRect(x: 0,y: 0,width: 300,height: 40) backboard.center = center promptLab1.text = "请使用电脑登陆" promptLab1.textColor = UIColor.white promptLab1.font = UIFont.boldSystemFont(ofSize: 15) promptLab1.textAlignment = .center promptLab1.backgroundColor = UIColor.clear promptLab1.numberOfLines = 1 promptLab1.frame = CGRect(x: 0,y: 0,width: 300,height: 15) promptLab2.text = "www.yiqiweikeshangchuan.com" promptLab2.textColor = UIColor.white promptLab2.font = UIFont.boldSystemFont(ofSize: 14) promptLab2.textAlignment = .center promptLab2.backgroundColor = UIColor.clear promptLab2.numberOfLines = 1 promptLab2.frame = CGRect(x: 0,y: 20,width: 300,height: 15) backboard.addSubview(promptLab1) backboard.addSubview(promptLab2) backboard.transform = CGAffineTransform(rotationAngle: CGFloat(M_PI_2)) return backboard }}extension UILabel { //温馨提示lable(下) func configDownPrompt(frame:CGRect) -> UILabel { let promptLab = UILabel() promptLab.text = "扫码登陆后进行上传" promptLab.textColor = UIColor.white promptLab.font = UIFont.boldSystemFont(ofSize: 15) promptLab.textAlignment = .center promptLab.backgroundColor = UIColor.clear promptLab.numberOfLines = 1 promptLab.transform = CGAffineTransform(rotationAngle: CGFloat(M_PI_2)) promptLab.frame = frame return promptLab }}
网络请求
import Foundationclass WKQrCodeViewModel { var url = "" //不含路径 private let netTool = NetworkTools.sharedTools; func qrCodeUpData(host : String, port : String, UUID : String, success : @escaping ()->(), failure : @escaping (_ errMsg : String) -> ()) { //协议 let url_protocol = "http://" //路径 let url_host = host; //端口号(暂用8889) let url_port = ":8889" //前缀 let path = "/connection" url = url_protocol + url_host + url_port print("第一次当前的url是\(url)") //url let urlStr: String = url + path //参数 let param = ["uuid":UUID] print("拼接后的网址是\(urlStr),parameterDic是\(param)") //请求 netTool.request(.POST, URLString: urlStr, parameters: param as [String : AnyObject]?) { (result, error) in if error == nil { guard let result = result as? [String: AnyObject] else { return } guard let status = result["status"] as? String else { return } print(status,result) if Int(status) == 0 { success() }else { guard let message = result["message"] as? String else { return } failure(message) } } else { failure("网络异常") } } } //第二次请求 func qrCodeUpDataAgain(para : Dictionary<String, String>, success:@escaping ()->(), failure:@escaping (_ errMsg : String)->()) -> Void { print(url) netTool.request(.POST, URLString: url+"/confrim", parameters: para as [String : AnyObject]?) { (result, error) in if error == nil { guard let result = result as? [String: AnyObject] else { return } guard let status = result["status"] as? String else { return } print(status,result) if Int(status) == 0 { success() }else { guard let message = result["message"] as? String else { return } //服务器返回失败消息 failure(message) } }else { failure("网络异常") } } }}
流程图
运行结果
客户端
模拟手机端的请求
手机端一般是2次请求
第一次需要告诉web端自己已经成功扫描二维码并解析
第二次是登陆确认
解析的步骤由手机端实现 这里我只是模拟 所以可以看到我的uuid和token是随便写的
node服务器端收到的信息
node服务器的搭建非常简单 http://blog.csdn.net/zhangsheng_1992/article/details/51322707
所有代码可以在这里找到 https://code.csdn.net/zhangsheng_1992/socket-io/tree/master
- php/socket.io实现扫码登录
- 关于PHP实现扫码登录
- Android 实现扫码登录
- php实现扫码支付
- 扫码登录的简易实现
- JAVA实现二维码扫码登录
- Java实现扫码二维码登录
- Java实现扫码二维码登录
- javaweb实现app扫码登录
- PHP实现微信开放平台扫码登录源码下载
- PHP实现微信开放平台扫码登录源码下载
- 个人网站实现扫码登录asp.net 扫码登录
- 扫码登录流程
- 扫码登录操作过程
- iOS 扫码登录
- android扫码登录
- 扫码登录
- APP扫码登录
- Node.js 入门篇
- 谷歌纸盒---基于智能手机的廉价VR眼镜
- /usr/local/nginx/sbin/nginx: error while loading shared libraries: libpcre.so.1
- 单例模式--常用保证内存new 的对象唯一
- Java多线程编程4--Lock的使用--公平锁和非公平锁
- php/socket.io实现扫码登录
- 一只计算机病毒的自白:我猜中了开头,却猜不中结局。
- MYSQL : innodb 索引排序,文件排序与 建立的索引的关系 分析
- ubuntu恢复unity桌面
- python各编码转换方法
- 小白说编译原理-9-最简单minus-c语言编译器
- 通过案例对SparkStreaming透彻理解三板斧之一:解密SparkStreaming另类实验及SparkStreaming本质解析
- AIX后台执行命令中4种方式
- 【Java故事系列】Java的发展历程