1. 网络编程的底层逻辑:先把这四件事想清楚
1.1 IP、端口、协议、IO模型:缺一不可的四个维度
很多刚入门的Java开发者把网络编程简单理解为“写Socket”,结果一碰到真实项目就懵了。其实网络编程脱不开四个维度:IP确定“在和谁说话”,端口确定“对方哪个程序在听”,协议确定“用双方都认得的规矩说话”,IO模型决定“你等数据的时候能不能干别的”。把这四个词拆开看,很多概念就顺了。
IP和端口好理解。你本机起一个服务,别人要访问,至少得知道你的IP和端口。这里有个容易踩的点:Java里ServerSocket默认绑定的是0.0.0.0,也就是所有网卡接口都能连,而localhost只监听回环地址。我第一次做联调测试就吃过亏,服务器上明明起了服务,别人死活连不上,查了半天发现是绑定地址写错了。所以写网络程序时,监听地址和对外暴露地址要分清楚。
协议就是通信的规矩。HTTP、TCP、UDP、WebSocket都是协议,但层级不同:HTTP是应用层协议,TCP和UDP是传输层协议。Java网络编程里,Socket直接操作的就是TCP,而HttpClient这类工具帮你把HTTP报文封装好了。很多面试题喜欢问“TCP和HTTP什么关系”,简单说是HTTP跑在TCP上,TCP只管可靠地把字节流送到,HTTP负责解释字节流里的请求和响应。
IO模型则是服务端性能的命门。最开始我们都是“一个客户端连上来,就开一个线程处理”,这叫BIO(阻塞IO),客户端多了线程数就爆炸。后来有了NIO,用少量线程管理大量连接。再后来Netty把NIO封装到极致。后面我会专门讲这部分,但先记住一句话:网络编程的复杂度,一半在协议,一半在IO模型。
1.2 生活类比:你是打电话,还是用对讲机
把TCP和UDP类比成通信工具,最好记。TCP像打电话:要先拨号建立连接,接通后你说一句我应一句,顺序不乱,对方没听清你会重说,挂断时也要互相确认。UDP像对讲机:按下就说话,不需要确认对方在不在听,可以说给一群人听,但可能丢字。所以TCP适合文件传输、网页请求,UDP适合视频通话、实时游戏。
对应到Java代码,TCP就是Socket和ServerSocket,通信前有connect的过程;UDP是DatagramSocket,new完直接send和receive,没有连接状态。后面我会分别给可运行的示例。但你现在可以把两个模型记在脑子里:一个是有状态的可靠字节流通道,一个是无状态的不可靠数据报文。
2. 零基础也能跑通的TCP实战:手写一个聊天程序
2.1 第一个ServerSocket程序:接管连接、收发数据
先上一个最简单的服务端程序,完成一个事:收到客户端发来的一句话,原样回过去。这段代码是理解后面一切的基础,建议你亲手敲一遍。
import java.io.*; import java.net.*; public class TcpEchoServer { public static void main(String[] args) throws IOException { // 监听本机9000端口 ServerSocket serverSocket = new ServerSocket(9000); System.out.println("服务器已启动,等待客户端连接..."); while (true) { // 阻塞等待客户端接入 Socket socket = serverSocket.accept(); System.out.println("客户端接入:" + socket.getInetAddress()); // 用BufferedReader读取客户端文本,用PrintWriter回写 BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream())); PrintWriter writer = new PrintWriter(socket.getOutputStream(), true); String line; while ((line = reader.readLine()) != null) { System.out.println("收到消息: " + line); writer.println("Echo: " + line); // 原样回给客户端 if ("bye".equalsIgnoreCase(line)) { break; } } socket.close(); } } }这里有个必须理解的点:accept()是阻塞的,程序会卡在这一行直到有客户端连上来。而内层循环会一直读到客户端关闭连接才结束。PrintWriter第二个参数true打开自动刷盘,否则客户端可能收不到数据,这是新手最容易踩的问题之一。
对应的客户端代码:
import java.io.*; import java.net.*; public class TcpEchoClient { public static void main(String[] args) throws IOException { Socket socket = new Socket("127.0.0.1", 9000); BufferedReader console = new BufferedReader(new InputStreamReader(System.in)); BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream())); PrintWriter writer = new PrintWriter(socket.getOutputStream(), true); String input; while ((input = console.readLine()) != null) { writer.println(input); String response = reader.readLine(); System.out.println("服务器响应: " + response); if ("bye".equalsIgnoreCase(input)) { break; } } socket.close(); } }先启动服务端,再启动客户端,然后在客户端控制台敲一句话,你就能看到回显。别看简单,这里已经覆盖了建立连接、发送数据、接收数据、关闭连接四个动作。很多网上教程会把这串代码写得花里胡哨,但核心就这些。
2.2 多线程处理多个客户端:别让一个连接占死主线程
上面的程序有个致命问题:一个客户端连上来后,主线程就陷进内层循环了,第二个客户端只能排队等。要支持多客户端,得给每个连接单独开线程,或者用线程池。
public class TcpChatServer { private static final ExecutorService pool = Executors.newFixedThreadPool(8); public static void main(String[] args) throws IOException { ServerSocket serverSocket = new ServerSocket(9000); while (true) { Socket socket = serverSocket.accept(); pool.submit(() -> handleClient(socket)); } } private static void handleClient(Socket socket) { try (socket; BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream())); PrintWriter writer = new PrintWriter(socket.getOutputStream(), true)) { String line; while ((line = reader.readLine()) != null) { System.out.println(Thread.currentThread().getName() + " 收到: " + line); writer.println("Echo: " + line); } } catch (IOException e) { e.printStackTrace(); } } }这个版本把每个连接丢给固定大小的线程池,同时处理8个客户端。线程池比“每连接一线程”好得多,因为线程创建销毁很贵,而且并发太高一万连接就挂。但线程池方案也有上限,如果每个客户端长时间占用线程,池子耗尽后新连接照样进不来,这也是后面要引入NIO的原因。现在你要记住的结论是:BIO加线程池能扛几百连接,但扛不住上万。
2.3 初学者的三个高频坑:端口占用、资源泄漏、中文乱码
先说端口占用。你跑一次服务端后,可能没关闭进程,下次启动就报Address already in use。这不是代码问题,是进程还活着。Windows下用netstat -ano | findstr 9000找到PID,再taskkill /F /PID;Linux下用lsof -i:9000或者fuser -k 9000/tcp。与其重启电脑,不如学会找进程。
再说资源泄漏。很多人只关了Socket,没关输入输出流。正确的顺序是先关流、再关Socket,或者像我上面那样用try-with-resources自动关。BufferedReader、PrintWriter都放在try括号里,就能避免连接数被耗尽。
最后是中文乱码。网络传输只有字节,没有字符编码这一说。你用getBytes()时得指定编码,两端不一致就会乱码。我习惯统一用UTF-8:new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)。PrintWriter最好也指定编码,比如new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true)。别依赖平台默认编码,部署到Linux服务器时默认编码通常不是UTF-8,坑你一次你就老实了。
3. TCP协议细节:连接管理与粘包问题
3.1 三次握手和四次挥手在Java代码层面意味着什么
面试必问三次握手,但光背“SYN、SYN+ACK、ACK”没用,你得映射到代码里。客户端new Socket(ip, port)这个调用,会触发三次握手;服务端accept()返回一个已经完成握手的Socket对象。也就是说,握手发生在连接建立阶段,在你调用connect()或accept()返回之前,系统内核已经帮你把过程处理完了。
四次挥手对应的是close()。当你调用close(),实际上是发起主动关闭,TCP会走完FIN、ACK、FIN、ACK四个步骤。这里有一个很经典的坑:如果两端都在等对方关闭,或者没有正确关闭流,握手挥手中的某个状态(比如CLOSE_WAIT、TIME_WAIT)会大量堆积。你线上排查时看到一堆CLOSE_WAIT,一定是有代码没关Socket。
网上说TIME_WAIT是主动关闭方产生的,持续约2MSL。如果你的服务端频繁主动关闭短连接,TIME_WAIT会很多,但这是正常现象,只要没有超过系统限制就不用慌。真正该慌的是CLOSE_WAIT,那说明对方关了,你这里没关干净。遇到大量CLOSE_WAIT,先检查是不是每个异常分支都调用了close。
3.2 粘包/拆包:最典型的网络编程面试题
很多新手第一次听说粘包都觉得很玄,其实它只针对TCP这种字节流协议。“流”的意思是:你send两条消息“Hello”和“World”,对端可能一次性收到“HelloWorld”,也可能收到“Hel”和“loWorld”。TCP不保证每次读写消息边界,它只保证字节顺序一致。这就像你往水管里丢两个乒乓球,水流会把它们黏在一起,也可能被冲散。
为什么会这样?因为底层有缓冲区,发送方多次write可能被合并成一个数据段发出,接收方也可能一次read到多个数据段。UDP没有这个问题,因为每个数据报都有明确边界。
解决粘包有几种常见方案:
- 固定长度:每次发100字节,不足补空格,接收方按长度截取。浪费空间,但最简单。
- 分隔符:比如用
\n结尾,读到换行就说明一条消息结束。HTTP早期就是按空行分隔header。但消息体里不能有分隔符,得转义。 - 长度前缀:每个消息开头用4字节表示后面数据长度。这也是最通用、最推荐的做法。
3.3 自定义协议设计入门:长度字段加数据体
与其只听概念,不如写一个最简单的长度前缀协议。假设消息格式是:前4个字节(int)表示数据长度,后面紧跟数据字节。
// 发送端 ByteArrayOutputStream baos = new ByteArrayOutputStream(); DataOutputStream dos = new DataOutputStream(baos); byte[] payload = "hello".getBytes(StandardCharsets.UTF_8); dos.writeInt(payload.length); dos.write(payload); byte[] frame = baos.toByteArray(); socket.getOutputStream().write(frame); socket.getOutputStream().flush(); // 接收端 DataInputStream dis = new DataInputStream(socket.getInputStream()); int length = dis.readInt(); byte[] data = new byte[length]; dis.readFully(data); // 读满length字节,读不够会阻塞 String message = new String(data, StandardCharsets.UTF_8);要点在于接收端必须用readFully,而不是read。read一次可能读不满length个字节,readFully则能保证读满,否则阻塞。这也是很多人调试粘包时,用read(byte[])发现数据不完整的原因。
掌握这个长度前缀协议,你就已经知道差不多所有RPC框架的消息头是怎么设计的了。后续再看Dubbo、gRPC之类,都是在这个思路上加版本号、请求ID、序列化方式等字段。
4. UDP实战:适合实时性要求高、可以丢数据的场景
4.1 DatagramSocket发送和接收
TCP写熟了,UDP简单得像儿戏。因为没有连接,直接发送和接收。
// 接收端 DatagramSocket server = new DatagramSocket(9001); byte[] buf = new byte[1024]; DatagramPacket packet = new DatagramPacket(buf, buf.length); server.receive(packet); // 阻塞等待 String msg = new String(packet.getData(), 0, packet.getLength(), StandardCharsets.UTF_8); System.out.println("收到来自" + packet.getAddress() + "的消息: " + msg); server.close();// 发送端 DatagramSocket client = new DatagramSocket(); byte[] data = "ping".getBytes(StandardCharsets.UTF_8); DatagramPacket packet = new DatagramPacket(data, data.length, InetAddress.getByName("127.0.0.1"), 9001); client.send(packet); client.close();留意DatagramPacket里必须带地址和端口,因为协议本身没有连接概念。在Java里,同一次new DatagramSocket()可以复用,向不同地址发数据,但每个包都要自己带目的地。
4.2 TCP vs UDP:什么时候选哪个
选择依据不是你喜不喜欢,而是业务容忍度。文件上传、交易请求、消息通知,这类丢一条都不行的场景,用TCP;视频帧、游戏位置、监控流,这类偶尔丢一帧无所谓的场景,用UDP。另外还有一个点:TCP有拥塞控制,会主动降速,UDP可以疯狂发包。实时音视频用UDP不是因为更“快”,而是因为TCP在网络出问题时宁可重传也不继续发,这会导致声音画面卡顿,UDP直接丢帧反而影响小。
4.3 一个简单的UDP广播实例
UDP广播在局域网内很有用,比如服务发现。发送端把目标地址设为255.255.255.255,端口设为一个约定好的端口:
DatagramSocket socket = new DatagramSocket(); socket.setBroadcast(true); byte[] data = "DISCOVER".getBytes(StandardCharsets.UTF_8); DatagramPacket packet = new DatagramPacket(data, data.length, InetAddress.getByName("255.255.255.255"), 9999); socket.send(packet);接收端监听9999端口就能收到。在实际项目中,服务启动时发一条广播,客户端收到后回一个包含IP和端口的单播包,就能让客户端自动找到服务端。这个套路在局域网打印机、智能设备配网里很常见。
5. HTTP实战:从客户端请求到服务端响应
5.1 现代Java发HTTP请求的正确姿势
以前的教程会让你用HttpURLConnection,但那个API写起来实在太啰嗦。JDK 11开始官方推出了java.net.http.HttpClient,是目前写Java HTTP客户端最顺手的方式。
HttpClient client = HttpClient.newHttpClient(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("http://localhost:8080/api/hello")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString("{\"name\":\"Java\"}")) .build(); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.statusCode()); System.out.println(response.body());这里你会看到几分函数式风格的味道:用builder构造请求,用BodyPublisher发数据,用BodyHandler处理响应。网络编程到这里已经不用再手动管缓冲区了,但前面TCP的知识并没有浪费,因为HTTP的连接管理、超时、keep-alive都依赖于底层Socket配置。你把HttpClient的connectTimeout设置一下,写着写着会发现不同之处。
5.2 极简HTTP服务器:解析请求行、返回JSON
用一个ServerSocket手写HTTP服务,是理解HTTP协议最快的方式。HTTP请求第一行是请求方法、路径和版本,比如GET /api/hello HTTP/1.1。所以你只要读一行,按空格拆分就能拿到Method和Path。
ServerSocket serverSocket = new ServerSocket(8080); while (true) { Socket socket = serverSocket.accept(); BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream())); String requestLine = reader.readLine(); // GET /api/hello HTTP/1.1 String[] parts = requestLine.split(" "); String method = parts[0]; String path = parts[1]; String body = "{\"message\":\"Hello " + path + "\"}"; byte[] bytes = body.getBytes(StandardCharsets.UTF_8); OutputStream os = socket.getOutputStream(); os.write("HTTP/1.1 200 OK\r\n".getBytes(StandardCharsets.UTF_8)); os.write("Content-Type: application/json\r\n".getBytes(StandardCharsets.UTF_8)); os.write(("Content-Length: " + bytes.length + "\r\n").getBytes(StandardCharsets.UTF_8)); os.write("\r\n".getBytes(StandardCharsets.UTF_8)); // 空行分隔响应头和响应体 os.write(bytes); os.flush(); socket.close(); }直接浏览器访问http://localhost:8080/api/hello,你就能收到JSON。这里必须设置Content-Length,不然浏览器不知道响应体到哪里结束,会一直等待连接关闭。这个例子虽然不能生产用,但能让你明白所有HTTP框架底层都在做同样的事情。
5.3 JSON处理的小技巧
Java处理JSON最常见的是Jackson或Gson。在项目里我推荐Jackson,因为Spring Boot默认也用它。一个最简单的序列化:
ObjectMapper mapper = new ObjectMapper(); Map<String, Object> map = new HashMap<>(); map.put("name", "Java"); String json = mapper.writeValueAsString(map);反序列化时如果碰到Date、LocalDateTime这种类型,序列化出来的是时间戳或数组,容易踩坑。建议在ObjectMapper上注册JavaTimeModule,并设置WRITE_DATES_AS_TIMESTAMPS为false,这样出来的才是可读日期字符串。这些小细节,面试不会考,但线上联调一定会遇到。
6. 高并发网络编程的基石:NIO与Selector
6.1 为什么阻塞IO撑不住几千连接
回到第2章的线程池服务,线程是昂贵资源。一个线程默认栈大小约1MB,8核机器开几千线程,光是栈内存就压垮JVM。到了互联网场景,网关可能同时挂几万个长连接,不可能每个连接一个线程。于是JDK 1.4引入NIO,让一个线程能管理成千上万个Channel。
NIO的三个核心是Buffer、Channel、Selector。Channel就是Socket通道,Buffer是缓冲区,数据先读到Buffer里再处理。Selector是“监工”,它能让一个线程同时监听多个Channel的事件,比如“某个连接有数据可读”“某个连接可以写”。没有数据时,Selector会阻塞,线程不会空转。
画个简化流程:注册一批Channel到Selector,循环调用select(),有事件就处理,没事件就睡觉。这就是典型的Reactor模型。Netty就是把这个模型做到了极致。
6.2 Buffer、Channel、Selector三件套怎么配合
我把一个最小NIO服务端写出来,注释尽量详细:
import java.net.InetSocketAddress; import java.nio.ByteBuffer; import java.nio.channels.*; import java.util.Iterator; public class NioServer { public static void main(String[] args) throws Exception { Selector selector = Selector.open(); ServerSocketChannel ssc = ServerSocketChannel.open(); ssc.bind(new InetSocketAddress(9002)); ssc.configureBlocking(false); // 非阻塞模式 ssc.register(selector, SelectionKey.OP_ACCEPT); ByteBuffer buffer = ByteBuffer.allocate(1024); // 缓冲区 while (true) { selector.select(); // 阻塞,直到有事件 Iterator<SelectionKey> keys = selector.selectedKeys().iterator(); while (keys.hasNext()) { SelectionKey key = keys.next(); keys.remove(); // 必须移除,否则会重复处理 if (key.isAcceptable()) { SocketChannel channel = ssc.accept(); channel.configureBlocking(false); channel.register(selector, SelectionKey.OP_READ); } else if (key.isReadable()) { SocketChannel channel = (SocketChannel) key.channel(); buffer.clear(); int len = channel.read(buffer); if (len > 0) { buffer.flip(); // 切换为读模式 channel.write(buffer); // 原样回写 } } } } } }注意configureBlocking(false)这句话,缺了它整个NIO都不成立。注册SocketChannel的OP_READ,这个Channel一旦有数据可读,就会进入selectedKeys集合。selectedKeys()返回的是本次准备好事件的集合,遍历完要清掉,这是最容易漏的。
还有buffer.flip(),理解Buffer的position、limit、capacity是NIO入门第一道坎。简单记忆:写入后想读到数据,先调用flip把limit设为当前位置、position归零,这样read/write方向就颠倒过来了。
6.3 一个可运行的NIO事件循环模型
上面那个代码,只是把“可读事件”和“可接受事件”处理了,但足够跑通。你找两个客户端连上来,都能收到回显。但它离生产还很远,因为单线程处理所有事件,其中一个Channel的业务阻塞,整个Selector就卡死。所以真实项目会用“主从Reactor”:一个线程专门accept,多个工作线程处理IO读写和业务。
这个套路不用自己造,Netty已经帮你封装好了。但从学习角度,我还是建议你亲手敲一遍原生NIO的Selector循环,哪怕只是几十行。敲完再去看Netty源码,你会理解EventLoop为什么是那副样子。
7. 实战进阶首选Netty:从学习路线到核心设计
7.1 Netty比原生NIO好在哪
原生NIO写起来已经够复杂了,还有各种细节坑。比如半包处理需要自己维护ByteBuf编解码,异常关闭连接要自己管理状态。Netty把这些都帮你封装好,还做了池化缓冲区、更复杂的Reactor模型、丰富的编解码器。
我个人的观点是:除非做纯学习或极端定制,生产环境直接用Netty。它不只是网络库,RPC框架、消息中间件、网关的底层基本都靠它,比如Dubbo、RocketMQ的通信层。学习Netty,等于在学整个Java分布式通信圈子的公共基础。
7.2 核心组件EventLoop、ChannelPipeline怎么记
Netty里四个词最重要。EventLoop:负责循环处理注册到它身上的Channel的所有事件,相当于一个线程配一个任务循环。ChannelPipeline:一个流水线,数据进入后按顺序经过一个个ChannelHandler。ChannelHandler:你真正写业务的地方,比如解码、编码、业务处理。ChannelFuture:异步操作的结果,类似回调。
把Pipeline理解成工厂传送带最形象。入站数据从头到尾漏过一个又一个handler,每个handler决定是否继续往下传;出站数据反方向走。所以粘包拆包,在Netty里就是一个“帧解码器”handler,比如LengthFieldBasedFrameDecoder,它把字节流拆成一个个完整帧交给业务handler。你第3章学的自定义协议,在这里就是一行配置。
7.3 给Java初学者的Netty学习路线
我见过很多同学一上来就啃Netty源码,两星期放弃。建议按这个顺序走:
- 第一阶段:跑通Netty官方Echo例子,理解服务器启动类
ServerBootstrap、EventLoopGroup、ChannelInitializer这些角色。 - 第二阶段:做一个最简单的聊天室,要求支持多人上线、消息广播。这让你必须处理自定义协议和ChannelGroup。
- 第三阶段:解决粘包、半包,用StringDecoder、LengthFieldBasedFrameDecoder等内置解码器。
- 第四阶段:看源码,主要看NioEventLoop的run方法如何循环选事件,和DefaultChannelPipeline如何传播事件。
这个顺序其实也是面试常考的顺序:你会不会写Netty程序,懂不懂事件循环,知不知道怎么解决粘包。按四阶段走完,至少能胜任常规的Netty开发岗位要求。
8. 面试高频考点与线上问题排查清单
8.1 必背的Java网络编程面试考点
结合这几年被问过和面试别人问过的题,考得最频繁的是这些:
- 三次握手和四次挥手,为什么要四次?核心是TCP全双工,两个方向都要分别关闭。四次挥手可能变成三次(延迟ACK合并)。
- TCP和UDP区别,典型应用各自有哪些。
- BIO、NIO、AIO有什么区别?BIO是阻塞IO,NIO是非阻塞,AIO是异步IO,目前生产主流是NIO和Netty。
- 粘包拆包怎么解决?固定长度、分隔符、长度前缀,Netty里对应哪些解码器。
- 一个TCP连接能发多少数据?吞吐量和什么有关?主要看带宽、延迟、窗口大小。
- 服务端大量CLOSE_WAIT是什么原因?通常是没正确关闭连接。
你如果能把每个问题背后都靠到代码场景上,面试官基本不会觉得你是背的。比如讲CLOSE_WAIT,顺便说自己在项目中用try-with-resources避免了资源泄漏,那就加分。
8.2 线上连接异常排查清单:超时、半连接、消息错乱
遇到网络问题别瞎猜,按顺序查。第一看网络通不通,ping和telnet ip port;第二看端口进程,netstat;第三看应用日志,有没有连接超时或读取超时;第四看内核参数,比如net.core.somaxconn代表accept队列长度,队列满了客户端会连接超时。
我碰到最经典的坑是:服务端起了多个实例,客户端只连IP,没有用负载均衡策略,部分连接被拒绝。还有一次是防火墙规则只放行了部分端口,客户端报Connection timed out,最后发现是操作系统的端口范围限制,客户端端口不够用了。
消息错乱问题基本都跟编解码有关。建议先在报文里加上magic number和length字段,过滤掉脏数据;同时在handler里打日志,记录收到16进制字节,比用肉眼看字符串排查高效得多。Wireshark抓包也非常有用,你可以看到TCP重传、乱序、连接释放全过程,能帮你省下大半调试时间。
8.3 个人建议:动手做一个最小项目打通全链路
学网络编程最怕“看了很多,一动手就废”。我建议你花一个周末做这个:用Netty写一个服务端和客户端,自定义协议传输字符串,支持心跳保活,再连上一个Redis存消息。做完之后你至少能说清楚Channel、EventLoop、编解码、断线重连这些事。
如果时间充裕,再加一个“并发压测”环节:用JMeter或自己写多线程客户端,同时开几百个连接发消息,观察CPU、内存、连接数曲线。这时候你会深刻理解为什么每连接一线程的模型撑不住高并发。踩过一次之后,再去看那些框架的设计,你会豁然开朗。
最后分享一个小技巧:遇到任何网络异常,先别急着查业务代码,用netstat或lsof把连接状态摸清楚,再决定下一步。这个习惯帮我排查过无数次线上问题,省下的时间够再做两个小项目。