Netty服务端请求接受过程源码剖析
目标
- 服务器启动后,客户端进行连接,服务器端此时要接受客户端请求,并且返回给客户端想要的请求,下面我们的目标就是分析Netty 服务器端启动后是怎么接受到客户端请求的。
- 我们的代码依然与上一篇中用同一个demo, 用io.netty.example下的echo包下的代码
- 我们直接debug模式启动Server端,让后在浏览器输入Http://localhost:8007,接着以下代码分析
源码剖析
- 在上一篇文章Netty启动过程源码分析中,我们知道了服务器最终注册 一个Accept事件等待客户端的连接,同时将NioServerSocketChannel注册到boss单例线程池中,也就是EventLoop如上图左边黄色区域部分
- 因此我们想要分析接受client连接的代码,先找到对应的EventLoop源码,如上图中NioEventLoop 循环,找到如下源码
//代码位置 NioEventLoop --- > run()
@Overrideprotected void run() {int selectCnt = 0;for (;;) {try {int strategy;try {strategy = selectStrategy.calculateStrategy(selectNowSupplier, hasTasks());switch (strategy) {.......// 处理各种strategy类型default:}} catch (IOException e) {// If we receive an IOException here its because the Selector is messed up. Let's rebuild// the selector and retry. https://github.com/netty/netty/issues/8566rebuildSelector0();selectCnt = 0;handleLoopException(e);continue;}selectCnt++;cancelledKeys = 0;needsToSelectAgain = false;final int ioRatio = this.ioRatio;boolean ranTasks;if (ioRatio == 100) {try {if (strategy > 0) {//对strategy事件进行处理processSelectedKeys();}} finally {ranTasks = runAllTasks();}} else if (strategy > 0) {final long ioStartTime = System.nanoTime();try {processSelectedKeys();} finally {.......}} else {ranTasks = runAllTasks(0); // This will run the minimum number of tasks}.......} catch (CancelledKeyException e) {.......} catch (Throwable t) {handleLoopException(t);}.......}}
- 如上代码中 strategy 更具请求的类型走不同的策略,最后处理策略的方法是 processSelectedKeys();,我们继续根核心方法 processSelectedKeys();,如下源码
//进入processSelectedKeys ---》processSelectedKeysOptimized(); ---〉processSelectedKey
private void processSelectedKey(SelectionKey k, AbstractNioChannel ch) {final AbstractNioChannel.NioUnsafe unsafe = ch.unsafe();if (!k.isValid()) {final EventLoop eventLoop;try {eventLoop = ch.eventLoop();} catch (Throwable ignored) {return;}if (eventLoop == this) {unsafe.close(unsafe.voidPromise());}return;}try {int readyOps = k.readyOps();if ((readyOps & SelectionKey.OP_CONNECT) != 0) {int ops = k.interestOps();ops &= ~SelectionKey.OP_CONNECT;k.interestOps(ops);unsafe.finishConnect();}if ((readyOps & SelectionKey.OP_WRITE) != 0) {ch.unsafe().forceFlush();}if ((readyOps & (SelectionKey.OP_READ | SelectionKey.OP_ACCEPT)) != 0 || readyOps == 0) {unsafe.read();}} catch (CancelledKeyException ignored) {unsafe.close(unsafe.voidPromise());}}
- 第一个if中最事件合法性验证,接着获取readyOps,我们debug得到是16,如下图
- 找到SelectionKey中16 代码的意义
/*** Operation-set bit for socket-accept operations.** <p> Suppose that a selection key's interest set contains* <tt>OP_ACCEPT</tt> at the start of a <a* href="Selector.html#selop">selection operation</a>. If the selector* detects that the corresponding server-socket channel is ready to accept* another connection, or has an error pending, then it will add* <tt>OP_ACCEPT</tt> to the key's ready set and add the key to its* selected-key set. </p>*/public static final int OP_ACCEPT = 1 << 4;
- 术语连接请求,这就是我们拿到了之前用Http://localhost:8007 请求的连接,接着继续跟进代码 EventLoopGroup —> processSelectedKey —> unsafe.read(); 其中unsafe是NioMessageUnsafed,上一篇中有过分析用来处理消息接收
- 继续跟进AbstractNioMessageChannel —> read() ,得到如下源码,删了一些对本次无关的一些代码,如下
public void read() {assert eventLoop().inEventLoop();final ChannelConfig config = config();final ChannelPipeline pipeline = pipeline();final RecvByteBufAllocator.Handle allocHandle = unsafe().recvBufAllocHandle();allocHandle.reset(config);boolean closed = false;Throwable exception = null;try {try {do {int localRead = doReadMessages(readBuf);......allocHandle.incMessagesRead(localRead);} while (allocHandle.continueReading());} catch (Throwable t) {exception = t;}int size = readBuf.size();for (int i = 0; i < size; i ++) {readPending = false;pipeline.fireChannelRead(readBuf.get(i));}......if (exception != null) {......}if (closed) {......}} finally {......}}}
-
assert eventLoop().inEventLoop(); 判断改eventLoop线程是否当前线程
-
ChannelConfig config = config(); 获取NioServerSocketChannelConfig
-
ChannelPipeline pipeline = pipeline(); 获取DefaultChannelPipeline。他是一个双向链表,可以看到内部包含 LoggingHandler,ServerBootStraptHandler
-
继续跟进 NioServersocketChannel —> doMessage(buf),可以进入到NioServerSocketChannel,找到doMessage方法
protected int doReadMessages(List<Object> buf) throws Exception {SocketChannel ch = SocketUtils.accept(javaChannel());try {if (ch != null) {buf.add(new NioSocketChannel(this, ch));return 1;}} catch (Throwable t) {logger.warn("Failed to create a new channel from an accepted socket.", t);try {ch.close();} catch (Throwable t2) {logger.warn("Failed to close a socket.", t2);}}return 0;}
-
参数buf是一个静态队列。private final List readBuf = new ArrayList(); 读取boss线程中的NioServerSocketChannel接受到的请求,并且将请求放到buf容器中
-
SocketChannel ch = SocketUtils.accept(javaChannel()); 通过Nio中工具类的建立连接,其实底层是调用了ServerSocketChannelImpl —> accept()方法建立TCP连接,并返回一个Nio中的SocketChannel
-
buf.add(new NioSocketChannel(this, ch)); 将获取到的Nio中SocketCHannel包装成Netty中的NioSocketChannel 并且添加到buf队列中(list)
-
doReadMessages到这分析完。
-
我们回到回到EventLoopGroup —> ProcessSelectedKey
-
循环遍历之前doReadMessage中获取的buf中的所有请求,调用Pipeline的firstChannelRead方法,用于处理这些接受的请求或者其他事件,在read方法中,循环调用ServerSocket的Pipeline的fireChannelRead方法,开始执行管道中的handler的ChannelRead方法,如下
- 继续跟进,进入 pipeline.fireChannelRead(readBuf.get(i)); 一直跟到AbstracChannelHandlerContext —> invokeChannelRead
private void invokeChannelRead(Object msg) {if (invokeHandler()) {try {((ChannelInboundHandler) handler()).channelRead(this, msg);} catch (Throwable t) {notifyHandlerException(t);}} else {fireChannelRead(msg);}}
- 进入 handler() 中,DefaultChannelPipeline —> handler()
- debug源码可以看到,在管道中添加了多个Handler,分别是:HeadContext,LoggingContext,ServerBootStrapAcceptor,TailContext 因此debug时候会依次进入每一个Handler中。我们重点看ServerBootStrapAcceptor中的channelRead方法
@Override@SuppressWarnings("unchecked")public void channelRead(ChannelHandlerContext ctx, Object msg) {final Channel child = (Channel) msg;child.pipeline().addLast(childHandler);setChannelOptions(child, childOptions, logger);setAttributes(child, childAttrs);try {childGroup.register(child).addListener(new ChannelFutureListener() {@Overridepublic void operationComplete(ChannelFuture future) throws Exception {if (!future.isSuccess()) {forceClose(child, future.cause());}}});} catch (Throwable t) {forceClose(child, t);}}
- 因为参数msg是NioSocketChannel,此处强制转成channel,
- child.pipeline().addLast(childHandler); 将我们在main方法中设置的EchoServerHandler添加到pipeline的handler链表中
- setChannelOptions 对TCP参数赋值
- setAttributes 设置各种属性
- childGroup.register(child).addListener(…) 将NioSocketChannel注册到 NioEventLoopGroup中的一个EventLoop中,并且添加一个监听器
- 以上NioEventLoopGroup就是我们main方法创建的数组workerGroup
- 进入register方法, MultithreadEventLoopGroup —>register , SingleThreadEventLoop —>register , AbstractChannel —> register,如下
- 首先看MultithreadEventLoopGroup中的register
@Overridepublic ChannelFuture register(Channel channel) {return next().register(channel);}
- 进入next()方法中,最终我们可以追到 DefaultEventExecutorChooserFactory — > PowerOfTwoEventExecutorChooser — > next() 内部类中的next
private static final class PowerOfTwoEventExecutorChooser implements EventExecutorChooser {private final AtomicInteger idx = new AtomicInteger();private final EventExecutor[] executors;PowerOfTwoEventExecutorChooser(EventExecutor[] executors) {this.executors = executors;}@Overridepublic EventExecutor next() {return executors[idx.getAndIncrement() & executors.length - 1];}}
-
入上我们通过debug可以看到,next返回的就是我们在workerGroup中创建的线程数组中的某一个子线程EventExecutor
-
接下来我们在回到register方法: AbstractChannel —> register 方法,如下:
@Overridepublic final void register(EventLoop eventLoop, final ChannelPromise promise) {......AbstractChannel.this.eventLoop = eventLoop;if (eventLoop.inEventLoop()) {register0(promise);} else {try {eventLoop.execute(new Runnable() {@Overridepublic void run() {register0(promise);}});} catch (Throwable t) {......}}}
- 关键方法register0
private void register0(ChannelPromise promise) {try {// check if the channel is still open as it could be closed in the mean time when the register// call was outside of the eventLoopif (!promise.setUncancellable() || !ensureOpen(promise)) {return;}boolean firstRegistration = neverRegistered;doRegister();neverRegistered = false;registered = true;// Ensure we call handlerAdded(...) before we actually notify the promise. This is needed as the// user may already fire events through the pipeline in the ChannelFutureListener.pipeline.invokeHandlerAddedIfNeeded();safeSetSuccess(promise);pipeline.fireChannelRegistered();// Only fire a channelActive if the channel has never been registered. This prevents firing// multiple channel actives if the channel is deregistered and re-registered.if (isActive()) {if (firstRegistration) {pipeline.fireChannelActive();} else if (config().isAutoRead()) {// This channel was registered before and autoRead() is set. This means we need to begin read// again so that we process inbound data.//// See https://github.com/netty/netty/issues/4805beginRead();}}} catch (Throwable t) {// Close the channel directly to avoid FD leak.closeForcibly();closeFuture.setClosed();safeSetFailure(promise, t);}}
- 进入 doRegister(); 方法:AbstractNioChannel —> doRegister
@Overrideprotected void doRegister() throws Exception {boolean selected = false;for (;;) {try {selectionKey = javaChannel().register(eventLoop().unwrappedSelector(), 0, this);return;} catch (CancelledKeyException e) {if (!selected) {// Force the Selector to select now as the "canceled" SelectionKey may still be// cached and not removed because no Select.select(..) operation was called yet.eventLoop().selectNow();selected = true;} else {// We forced a select operation on the selector before but the SelectionKey is still cached// for whatever reason. JDK bug ?throw e;}}}}
- 上代码,selectionKey = javaChannel().register(eventLoop().unwrappedSelector(), 0, this);此处我们将bossGroup中的EventLoop的channel 注册到workerGroup中的EventLoop中的 select中,方法中会得到一个selectionKey
- 我们可以看register方法的注视,如下:
Registers this channel with the given selector, returning a selectionkey.
使用给定的选择器注册此通道,并返回选择键。
- 接着debug,最终会到 AbstractNioChannel 中的doBeginRead方法
@Overrideprotected void doBeginRead() throws Exception {// Channel.read() or ChannelHandlerContext.read() was calledfinal SelectionKey selectionKey = this.selectionKey;if (!selectionKey.isValid()) {return;}readPending = true;final int interestOps = selectionKey.interestOps();if ((interestOps & readInterestOp) == 0) {selectionKey.interestOps(interestOps | readInterestOp);}}
- 此方法比较难进入,包含了几个异步,将之前的断电去掉,再次http请求,可以到这个方法中
- 追到这里,针对客户的连接已经完成,接下来是读取监听事件,也就是bossGroup的连接建立,注册步骤已近完成了,接下来就是workerGroup中的事件处理了
Netty接收请求过程梳理
-
总流程:接收连接 — 》创建一个新的NioSocketChannel —〉 注册到一个WorkerEventLoop上 —》 注册selecotRead事件
- 服务器沦陷Accept事件(文中最开始的那个for循环),获取事件后调用unsafe的read方法,这个unsafe是ServerSocket的内部类,改方法内部由2部分组成
- doReadMessage 用于创建NioSocketChannel对象,改对象包装JDK的NioChannel客户端,该方法创建一个ServerSocketChannel
- 之后执行pipeline.firstChannelRead方法,并且将自己绑定到一个chooser选择器选择的workerGroup中的某个EventLoop上,并且注册一个0(连接),表示注册成功,但是并没有注册1 (读取)
-
上一篇:Netty启动流程源码剖析
相关文章:
Netty服务端请求接受过程源码剖析
目标 服务器启动后,客户端进行连接,服务器端此时要接受客户端请求,并且返回给客户端想要的请求,下面我们的目标就是分析Netty 服务器端启动后是怎么接受到客户端请求的。我们的代码依然与上一篇中用同一个demo, 用io.…...
金三银四春招特供|高质量面试攻略
🔰 全文字数 : 1万5千 🕒 阅读时长 : 20min 📋 关键词 : 求职规划、面试准备、面试技巧、谈薪职级 👉 公众号 : 大摩羯先生 本篇来聊聊一个老生常谈的话题————“面试”。利用近三周工作午休时间整理了这篇洋洋洒洒却饱含真诚…...
搭建Hexo博客-第4章-绑定自定义域名
搭建Hexo博客-第4章-绑定自定义域名 搭建Hexo博客-第4章-绑定自定义域名 搭建Hexo博客-第4章-绑定自定义域名 在这一篇文章中,我将会介绍如何给博客绑定你自己的域名。其实绑定域名本应该很简单的,但我当初在这上走了不少弯路,所以我觉得有…...
lightdb-sql拦截
文章目录LightDB - sql 审核拦截一 简介二 参数2.1 lightdb_sql_mode2.2 lt_firewall.lightdb_business_time三 规则介绍及使用3.1 select_without_where3.1.1 案例3.2 update_without_where/delete_without_where3.2.1 案例3.3 high_risk_ddl3.3.1 案例LightDB - sql 审核拦截…...
二进制中1的个数-剑指Offer-java位运算
一、题目描述编写一个函数,输入是一个无符号整数(以二进制串的形式),返回其二进制表达式中数字位数为 1 的个数(也被称为 汉明重量).)。提示:请注意,在某些语言(如 Java&…...
学自动化测试可以用这几个练手项目
练手项目的业务逻辑比较简单,只适合练手,不能代替真实项目。 学习自动化测试最难的是没有合适的项目练习。 测试本身既要讲究科学,又有艺术成分,单单学几个 api 的调用很难应付工作中具体的问题。 你得知道什么场景下需要添加显…...
2023年保健饮品行业分析:市场规模不断攀升,年度销额增长近140%
随着人们健康意识的不断增强,我国保健品市场需求持续增长,同时,保健饮品的市场规模也在不断攀升。 根据鲸参谋电商数据显示,2022年度,京东平台上保健饮品的年度销量超60万件,同比增长了约124%;该…...
2023-02-17 学习记录--TS-邂逅TS(一)
TS-邂逅TS(一) 不积跬步,无以至千里;不积小流,无以成江海。💪🏻 一、TypeScript在线编译器 https://www.typescriptlang.org/play/ 二、类型 1、普通类型 number(数值型ÿ…...
SpringMVC创建异步回调请求的4种方式
首先要明确一点,同步请求和异步请求对于客户端用户来讲是一样的,都是需客户端等待返回结果。不同之处在于请求到达服务器之后的处理方式,下面用两张图解释一下同步请求和异步请求在服务端处理方式的不同:同步请求异步请求两个流程…...
MySQL(二)表的操作
一、创建表 CREATE TABLE table_name ( field1 datatype, field2 datatype, field3 datatype ) character set 字符集 collate 校验规则 engine 存储引擎; 说明: field 表示列名 datatype 表示列的类型 character set 字符集,如…...
SpringCloud - 入门
目录 服务架构演变 单体架构 分布式架构 分布式架构要考虑的问题 微服务 初步认识 案例Demo 服务拆分注意事项 服务拆分示例 服务调用 服务架构演变 单体架构 将业务的所有功能集中在一个项目中开发,打成一个包部署优点: 架构简单部署成本低缺…...
进一步了解C++函数的各种参数以及重载,了解C++部分的内存模型,C++独特的引用方式,巧妙替换指针,初步了解类与对象。满满的知识,希望大家能多多支持
C的编程精华,走过路过千万不要错过啊!废话少说,我们直接进入正题!!!! 函数高级 C的函数提高 函数默认参数 在C中,函数的形参列表中的形参是可以有默认值的。 语法:返…...
Chapter6:机器人SLAM与自主导航
ROS1{\rm ROS1}ROS1的基础及应用,基于古月的课,各位可以去看,基于hawkbot{\rm hawkbot}hawkbot机器人进行实际操作。 ROS{\rm ROS}ROS版本:ROS1{\rm ROS1}ROS1的Melodic{\rm Melodic}Melodic;实际机器人:Ha…...
Sass的使用要点
Sass 是一个 CSS 预处理器,完全兼容所有版本的 CSS。实际上,Sass 并没有真正为 CSS 语言添加任何新功能。只是在许多情况下可以可以帮助我们减少 CSS 重复的代码,节省开发时间。 一、注释 方式一:双斜线 // 方式二:…...
计算机启动过程,从按下电源按钮到登录界面的详细步骤
1、背景 自接触计算机以来,一直困扰着我一个问题。当我们按下电脑的开机键后,具体发生了哪些过程呢?计算机启动的具体步骤是什么? 计算机启动过程通常分为五个步骤:电源自检、BIOS自检、引导设备选择、引导程序加载和…...
LeetCode 刷题之 BFS 广度优先搜索【Python实现】
1. BFS 算法框架 BFS:用来搜索 最短路径 比较合适,如:求二叉树最小深度、最少步数、最少交换次数,一般与 队列 搭配使用,空间复杂度比 DFS 大很多DFS:适合搜索全部的解,如:寻找最短…...
Hadoop01【尚硅谷】
大数据学习笔记 大数据概念 大数据:指无法在一定时间范围内用常规软件工具进行捕捉、管理和处理的数据集合,是需要新处理模式才能具有更强的决策力、洞察发现力和流程优化能力的海量、高增长率和多样化的信息资产。 主要解决,海量数据的存储…...
Echarts 配置横轴竖轴指示线,更换颜色、线型和大小
第018个点击查看专栏目录本示例是描述如何在Echarts上配置横轴竖轴指示线,更换颜色、线型和大小。方法很简单,参考示例源代码。 文章目录示例效果示例源代码(共85行)相关资料参考专栏介绍示例效果 示例源代码(共85行&a…...
OpenAI 官方API Java版SDK,两行代码即可调用。包含GhatGPT问答接口。
声明:这是一个非官方的社区维护的库。 已经支持OpenAI官方的全部api,有bug欢迎朋友们指出,互相学习。 注意:由于这个接口: https://platform.openai.com/docs/api-reference/files/retrieve-content 免费用户无法使…...
SpringBoot 日志文件
(一)日志文件有什么用?除了发现和定位问题之外,我们还可以通过日志实现以下功能:记录用户登录日志,以便分析用户是正常登录还是恶意破解用户。记录系统的操作日志,以便数据恢复和定位操作 。记录程序的执行时间&#x…...
SQL71 检索供应商名称
描述Vendors表有字段供应商名称(vend_name)、供应商国家(vend_country)、供应商州(vend_state)vend_namevend_countryvend_stateappleUSACAvivoCNAshenzhenhuaweiCNAxian【问题】编写 SQL 语句,…...
02:入门篇 - 漫谈 CTK
作者: 一去、二三里 个人微信号: iwaleon 微信公众号: 高效程序员 十万个为什么 五千个在哪里?七千个怎么办?十万个为什么?。。。生活中,有很多奥秘在等着我们去思考、揭示! 同样地,在使用 CTK 时,很多小伙伴一定也存在诸多疑问: 为什么 CTK Plugin Framework 要借…...
SpringBoot常用注解
SpringBootApplication注解包含如下三个SpringBootConfigurationEnableAutoConfigurationComponentScanSpringBootConfiguration等同于Configuration,是属于spring的一个配置类这里的 Configuration 对我们来说并不陌生,它就是 JavaConfig 形式的 Spring…...
RBAC权限模型
什么是RBAC权限模型? RBAC是基于角色的访问控制,在RBAC中,权限与角色相关联,用户通过成为适当角色的成员而得到这些角色的权限。 1.0级 用户、角色、权限 2.0 权限分级 公司>部门>小组 2.1 权限继承 ps: 这个人是一个小组长…...
【郭东白架构课 模块一:生存法则】07|法则三:架构师如何找到自己的商业模式?
你好,我是郭东白,今天我们来聊聊架构活动中对商业价值的考量。 今天我们要讲的是架构师的第三个生存法则:作为一个架构师,必须要在有限的资源下最大化架构活动所带来的商业价值。对于任何一个架构活动而言,架构师的可…...
STM32 - 看门狗
独立看门狗 IWDG专业时钟LSI 低功耗仍可以运行对定时的控制比较松喂狗这些时间是按照40kHz时钟给出。实际上,MCU内部的RC频率会在30kHz到60kHz之间变化。此外,即使RC振荡器的频率是精确的,确切的时序仍然依赖于APB接口时钟与RC振荡器时钟之间…...
Redis集群搭建
一、哨兵模式 在 redis3.0之前,redis使用的哨兵架构,它借助 sentinel 工具来监控 master 节点的状态;如果 master 节点异常,则会做主从切换,将一台 slave 作为 master。 哨兵模式的缺点: (1&…...
车载基础软件——AUTOSAR AP典型应用案例
我是穿拖鞋的汉子,魔都中一位坚持长期主义的工程师! 最近不知道为何特别喜欢苏轼的一首词: 缺月挂疏桐,漏断人初静。谁见幽人独往来,缥缈孤鸿影。 惊起却回头,有恨无人省。拣尽寒枝不肯栖,寂寞…...
消息中间件----内存数据库 Redis7(第3章 Redis 命令)
Redis 根据命令所操作对象的不同,可以分为三大类:对 Redis 进行基础性操作的命令,对 Key 的操作命令,对 Value 的操作命令。3.1 Redis 基本命令首先通过 redis-cli 命令进入到 Redis 命令行客户端,然后再运行下面的命令…...
react-03-react-router-dom-路由
react-router-dom:react路由 印记中文:react-router-dom 1、路由原理 <!DOCTYPE html> <html lang"en"> <head><meta charset"UTF-8"><title>前端路由的基石_history</title> </head> <body><a hre…...
在深圳做网站平台需要什么备案/杭州全网推广
字典的常用方法 方便举例,先创建2个字典 list_test{"bob":19,"aoa":18,"coc":17} list_b{qqq:000}参数返回值含义.items()dict_items([(‘bob’, 19), (‘aoa’, 18), (‘coc’, 17)])返回所有键值.keys()dict_keys([‘bob’, ‘ao…...
哪里可以做网站平台/百度人工客服电话怎么转人工
thinkphp5使用无限极分类来源:中文源码网 浏览: 次 日期:2019年11月5日【下载文档: thinkphp5使用无限极分类.txt 】(友情提示:右键点上行txt文档名->目标另存为)thinkphp5使用无限极分类本文实例为大家分享了thinkphp5使用无限极分…...
怎么联网访问自己做的网站/qq空间秒赞秒评网站推广
原理很简单,根据你的给定的字段和之前设定的reduce值来分区 比如说 我先设置成 set mapreduce.job.reduces3; 然后将id 分成三个区,然后按成绩排序 select * from score distribute by s_id sort by s_score; 注:可能打印出来不是很明显&a…...
网站建设的七大优缺点/扬州seo
最近在做identityServer3ADFS 实现域账号第三方授权验证,发现一个问题,在我们网站跳转到域账户登录页面,这个页面有点不美观,那么我们改如何自定义这个登录界面呢? ADFS安装配置这里不做提示,百度大把资料&…...
北京网站建设求职简历/注册百度推广账号
http://www.neoease.com/css-javascript-combo-tool/ http://www.neoease.com/minimize-javascript-files-using-ycombo/ 转载于:https://www.cnblogs.com/silentjesse/p/3921215.html...
如何做婚庆公司的网站/电商培训班一般多少钱
相关的一些框架 1、zend framework zend公司开发的官方的框架,功能很强大,重量级框架。 2、Yii 国人自己开发的重量级框架,该框架的特点就是代码的可重用性非常好。 3、CI(code Igniter) 轻量级的框架, 4、…...