1.1. 文章来由
1.2. Netty技术文章背后的故事
1.2.1. 机缘巧合接触Java NIO编程
1.2.2. 结识Netty
1.2.3. 赋能更多开发者
2.1. 培养技术总结和写作习惯
2.2. 循序渐进,厚积薄发
2.3. 技术写作技巧
3.1. 有的放矢,先掌握核心流程
3.2. 扎实的基础知识
3.2.1. Java NIO类库
3.2.2. 多线程编程
3.2.3. 编解码技术
3.3. 源码学习
3.3.1. 串行化设计避免线程竞争
3.3.2. 定时任务与时间轮算法
3.4. 项目实践
最近在某高校做技术交流时,有个博士加我微信时发现他是《Netty权威指南》的读者,研究生做项目时涉及到网络通信于是买了这本书边学习边实践。2022年ArchSummit架构师峰会杭州站讲师互动环节,来自阿里、小红书、得物等很多平台中间件的90后架构师说在上学时就读过《Netty权威指南》或者学习过我写的Netty系列技术文章,受益很多。
博文视点的编辑小姐姐有点感慨的告诉我,最近几年技术发展非常快,各种热门技术层出不穷,特别是AI大模型、AIGC方向,像Netty这样的基础开源框架能够10几年来长盛不衰,也是不太多的。她同时提到,《Netty权威指南》已经出版10年了,是不是可以写一篇关于Netty学习和实践,甚至是开源软件学习方法之类的文章纪念下,供更多的初学者借鉴。
正好,最近有个Netty读者问我如何在智能充电桩等物联网领域用好Netty,还有一些同学说感觉自己都学会了,但是不知道如何在实际项目中使用Netty。我想从技术写作者和学习者两个维度分享下这10年的经验,抛砖引玉,供大家参考。
09年开始,移动互联网飞速发展,对应的Java分布式中间件逐步流行起来,但是受到Tomcat/Jetty等 HTTP BIO编程模式的影响,大量的Java中间件(含开源)仍然采用低效的BIO模式进行通信调度,一旦对方响应慢就会阻塞通信线程,需要通过启动更多的线程以及设置超时时间来缓解通信阻塞问题,示例如下:
采用BIO带来的主要问题如下:
性能问题:一连接一线程模型导致服务端的并发接入数和系统吞吐量受到极大限制
可靠性问题:由于I/O操作采用同步阻塞模式,当网络拥塞或者通信对端处理缓慢会导致I/O线程被挂住,阻塞时间无法预测。
尽管2004年JDK1.4版本开始首次提供了NIO 1.0类库,但是对于Java程序员而言NIO编程难度很高,当时主流的J2EE服务器,几乎全部基于同步阻塞I/O构建,网上能够系统性学习Java NIO通信的文章很少,出了问题更是很难从网上寻找到答案,以至于10年后到了2014年,国内大部分的Java中间件仍然使用BIO通信。
1.2.1. 机缘巧合接触Java NIO编程
08年我参与设计和开发的一个电信系统在月初出帐期,总是发生大量的连接超时和读写超时异常,业务的失败率相比于平时高了很多,后经过排查,发现问题的主要原因出现在下游网元的处理性能上,月初的时候BSS出帐,在出帐期间BSS系统运行缓慢,由于我们采用了同步阻塞式的HTTP+XML进行通信,服务端出账期间无法及时返回HTTP响应,导致客户端大面积超时,同时把Tomcat线程挂住了(超时时间到了才能释放),导致其它电信系统调用我们系统也大面积超时,很多系统也被挂住了,形成了雪崩效应。客户希望我们能够从技术上解决该问题,受限于Tomcat以及大家当时对Java NIO编程的积累不够,该问题很长一段时间都没能从根本上解决,只能通过临时扩容、调整线程池策略和HTTP超时时间来规避。
09年我在做一个短信/彩信网关项目时,传统基于Tomcat BIO通信模式不仅性能差,可靠性也极差,网关要跟大量周边系统做交互,一旦被I/O阻塞,吞吐量瞬间就掉下来了,我们决定进行技术升级,从BIO切换到NIO。比较幸运的是,公司架构部派了两名资深架构师来支援我们,其中一个是做交换机出身,对Linux的I/O系统非常熟悉,在他们的指导下,我第一次全面接触到异步I/O编程和高性能电信级协议栈的开发,眼界大开——异步高性能内部协议栈、异步HTTP、异步SOAP、异步SMPP…所有的协议栈都是异步非阻塞。无论是性能还是可靠性,都可以“秒杀”传统基于BIO开发的应用服务器和各种协议栈。
当时我们的NIO框架是基于Java NIO类库自研的,NIO的Bug以及各种坑很多,整个业界的积累都非常少,那个年代资料匮乏,能够交流和探讨的圈内人很少(公司熟悉通信的都是C/C++大佬,Java NIO对大家都是无人区),一旦踩住“地雷”,就需要夜以继日的定位。在随后2年多的时间里,经历了10多次的通宵、凌晨被一线的测试/运维人员拉电话会议等种种问题之后,我们自研的NIO框架才逐渐稳定和成熟,在此期间解决的BUG总计约有20~30个。这段自研NIO框架的经历尽管不是一帆风顺,但是却打下了非常扎实的功底,后续学习和使用Netty可以得心应手。
1.2.2. 结识Netty
网关异步化改造成功之后,后续新立项的Java中间件计划统一采用NIO模式开发,此时就需要一个通用的NIO框架来支撑微服务框架、BPM、MQ等中间件。我原计划将网关孵化的NIO代码改造成通用的NIO框架。但是,10年之后,开源社区孵化出了两款很优秀的NIO框架:Mina和Netty。2周技术选型,我把Mina和Netty的核心代码读了一遍,并进行了性能对比测试,尽管Netty的代码更庞大、也更晦涩,但是对于细节处理的更好。例如ByteBuffer的分配考虑到了通过池化技术来提升性能,Mina却采用了每次请求时重新创建和分配ByteBuffer的机制,在长连接场景下每次请求都进行一次ByteBuffer的申请和释放对性能影响很大(实测约12%),尽管当时我给Mina作者提了优化建议,但是他坚持认为对于现代JVM而言ByteBuffer的申请和释放对性能影响很小,最终没采纳我的优化建议。
Netty框架尽管架构设计和代码实现都很优秀,但是想要学好并驾驭它还是有很大难度的,要掌握Java NIO类库、多线程编程、编解码技术等知识,对于初学者而言,入手门槛很高。
1.2.3. 赋能更多开发者
2014年春节前,我在新浪微博分享了一篇博文《Netty5.0架构剖析和源码解读》,短短1个月下载量达到了4000多(最终10W+次下载)。很多网友向我咨询NIO编程技术、NIO框架如何选择等问题,很多NIO初学者建议我系统化地写一些NIO技术/NIO框架领域的文章供大家参考学习。
作为最流行、表现最优异的NIO框架,Netty深受大家喜爱,但是长期以来除了UserGuide之外,国内鲜有Netty相关的技术书籍供广大NIO编程爱好者学习和参考。由于Netty源码的复杂性和NIO编程本身的技术门槛限制,对于大多数读者而言,通过自己阅读和分析源码来深入掌握Netty的设计原理和实现细节是件困难的事情。从2011年开始我系统性地分析和应用了Netty,转瞬间已经过去了3年多。在这3年的时间里,我们的系统经受了无数严苛的考验,对Netty也有了更深刻的体验并积累了丰富的实战经验。我们是开源框架Netty的受益者,为了让更多的朋友和同行能够了解NIO编程,深入学习和掌握Netty这个NIO利器,我在infoQ等技术社区分享了很多Netty学习和案例方面的文章,这些文章帮助了很多开发者入门Netty。
在我们的职业生涯中会经历很多项目,接触到很多技术,随着时间的流逝,技术细节在我们脑海中会逐渐淡化掉,例如,现在公司有些项目咨询我Netty某个具体问题时,我还是需要去翻代码,如果放在10年前我可以直接给出答案。技术需要总结和沉淀,通过传播帮助更多的人,同时也有利于树立在公司内外的技术品牌和影响力。
我遇到很多技术大牛但是却不善于进行技术写作,他们一天可以写500行代码,但是技术文章却一页也写不出来,技术能力强不等于技术写作水平就高。
一个项目做完之后,最重要的就是对项目进行复盘总结,主要包括:
项目的成功和失败点,经验教训总结
技术总结,包括学习类、问题案例类。
很少有团队或项目会硬性要求大家做技术类总结,所以培养主动总结习惯非常重要。技术总结有很多种方式,例如定位的问题案例集,可以分类总结和管理:I/O通信类、数据库、RPC调用等等。无论这些问题是否由自己定位和解决,通过输出案例集,一方面可以提升自己写作水平,另外也能提升自己在相关技术领域的实践能力。还有就是技术学习心得,例如项目中用到了Netty,但只用到了某几个功能特性,你想更全面和深入的掌握Netty,可能会结合Netty技术书籍或者文章来学习。你当前还不是项目组/公司的Netty技术专家,但是依然可以大胆的把自己学习和项目实践的心得写出来分享给更多人,写错了,有高人指点,或者引发讨论,最终得出正确答案,总结和分享过程本身就是能力提升的过程。当你在公司技术论坛写了几十篇相关领域技术文章之后,也许你就成为这个领域的专家了。
当坚持成为习惯之后,技术写作就会驾轻就熟,像跑步一样会形成一种肌肉记忆,逐年沉淀的技术总结文章会成为个人乃至公司的宝贵资产,让新加入的同学更快的成长,避免重复踩坑。
“台上一分钟,台下十年功”,一篇深受读者喜爱的技术文章背后是作者深厚的技术功底,以及长期的经验总结和积累。典型的技术专家成长路径如下:
刚工作的前三年是打基础的关键阶段,也是成长最快的三年,这个阶段除了要完成项目开发任务,同时还需要养成主动学习新知识的习惯,对于项目用到的关键技术不仅仅只停留在会使用阶段,而且要能够用好、甚至改进优化它。如果使用的是开源软件或者公司内平台部门开发的框架,尽量去研究下代码,加深理解。打基础阶段建议多做笔记,一来提升自己的文字功底,同时可以积累素材。16年我在广州给省移动公司做技术培训时,有个做游戏开发的广州读者拿了《Netty权威指南》的读书笔记找我签名,100多页的读书笔记让我很吃惊,他把关键技术点、困惑点,以及自己的理解都记录了下来,后来成为了项目组Netty技术专家。这个阶段的技术成果有三种表现形态:项目代码、技术学习/案例总结文档、停留在脑海中的经验。这些总结文档在后续写技术文章时非常有用,可以按需选取和加工,很多技术经验和问题细节随着时间流逝,没文档很难存续。上周还有测试和开发同学找我问17年的一个微服务故障案例细节,还好当时我做了案例总结。
三到五年是重要分水岭,一部分人脱颖而出逐渐成长为技术专家,一部分人则“泯然与大众”,沦为“搬砖的码农”。如果前几年积累的素材比较多,可以尝试在项目组、部门甚至公司多做一些技术分享,逐步打造自己的技术标签。当其他项目组遇到相关技术难题时往往会主动求助于你,帮别人解决问题的同时也是能力提升的过程,也许帮助别人并不会直接带来绩效收益,但是“赠人玫瑰,手留余香”,能成为部门或者公司某个技术领域公认的技术专家会为未来职业发展带来很大的帮助。
公司内外的各种技术大会提供了更广阔的舞台,技术专家要善于利用这些舞台结识行业或者领域牛人,有时跨界交流也能碰撞出灵感火花,当你从听众逐步成为主流技术大会的分享嘉宾时,离业界技术大牛也越来越近了。个人技术成长过程中会遇到瓶颈期,这时打开天花板的方法就是多走出去跟行业牛人/老师交流,哪怕在技术大会上跟嘉宾们聊三五分钟。
技术积累不是短跑而是马拉松,开始不需要冲的太猛,太想赢弄不好要输。循序渐进、持之以恒,每年进步一点点,三年、五年之后也能结出丰硕成果。
技术写作跟写作文类似,首先要制定大纲,围绕某个主题写,例如 Spring Bean的生命周期管理,一篇文章要把某个技术问题或者知识点讲透。不能太发散,“东一斧头西一榔头”,如果有多个主题,建议拆分为系列文章来写。篇幅不要过长,这会让读者无法通过碎片化时间一次学完,知识点过多掌握起来也有难度,会让部分读者产生挫败感,认为很多东西他记不住。“小猪佩奇”就很值得借鉴,考虑到小朋友的注意力和接受能力等因素,每集都是4分多钟,恰到好处。
题材的选取也很讲究,尽量不要选择科普类型的内容写,门槛太低了,谁都可以写,选择自己有积累的领域,这些文章更有深度和广度,借鉴意义大就更容易得到读者青睐。
我们的项目中大量使用开源软件,如何学好、用好开源软件是每个开发者都要面临的问题,通过分析如何学好Netty,为其它开源软件的学习提供借鉴。
经过10几年的发展,Netty的功能越来越丰富,涉及外延知识面越来越广,但是在我们实际项目中却不需要用到它的所有功能。首先要掌握它的核心流程,Netty作为NIO框架,它的核心就是服务端创建、客户端连接接入、消息的发送和读取。我们不能被它繁杂的功能吓到自乱阵脚:
通过Netty示例代码echo来调试服务端和客户端,学习下TCP Server和TCP Client如何创建,消息如何发送和读取:
通过代码调试,画出Netty服务端创建的时序图:
时序图梳理出来之后,接着就是学习并掌握JDK Selector、ServerSocketChannel等NIO类库的使用,熟悉这些基础类库之后再消化Netty的封装类,例如ServerSocketChannel等。
客户端创建同样需要先梳理出时序图,再逐步掌握SocketChannel、NioSocketChannel等核心功能类:
Netty中大量使用了设计模式、多线程编程、Java NIO类库、编解码技术等,要想学好Netty,首先要把相关联的基础知识打牢。
3.2.1. Java NIO类库
Netty基于Java NIO类库封装,因此学习Netty时首先要掌握Java NIO类库的几个核心类:
缓冲区Buffer:Buffer是一个对象,它包含一些要写入或者要读出的数据。在NIO类库中加入Buffer对象,体现了新库与原I/O的一个重要区别。在面向流的I/O中,可以将数据直接写入或者将数据直接读到Stream对象中。在NIO库中,所有数据都是用缓冲区处理的。在读取数据时,它是直接读到缓冲区中的;在写入数据时,写入到缓冲区中。任何时候访问NIO中的数据,都是通过缓冲区进行操作。
缓冲区实质上是一个数组。通常它是一个字节数组(ByteBuffer),也可以使用其他种类的数组。但是一个缓冲区不仅仅是一个数组,缓冲区提供了对数据的结构化访问以及维护读写位置(limit)等信息。
最常用的缓冲区是ByteBuffer,一个ByteBuffer提供了一组功能用于操作byte数组。除了ByteBuffer,还有其他的一些缓冲区,事实上,每一种Java基本类型(除了Boolean类型)都对应有一种缓冲区,继承关系如下:
通道Channel:Channel是一个通道,可以通过它读取和写入数据,它就像自来水管一样,网络数据通过Channel读取和写入。通道与流的不同之处在于通道是双向的,流只是在一个方向上移动(一个流必须是InputStream或者OutputStream的子类),而且通道可以用于读、写或者同时用于读写。
因为Channel是全双工的,所以它可以比流更好地映射底层操作系统的API。特别是在UNIX网络编程模型中,底层操作系统的通道都是全双工的,同时支持读写操作,它的继承关系如下:
多路复用器Selector:Selector是Java NIO编程的基础,熟练地掌握Selector对于掌握NIO编程至关重要。多路复用器提供选择已经就绪的任务的能力。简单来讲,Selector会不断地轮询注册在其上的Channel,如果某个Channel上面有新的TCP连接接入、读和写事件,这个Channel就处于就绪状态,会被Selector轮询出来,然后通过SelectionKey可以获取就绪Channel的集合,进行后续的I/O操作。
一个多路复用器Selector可以同时轮询多个Channel,这也就意味着只需要一个线程负责Selector的轮询,就可以接入成千上万的客户端,这确实是个巨大的进步。
3.2.2. 多线程编程
Netty框架大量使用到了并发编程技术,主要包括:
synchronized锁的使用
volatile关键字使用
CAS指令和原子类
线程安全类
读写锁
以volatile为例,NioEventLoop通过ioRatio控制I/O操作和其它任务运行比例,它的定义如下:
它被定义为volatile,为什么呢?首先对volatile关键字进行说明,然后再结合Netty的代码进行分析。关键字volatile是JAVA提供的最轻量级的同步机制,JAVA内存模型对volatile专门定义了一些特殊的访问规则,下面我们就看下它的规则:
当一个变量被volatile修饰后,它将具备两种特性:
线程可见性:当一个线程修改了被volatile修饰的变量后,无论是否加锁,其它线程都可以立即看到最新的修改,而普通变量却做不到这点;
禁止指令重排序优化,普通的变量仅仅保证在该方法的执行过程中所有依赖赋值结果的地方都能获取正确的结果,而不能保证变量赋值操作的顺序与程序代码的执行顺序一致。
ioRatio在NioEventLoop并没有被修改为什么要定义成volatile呢?因为NioEventLoop提供了重新设置I/O执行时间比例的公共方法,接口如下:
外部其它线程调用该方法会修改ioRatio,这样就形成了一个线程写、一个线程读,根据前面针对volatile的应用总结,此时可以使用volatile来代替传统的synchronized关键字提升并发访问的性能。Netty中大量使用了volatile来修改成员变量,如果理解了volatile的应用场景,读懂Netty volatile的相关代码还是比较容易的。
3.2.3. 编解码技术
通常我们也习惯将编码(Encode)称为序列化(serialization),它将对象序列化为字节数组,用于网络传输、数据持久化或者其它用途。反之,解码(Decode)/反序列化(deserialization)把从网络、磁盘等读取的字节数组还原成原始对象(通常是原始对象的拷贝),以方便后续的业务逻辑操作。
进行远程跨进程服务调用时(例如RPC调用),需要使用特定的编解码技术,对需要进行网络传输的对象做编码或者解码,以便完成远程调用。
Java默认提供了序列化机制,需要序列化的Java对象只需要实现java.io.Serializable接口并生成序列化ID,这个类就能够通过java.io.ObjectInput和java.io.ObjectOutput序列化和反序列化。
由于使用简单,开发门槛低,Java序列化得到了广泛的应用,但是由于它自身存在很多缺点,因此Netty并没有选择它,Java序列化的主要缺点:无法跨语言、序列化后的码流太大、性能差。
Netty的编解码技术学习,主要集中在如下工程中,当掌握其中一种编解码技术之后,学习其它的也比较容易,只要按照对应的编解码规则实现即可:
熟练掌握开源软件源码学习不可或缺,源码的学习要能够举一反三,理解作者为什么要这样设计,是否有更优的实现,要带着思考来学习。
以Netty最核心的NioEventLoop类为例进行说明,NioEventLoop是Netty的Reactor线程,它的职责如下:
作为服务端Acceptor线程,负责处理客户端的请求接入;
作为客户端Connecor线程,负责注册监听连接操作位,用于判断异步连接结果;
作为I/O线程,监听网络读操作位,负责从SocketChannel中读取报文;
作为I/O线程,负责向SocketChannel写入报文发送给对方,如果发生写半包,会自动注册监听写事件,用于后续继续发送半包数据,直到数据全部发送完成;
作为定时任务线程,可以执行定时任务,例如链路空闲检测和发送心跳消息等;
作为线程执行器可以执行普通的任务线程(Runnable)
读懂了功能之后,要理解和总结下它的设计理念,通过分析,发现它的设计理念核心是串行化无锁设计,避免线程竞争。
3.3.1. 串行化设计避免线程竞争
当系统在运行过程中,如果频繁的进行线程上下文切换,会带来额外的性能损耗。多线程并发执行某个业务流程,业务开发者还需要时刻对线程安全保持警惕,哪些数据可能会被并发修改,如何保护?这不仅降低了开发效率,也会带来额外的性能损耗。
为了解决上述问题,Netty采用了串行化设计理念,从消息的读取、编码以及后续Handler的执行,始终都由I/O线程NioEventLoop负责,这就意外着整个流程不会进行线程上下文的切换,数据也不会面临被并发修改的风险:
一个NioEventLoop聚合了一个多路复用器Selector,因此可以处理成百上千的客户端连接,Netty的处理策略是每当有一个新的客户端接入,则从NioEventLoop线程组中顺序获取一个可用的NioEventLoop,当到达数组上限之后,重新返回到0,通过这种方式,可以基本保证各个NioEventLoop的负载均衡。一个客户端连接只注册到一个NioEventLoop上,这样就避免了多个I/O线程去并发操作它。
Netty通过串行化设计理念降低了用户的开发难度,提升了处理性能。利用线程组实现了多个串行化线程水平并行执行,线程之间并没有交集,这样既可以充分利用多核提升并行处理能力,同时避免了线程上下文的切换和并发保护带来的额外性能损耗。
3.3.2. 定时任务与时间轮算法
在Netty中,有很多功能依赖定时任务,比较典型的有两种:
客户端连接超时控制;
链路空闲检测。
一种比较常用的设计理念是在NioEventLoop中聚合JDK的定时任务线程池ScheduledExecutorService,通过它来执行定时任务。这样做单纯从性能角度看不是最优,原因有如下三点:
在I/O线程中聚合了一个独立的定时任务线程池,这样在处理过程中会存在线程上下文切换问题,这就打破了Netty的串行化设计理念;
存在多线程并发操作问题,因为定时任务Task和I/O线程NioEventLoop可能同时访问并修改同一份数据;
JDK的ScheduledExecutorService从性能角度看,存在性能优化空间。
Netty的定时任务调度基于时间轮算法调度:
时间轮的执行由NioEventLoop来负责检测,首先看任务队列中是否有超时的定时任务和普通任务,如果有则按照比例循环执行这些任务。如果没有需要理解执行的任务,则调用Selector的select方法进行等待,等待的时间为定时任务队列中第一个超时的定时任务时延。从定时任务Task队列中弹出delay最小的Task,计算超时时间。经过周期tick之后,扫描定时任务列表,将超时的定时任务移除到普通任务队列中,等待执行,检测和拷贝任务完成之后,就执行到期的定时任务。
为了保证定时任务的执行不会因为过度挤占I/O事件的处理,Netty提供了I/O执行比例供用户设置,用户可以设置分配给I/O的执行比例,防止因为海量定时任务的执行导致I/O处理超时或者积压。
“纸上得来终觉浅,绝知此事要躬行”,只有在实际项目中把Netty用起来,解决实际项目中遇到的各种问题,才能不断加深对Netty的理解,用好它。以实际项目中客户端连接多个服务端为例进行说明。
有读者向我咨询Netty客户端想同时连接多个服务端,如下写法是否正确:
EventLoopGroup group = new NioEventLoopGroup();
try {
Bootstrap b = new Bootstrap();
b.group(group)
……代码省略
// Start the client.
ChannelFuture f1 = b.connect(HOST, PORT);
ChannelFuture f2 = b.connect(HOST2, PORT2);
// Wait until the connection is closed.
f1.channel().closeFuture().sync();
f2.channel().closeFuture().sync();
……代码省略
}
如果你掌握了Netty客户端连接相关代码就知道上述写法没问题:尽管Bootstrap自身不是线程安全的,但是执行Bootstrap的连接操作是串行执行的,而且connect(String inetHost, int inetPort)方法本身是线程安全的,它会创建一个新的NioSocketChannel,并从初始构造的EventLoopGroup中选择一个NioEventLoop线程执行真正的Channel连接操作,与执行Bootstrap的线程无关,所以通过一个Bootstrap连续发起多个连接操作是安全的,它的原理如下:
尽管上面写法没问题,但是却容易踩坑,在同一个Bootstrap中连续创建多个客户端连接,需要注意的是EventLoopGroup是共享的,也就是说这些连接共用一个NIO线程组EventLoopGroup,当某个链路发生异常或者关闭时,只需要关闭并释放Channel本身即可,不能同时销毁Channel所使用的NioEventLoop和所在的线程组EventLoopGroup,例如下面的代码片段就是错误的:
ChannelFuture f1 = b.connect(HOST, PORT);
ChannelFuture f2 = b.connect(HOST2, PORT2);
f1.channel().closeFuture().sync();
} finally {
group.shutdownGracefully();
}
稍有不慎就会因为某个链接异常而把客户端共享的I/O线程池关闭掉,这些经验都需要在项目实战中积累,源码阅读 + 项目实战 + 经验总结,是扎实掌握开源软件的关键组合。
李林锋,10多年NIO框架设计和开发经验,精通Netty等NIO框架,《Netty权威指南》作者。
↑限时五折优惠↑
尊敬的博文视点用户您好: 欢迎您访问本站,您在本站点访问过程中遇到任何问题,均可以在本页留言,我们会根据您的意见和建议,对网站进行不断的优化和改进,给您带来更好的访问体验! 同时,您被采纳的意见和建议,管理员也会赠送您相应的积分...
时隔一周,让大家时刻挂念的《Unity3D实战核心技术详解》终于开放预售啦! 这本书不仅满足了很多年轻人的学习欲望,并且与实际开发相结合,能够解决工作中真实遇到的问题。预售期间优惠多多,实在不容错过! Unity 3D实战核心技术详解 ...
如题 ...
读者评论