tomcat作為一款web服務器本身很復雜,代碼量也很大,但是模塊化很強,最核心的模塊還是連接器Connector和容器Container。具體請看下圖:
從圖中可以看出
a. 高亮的兩塊是Connector和Container,為什么說他們兩最核心,其中Connector是負責接收http請求,當你在瀏覽器中輸入URL為http://www.demon.com,這樣的http請求就會被Connector接收並轉發給容器Container。
到了Container后,會經過不同的容器層有:
Engine:表示整個Catalina servlet引擎;
Host: 表示包含有一個或多個Context容器的虛擬主機;
Context: 表示一個Web應用程序,一個Context可以有多個Wrapper;
Wrapper: 表示一個獨立的servlet
一層層的處理后最終到達的是Wrapper即特定的serlvet處理器,具體用於處理http的請求響應。
b. 圖中除了Connector和Container以外還有一些Naming、Session等一些服務,統統封裝在了一個service里面,可以用於對外提供各種服務,比如我們不僅希望 tomcat可以提供一個web請求響應的服務,還希望知道其中的詳細處理細節,我們可以使用service中的日志服務,用於打印一些請求處理過程中的細節信息。
c. 有了這些service模塊,我們還需要有一個落腳點,這個落腳點用於控制service的生殺大全,負責service的整個生命周期,包括service的初始化、啟動、結束等等。
d. 綜上舉一個例子,現在有A軟件公司,共有三個部門——研發部門、財務部門、技術支持部門
其中每個部門相當於一個service,在每個service中可以提供不同的服務,比如研發部門可以提供功能開發服務、功能測試服務、持續集成部署服務、美工服務等。
而封裝在server中的各個服務由server來統一管理,好比A公司可以實現研發部門的組建(或重組)——開工——裁撤等一系列的生命周期功能,如果A公司逢上國家法定節假日需要放假休息,那么相關的service部門也都會執行放假模式,同理,如果A公司正常運營上班,那么各個service也都會切換到上班模式。有了server的存在,從而方便對於service的管理。
大致了解了tomcat的架構和工作原理,我們來看看平時我們通過點擊startup.bat來啟動tomcat是如何從代碼層面實現的,在啟動過程中又做了哪些事情(基於tomcat6版本的源碼)。
1.啟動入口
在代碼中,tomcat的啟動是通過運行org.apache.catalina.startup.Bootstrap類的main方法來啟動服務的
public static void main(String args[]) { if (daemon == null) { daemon = new Bootstrap(); try { daemon.init(); } catch (Throwable t) { t.printStackTrace(); return; } } try { String command = "start"; if (args.length > 0) { command = args[args.length - 1]; } if (command.equals("startd")) { args[args.length - 1] = "start"; daemon.load(args); daemon.start(); } else if (command.equals("stopd")) { args[args.length - 1] = "stop"; daemon.stop(); } else if (command.equals("start")) { daemon.setAwait(true); daemon.load(args); daemon.start(); } else if (command.equals("stop")) { daemon.stopServer(args); } else { log.warn("Bootstrap: command \"" + command + "\" does not exist."); } } catch (Throwable t) { t.printStackTrace(); } }
a. 初始化Bootstrap對象
b. 根據具體的需求完成服務的加載、啟動和關閉的功能
備注:這里運行或調試main方法的時候需要在VM arguments中填入類似-Dcatalina.home="C:\Users\Administrator\Desktop\tomcat\apache-tomcat-6.0.43-src\output\build"這樣的參數,具體操作參見《探秘Tomcat(一)——Myeclipse中導入Tomcat源碼》
2.Bootstrap的初始化
2.1 init方法
public void init() throws Exception { // Set Catalina path setCatalinaHome(); setCatalinaBase(); initClassLoaders(); Thread.currentThread().setContextClassLoader(catalinaLoader); SecurityClassLoad.securityClassLoad(catalinaLoader); // Load our startup class and call its process() method if (log.isDebugEnabled()) log.debug("Loading startup class"); Class startupClass = catalinaLoader.loadClass ("org.apache.catalina.startup.Catalina"); Object startupInstance = startupClass.newInstance(); // Set the shared extensions class loader if (log.isDebugEnabled()) log.debug("Setting startup class properties"); String methodName = "setParentClassLoader"; Class paramTypes[] = new Class[1]; paramTypes[0] = Class.forName("java.lang.ClassLoader"); Object paramValues[] = new Object[1]; paramValues[0] = sharedLoader; Method method = startupInstance.getClass().getMethod(methodName, paramTypes); method.invoke(startupInstance, paramValues); catalinaDaemon = startupInstance; }
a. 其中setCatalinaHome和setCatalinaBase分別用於為catalina.name和catalina.base賦值;
b. initClassLoaders用於初始類加載器,分別初始化了三個加載器CommonLoader、CatalinaLoader和SharedLoader,從代碼可以發現CommonLoader是CatalinaLoader和SharedLoader的父類,最終初始化完成,兩個子類都指向CommonLoader;
c. 通過反射機制生成一份org.apache.catalina.startup.Catalina的實例用於啟動。
2.2 createClassLoader方法
下面我們來看看init->initClassLoaders->createClassLoader這個方法,通過這個方法我們分別得到了三個加載器CommonLoader、CatalinaLoader和SharedLoader,我們加載所需要的目錄和jar包等。
private ClassLoader createClassLoader(String name, ClassLoader parent) throws Exception { String value = CatalinaProperties.getProperty(name + ".loader");//得到的value為${catalina.base}/lib,${catalina.base}/lib/*.jar,${catalina.home}/lib,${catalina.home}/lib/*.jar if ((value == null) || (value.equals(""))) return parent; ArrayList repositoryLocations = new ArrayList(); ArrayList repositoryTypes = new ArrayList(); int i; StringTokenizer tokenizer = new StringTokenizer(value, ",");//用於分隔String的類 while (tokenizer.hasMoreElements()) {//遍歷value中的值 String repository = tokenizer.nextToken(); // Local repository boolean replace = false; String before = repository; while ((i=repository.indexOf(CATALINA_HOME_TOKEN))>=0) {//這樣的遍歷說明如果value的每項里面包含了${catalina.home}或者${catalina.base}就將標記變量replace置為true,並且確確實實的替換這些變量,比如這里講${catalina.base}替換為C:\Users\Administrator\Desktop\tomcat\apache-tomcat-6.0.43-src\output\build。每次遍歷完value值后,都會根據存儲的repository的類型添加不同的值到repositoryTypes中去。 replace=true; if (i>0) { repository = repository.substring(0,i) + getCatalinaHome() + repository.substring(i+CATALINA_HOME_TOKEN.length()); } else { repository = getCatalinaHome() + repository.substring(CATALINA_HOME_TOKEN.length()); } } while ((i=repository.indexOf(CATALINA_BASE_TOKEN))>=0) { replace=true; if (i>0) { repository = repository.substring(0,i) + getCatalinaBase() + repository.substring(i+CATALINA_BASE_TOKEN.length()); } else { repository = getCatalinaBase() + repository.substring(CATALINA_BASE_TOKEN.length()); } } if (replace && log.isDebugEnabled()) log.debug("Expanded " + before + " to " + repository); // Check for a JAR URL repository try { URL url=new URL(repository); repositoryLocations.add(repository); repositoryTypes.add(ClassLoaderFactory.IS_URL); continue; } catch (MalformedURLException e) { // Ignore } if (repository.endsWith("*.jar")) { repository = repository.substring (0, repository.length() - "*.jar".length()); repositoryLocations.add(repository); repositoryTypes.add(ClassLoaderFactory.IS_GLOB); } else if (repository.endsWith(".jar")) { repositoryLocations.add(repository); repositoryTypes.add(ClassLoaderFactory.IS_JAR); } else { repositoryLocations.add(repository); repositoryTypes.add(ClassLoaderFactory.IS_DIR); } } String[] locations = (String[]) repositoryLocations.toArray(new String[0]); Integer[] types = (Integer[]) repositoryTypes.toArray(new Integer[0]);//分別將兩個list集合轉化為數組 ClassLoader classLoader = ClassLoaderFactory.createClassLoader (locations, types, parent);//調用createClassLoader方法,其中細加載到每個目錄和具體的目錄下面的文件,注意其中的加載的數據集使用LinkedHashSet,這是利用了set集合的特性,不允許重復元素的出現,因為這里面會有重復加載的情況,所以用set保證了唯一性。通過這個操作等於加載了所有的文件,具體類加載以及URLClassLoader的使用可以參見http://blog.csdn.net/mycomputerxiaomei/article/details/24470465 // Retrieving MBean server MBeanServer mBeanServer = null;//得到一個MBean的對象 if (MBeanServerFactory.findMBeanServer(null).size() > 0) { mBeanServer = (MBeanServer) MBeanServerFactory.findMBeanServer(null).get(0); } else { mBeanServer = ManagementFactory.getPlatformMBeanServer(); } // Register the server classloader ObjectName objectName = new ObjectName("Catalina:type=ServerClassLoader,name=" + name); mBeanServer.registerMBean(classLoader, objectName);//將類加載器注冊到mbean服務上,這樣做的作用是,一旦classLoader有變化了,就會有notification。 return classLoader; }
這里有個小細節在createClassLoader方法中有調用方法getCatalinaBase,而getCatalinaBase又會去調用getCatalinaHome,而不是將getCatalinaHome中的具體功能代碼又冗余的寫一遍,這是一個很好的習慣^_^
至此,我們完成了Bootstrap對象的初始化,為catalina.home等,初始化了三個類加載器。
3.server的加載和啟動
3.1 deamon.load方法
public void load() { long t1 = System.nanoTime(); initDirs(); // Before digester - it may be needed initNaming(); // Create and execute our Digester Digester digester = createStartDigester(); InputSource inputSource = null; InputStream inputStream = null; File file = null; try { file = configFile(); inputStream = new FileInputStream(file); inputSource = new InputSource("file://" + file.getAbsolutePath()); } catch (Exception e) { ; } if (inputStream == null) { try { inputStream = getClass().getClassLoader() .getResourceAsStream(getConfigFile()); inputSource = new InputSource (getClass().getClassLoader() .getResource(getConfigFile()).toString()); } catch (Exception e) { ; } } // This should be included in catalina.jar // Alternative: don't bother with xml, just create it manually. if( inputStream==null ) { try { inputStream = getClass().getClassLoader() .getResourceAsStream("server-embed.xml"); inputSource = new InputSource (getClass().getClassLoader() .getResource("server-embed.xml").toString()); } catch (Exception e) { ; } } if ((inputStream == null) && (file != null)) { log.warn("Can't load server.xml from " + file.getAbsolutePath()); if (file.exists() && !file.canRead()) { log.warn("Permissions incorrect, read permission is not allowed on the file."); } return; } try { inputSource.setByteStream(inputStream); digester.push(this); digester.parse(inputSource); inputStream.close(); } catch (Exception e) { log.warn("Catalina.start using " + getConfigFile() + ": " , e); return; } // Stream redirection initStreams(); // Start the new server if (getServer() instanceof Lifecycle) { try { getServer().initialize(); } catch (LifecycleException e) { if (Boolean.getBoolean("org.apache.catalina.startup.EXIT_ON_INIT_FAILURE")) throw new java.lang.Error(e); else log.error("Catalina.start", e); } } long t2 = System.nanoTime(); if(log.isInfoEnabled()) log.info("Initialization processed in " + ((t2 - t1) / 1000000) + " ms"); }
a. 前面的完成一些初始化的工作;
b. createStartDigester方法很重要,用於聲明要實例化的所有服務,當然這個工作需要與file = configFile();中聲明的位於conf下的server.xml進行配合;
c. 通過digester.parse(inputSource)解析server.xml中的元素
protected Digester createStartDigester() { long t1=System.currentTimeMillis(); // Initialize the digester Digester digester = new Digester(); digester.setValidating(false); digester.setRulesValidation(true); HashMap<Class, List<String>> fakeAttributes = new HashMap<Class, List<String>>(); ArrayList<String> attrs = new ArrayList<String>(); attrs.add("className"); fakeAttributes.put(Object.class, attrs); digester.setFakeAttributes(fakeAttributes); digester.setClassLoader(StandardServer.class.getClassLoader()); // Configure the actions we will be using digester.addObjectCreate("Server", "org.apache.catalina.core.StandardServer", "className"); digester.addSetProperties("Server"); digester.addSetNext("Server", "setServer", "org.apache.catalina.Server"); digester.addObjectCreate("Server/GlobalNamingResources", "org.apache.catalina.deploy.NamingResources"); digester.addSetProperties("Server/GlobalNamingResources"); digester.addSetNext("Server/GlobalNamingResources", "setGlobalNamingResources", "org.apache.catalina.deploy.NamingResources"); digester.addObjectCreate("Server/Listener", null, // MUST be specified in the element "className"); digester.addSetProperties("Server/Listener"); digester.addSetNext("Server/Listener", "addLifecycleListener", "org.apache.catalina.LifecycleListener"); digester.addObjectCreate("Server/Service", "org.apache.catalina.core.StandardService", "className"); digester.addSetProperties("Server/Service"); digester.addSetNext("Server/Service", "addService", "org.apache.catalina.Service"); digester.addObjectCreate("Server/Service/Listener", null, // MUST be specified in the element "className"); digester.addSetProperties("Server/Service/Listener"); digester.addSetNext("Server/Service/Listener", "addLifecycleListener", "org.apache.catalina.LifecycleListener"); //Executor digester.addObjectCreate("Server/Service/Executor", "org.apache.catalina.core.StandardThreadExecutor", "className"); digester.addSetProperties("Server/Service/Executor"); digester.addSetNext("Server/Service/Executor", "addExecutor", "org.apache.catalina.Executor"); digester.addRule("Server/Service/Connector", new ConnectorCreateRule()); digester.addRule("Server/Service/Connector", new SetAllPropertiesRule(new String[]{"executor"})); digester.addSetNext("Server/Service/Connector", "addConnector", "org.apache.catalina.connector.Connector"); digester.addObjectCreate("Server/Service/Connector/Listener", null, // MUST be specified in the element "className"); digester.addSetProperties("Server/Service/Connector/Listener"); digester.addSetNext("Server/Service/Connector/Listener", "addLifecycleListener", "org.apache.catalina.LifecycleListener"); // Add RuleSets for nested elements digester.addRuleSet(new NamingRuleSet("Server/GlobalNamingResources/")); digester.addRuleSet(new EngineRuleSet("Server/Service/")); digester.addRuleSet(new HostRuleSet("Server/Service/Engine/")); digester.addRuleSet(new ContextRuleSet("Server/Service/Engine/Host/")); digester.addRuleSet(ClusterRuleSetFactory.getClusterRuleSet("Server/Service/Engine/Host/Cluster/")); digester.addRuleSet(new NamingRuleSet("Server/Service/Engine/Host/Context/")); // When the 'engine' is found, set the parentClassLoader. digester.addRule("Server/Service/Engine", new SetParentClassLoaderRule(parentClassLoader)); digester.addRuleSet(ClusterRuleSetFactory.getClusterRuleSet("Server/Service/Engine/Cluster/")); long t2=System.currentTimeMillis(); if (log.isDebugEnabled()) log.debug("Digester for server.xml created " + ( t2-t1 )); return (digester); }
從這里我們看到了很多熟悉的字眼比如StandardServer、Engine、Host、Context等,可以對比看下server.xml中的標簽

1 <?xml version='1.0' encoding='utf-8'?> 2 <!-- 3 Licensed to the Apache Software Foundation (ASF) under one or more 4 contributor license agreements. See the NOTICE file distributed with 5 this work for additional information regarding copyright ownership. 6 The ASF licenses this file to You under the Apache License, Version 2.0 7 (the "License"); you may not use this file except in compliance with 8 the License. You may obtain a copy of the License at 9 10 http://www.apache.org/licenses/LICENSE-2.0 11 12 Unless required by applicable law or agreed to in writing, software 13 distributed under the License is distributed on an "AS IS" BASIS, 14 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 See the License for the specific language governing permissions and 16 limitations under the License. 17 --> 18 <!-- Note: A "Server" is not itself a "Container", so you may not 19 define subcomponents such as "Valves" at this level. 20 Documentation at /docs/config/server.html 21 --> 22 <Server port="8005" shutdown="SHUTDOWN"> 23 24 <!--APR library loader. Documentation at /docs/apr.html --> 25 <Listener className="org.apache.catalina.core.AprLifecycleListener" SSLEngine="on" /> 26 <!--Initialize Jasper prior to webapps are loaded. Documentation at /docs/jasper-howto.html --> 27 <Listener className="org.apache.catalina.core.JasperListener" /> 28 <!-- Prevent memory leaks due to use of particular java/javax APIs--> 29 <Listener className="org.apache.catalina.core.JreMemoryLeakPreventionListener" /> 30 <!-- JMX Support for the Tomcat server. Documentation at /docs/non-existent.html --> 31 <Listener className="org.apache.catalina.mbeans.ServerLifecycleListener" /> 32 <Listener className="org.apache.catalina.mbeans.GlobalResourcesLifecycleListener" /> 33 34 <!-- Global JNDI resources 35 Documentation at /docs/jndi-resources-howto.html 36 --> 37 <GlobalNamingResources> 38 <!-- Editable user database that can also be used by 39 UserDatabaseRealm to authenticate users 40 --> 41 <Resource name="UserDatabase" auth="Container" 42 type="org.apache.catalina.UserDatabase" 43 description="User database that can be updated and saved" 44 factory="org.apache.catalina.users.MemoryUserDatabaseFactory" 45 pathname="conf/tomcat-users.xml" /> 46 </GlobalNamingResources> 47 48 <!-- A "Service" is a collection of one or more "Connectors" that share 49 a single "Container" Note: A "Service" is not itself a "Container", 50 so you may not define subcomponents such as "Valves" at this level. 51 Documentation at /docs/config/service.html 52 --> 53 <Service name="Catalina"> 54 55 <!--The connectors can use a shared executor, you can define one or more named thread pools--> 56 <!-- 57 <Executor name="tomcatThreadPool" namePrefix="catalina-exec-" 58 maxThreads="150" minSpareThreads="4"/> 59 --> 60 61 62 <!-- A "Connector" represents an endpoint by which requests are received 63 and responses are returned. Documentation at : 64 Java HTTP Connector: /docs/config/http.html (blocking & non-blocking) 65 Java AJP Connector: /docs/config/ajp.html 66 APR (HTTP/AJP) Connector: /docs/apr.html 67 Define a non-SSL HTTP/1.1 Connector on port 8080 68 --> 69 <Connector port="8080" protocol="HTTP/1.1" 70 connectionTimeout="20000" 71 redirectPort="8443" /> 72 <!-- A "Connector" using the shared thread pool--> 73 <!-- 74 <Connector executor="tomcatThreadPool" 75 port="8080" protocol="HTTP/1.1" 76 connectionTimeout="20000" 77 redirectPort="8443" /> 78 --> 79 <!-- Define a SSL HTTP/1.1 Connector on port 8443 80 This connector uses the JSSE configuration, when using APR, the 81 connector should be using the OpenSSL style configuration 82 described in the APR documentation --> 83 <!-- 84 <Connector port="8443" protocol="HTTP/1.1" SSLEnabled="true" 85 maxThreads="150" scheme="https" secure="true" 86 clientAuth="false" sslProtocol="TLS" /> 87 --> 88 89 <!-- Define an AJP 1.3 Connector on port 8009 --> 90 <Connector port="8009" protocol="AJP/1.3" redirectPort="8443" /> 91 92 93 <!-- An Engine represents the entry point (within Catalina) that processes 94 every request. The Engine implementation for Tomcat stand alone 95 analyzes the HTTP headers included with the request, and passes them 96 on to the appropriate Host (virtual host). 97 Documentation at /docs/config/engine.html --> 98 99 <!-- You should set jvmRoute to support load-balancing via AJP ie : 100 <Engine name="Catalina" defaultHost="localhost" jvmRoute="jvm1"> 101 --> 102 <Engine name="Catalina" defaultHost="localhost"> 103 104 <!--For clustering, please take a look at documentation at: 105 /docs/cluster-howto.html (simple how to) 106 /docs/config/cluster.html (reference documentation) --> 107 <!-- 108 <Cluster className="org.apache.catalina.ha.tcp.SimpleTcpCluster"/> 109 --> 110 111 <!-- The request dumper valve dumps useful debugging information about 112 the request and response data received and sent by Tomcat. 113 Documentation at: /docs/config/valve.html --> 114 <!-- 115 <Valve className="org.apache.catalina.valves.RequestDumperValve"/> 116 --> 117 118 <!-- This Realm uses the UserDatabase configured in the global JNDI 119 resources under the key "UserDatabase". Any edits 120 that are performed against this UserDatabase are immediately 121 available for use by the Realm. --> 122 <Realm className="org.apache.catalina.realm.UserDatabaseRealm" 123 resourceName="UserDatabase"/> 124 125 <!-- Define the default virtual host 126 Note: XML Schema validation will not work with Xerces 2.2. 127 --> 128 <Host name="localhost" appBase="webapps" 129 unpackWARs="true" autoDeploy="true" 130 xmlValidation="false" xmlNamespaceAware="false"> 131 132 <!-- SingleSignOn valve, share authentication between web applications 133 Documentation at: /docs/config/valve.html --> 134 <!-- 135 <Valve className="org.apache.catalina.authenticator.SingleSignOn" /> 136 --> 137 138 <!-- Access log processes all example. 139 Documentation at: /docs/config/valve.html --> 140 <!-- 141 <Valve className="org.apache.catalina.valves.AccessLogValve" directory="logs" 142 prefix="localhost_access_log." suffix=".txt" pattern="common" resolveHosts="false"/> 143 --> 144 145 </Host> 146 </Engine> 147 </Service> 148 </Server>
Digester就是根據web.xml中聲明的標簽生成對象,並把元素的屬性賦值為對象的屬性,並關聯起他們之間的父子關系。
3.2 start方法
既然已經加載好了server以及所需要的service,那么就可以開始啟動了。
public void start() throws Exception { if( catalinaDaemon==null ) init(); Method method = catalinaDaemon.getClass().getMethod("start", (Class [] )null); method.invoke(catalinaDaemon, (Object [])null); }
通過反射機制,可是調用Catalina類下面的start方法。
下面我們看看Catalina中的start方法具體都做了什么
public void start() { if (getServer() == null) { load(); } if (getServer() == null) { log.fatal("Cannot start server. Server instance is not configured."); return; } long t1 = System.nanoTime(); // Start the new server if (getServer() instanceof Lifecycle) { try { ((Lifecycle) getServer()).start(); } catch (LifecycleException e) { log.error("Catalina.start: ", e); } } long t2 = System.nanoTime(); if(log.isInfoEnabled()) log.info("Server startup in " + ((t2 - t1) / 1000000) + " ms"); try { // Register shutdown hook if (useShutdownHook) { if (shutdownHook == null) { shutdownHook = new CatalinaShutdownHook(); } Runtime.getRuntime().addShutdownHook(shutdownHook); // If JULI is being used, disable JULI's shutdown hook since // shutdown hooks run in parallel and log messages may be lost // if JULI's hook completes before the CatalinaShutdownHook() LogManager logManager = LogManager.getLogManager(); if (logManager instanceof ClassLoaderLogManager) { ((ClassLoaderLogManager) logManager).setUseShutdownHook( false); } } } catch (Throwable t) { // This will fail on JDK 1.2. Ignoring, as Tomcat can run // fine without the shutdown hook. } if (await) { await(); stop(); } }
a. 首先判斷這個server是否為空,如果為空則需要先加載load
b. 判斷getServer是否是Lifecycle的實例,顯然是,我們可以從StandardServer類的聲明中
“public final class StandardServer implements Lifecycle, Server, MBeanRegistration”可以看出,StandardServer實現了LifeCycle的接口,Server接口。
而且當我們進入Server類的時候,可以看到類的注釋上寫了:一般而且如果某類實現了Server接口,同時也要實現LifeCycle接口,這也正好驗證了這里StandardServer的聲明;
c. 如果滿足是LifeCycle的實例的條件,則執行StandardServer中的start方法,該方法主要用於啟動所有前面解析出來的service,包括進入類Connector啟動Connector服務,進入Container中依次通過Engine、Host、Context和Wrapper啟動相應的服務。
至此,就完成了
- Bootstrap的初始化
- 加載server服務
- 啟動server服務
最終實現了啟動tomcat的目的,其實現在回頭來看,啟動一個服務器無非就是啟動了一個server^^
如果您覺得閱讀本文對您有幫助,請點一下“推薦”按鈕,您的“推薦”將是我最大的寫作動力!如果您想持續關注我的文章,請掃描二維碼,關注JackieZheng的微信公眾號,我會將我的文章推送給您,並和您一起分享我日常閱讀過的優質文章。
友情贊助
如果你覺得博主的文章對你那么一點小幫助,恰巧你又有想打賞博主的小沖動,那么事不宜遲,趕緊掃一掃,小額地贊助下,攢個奶粉錢,也是讓博主有動力繼續努力,寫出更好的文章^^。
1. 支付寶 2. 微信