|
|
51CTO旗下网站
|
|
移动端

查漏补缺:连接器在Tomcat中是如何设计的

既然是来解析连接器(Connector),那么我们直接从源码入手,后面所有源码我会剔除不重要部分,所以会忽略大部分源码细节,只关注流程。

作者:编辑小猿来源:今日头条|2019-09-24 09:46

从连接器(Connector)源码说起

既然是来解析连接器(Connector),那么我们直接从源码入手,后面所有源码我会剔除不重要部分,所以会忽略大部分源码细节,只关注流程。源码如下(高能预警,大量代码):

  1. public class Connector extends LifecycleMBeanBase { 
  2.  public Connector() { 
  3.  this("org.apache.coyote.http11.Http11NioProtocol"); 
  4.  } 
  5.  public Connector(String protocol) { 
  6.  boolean aprConnector = AprLifecycleListener.isAprAvailable() && 
  7.  AprLifecycleListener.getUseAprConnector(); 
  8.  if ("HTTP/1.1".equals(protocol) || protocol == null) { 
  9.  if (aprConnector) { 
  10.  protocolHandlerClassName = "org.apache.coyote.http11.Http11AprProtocol"
  11.  } else { 
  12.  protocolHandlerClassName = "org.apache.coyote.http11.Http11NioProtocol"
  13.  } 
  14.  } else if ("AJP/1.3".equals(protocol)) { 
  15.  if (aprConnector) { 
  16.  protocolHandlerClassName = "org.apache.coyote.ajp.AjpAprProtocol"
  17.  } else { 
  18.  protocolHandlerClassName = "org.apache.coyote.ajp.AjpNioProtocol"
  19.  } 
  20.  } else { 
  21.  protocolHandlerClassName = protocol; 
  22.  } 
  23.  // Instantiate protocol handler 
  24.  ProtocolHandler p = null
  25.  try { 
  26.  Class<?> clazz = Class.forName(protocolHandlerClassName); 
  27.  p = (ProtocolHandler) clazz.getConstructor().newInstance(); 
  28.  } catch (Exception e) { 
  29.  log.error(sm.getString( 
  30.  "coyoteConnector.protocolHandlerInstantiationFailed"), e); 
  31.  } finally { 
  32.  this.protocolHandler = p; 
  33.  } 
  34.  // Default for Connector depends on this system property 
  35.  setThrowOnFailure(Boolean.getBoolean("org.apache.catalina.startup.EXIT_ON_INIT_FAILURE")); 
  36.  } 

我们来看看Connector的构造方法,其实只做了一件事情,就是根据协议设置对应的ProtocolHandler,根据名称我们知道,这是协议处理类,所以连接器内部的一个重要子模块就是ProtocolHandler。

关于生命周期

我们看到Connector继承了LifecycleMBeanBase,我们来看看Connector的最终继承关系:

金九银十,查漏补缺:连接器在Tomcat中是如何设计的

我们看到最终实现的是Lifecycle接口,我们看看这个接口是何方神圣。我把其接口的注释拿下来解释下

  1. /** 
  2.  * Common interface for component life cycle methods. Catalina components 
  3.  * may implement this interface (as well as the appropriate interface(s) for 
  4.  * the functionality they support) in order to provide a consistent mechanism 
  5.  * to start and stop the component. 
  6.  * start() 
  7.  * ----------------------------- 
  8.  * | | 
  9.  * | init() | 
  10.  * NEW -»-- INITIALIZING | 
  11.  * | | | | ------------------«----------------------- 
  12.  * | | |auto | | | 
  13.  * | | \|/ start() \|/ \|/ auto auto stop() | 
  14.  * | | INITIALIZED --»-- STARTING_PREP --»- STARTING --»- STARTED --»--- | 
  15.  * | | | | | 
  16.  * | |destroy()| | | 
  17.  * | --»-----«-- ------------------------«-------------------------------- ^ 
  18.  * | | | | 
  19.  * | | \|/ auto auto start() | 
  20.  * | | STOPPING_PREP ----»---- STOPPING ------»----- STOPPED -----»----- 
  21.  * | \|/ ^ | ^ 
  22.  * | | stop() | | | 
  23.  * | | -------------------------- | | 
  24.  * | | | | | 
  25.  * | | | destroy() destroy() | | 
  26.  * | | FAILED ----»------ DESTROYING ---«----------------- | 
  27.  * | | ^ | | 
  28.  * | | destroy() | |auto | 
  29.  * | --------»----------------- \|/ | 
  30.  * | DESTROYED | 
  31.  * | | 
  32.  * | stop() | 
  33.  * ----»-----------------------------»------------------------------ 
  34.  * 
  35.  * Any state can transition to FAILED. 
  36.  * 
  37.  * Calling start() while a component is in states STARTING_PREP, STARTING or 
  38.  * STARTED has no effect. 
  39.  * 
  40.  * Calling start() while a component is in state NEW will cause init() to be 
  41.  * called immediately after the start() method is entered. 
  42.  * 
  43.  * Calling stop() while a component is in states STOPPING_PREP, STOPPING or 
  44.  * STOPPED has no effect. 
  45.  * 
  46.  * Calling stop() while a component is in state NEW transitions the component 
  47.  * to STOPPED. This is typically encountered when a component fails to start and 
  48.  * does not start all its sub-components. When the component is stopped, it will 
  49.  * try to stop all sub-components - even those it didn't start. 
  50.  * 
  51.  * Attempting any other transition will throw {@link LifecycleException}. 
  52.  * 
  53.  * </pre> 
  54.  * The {@link LifecycleEvent}s fired during state changes are defined in the 
  55.  * methods that trigger the changed. No {@link LifecycleEvent}s are fired if the 
  56.  * attempted transition is not valid. 

这段注释翻译就是,这个接口是提供给组件声明周期管理的,并且提供了声明周期流转图。这里我们只需要知道正常流程即可:

  1. New--->Init()---->Start()---->Stop()--->Destory() 

从生命周期探索连接器

根据上面的生命周期说明,我们可以知道连接器(Connector)就是按照如此的声明周期管理的,所以我们找到了线索,所以连接器肯定会先初始化然后再启动。我们查看其initInternal()方法可以知道连接器初始化做了什么事情,源码如下:

  1. @Override 
  2.  protected void initInternal() throws LifecycleException { 
  3.  super.initInternal(); 
  4.  if (protocolHandler == null) { 
  5.  throw new LifecycleException( 
  6.  sm.getString("coyoteConnector.protocolHandlerInstantiationFailed")); 
  7.  } 
  8.  // Initialize adapter 
  9.  adapter = new CoyoteAdapter(this); 
  10.  protocolHandler.setAdapter(adapter); 
  11.  if (service != null) { 
  12.  protocolHandler.setUtilityExecutor(service.getServer().getUtilityExecutor()); 
  13.  } 
  14.  // Make sure parseBodyMethodsSet has a default 
  15.  if (null == parseBodyMethodsSet) { 
  16.  setParseBodyMethods(getParseBodyMethods()); 
  17.  } 
  18.  if (protocolHandler.isAprRequired() && !AprLifecycleListener.isInstanceCreated()) { 
  19.  throw new LifecycleException(sm.getString("coyoteConnector.protocolHandlerNoAprListener"
  20.  getProtocolHandlerClassName())); 
  21.  } 
  22.  if (protocolHandler.isAprRequired() && !AprLifecycleListener.isAprAvailable()) { 
  23.  throw new LifecycleException(sm.getString("coyoteConnector.protocolHandlerNoAprLibrary"
  24.  getProtocolHandlerClassName())); 
  25.  } 
  26.  if (AprLifecycleListener.isAprAvailable() && AprLifecycleListener.getUseOpenSSL() && 
  27.  protocolHandler instanceof AbstractHttp11JsseProtocol) { 
  28.  AbstractHttp11JsseProtocol<?> jsseProtocolHandler = 
  29.  (AbstractHttp11JsseProtocol<?>) protocolHandler; 
  30.  if (jsseProtocolHandler.isSSLEnabled() && 
  31.  jsseProtocolHandler.getSslImplementationName() == null) { 
  32.  // OpenSSL is compatible with the JSSE configuration, so use it if APR is available 
  33.  jsseProtocolHandler.setSslImplementationName(OpenSSLImplementation.class.getName()); 
  34.  } 
  35.  } 
  36.  try { 
  37.  protocolHandler.init(); 
  38.  } catch (Exception e) { 
  39.  throw new LifecycleException( 
  40.  sm.getString("coyoteConnector.protocolHandlerInitializationFailed"), e); 
  41.  } 
  42.  } 

根据上面源码,我们发现主要是处理protocolHandler并初始化它,同时我们注意到了protocolHandler 设置了一个适配器,我们看看这个适配器是做啥的,跟踪源码如下:

  1. /** 
  2.  * The adapter, used to call the connector. 
  3.  * 
  4.  * @param adapter The adapter to associate 
  5.  */ 
  6.  public void setAdapter(Adapter adapter); 

这个注释已经说的很直白了,这个适配器就是用来调用连接器的。我们再继续看看protocolHandler的初始化方法

  1.  /** 
  2.  * Endpoint that provides low-level network I/O - must be matched to the 
  3.  * ProtocolHandler implementation (ProtocolHandler using NIO, requires NIO 
  4.  * Endpoint etc.). 
  5.  */ 
  6. private final AbstractEndpoint<S,?> endpoint; 
  7. public void init() throws Exception { 
  8.  if (getLog().isInfoEnabled()) { 
  9.  getLog().info(sm.getString("abstractProtocolHandler.init", getName())); 
  10.  logPortOffset(); 
  11.  } 
  12.  if (oname == null) { 
  13.  // Component not pre-registered so register it 
  14.  oname = createObjectName(); 
  15.  if (oname != null) { 
  16.  Registry.getRegistry(nullnull).registerComponent(this, oname, null); 
  17.  } 
  18.  } 
  19.  if (this.domain != null) { 
  20.  rgOname = new ObjectName(domain + ":type=GlobalRequestProcessor,name=" + getName()); 
  21.  Registry.getRegistry(nullnull).registerComponent( 
  22.  getHandler().getGlobal(), rgOname, null); 
  23.  } 
  24.  String endpointName = getName(); 
  25.  endpoint.setName(endpointName.substring(1, endpointName.length()-1)); 
  26.  endpoint.setDomain(domain); 
  27.  endpoint.init(); 
  28.  } 

这里出现了一个新的对象,endpoint,根据注释我们可以知道endpoint是用来处理网络IO的,而且必须匹配到指定的子类(比如Nio,就是NioEndPoint处理)。endpoint.init()实际上就是做一些网络的配置,然后就是初始化完毕了。根据我们上面的周期管理,我们知道init()后就是start(),所以我们查看Connector的start()源码:

  1. protected void startInternal() throws LifecycleException { 
  2. // Validate settings before starting 
  3. if (getPortWithOffset() < 0) { 
  4. throw new LifecycleException(sm.getString( 
  5. "coyoteConnector.invalidPort"Integer.valueOf(getPortWithOffset()))); 
  6. setState(LifecycleState.STARTING); 
  7. try { 
  8. protocolHandler.start(); 
  9. } catch (Exception e) { 
  10. throw new LifecycleException( 
  11. sm.getString("coyoteConnector.protocolHandlerStartFailed"), e); 

其实就是主要调用 protocolHandler.start()方法,继续跟踪,为了方便表述,我会把接下来的代码统一放在一起说明,代码如下:

  1. //1.类:AbstractProtocol implements ProtocolHandler, 
  2.  MBeanRegistration 
  3.  public void start() throws Exception { 
  4.  // 省略部分代码 
  5.  endpoint.start(); 
  6.  } 
  7. //2. 类:AbstractEndPoint  
  8. public final void start() throws Exception { 
  9.  // 省略部分代码 
  10.  startInternal(); 
  11.  } 
  12.  /**3.类:NioEndPoint extends AbstractJsseEndpoint<NioChannel,SocketChannel> 
  13.  * Start the NIO endpoint, creating acceptor, poller threads. 
  14.  */ 
  15.  @Override 
  16.  public void startInternal() throws Exception { 
  17.  //省略部分代码 
  18.   
  19.  // Start poller thread 
  20.  poller = new Poller(); 
  21.  Thread pollerThread = new Thread(poller, getName() + "-ClientPoller"); 
  22.  pollerThread.setPriority(threadPriority); 
  23.  pollerThread.setDaemon(true); 
  24.  pollerThread.start(); 
  25.  startAcceptorThread(); 
  26.  } 
  27.  } 

到这里,其实整个启动代码就完成了,我们看到最后是在NioEndPoint创建了一个Poller,并且启动它,这里需要补充说明下,这里只是以NioEndPoint为示列,其实Tomcat 主要提供了三种实现,分别是AprEndPoint,NioEndPoint,Nio2EndPoint,这里表示了tomcat支持的I/O模型:

  • APR:采用 Apache 可移植运行库实现,它根据不同操作系统,分别用c重写了大部分IO和系统线程操作模块,据说性能要比其他模式要好(未实测)。
  • NIO:非阻塞 I/O
  • NIO.2:异步 I/O

上述代码主要是开启两个线程,一个是Poller,一个是开启Acceptor,既然是线程,核心的代码肯定是run方法,我们来查看源码,代码如下:

  1. //4.类:Acceptor<U> implements Runnable 
  2.  public void run() { 
  3.  //省略了部分代码 
  4.  U socket = null
  5.  socket = endpoint.serverSocketAccept(); 
  6.  // Configure the socket 
  7.  if (endpoint.isRunning() && !endpoint.isPaused()) { 
  8.  // setSocketOptions() will hand the socket off to 
  9.  // an appropriate processor if successful 
  10.  //核心逻辑 
  11.  if (!endpoint.setSocketOptions(socket)) { 
  12.  endpoint.closeSocket(socket); 
  13.  } 
  14.  } else { 
  15.  endpoint.destroySocket(socket); 
  16.  } 
  17.   
  18.  state = AcceptorState.ENDED; 
  19. //5.类:NioEndpoint 
  20. protected boolean setSocketOptions(SocketChannel socket) { 
  21.  // Process the connection 
  22.  //省略部分代码 
  23.  try { 
  24.  // Disable blocking, polling will be used 
  25.  socket.configureBlocking(false); 
  26.  Socket sock = socket.socket(); 
  27.  socketProperties.setProperties(sock); 
  28.  NioSocketWrapper socketWrapper = new NioSocketWrapper(channel, this); 
  29.  channel.setSocketWrapper(socketWrapper); 
  30.  socketWrapper.setReadTimeout(getConnectionTimeout()); 
  31.  socketWrapper.setWriteTimeout(getConnectionTimeout()); 
  32.  socketWrapper.setKeepAliveLeft(NioEndpoint.this.getMaxKeepAliveRequests()); 
  33.  socketWrapper.setSecure(isSSLEnabled()); 
  34.  //核心逻辑 
  35.  poller.register(channel, socketWrapper); 
  36.  return true
  37.   
  38.  } 

这里可以发现Acceptor主要就是接受socket,然后把它注册到poller中,我们继续看看是如何注册的。

  1. /**6.类NioEndpoint 
  2.  * Registers a newly created socket with the poller. 
  3.  * 
  4.  * @param socket The newly created socket 
  5.  * @param socketWrapper The socket wrapper 
  6.  */ 
  7.  public void register(final NioChannel socket, final NioSocketWrapper socketWrapper) { 
  8.  socketWrapper.interestOps(SelectionKey.OP_READ);//this is what OP_REGISTER turns into
  9.  PollerEvent r = null
  10.  if (eventCache != null) { 
  11.  r = eventCache.pop(); 
  12.  } 
  13.  if (r == null) { 
  14.  r = new PollerEvent(socket, OP_REGISTER); 
  15.  } else { 
  16.  r.reset(socket, OP_REGISTER); 
  17.  } 
  18.  addEvent(r); 
  19.  } 
  20. /** 7.类:PollerEvent implements Runnable 
  21.  public void run() { 
  22.  //省略部分代码 
  23.  socket.getIOChannel().register(socket.getSocketWrapper().getPoller().getSelector(), SelectionKey.OP_READ, socket.getSocketWrapper()); 
  24.  } 

这里发现最终就是采用NIO模型把其注册到通道中。(这里涉及NIO网络编程知识,不了解的同学可以传送这里)。那么注册完毕后,我们看看Poller做了什么事情。

  1. */  
  2.  /**8.类:NioEndPoint内部类 Poller implements Runnable 
  3.  **/  
  4.  @Override 
  5.  public void run() { 
  6.  // Loop until destroy() is called 
  7.  while (true) { 
  8.  //省略部分代码 
  9.  Iterator<SelectionKey> iterator = 
  10.  keyCount > 0 ? selector.selectedKeys().iterator() : null
  11.  // Walk through the collection of ready keys and dispatch 
  12.  // any active event. 
  13.  while (iterator != null && iterator.hasNext()) { 
  14.  SelectionKey sk = iterator.next(); 
  15.  NioSocketWrapper socketWrapper = (NioSocketWrapper) sk.attachment(); 
  16.  // Attachment may be null if another thread has called 
  17.  // cancelledKey() 
  18.  if (socketWrapper == null) { 
  19.  iterator.remove(); 
  20.  } else { 
  21.  iterator.remove(); 
  22.  //sock处理 
  23.  processKey(sk, socketWrapper); 
  24.  } 
  25.  } 
  26.  //省略部分代码 
  27.  }  

这个就是通过selector把之前注册的事件取出来,从而完成了调用。

  1. //9.类: NioEndPoint内部类 Poller implements Runnable  
  2. protected void processKey(SelectionKey sk, NioSocketWrapper socketWrapper) { 
  3.  //省略大部分代码 
  4.  processSocket(socketWrapper, SocketEvent.OPEN_WRITE, true
  5.   
  6.   
  7. //10.类:AbstractEndPoint  
  8. public boolean processSocket(SocketWrapperBase<S> socketWrapper, 
  9.  SocketEvent event, boolean dispatch) { 
  10.  //省略部分代码 
  11.  Executor executor = getExecutor(); 
  12.  if (dispatch && executor != null) { 
  13.  executor.execute(sc); 
  14.  } else { 
  15.  sc.run(); 
  16.  } 
  17.   
  18.  return true
  19.  }  
  20. //11.类:SocketProcessorBase implements Runnable  
  21. public final void run() { 
  22.  synchronized (socketWrapper) { 
  23.  // It is possible that processing may be triggered for read and 
  24.  // write at the same time. The sync above makes sure that processing 
  25.  // does not occur in parallel. The test below ensures that if the 
  26.  // first event to be processed results in the socket being closed, 
  27.  // the subsequent events are not processed. 
  28.  if (socketWrapper.isClosed()) { 
  29.  return
  30.  } 
  31.  doRun(); 
  32.  } 
  33.  } 
  34.   
  35. //类:12.NioEndPoint extends AbstractJsseEndpoint<NioChannel,SocketChannel>  
  36. protected void doRun() { 
  37.  //省略部分代码 
  38.  if (handshake == 0) { 
  39.  SocketState state = SocketState.OPEN
  40.  // Process the request from this socket 
  41.  if (event == null) { 
  42.  state = getHandler().process(socketWrapper, SocketEvent.OPEN_READ); 
  43.  } else { 
  44.  state = getHandler().process(socketWrapper, event); 
  45.  } 
  46.  if (state == SocketState.CLOSED) { 
  47.  poller.cancelledKey(key, socketWrapper); 
  48.  } 
  49.  } 
  50.  }  
  51.   

Poller调用的run方法或者用Executor线程池去执行run(),最终调用都是各个子EndPoint中的doRun()方法,最终会取一个Handler去处理socketWrapper。继续看源码:

  1. //类:13.AbstractProtocol内部类ConnectionHandler implements AbstractEndpoint.Handler<S> 
  2.  public SocketState process(SocketWrapperBase<S> wrapper, SocketEvent status) { 
  3.  //省略部分代码 
  4.   
  5.  state = processor.process(wrapper, status); 
  6.   
  7.  return SocketState.CLOSED; 
  8.  } 
  9.   
  10. //类:14.AbstractProcessorLight implements Processor  
  11. public SocketState process(SocketWrapperBase<?> socketWrapper, SocketEvent status) 
  12.  throws IOException { 
  13.  //省略部分代码 
  14.   
  15.  state = service(socketWrapper); 
  16.   
  17.  return state; 
  18.  } 

这部分源码表明最终调用的process是通过一个Processor接口的实现类来完成的,这里最终也是会调用到各个子类中,那么这里的处理器其实就是处理应用协议,我们可以查看AbstractProcessorLight的实现类,分别有AjpProcessor、Http11Processor、StreamProcessor,分别代表tomcat支持三种应用层协议,分别是:

  • AJP协议
  • HTTP.1协议
  • HTTP2.0协议

这里我们以常用的HTTP1.1为例,继续看源码:

  1. //类:15. Http11Processor extends AbstractProcessor 
  2. public SocketState service(SocketWrapperBase<?> socketWrapper) 
  3.  throws IOException { 
  4.  //省略大部分代码 
  5.  getAdapter().service(request, response); 
  6.  //省略大部分代码  
  7.  }  
  8. //类:16 CoyoteAdapter implements Adapter 
  9. public void service(org.apache.coyote.Request req, org.apache.coyote.Response res) 
  10.  throws Exception { 
  11.  Request request = (Request) req.getNote(ADAPTER_NOTES); 
  12.  Response response = (Response) res.getNote(ADAPTER_NOTES); 
  13.  postParseSuccess = postParseRequest(req, request, res, response); 
  14.  if (postParseSuccess) { 
  15.  //check valves if we support async 
  16.  request.setAsyncSupported( 
  17.  connector.getService().getContainer().getPipeline().isAsyncSupported()); 
  18.  // Calling the container 
  19.  connector.getService().getContainer().getPipeline().getFirst().invoke( 
  20.  request, response); 
  21.  } 
  22.   
  23.  } 

这里我们发现协议处理器最终会调用适配器(CoyoteAdapter),而适配器最终的工作是转换Request和Response对象为HttpServletRequest和HttpServletResponse,从而可以去调用容器,到这里整个连接器的流程和作用我们就已经分析完了。

小结

那么我们来回忆下整个流程,我画了一张时序图来说明:

金九银十,查漏补缺:连接器在Tomcat中是如何设计的

这张图包含了两个流程,一个是组件的初始化,一个是调用的流程。连接器(Connector)主要初始化了两个组件,ProtcoHandler和EndPoint,但是我们从代码结构发现,他们两个是父子关系,也就是说ProtcoHandler包含了EndPoint。后面的流程就是各个子组件的调用链关系,总结来说就是Acceptor负责接收请求,然后注册到Poller,Poller负责处理请求,然后调用processor处理器来处理,最后把请求转成符合Servlet规范的request和response去调用容器(Container)。点击免费“领取Java架构资料”

我们流程梳理清楚了,接下来我们来结构化的梳理下:

回到连接器(Connector)是源码,我们发现,上述说的模块只有ProtocolHandler和Adapter两个属于连接器中,也就是说,连接器只包含了这两大子模块,那么后续的EndPoint、Acceptor、Poller、Processor都是ProtocolHandler的子模块。 而Acceptor和Poller两个模块的核心功能都是在EndPoint 中完成的,所以是其子模块,而Processor比较独立,所以它和EndPoint是一个级别的子模块。

我们用图来说明下上述的关系:

金九银十,查漏补缺:连接器在Tomcat中是如何设计的

根据上图我们可以知道,连接器主要负责处理连接请求,然后通过适配器调用容器。那么具体流程细化可以如下:

  • Acceptor监听网络请求,获取请求。
  • Poller获取到监听的请求提交线程池进行处理。
  • Processor根据具体的应用协议(HTTP/AJP)来生成Tomcat Request对象。
  • Adapter把Request对象转换成Servlet标准的Request对象,调用容器。

总结

我们从连接器的源码,一步一步解析,分析了连接器主要包含了两大模块,ProtocolHandler和Adapter。ProtocolHandler主要包含了Endpoint模块和Processor模块。Endpoint模块主要的作用是连接的处理,它委托了Acceptor子模块进行连接的监听和注册,委托子模块Poller进行连接的处理;而Processor模块主要是应用协议的处理,最后提交给Adapter进行对象的转换,以便可以调用容器(Container)。另外我们也在分析源码的过程中补充了一些额外知识点:

  • 当前Tomcat版本支持的IO模型为:APR模型、NIO模型、NIO.2模型
  • Tomcat支持的协议是AJP和HTTP,其中HTTP又分为HTTP1.1和HTTP2.0

【编辑推荐】

  1. Java Web应用服务器之一:Tomcat监控选型及实践
  2. Java程序员必备:Tomcat配置技巧Top10
  3. 用了10多年的 Tomcat 居然有bug,这能忍?
  4. Docker构建Tomcat Web服务器与Tomcat如何优化
  5. 详解Apache 和 Tomcat 整合原理、配置方案
【责任编辑:武晓燕 TEL:(010)68476606】


点赞 0
分享:
大家都在看
猜你喜欢

订阅专栏+更多

用Python玩转excel

用Python玩转excel

让重复操作傻瓜化
共3章 | DE8UG

187人订阅学习

AI入门级算法

AI入门级算法

算法常识
共22章 | 周萝卜123

164人订阅学习

这就是5G

这就是5G

5G那些事儿
共15章 | armmay

132人订阅学习

读 书 +更多

网络系统开发实例精粹(JSP版)

《网络系统开发实例精粹》以实际的软件开发项目实例介绍贯穿始末,逐层深入的介绍了应用JSP开发Web应用程序的详细过程。全书以深透软件工程...

订阅51CTO邮刊

点击这里查看样刊

订阅51CTO邮刊

51CTO服务号

51CTO官微