Tomcat-7 Tomcat Default Connector

A Tomcat connector is an independent module that can be plugged into a servlet container. There are already many connectors in existence. Examples include Coyote, mod_jk, mod_jk2, and mod_webapp, A Tomcat connector must meet the following requirements:

  1. It must implements the org.apache.catalina.Connector interface.
  2. It must create request object whose class implements the org.apache.catalina.Request.
  3. It must create response object whose class implements the org.apache.catalina.Response.

Tomcat4’s default connector works similarly to the simple connector in Tomcat-5 topic. It waits for incoming HTTP request, create request object and response object, the passes the request object and response object to container. A connector passes the request and response object to container by calling the org.apache.catalina.Container interface’s invoke method, which has the following signature:

public void invoke( org.apache.catalina.Request request, org.apache.catalina.Response response);

Inside the invoke method, the container loads the servlet class, calls its service method, manages session, logs error messages, etc.

The Default connector also employs a few optimizations not used in Tomcat-5’s connector. The first is to provides a pool of objects to avoid expensive object creation. Secondly, in many place it uses char arrays instead of string.

The connector interface

A tomcat connector must implements the org.apache.catalina.connector interface. Of many methods in this interface, the most important are getContainer, setContainer, createRequest and createResponse. setContainer is used to associate the connector with a container. getContainer return the associated container. createRequest constructs a request object for incoming HTTP request and createResponse creates a response object.

Please see UML class diagram below:

Default-Connector

The org.apache.catalina.connector.http.HttpConnector is an implements of the Connector interface. Now, take a close look at this UML of default connector. Note that the implements of the Request and Response interfaces have been omitted to keep the diagram and simple and clear. The org.apache.catalina prefix has also been omitted from the type names. except for the SimpleContainer class. Therefore, Connector should be read org.apache.catalina.Connector, util.StringManager org.apache.catalina.util.StringManager, etc.

A Connector has one-to-one relationship with a Container. The navigability of the arrow representing the relationship reveals that Connector knows about the Container but not the other way around. Also note that, unlike Tomcat-5 topic, the relationship between HttpConnector and HttpProcessor is one-to-many.

The HttpConnector class

This class implements org.apache.catalina.Connector (to make it eligible to work with Catalina), java.lang.Runnable (so that its instance can can work in its own thread), and org.apache.catalina.Lifecycle. The lifecycle interface is used to maintain the life of every Catalina component that implements it.

By implementing Lifecycle, after you have created an instance of HttpConnector, you should call its initialize and start methods. Both methods must be only called once during the life time of the component.

Create a Server Socket

The initialize method of HttpConnector class calls the open private method that returns an instance of java.net.ServerSocket. and assigns it to serverSocket variable. However, instead of calling the java.net.ServerSocket constructor, the open method obtains an instance of ServerSocket from a server factory. Please refer to org.apache.catalina.net.ServerSocketFactory and org.apache.catalina.net.DefaultServerSocketFactory to study details.

Maintaining HttpProcessor instances

In the default connector, The HttpConnector has a pool of HttpProcessor objects to avoid creating HttpProcessor objects all the time and each instance of HttpProcessor has a thread its own. Therefore, the HttpConnector can serve multiple HTTP requests simultaneously.

HttpConnector uses a stack to store all the instances of HttpProcessor. The number of HttpProcessor instances created is determined by two variables: minProcessors and maxProcessor. Initially, the HttpConnector object creates minProcessor instances of HttpProcessor. If there are more requests than the HttpProcessor instances can serve at a time. the HttpConnector creates more HttpProcessor instances until the number of instances reaches maxProcessors. After this point is reached and there are still not enough HttpProcessor instance, the incoming HTTP requests will be ignored. If you want HttpConnector to keep creating HttpProcessor instance, set maxProcessors to a negative number. In addition, the curProcessors variable keeps the current number of HttpProcessor instances.

Each HttpProcessor instance is responsible for parsing the HTTP request line and headers and populates a request object. Therefore, each HttpProcessor is associated with a request object and a response object.

Serving HTTP requests

The HttpConnector has its main logic in its run method. The run method contains a while loop where the server socket waits for a HTTP request until the HttpConnector is stopped.

