目錄 一、前言
二、目標(biāo)
三、方案
四、實(shí)現(xiàn)
1. 工程結(jié)構(gòu)
2. 處理占位符配置
3. 定義攔截注解
4. 處理對(duì)象掃描裝配
5. 解析xml中調(diào)用掃描
五、測(cè)試
1. 事先準(zhǔn)備
2. 屬性配置文件
3. spring.xml 配置對(duì)象
4. 單元測(cè)試(占位符)
5. 單元測(cè)試(包掃描)
六、總結(jié)
七、系列推薦
一、前言 忒復(fù)雜,沒(méi)等搞明白大促都過(guò)去了!
你經(jīng)歷過(guò)618和雙11嗎?你加入過(guò)大促時(shí)候那么多復(fù)雜的營(yíng)銷(xiāo)活動(dòng)賺幾毛錢(qián)嗎?你開(kāi)發(fā)過(guò)連讀明白玩法都需要一周但只使用3天的大促需求嗎?有時(shí)候?qū)τ谟行┊a(chǎn)品的需求真的是太復(fù)雜了,復(fù)雜到開(kāi)發(fā)、測(cè)試都需要在整個(gè)過(guò)程中不斷的學(xué)習(xí)最后才可能讀懂產(chǎn)品為啥這樣的玩,要是一個(gè)長(zhǎng)期的活動(dòng)可能也就算了,培養(yǎng)用戶(hù)心智嗎!但這一整套拉新、助力、激活、下單、投保、領(lǐng)券、消費(fèi)、開(kāi)紅包等等一連串的騷操作下來(lái),如果在線上只用3天呢,或者是只用1天,那TM連參與的用戶(hù)都沒(méi)弄明白呢,活動(dòng)就結(jié)束了,最后能打來(lái)什么樣好的數(shù)據(jù)呢?對(duì)于這樣流程復(fù)雜,估計(jì)連羊毛黨都看不上!?。?/em>
以上只是舉個(gè)例子,大部分時(shí)候并不會(huì)搞的這么惡心,評(píng)審也是過(guò)不去的!而同樣的道理用在程序設(shè)計(jì)開(kāi)發(fā)和使用中也是一樣的,如果你把你的代碼邏輯實(shí)現(xiàn)的過(guò)于分散,讓外部調(diào)用方在使用的時(shí)候,需要調(diào)用你的接口多個(gè)和多次,還沒(méi)有消息觸達(dá),只能定時(shí)自己輪訓(xùn)你的接口查看訂單狀態(tài),每次還只能查10條,查多了你說(shuō)不行,等等反人類(lèi)的設(shè)計(jì),都會(huì)給調(diào)用方帶來(lái)要干你的體會(huì)。
所以,如果我們能在完成目的的情況下,都是希望盡可能流程簡(jiǎn)單、模式清晰、自動(dòng)服務(wù)。那這在Spring的框架中也是有所體現(xiàn)的,這個(gè)框架的普及使用程度和它所能帶來(lái)的方便性是分不開(kāi)的,而我們?nèi)绻茏龅饺绱说姆奖?,那肯定是一種好的設(shè)計(jì)和實(shí)現(xiàn)。
二、目標(biāo) 其實(shí)到本章節(jié)我們已經(jīng)把關(guān)于 IOC 和 AOP 全部核心內(nèi)容都已經(jīng)實(shí)現(xiàn)完成了,只不過(guò)在使用上還有點(diǎn)像早期的 Spring 版本,需要一個(gè)一個(gè)在 spring.xml 中進(jìn)行配置。這與實(shí)際的目前使用的 Spring 框架還是有蠻大的差別,而這種差別其實(shí)都是在核心功能邏輯之上建設(shè)的在更少的配置下,做到更簡(jiǎn)化的使用。
這其中就包括:包的掃描注冊(cè)、注解配置的使用、占位符屬性的填充等等,而我們的目標(biāo)就是在目前的核心邏輯上填充一些自動(dòng)化的功能,讓大家可以學(xué)習(xí)到這部分的設(shè)計(jì)和實(shí)現(xiàn),從中體會(huì)到一些關(guān)于代碼邏輯的實(shí)現(xiàn)過(guò)程,總結(jié)一些編碼經(jīng)驗(yàn)。
三、方案 首先我們要考慮??,為了可以簡(jiǎn)化 Bean 對(duì)象的配置,讓整個(gè) Bean 對(duì)象的注冊(cè)都是自動(dòng)掃描的,那么基本需要的元素包括:掃描路徑入口、XML解析掃描信息、給需要掃描的Bean對(duì)象做注解標(biāo)記、掃描Class對(duì)象摘取Bean注冊(cè)的基本信息,組裝注冊(cè)信息、注冊(cè)成Bean對(duì)象。那么在這些條件元素的支撐下,就可以實(shí)現(xiàn)出通過(guò)自定義注解和配置掃描路徑的情況下,完成 Bean 對(duì)象的注冊(cè)。除此之外再順帶解決一個(gè)配置中占位符屬性的知識(shí)點(diǎn),比如可以通過(guò) ${token} 給 Bean 對(duì)象注入進(jìn)去屬性信息,那么這個(gè)操作需要用到 BeanFactoryPostProcessor,因?yàn)樗梢蕴幚?在所有的 BeanDefinition 加載完成后,實(shí)例化 Bean 對(duì)象之前,提供修改 BeanDefinition 屬性的機(jī)制 而實(shí)現(xiàn)這部分內(nèi)容是為了后續(xù)把此類(lèi)內(nèi)容結(jié)合到自動(dòng)化配置處理中。整體設(shè)計(jì)結(jié)構(gòu)如下圖:
結(jié)合bean的生命周期,包掃描只不過(guò)是掃描特定注解的類(lèi),提取類(lèi)的相關(guān)信息組裝成BeanDefinition注冊(cè)到容器中。
在XmlBeanDefinitionReader中解析<context:component-scan />標(biāo)簽,掃描類(lèi)組裝BeanDefinition然后注冊(cè)到容器中的操作在ClassPathBeanDefinitionScanner#doScan中實(shí)現(xiàn)。
自動(dòng)掃描注冊(cè)主要是掃描添加了自定義注解的類(lèi),在xml加載過(guò)程中提取類(lèi)的信息,組裝 BeanDefinition 注冊(cè)到 Spring 容器中。 所以我們會(huì)用到 <context:component-scan /> 配置包路徑并在 XmlBeanDefinitionReader 解析并做相應(yīng)的處理。這里的處理會(huì)包括對(duì)類(lèi)的掃描、獲取注解信息等 最后還包括了一部分關(guān)于 BeanFactoryPostProcessor 的使用,因?yàn)槲覀冃枰瓿蓪?duì)占位符配置信息的加載,所以需要使用到 BeanFactoryPostProcessor 在所有的 BeanDefinition 加載完成后,實(shí)例化 Bean 對(duì)象之前,修改 BeanDefinition 的屬性信息。這一部分的實(shí)現(xiàn)也為后續(xù)處理關(guān)于占位符配置到注解上做準(zhǔn)備 四、實(shí)現(xiàn) 1. 工程結(jié)構(gòu)small-spring-step-12 └── src ├── main │ └── java │ └── cn.bugstack.springframework │ ├── aop │ │ ├── aspectj │ │ │ └── AspectJExpressionPointcut.java │ │ │ └── AspectJExpressionPointcutAdvisor.java │ │ ├── framework │ │ │ ├── adapter │ │ │ │ └── MethodBeforeAdviceInterceptor.java │ │ │ ├── autoproxy │ │ │ │ └── MethodBeforeAdviceInterceptor.java │ │ │ ├── AopProxy.java │ │ │ ├── Cglib2AopProxy.java │ │ │ ├── JdkDynamicAopProxy.java │ │ │ ├── ProxyFactory.java │ │ │ └── ReflectiveMethodInvocation.java │ │ ├── AdvisedSupport.java │ │ ├── Advisor.java │ │ ├── BeforeAdvice.java │ │ ├── ClassFilter.java │ │ ├── MethodBeforeAdvice.java │ │ ├── MethodMatcher.java │ │ ├── Pointcut.java │ │ ├── PointcutAdvisor.java │ │ └── TargetSource.java │ ├── beans │ │ ├── factory │ │ │ ├── config │ │ │ │ ├── AutowireCapableBeanFactory.java │ │ │ │ ├── BeanDefinition.java │ │ │ │ ├── BeanFactoryPostProcessor.java │ │ │ │ ├── BeanPostProcessor.java │ │ │ │ ├── BeanReference.java │ │ │ │ ├── ConfigurableBeanFactory.java │ │ │ │ ├── InstantiationAwareBeanPostProcessor.java │ │ │ │ └── SingletonBeanRegistry.java │ │ │ ├── support │ │ │ │ ├── AbstractAutowireCapableBeanFactory.java │ │ │ │ ├── AbstractBeanDefinitionReader.java │ │ │ │ ├── AbstractBeanFactory.java │ │ │ │ ├── BeanDefinitionReader.java │ │ │ │ ├── BeanDefinitionRegistry.java │ │ │ │ ├── CglibSubclassingInstantiationStrategy.java │ │ │ │ ├── DefaultListableBeanFactory.java │ │ │ │ ├── DefaultSingletonBeanRegistry.java │ │ │ │ ├── DisposableBeanAdapter.java │ │ │ │ ├── FactoryBeanRegistrySupport.java │ │ │ │ ├── InstantiationStrategy.java │ │ │ │ └── SimpleInstantiationStrategy.java │ │ │ ├── support │ │ │ │ └── XmlBeanDefinitionReader.java │ │ │ ├── Aware.java │ │ │ ├── BeanClassLoaderAware.java │ │ │ ├── BeanFactory.java │ │ │ ├── BeanFactoryAware.java │ │ │ ├── BeanNameAware.java │ │ │ ├── ConfigurableListableBeanFactory.java │ │ │ ├── DisposableBean.java │ │ │ ├── FactoryBean.java │ │ │ ├── HierarchicalBeanFactory.java │ │ │ ├── InitializingBean.java │ │ │ ├── ListableBeanFactory.java │ │ │ └── PropertyPlaceholderConfigurer.java │ │ ├── BeansException.java │ │ ├── PropertyValue.java │ │ └── PropertyValues.java │ ├── context │ │ ├── annotation │ │ │ ├── ClassPathBeanDefinitionScanner.java │ │ │ ├── ClassPathScanningCandidateComponentProvider.java │ │ │ └── Scope.java │ │ ├── event │ │ │ ├── AbstractApplicationEventMulticaster.java │ │ │ ├── ApplicationContextEvent.java │ │ │ ├── ApplicationEventMulticaster.java │ │ │ ├── ContextClosedEvent.java │ │ │ ├── ContextRefreshedEvent.java │ │ │ └── SimpleApplicationEventMulticaster.java │ │ ├── support │ │ │ ├── AbstractApplicationContext.java │ │ │ ├── AbstractRefreshableApplicationContext.java │ │ │ ├── AbstractXmlApplicationContext.java │ │ │ ├── ApplicationContextAwareProcessor.java │ │ │ └── ClassPathXmlApplicationContext.java │ │ ├── ApplicationContext.java │ │ ├── ApplicationContextAware.java │ │ ├── ApplicationEvent.java │ │ ├── ApplicationEventPublisher.java │ │ ├── ApplicationListener.java │ │ └── ConfigurableApplicationContext.java │ ├── core.io │ │ ├── ClassPathResource.java │ │ ├── DefaultResourceLoader.java │ │ ├── FileSystemResource.java │ │ ├── Resource.java │ │ ├── ResourceLoader.java │ │ └── UrlResource.java │ ├── stereotype │ │ └── Component.java │ └── utils │ └── ClassUtils.java └── test └── java └── cn.bugstack.springframework.test ├── bean │ ├── IUserService.java │ └── UserService.java └── ApiTest.java工程源碼 :公眾號(hào)「bugstack蟲(chóng)洞?!?,回復(fù):Spring 專(zhuān)欄,獲取完整源碼
在Bean的生命周期中自動(dòng)加載包掃描注冊(cè)Bean對(duì)象和設(shè)置占位符屬性的類(lèi)關(guān)系,如圖 14-2
圖 14-2 整個(gè)類(lèi)的關(guān)系結(jié)構(gòu)來(lái)看,其實(shí)涉及的內(nèi)容并不多,主要包括的就是 xml 解析類(lèi) XmlBeanDefinitionReader 對(duì) ClassPathBeanDefinitionScanner#doScan 的使用。 在 doScan 方法中處理所有指定路徑下添加了注解的類(lèi),拆解出類(lèi)的信息:名稱(chēng)、作用范圍等,進(jìn)行創(chuàng)建 BeanDefinition 好用于 Bean 對(duì)象的注冊(cè)操作。 PropertyPlaceholderConfigurer 目前看上去像一塊單獨(dú)的內(nèi)容,后續(xù)會(huì)把這塊的內(nèi)容與自動(dòng)加載 Bean 對(duì)象進(jìn)行整合,也就是可以在注解上使用占位符配置一些在配置文件里的屬性信息。 2. 處理占位符配置cn.bugstack.springframework.beans.factory.PropertyPlaceholderConfigurer
public class PropertyPlaceholderConfigurer implements BeanFactoryPostProcessor { /** * Default placeholder prefix: {@value } */ public static final String DEFAULT_PLACEHOLDER_PREFIX = "${" ; /** * Default placeholder suffix: {@value } */ public static final String DEFAULT_PLACEHOLDER_SUFFIX = "}" ; private String location; @Override public void postProcessBeanFactory (ConfigurableListableBeanFactory beanFactory) throws BeansException { // 加載屬性文件 try { DefaultResourceLoader resourceLoader = new DefaultResourceLoader(); Resource resource = resourceLoader.getResource(location); Properties properties = new Properties(); properties.load(resource.getInputStream()); String[] beanDefinitionNames = beanFactory.getBeanDefinitionNames(); for (String beanName : beanDefinitionNames) { BeanDefinition beanDefinition = beanFactory.getBeanDefinition(beanName); PropertyValues propertyValues = beanDefinition.getPropertyValues(); for (PropertyValue propertyValue : propertyValues.getPropertyValues()) { Object value = propertyValue.getValue(); if (!(value instanceof String)) continue ; String strVal = (String) value; StringBuilder buffer = new StringBuilder(strVal); int startIdx = strVal.indexOf(DEFAULT_PLACEHOLDER_PREFIX); int stopIdx = strVal.indexOf(DEFAULT_PLACEHOLDER_SUFFIX); if (startIdx != -1 && stopIdx != -1 && startIdx < stopIdx) { String propKey = strVal.substring(startIdx + 2 , stopIdx); String propVal = properties.getProperty(propKey); buffer.replace(startIdx, stopIdx + 1 , propVal); propertyValues.addPropertyValue(new PropertyValue(propertyValue.getName(), buffer.toString())); } } } } catch (IOException e) { throw new BeansException("Could not load properties" , e); } } public void setLocation (String location) { this .location = location; } }依賴(lài)于 BeanFactoryPostProcessor 在 Bean 生命周期的屬性,可以在 Bean 對(duì)象實(shí)例化之前,改變屬性信息。所以這里通過(guò)實(shí)現(xiàn) BeanFactoryPostProcessor 接口,完成對(duì)配置文件的加載以及摘取占位符中的在屬性文件里的配置。 這樣就可以把提取到的配置信息放置到屬性配置中了,buffer.replace(startIdx, stopIdx + 1, propVal); propertyValues.addPropertyValue 3. 定義攔截注解cn.bugstack.springframework.context.annotation.Scope
@Target ({ElementType.TYPE, ElementType.METHOD})@Retention (RetentionPolicy.RUNTIME)@Documented public @interface Scope { String value () default "singleton" ; }用于配置作用域的自定義注解,方便通過(guò)配置Bean對(duì)象注解的時(shí)候,拿到Bean對(duì)象的作用域。不過(guò)一般都使用默認(rèn)的 singleton cn.bugstack.springframework.stereotype.Component
@Target (ElementType.TYPE)@Retention (RetentionPolicy.RUNTIME)@Documented public @interface Component { String value () default "" ; }Component 自定義注解大家都非常熟悉了,用于配置到 Class 類(lèi)上的。除此之外還有 Service、Controller,不過(guò)所有的處理方式基本一致,這里就只展示一個(gè) Component 即可。 4. 處理對(duì)象掃描裝配cn.bugstack.springframework.context.annotation.ClassPathScanningCandidateComponentProvider
public class ClassPathScanningCandidateComponentProvider { public Set<BeanDefinition> findCandidateComponents (String basePackage) { Set<BeanDefinition> candidates = new LinkedHashSet<>(); Set<Class<?>> classes = ClassUtil.scanPackageByAnnotation(basePackage, Component.class ) ; for (Class<?> clazz : classes) { candidates.add(new BeanDefinition(clazz)); } return candidates; } }這里先要提供一個(gè)可以通過(guò)配置路徑 basePackage=cn.bugstack.springframework.test.bean,解析出 classes 信息的工具方法 findCandidateComponents,通過(guò)這個(gè)方法就可以掃描到所有 @Component 注解的 Bean 對(duì)象了。 cn.bugstack.springframework.context.annotation.ClassPathBeanDefinitionScanner
public class ClassPathBeanDefinitionScanner extends ClassPathScanningCandidateComponentProvider { private BeanDefinitionRegistry registry; public ClassPathBeanDefinitionScanner (BeanDefinitionRegistry registry) { this .registry = registry; } public void doScan (String... basePackages) { for (String basePackage : basePackages) { Set<BeanDefinition> candidates = findCandidateComponents(basePackage); for (BeanDefinition beanDefinition : candidates) { // 解析 Bean 的作用域 singleton、prototype String beanScope = resolveBeanScope(beanDefinition); if (StrUtil.isNotEmpty(beanScope)) { beanDefinition.setScope(beanScope); } registry.registerBeanDefinition(determineBeanName(beanDefinition), beanDefinition); } } } private String resolveBeanScope (BeanDefinition beanDefinition) { Class<?> beanClass = beanDefinition.getBeanClass(); Scope scope = beanClass.getAnnotation(Scope.class ) ; if (null != scope) return scope.value(); return StrUtil.EMPTY; } private String determineBeanName (BeanDefinition beanDefinition) { Class<?> beanClass = beanDefinition.getBeanClass(); Component component = beanClass.getAnnotation(Component.class ) ; String value = component.value(); if (StrUtil.isEmpty(value)) { value = StrUtil.lowerFirst(beanClass.getSimpleName()); } return value; } }ClassPathBeanDefinitionScanner 是繼承自 ClassPathScanningCandidateComponentProvider 的具體掃描包處理的類(lèi),在 doScan 中除了獲取到掃描的類(lèi)信息以后,還需要獲取 Bean 的作用域和類(lèi)名,如果不配置類(lèi)名基本都是把首字母縮寫(xiě)。 5. 解析xml中調(diào)用掃描cn.bugstack.springframework.beans.factory.xml.XmlBeanDefinitionReader
public class XmlBeanDefinitionReader extends AbstractBeanDefinitionReader { protected void doLoadBeanDefinitions (InputStream inputStream) throws ClassNotFoundException, DocumentException { SAXReader reader = new SAXReader(); Document document = reader.read(inputStream); Element root = document.getRootElement(); // 解析 context:component-scan 標(biāo)簽,掃描包中的類(lèi)并提取相關(guān)信息,用于組裝 BeanDefinition Element componentScan = root.element("component-scan" ); if (null != componentScan) { String scanPath = componentScan.attributeValue("base-package" ); if (StrUtil.isEmpty(scanPath)) { throw new BeansException("The value of base-package attribute can not be empty or null" ); } scanPackage(scanPath); } // ... 省略其他 // 注冊(cè) BeanDefinition getRegistry().registerBeanDefinition(beanName, beanDefinition); } private void scanPackage (String scanPath) { String[] basePackages = StrUtil.splitToArray(scanPath, ',' ); ClassPathBeanDefinitionScanner scanner = new ClassPathBeanDefinitionScanner(getRegistry()); scanner.doScan(basePackages); } }關(guān)于 XmlBeanDefinitionReader 中主要是在加載配置文件后,處理新增的自定義配置屬性 component-scan,解析后調(diào)用 scanPackage 方法,其實(shí)也就是我們?cè)?ClassPathBeanDefinitionScanner#doScan 功能。 另外這里需要注意,為了可以方便的加載和解析xml,XmlBeanDefinitionReader 已經(jīng)全部替換為 dom4j 的方式進(jìn)行解析處理。 五、測(cè)試 1. 事先準(zhǔn)備@Component ("userService" )public class UserService implements IUserService { private String token; public String queryUserInfo () { try { Thread.sleep(new Random(1 ).nextInt(100 )); } catch (InterruptedException e) { e.printStackTrace(); } return "小傅哥,100001,深圳" ; } public String register (String userName) { try { Thread.sleep(new Random(1 ).nextInt(100 )); } catch (InterruptedException e) { e.printStackTrace(); } return "注冊(cè)用戶(hù):" + userName + " success!" ; } @Override public String toString () { return "UserService#token = { " + token + " }" ; } public String getToken () { return token; } public void setToken (String token) { this .token = token; } }給 UserService 類(lèi)添加一個(gè)自定義注解 @Component("userService") 和一個(gè)屬性信息 String token。這是為了分別測(cè)試包掃描和占位符屬性。 2. 屬性配置文件token=RejDlI78hu223Opo983Ds這里配置一個(gè) token 的屬性信息,用于通過(guò)占位符的方式進(jìn)行獲取 3. spring.xml 配置對(duì)象spring-property.xml
<?xml version="1.0" encoding="UTF-8"?> <beans xmlns ="http://www./schema/beans" xmlns:xsi ="http://www./2001/XMLSchema-instance" xmlns:context ="http://www./schema/context" xsi:schemaLocation ="http://www./schema/beans http://www./schema/beans/spring-beans.xsd http://www./schema/context" > <bean class ="cn.bugstack.springframework.beans.factory.PropertyPlaceholderConfigurer" > <property name ="location" value ="classpath:token.properties" /> </bean > <bean id ="userService" class ="cn.bugstack.springframework.test.bean.UserService" > <property name ="token" value ="${token}" /> </bean > </beans > 加載 classpath:token.properties 設(shè)置占位符屬性值 ${token} spring-scan.xml
<?xml version="1.0" encoding="UTF-8"?> <beans xmlns ="http://www./schema/beans" xmlns:xsi ="http://www./2001/XMLSchema-instance" xmlns:context ="http://www./schema/context" xsi:schemaLocation ="http://www./schema/beans http://www./schema/beans/spring-beans.xsd http://www./schema/context" > <context:component-scan base-package ="cn.bugstack.springframework.test.bean" /> </beans > 添加 component-scan 屬性,設(shè)置包掃描根路徑 4. 單元測(cè)試(占位符)@Test public void test_property () { ClassPathXmlApplicationContext applicationContext = new ClassPathXmlApplicationContext("classpath:spring-property.xml" ); IUserService userService = applicationContext.getBean("userService" , IUserService.class ) ; System.out.println("測(cè)試結(jié)果:" + userService); }測(cè)試結(jié)果
測(cè)試結(jié)果:UserService#token = { RejDlI78hu223Opo983Ds } Process finished with exit code 0 通過(guò)測(cè)試結(jié)果可以看到 UserService 中的 token 屬性已經(jīng)通過(guò)占位符的方式設(shè)置進(jìn)去配置文件里的 token.properties 的屬性值了。 5. 單元測(cè)試(包掃描)@Test public void test_scan () { ClassPathXmlApplicationContext applicationContext = new ClassPathXmlApplicationContext("classpath:spring-scan.xml" ); IUserService userService = applicationContext.getBean("userService" , IUserService.class ) ; System.out.println("測(cè)試結(jié)果:" + userService.queryUserInfo()); }測(cè)試結(jié)果
測(cè)試結(jié)果:小傅哥,100001 ,深圳 Process finished with exit code 0 通過(guò)這個(gè)測(cè)試結(jié)果可以看出來(lái),現(xiàn)在使用注解的方式就可以讓 Class 注冊(cè)完成 Bean 對(duì)象了。 六、總結(jié) 通過(guò)整篇的內(nèi)容實(shí)現(xiàn)可以看出來(lái),目前的功能添加其實(shí)已經(jīng)不復(fù)雜了,都是在 IOC 和 AOP 核心的基礎(chǔ)上來(lái)補(bǔ)全功能。這些補(bǔ)全的功能也是在完善 Bean 的生命周期,讓整個(gè)功能使用也越來(lái)越容易。 在你不斷的實(shí)現(xiàn)著 Spring 的各項(xiàng)功能時(shí),也可以把自己在平常使用 Spring 的一些功能想法融入進(jìn)來(lái),比如像 Spring 是如何動(dòng)態(tài)切換數(shù)據(jù)源的,線程池是怎么提供配置的,這些內(nèi)容雖然不是最基礎(chǔ)的核心范圍,但也非常重要。 可能有些時(shí)候這些類(lèi)實(shí)現(xiàn)的內(nèi)容對(duì)新人來(lái)說(shuō)比較多,可以一點(diǎn)點(diǎn)動(dòng)手實(shí)現(xiàn)逐步理解,在把一些稍微較有難度的內(nèi)容實(shí)現(xiàn)后,其實(shí)后面也就沒(méi)有那么難理解了。 七、系列推薦