while (!stopped) {
    Socket socket = null;
    try {
        socket = serverSocket.accept();
        ...

For each HTTP request, it obtain an HttpProcessor instance by calling the createProcessor private method.

HttpProcessor processor = createProcessor();

However, most of the time the createProcessor method does not create a new HttpProcessor. Instead, it gets one from the pool. If there is still an HttpProcessor in the stack, createProcessor pops one. if the stack is empty and the maximum number of HttpProcess instances has not been exceeded, createProcessor method create one. However, if the Maximum number has been reached, createProcessor method returns null. If this happens, the socket is simply closed and the incoming HTTP request is not processed. If createProcessor method does not return null, the client socket is passed to the HttpProcessor’s assign method:

processor.assign(socket);

 

It is now the HttpProcessor’s jot to read the socket input stream and parse HTTP request. An important note is this. The assign method must return straight away and not wait until the HttpProcessor finished the parsing, so the next incoming HTTP request can be served. Since each HttpProcessor instance has a thread of its own for parsing, this is not very hard to achieve.

run method of HttpConnector class:

/**
* The background thread that listens for incoming TCP/IP connections and
* hands them off to an appropriate processor.
*/
public void run() {
    // Loop until we receive a shutdown command
    while (!stopped) {
        // Accept the next incoming connection from the server socket
        Socket socket = null;
        try {
            // if (debug >= 3)
            // log("run: Waiting on serverSocket.accept()");
            socket = serverSocket.accept();
            // if (debug >= 3)
            // log("run: Returned from serverSocket.accept()");
            if (connectionTimeout > 0)
                socket.setSoTimeout(connectionTimeout);
            socket.setTcpNoDelay(tcpNoDelay);
        } catch (AccessControlException ace) {
            log("socket accept security exception", ace);
            continue;
        } catch (IOException e) { 
            // log("run: Accept returned IOException", e);
            try {
                // If reopening fails, exit
                synchronized (threadSync) {
                    if (started && !stopped)
                        log("accept error: ", e);
                    if (!stopped) { 
                        // log("run: Closing server socket");
                        serverSocket.close(); 
                        // log("run: Reopening server socket");
                        serverSocket = open();
                    }
                } 
                // log("run: IOException processing completed");
            } catch (IOException ioe) {
                log("socket reopen, io problem: ", ioe);
                break;
            } catch (KeyStoreException kse) {
                log("socket reopen, keystore problem: ", kse);
                break;
            } catch (NoSuchAlgorithmException nsae) {
                log("socket reopen, keystore algorithm problem: ", nsae);
                break;
            } catch (CertificateException ce) {
                log("socket reopen, certificate problem: ", ce);
                break;
            } catch (UnrecoverableKeyException uke) {
                log("socket reopen, unrecoverable key: ", uke);
                break;
            } catch (KeyManagementException kme) {
                log("socket reopen, key management problem: ", kme);
                break;
            }            
            continue;
        }
        // Hand this socket off to an appropriate processor
        HttpProcessor processor = createProcessor();
        if (processor == null) {
            try {
                log(sm.getString("httpConnector.noProcessor"));
                socket.close();
            } catch (IOException e) {
                ;
            }
            continue;
        }
        // if (debug >= 3)
        // log("run: Assigning socket to processor " + processor);
       processor.assign(socket); 
        // The processor will recycle itself when it finishes
    }
    // Notify the threadStop() method that we have shut ourselves down
    // if (debug >= 3)
    // log("run: Notifying threadStop() that we have shut down");
    synchronized (threadSync) {
        threadSync.notifyAll();
    }
}

assign method of HttpProcessor class:

/**
  * Process an incoming TCP/IP connection on the specified socket.  Any exception that occurs during processing must be logged and swallowed. NOTE:  This method is called from our Connector's thread.  We must assign it to our own thread so that multiple simultaneous requests can be handled.
  *
  * @param socket TCP socket to process
  */
synchronized void assign(Socket socket) {
     // Wait for the Processor to get the previous Socket
     while (available) {
         try {
             wait();
         } catch (InterruptedException e) {
         }
     }
     // Store the newly available Socket and notify our thread
     this.socket = socket;
     available = true;
     notifyAll();
         log(" An incoming request is being assigned");
}

The HttpProcessor class

We are most interested in known how the HttpProcess class makes its assign method asynchronous so that the HttpConnector instance can serve many HTTP requests at the same time. Another important method of the HttpProcessor class is the private process method which parses the HTTP request and invoke the container's invoke method.

In the default connector, the HttpProcessor class implements the Java.lang.Runnable interface and each instance of HttpProcessor runs in its own thread. which we call the "processor thread". For each HttpProcessor instance HttpConnector creates, its start method is called, effectively starting the "processor thread" of the HttpProcessor instance.  Blow code snippet presents the run method in the HttpProcessor class in the default connector:

/**
* The background thread that listens for incoming TCP/IP connections and hands them off to an appropriate processor.
*/
public void run() {
    // Process requests until we receive a shutdown signal
    while (!stopped) {
        // Wait for the next socket to be assigned
        Socket socket = await();
        if (socket == null)
            continue;
        // Process the request from this socket
        try {
            process(socket);
        } catch (Throwable t) {
            log("process.invoke", t);
        }
        // Finish up this request
        connector.recycle(this);
    }
    // Tell threadStop() we have shut ourselves down successfully
    synchronized (threadSync) {
        threadSync.notifyAll();
    }
}

The while loop in the run method keeps going in this order: get a socket, process it, call the connector’s recycle method to push the current HttpProcessor instance back to the stack.

Notice that the wile loop in the run method stops at the await method. The await method holds the control flow of the “processor thread” until it get a new socket from the HttpConnector. In other word, until the HttpConnector calls the assign method of the HttpProcessor instance, However, the await method runs on a different thread than the assign method. The assign method is called from run method of HttpConnector instance. We name the the thread that the HttpConnector instance’s run method runs on the “connector thread”. How does the assign method tell the await method that it has been called? Using a boolean variable called available and by using the wait and notifyAll methods of java.lang.Object.

await method of HttpProcessor class:

/**
* Await a newly assigned Socket from our Connector, or <code>null</code>
* if we are supposed to shut down.
*/
private synchronized Socket await() {
    // Wait for the Connector to provide a new Socket
    while (!available) {
        try {
            wait();
        } catch (InterruptedException e) {
        }
    }
    // Notify the Connector that we have received this Socket
    Socket socket = this.socket;
    available = false;
    notifyAll();
    if ((debug >= 1) && (socket != null))
        log("  The incoming request has been awaited");
    return (socket);
}

Initially, when the “processor thread” has just started, available is false, so the thread waits inside the while loop. It will wait until the another thread calls the notify or notifyAll method. This is to say that calling the wait method causes the “processor thread” to pause until the “connector thread” invokes the notifyAll method for HttpProcessor instance.

Request object

Request-Object

Response object

Response-Object

Processing requests

At this point, you already understand about the request and response objects and how the HttpConnector object creates them. Now is the last bit of process. In this section, we focus on the processor method of HttpProcessor class, which is called by run method Httpprocessor instance after a socket is assigned to it.  The process method does the following:

  • Parse the connection.
  • Parse the request.
  • Parse the headers.

Each operation is discussed in the sub-section of this section after the process method is explained.

/**
* Process an incoming HTTP request on the Socket that has been assigned
* to this Processor.  Any exceptions that occur during processing must be
* swallowed and dealt with.
*
* @param socket The socket on which we are connected to the client
*/
private void process(Socket socket) {

    // The process method uses the boolean ok to indicated that there is no error during the process and boolean finishResponse to indicated that the finishResponse method of the Request interface should be called.
    boolean ok = true;
    boolean finishResponse = true;

    SocketInputStream input = null;
    OutputStream output = null;
    // Construct and initialize the objects we will need
    try {
        // A SocketInputStream is used to wrap the socket's input stream.
        // Note that the constructor of SocketInputStream is also passed the buffer size from connector, not from a local variable of HttpProcess class. This is because the HttpProcess is not accessible by the user of the default connector. By putting the buffer size in the Connector interface, this allows anyone using the connector to set the buffer size.

        input = new SocketInputStream(socket.getInputStream(), connector.getBufferSize());
    } catch (Exception e) {
        log("process.create", e);
        ok = false;
    }
    // Then there is a while loop which keeps reading input stream until the HttpProcess is stoped, an exception is thrown, or the connector is closed.
    keepAlive = true;
    while (!stopped && ok && keepAlive) {
        // while loop starts by setting finishResponse to true and obtaining output stream and performing some initialization to request and response objects.
        finishResponse = true;
        try {
            request.setStream(input);
            request.setResponse(response);
            output = socket.getOutputStream();
            response.setStream(output);
            response.setRequest(request);
            ((HttpServletResponse) response.getResponse()).setHeader("Server", SERVER_INFO);
        } catch (Exception e) {
            log("process.create", e);
            ok = false;
        }
        // Afterwards, start parsing the incoming HTTP request by calling parseConnectiion, parseRequest, and parseHeader methods, all of which will be discussed in the sub-section of this section.
        try {
            if (ok) {
                parseConnection(socket);
                // The parseRequest method obtains the value of HTTP protocol, which can be HTTP 0.9, HTTP 1.0 or HTTP1.1. If the HTTP is 1.0, the keepAlive should be set false because HTTP 1.0 does not support persistent connections.
                parseRequest(input, output);
                if (!request.getRequest().getProtocol().startsWith("HTTP/0"))
                    parseHeaders(input);
               // If the protocol is HTTP 1.1, it will respond to the Expect: 100-continue header, if the web client sent this header, by calling the ackRequest . It will also check if chunking is allowed.
                if (http11) {
                    // Sending a request acknowledge back to the client if requested.
                    ackRequest(output);
                    // If the protocol is HTTP/1.1, chunking is allowed.
                    if (connector.isChunkingAllowed())
                        response.setAllowChunking(true);
                }
            }
        // During the parsing of the HTTP request, one of the many exceptions might be thrown. Any exception will set ok or finishResponse to false.
        } catch (EOFException e) {
            // It's very likely to be a socket disconnect on either the
            // client or the server
            ok = false;
            finishResponse = false;
        } catch (ServletException e) {
            ok = false;
            try {
                ((HttpServletResponse) response.getResponse())
                    .sendError(HttpServletResponse.SC_BAD_REQUEST);
            } catch (Exception f) {
                ;
            }
        } catch (InterruptedIOException e) {
            if (debug > 1) {
                try {
                    log("process.parse", e);
                    ((HttpServletResponse) response.getResponse())
                        .sendError(HttpServletResponse.SC_BAD_REQUEST);
                } catch (Exception f) {
                    ;
                }
            }
            ok = false;
        } catch (Exception e) {
            try {
                log("process.parse", e);
                ((HttpServletResponse) response.getResponse()).sendError
                    (HttpServletResponse.SC_BAD_REQUEST);
            } catch (Exception f) {
                ;
            }
            ok = false;
        }
        // Ask our Container to process this request
        // After the parsing, the process method passes the request and response objects to the connector’s invoke method.
        try {
            ((HttpServletResponse) response).setHeader
                ("Date", FastHttpDateFormat.getCurrentDate());
            if (ok) {
               connector.getContainer().invoke(request, response);
            }
        } catch (ServletException e) {
            log("process.invoke", e);
            try {
                ((HttpServletResponse) response.getResponse()).sendError
                    (HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
            } catch (Exception f) {
                ;
            }
            ok = false;
        } catch (InterruptedIOException e) {
            ok = false;
        } catch (Throwable e) {
            log("process.invoke", e);
            try {
                ((HttpServletResponse) response.getResponse()).sendError
                    (HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
            } catch (Exception f) {
                ;
            }
            ok = false;
        }
        // Finish up the handling of the request
        // Afterwards, if finishResponse is still true, the response object’s finishResponse method and request object’s finishRequest method are called, and the output is flushed.
        if (finishResponse) {
            try {
                response.finishResponse();
            } catch (IOException e) {
                ok = false;
            } catch (Throwable e) {
                log("process.invoke", e);
                ok = false;
            }
            try {
                request.finishRequest();
            } catch (IOException e) {
                ok = false;
            } catch (Throwable e) {
                log("process.invoke", e);
                ok = false;
            }
            try {
                if (output != null)
                    output.flush();
            } catch (IOException e) {
                ok = false;
            }
        }
        // We have to check if the connection closure has been requested by the application or the response stream (in case of HTTP/1.0 and keep-alive).
        // The last part of while loop checks if the response’s Connection header has been set to close from inside the servlet or if the protocol is HTTP 1.0. If this is the case, keepAlive is set to false. Also, the request and response objects are the recycled.

        if ( "close".equals(response.getHeader("Connection")) ) {
            keepAlive = false;
        }
        // End of request processing
        status = Constants.PROCESSOR_IDLE;
        // Recycling the request and the response objects
        request.recycle();
        response.recycle();
    }
    // At this stage, the while loop will start from the beginning if keepAlive is true, there is no error during the previous parsing and from the container’s invoke method, or the HttpProcessor instance has no been stopped. Otherwise, the shutdownInput method is called and the socket is closed.
    try {
        // The shutdownInput method checks if there are any unread bytes. If there are, it skips those bytes.
        shutdownInput(input);
        socket.close();
    } catch (IOException e) {
        ;
    } catch (Throwable e) {
        log("process.invoke", e);
    }
    socket = null;
}

In addition, the process method also uses the instance boolean variables keepAlive, stoped, and http11. keepAlive indicates that connection is persistent, stoped indicates that HttpProcess instance has been stopped by the connector so that the process should also stop, and http11 indicates that HTTP request is coming from web client that supports HTTP 1.1.

The ackRequest method checks the value of sendAck and sends the following string if sendAck is true.

HTTP/1.1 100 Continue/r/n/r/n

Parsing the Connection

The parseConnection method obtains the Internet address from the socket and assigns it to the HttpRequestImpl object. It also checks if a proxy is used and assigns the socket to the request object.

/**
* Parse and record the connection parameters related to this request.
* @param socket The socket on which we are connected
* @exception IOException if an input/output error occurs
* @exception ServletException if a parsing error occurs
*/
private void parseConnection(Socket socket) throws IOException, ServletException {
    if (debug >= 2)
        log("  parseConnection: address=" + socket.getInetAddress() +", port=" + connector.getPort());
    ((HttpRequestImpl) request).setInet(socket.getInetAddress());
    if (proxyPort != 0)
        request.setServerPort(proxyPort);
    else
        request.setServerPort(serverPort);
    request.setSocket(socket);
}

parsing header

The parseHeaders method uses the HttpHeader and DefaultHeaders classes in the org.apache.catalina.connector.http package. The HttpHeader class represents and an HTTP request header. Instead of working with string like "A simple connector", the HttpHeader class uses character array to avoid expensive string operations. The DefaultHeaders class is a final class containing the standard HTTP request headers in character arrays:

static final char[] AUTHORIZATION_NAME = "authorization".toCharArray();
static final char[] ACCEPT_LANGUAGE_NAME = "accept-language".toCharArray();
static final char[] COOKIE_NAME = "cookie".toCharArray();
static final char[] CONTENT_LENGTH_NAME = "content-length".toCharArray();
static final char[] CONTENT_TYPE_NAME = "content-type".toCharArray();
static final char[] HOST_NAME = "host".toCharArray();
static final char[] CONNECTION_NAME = "connection".toCharArray();
static final char[] CONNECTION_CLOSE_VALUE = "close".toCharArray();
static final char[] EXPECT_NAME = "expect".toCharArray();
static final char[] EXPECT_100_VALUE = "100-continue".toCharArray();
static final char[] TRANSFER_ENCODING_NAME ="transfer-encoding".toCharArray();

The parseHeaders method contains a while loop that keeps reading HTTP request until there is no more headers to read.

/**
* Parse the incoming HTTP request headers, and set the appropriate  request headers.
* @param input The input stream connected to our socket
* @exception IOException if an input/output error occurs
* @exception ServletException if a parsing error occurs
*/
private void parseHeaders(SocketInputStream input) throws IOException, ServletException { 
    while (true) {
        // The while loop starts by calling allocateHeader method of request object to obtain an instance of empty HttpHeader. The instance is passed to the readHeader method of SocketInputStream.
        HttpHeader header = request.allocateHeader(); 
        // Read the next header
        input.readHeader(header);
        // If all header have been read, the readHeader method will assign no name to the HttpHeader instance, and this is time for parseHeader method to return.
        if (header.nameEnd == 0) {
            if (header.valueEnd == 0) {
                return;
            } else {
                throw new ServletException (sm.getString("httpProcessor.parseHeaders.colon"));
            }
        }
        // If there is a header name, there must also be a header value:
        String value = new String(header.value, 0, header.valueEnd);
        if (debug >= 1)
            log(" Header " + new String(header.name, 0, header.nameEnd)  + " = " + value);
        // Set the corresponding request headers
        // Next, the parseHeaders method compares the header name with the standard name in DefaultHeader class. Note that comparison is performed between  tow character arrays, not between two Strings.
        if (header.equals(DefaultHeaders.AUTHORIZATION_NAME)) {
            request.setAuthorization(value);
        } else if (header.equals(DefaultHeaders.ACCEPT_LANGUAGE_NAME)) {
            parseAcceptLanguage(value);
        } else if (header.equals(DefaultHeaders.COOKIE_NAME)) {
            Cookie cookies[] = RequestUtil.parseCookieHeader(value);
            for (int i = 0; i < cookies.length; i++) {
                if (cookies[i].getName().equals(Globals.SESSION_COOKIE_NAME)) {
                    // Override anything requested in the URL
                    if (!request.isRequestedSessionIdFromCookie()) {
                        // Accept only the first session id cookie
                        request.setRequestedSessionId(cookies[i].getValue());
                        request.setRequestedSessionCookie(true);
                        request.setRequestedSessionURL(false);
                        if (debug >= 1) log(" Requested cookie session id is "+ ((HttpServletRequest) request.getRequest()) .getRequestedSessionId());
                    }
                }
                if (debug >= 1) log(" Adding cookie " + cookies[i].getName() + "=" + cookies[i].getValue());
                request.addCookie(cookies[i]);
            }
        } else if (header.equals(DefaultHeaders.CONTENT_LENGTH_NAME)) {
            int n = -1;
            try {
                n = Integer.parseInt(value);
            } catch (Exception e) {
                throw new ServletException(sm.getString("httpProcessor.parseHeaders.contentLength"));
            }
            request.setContentLength(n);
        } else if (header.equals(DefaultHeaders.CONTENT_TYPE_NAME)) {
            request.setContentType(value);
        } else if (header.equals(DefaultHeaders.HOST_NAME)) {
            int n = value.indexOf(':');
            if (n < 0) {
                if (connector.getScheme().equals("http")) {
                    request.setServerPort(80);
                } else if (connector.getScheme().equals("https")) {
                    request.setServerPort(443);
                }
                if (proxyName != null)
                    request.setServerName(proxyName);
                else
                    request.setServerName(value);
            } else {
                if (proxyName != null)
                    request.setServerName(proxyName);
                else
                    request.setServerName(value.substring(0, n).trim());
                if (proxyPort != 0)
                    request.setServerPort(proxyPort);
                else {
                    int port = 80;
                    try {
                        port = Integer.parseInt(value.substring(n+1).trim());
                    } catch (Exception e) {
                        throw new ServletException(sm.getString("httpProcessor.parseHeaders.portNumber"));
                    }
                    request.setServerPort(port);
                }
            }
        } else if (header.equals(DefaultHeaders.CONNECTION_NAME)) {
            if (header.valueEquals(DefaultHeaders.CONNECTION_CLOSE_VALUE)) {
                keepAlive = false;
                response.setHeader("Connection", "close");
            }
            //request.setConnection(header);
            /*
              if ("keep-alive".equalsIgnoreCase(value)) {
              keepAlive = true;
              }
            */
        } else if (header.equals(DefaultHeaders.EXPECT_NAME)) {
            // If an Expect: 100-continue header is found in the HTTP request, sendAck will be set to true.
            if (header.valueEquals(DefaultHeaders.EXPECT_100_VALUE))
                sendAck = true;
            else
                throw new ServletException(sm.getString("httpProcessor.parseHeaders.unknownExpectation"));
        } else if (header.equals(DefaultHeaders.TRANSFER_ENCODING_NAME)) {
            //request.setTransferEncoding(header);
        }
        request.nextHeader();
    }
}

Simply understanding as the following:

Connector: This class is responsible for listening port to receive connection from the client, and assigns the socket associated with incoming HTTP request to processor(invoke assign method of processor).

Processor: For each incoming HTTP request, assign a single process to handle the request, parse connection, headers and request body, create request object and response object and assign them to container(invoke invoke method of container).

Container: Parse URI and load corresponding servlet class or static resource(invoke service method of servlet).

Servlet: Respond the request.

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值