ชิโร่
Apache Shiro เป็นกรอบการรับรองความถูกต้องและการอนุญาตที่มีน้ำหนักเบา เมื่อเทียบกับความปลอดภัยของฤดูใบไม้ผลินั้นง่ายและใช้งานง่ายและมีความยืดหยุ่นสูง Springboot นั้นให้การสนับสนุนด้านความปลอดภัยหลังจากทั้งหมดมันเป็นสิ่งที่เป็นของตัวเอง Springboot ไม่รวมชิโร่ในขณะนี้ดังนั้นคุณต้องจับคู่ด้วยตัวเอง
1. เพิ่มการพึ่งพา
<Ependency> <mentiD> org.apache.shiro </groupId> <ratifactid> Shiro-spring </artifactid> <version> 1.2.5 </version> </การพึ่งพา> <การพึ่งพา> <roupid> org.apache.shiro </groupid> <ratifactid>
2. เขียนคลาสการกำหนดค่า Shiro
แพ็คเกจ com.xbz.web.system.config; นำเข้าที่. pollux.thymeleaf.shiro.dialect.shirodialect; นำเข้า org.apache.shiro.authc.credential.credentialsmatcher; นำเข้า org.apache.shiro.authc.credential.hashedCredentialSmatcher; นำเข้า org.apache.shiro.cache.ehcache.ehcachemanager; นำเข้า org.apache.shiro.codec.base64; นำเข้า org.apache.shiro.session.sessionListener; นำเข้า org.apache.shiro.session.mgt.sessionManager; นำเข้า org.apache.shiro.session.mgt.eis.memorysessiondao; นำเข้า org.apache.shiro.session.mgt.eis.sessiondao; นำเข้า org.apache.shiro.spring.lifecyclebeanpostprocessor; นำเข้า org.apache.shiro.spring.security.interceptor.authorizationattributesourceadvisor; นำเข้า org.apache.shiro.spring.web.shirofilterfactorybean; นำเข้า org.apache.shiro.web.mgt.cookieremembermemanager; นำเข้า org.apache.shiro.web.mgt.defaultwebsecurityManager; นำเข้า org.apache.shiro.web.servlet.simplecookie; นำเข้า org.apache.shiro.web.session.mgt.defaultwebsessionmanager; นำเข้า org.springframework.aop.framework.autoproxy.defaultadvisorautoproxycreator; นำเข้า org.springframework.boot.autoconfigure.condition.condition.conditionalonmissingbean; นำเข้า org.springframework.context.annotation.bean; นำเข้า org.springframework.context.annotation.configuration; นำเข้า org.springframework.context.annotation.dependson; นำเข้า java.util.arraylist; นำเข้า Java.util.Collection; นำเข้า java.util.linkedhashmap; นำเข้า java.util.map; /*** คลาสการกำหนดค่า Shiro* Apacheshiro Core ใช้การควบคุมการอนุญาตและการสกัดกั้นผ่านตัวกรองเช่นเดียวกับ SpringMVC ใช้ DispachServlet เพื่อควบคุมการแจกจ่ายคำขอ * เนื่องจากมันใช้ตัวกรองนั่นคือการกรองและการตรวจสอบการอนุญาตผ่านกฎ URL เราจำเป็นต้องกำหนดชุดของกฎและสิทธิ์การเข้าถึงเกี่ยวกับ URL* / @configuration ชั้นเรียนสาธารณะ Shiroconfiguration { / *** defaultAdvisorautoproxycreator */ @Bean @ConditionAlonMissingBean Public DefaultAdvisorautoproxycreator DefaultAdvisorautoproxycreator () {DefaultAdvisorautoproxycreator defaultaap = ใหม่ defaultAdvisorautoproxycreator (); defaultaap.setproxytargetClass (จริง); ส่งคืน defaultaap; } /*** ShirofilterFactoryBean: เพื่อสร้าง Shirofilter ให้จัดการไฟล์ทรัพยากรที่สกัดกั้น * ส่วนใหญ่จะดูแลข้อมูลสามรายการ, SecurityManager, ตัวกรอง, FilterChainDefinitionManager * หมายเหตุ: การกำหนดค่า shirofilterfactorybean เดียวคือหรือรายงานข้อผิดพลาดเพราะเมื่อเริ่มต้น shirofilterfactorybean จำเป็นต้องฉีด: SecurityManager * * คำอธิบายคำจำกัดความตัวกรอง * 1. URL สามารถกำหนดค่าตัวกรองหลายตัว บทบาท * */ @Bean สาธารณะ ShirofilterFactoryBean ShirofilterFactoryBean () {ShirofilterFactoryBean ShirofilterFactoryBean = ใหม่ ShirofilterFactoryBean (); ShirofilterFactoryBean.SetSecurityManager (SecurityManager ()); shirofilterfactorybean.setloginurl ("/login"); // ไม่ตั้งค่าหน้า login.jsp ภายใต้ไดเรกทอรีรากของเว็บโครงการ shirofilterfactorybean.setsuccessurl ("/index"); // การเชื่อมต่อกับการกระโดดหลังจากการเข้าสู่ระบบ // custom interceptor, การตั้งค่าสำหรับตัวกรองหลายตัว*// map <สตริง, ตัวกรอง> ตัวกรอง = new LinkedHashMap <> (); // logoutFilter logoutFilter = ใหม่ logoutFilter (); // จำกัด จำนวนหมายเลขออนไลน์ของบัญชีเดียวกันในเวลาเดียวกัน หรือการลงชื่อเข้าใช้เดียว ฯลฯ // logoutfilter.setredirecturl ("/ล็อกอิน"); // filters.put ("logout", null); // ShirofilterFactoryBean.setFilters (ตัวกรอง); แผนที่ <สตริงสตริง> filterChaInDefinitionMap = ใหม่ linkedHashMap <> (); // FilterChainDefinitionManager จะต้องเป็น LinkedHashMap เพราะต้องตรวจสอบให้แน่ใจว่า FilterChainDefinitionMap.put ("/CSS/**", "anon"); // ทรัพยากรคงที่ไม่จำเป็นต้องได้รับอนุญาตหากมีไฟล์ในไดเรกทอรีอื่น ๆ (เช่น JS, IMG ฯลฯ ) และ FilterChainDefinitionMap.put ("/", "anon"); FilterChainDefinitionMap.put ("/เข้าสู่ระบบ", "anon"); // กำหนดค่าส่วนหนึ่งของ URL FilterChainDefinitionMap.put ("/logout", "logout"); FilterChainDefinitionMap.put ("/ผู้ใช้/**", "Authc, บทบาท [role_user]"); // ผู้ใช้เป็นบทบาท Role_user ในการเข้าถึง บทบาทผู้ใช้ควบคุมพฤติกรรมของผู้ใช้ FilterChainDefinitionMap.put ("/events/**", "authc, บทบาท [role_admin]"); // FilterChainDefinitionMap.put ("/ผู้ใช้/แก้ไข/**", "Authc, Perms [ผู้ใช้: แก้ไข]"); // สำหรับการทดสอบค่าตายได้รับการแก้ไขและสามารถอ่านได้จากฐานข้อมูลหรือการกำหนดค่าอื่น ๆ ที่นี่สิทธิ์ใช้เพื่อควบคุม FilterChainDefinitionMap.put ("/**", "Authc"); // ทรัพยากรที่จำเป็นต้องเข้าสู่ระบบโดยทั่วไปจะใส่ /** ที่ ShirofilterFactoryBean.setFilterChainDefinitionMap (FilterChainDefinitionMap); ส่งคืน ShirofilterFactoryBean; } // ภูมิภาคคุกกี้และเซสชั่น // =============================== การจัดการคุกกี้และเซสชันเริ่มต้น ========================== /** การจัดการวัตถุคุกกี้*/สาธารณะ SimpleCookie RememberMecookie () {// พารามิเตอร์นี้เป็นชื่อของคุกกี้ซึ่งสอดคล้องกับชื่อของช่องทำเครื่องหมายที่ปลายด้านหน้า = จำ mimplecookie simplecookie = ใหม่ simplecookie (cookie_name); SimpleCookie.SetMaxage (604800); // จำไว้ว่าคุกกี้ของฉันมีผลเป็นเวลา 7 วันและหน่วยจะถูกส่งคืน SimpleCookie; } / ** วัตถุการจัดการคุกกี้: จดจำฉันฟังก์ชั่น* / Public CookierememberMemanager RememberMemanager () {CookierememberMemanager CookierememberMemanager = ใหม่ CookierememberMemanager (); cookieremembermemanager.setcookie (RememberMecookie ()); cookieremembermemanager.setcipherkey (base64.decode ("3avvhmflus0kta3kprsdag ==")); // memageme คีย์การเข้ารหัสคุกกี้คุกกี้แสดงให้เห็นว่าแต่ละรายการมีความยาวอัลกอริทึม AES ที่แตกต่างกัน (128 256 512 บิต) } @Bean SessionDao SessionDao () {ส่งคืนหน่วยความจำใหม่ ๆ DAO (); } @Bean Public SessionManager SessionManager () {DefaultWebebsessionAger SessionManager = ใหม่ defaultWebSessionManager (); คอลเลกชัน <SessionListener> Listeners = new ArrayList <> (); Listeners.add (ใหม่ bdsessionListener ()); SessionManager.setsessionListeners (ผู้ฟัง); SessionManager.setsessiondao (SessionDao ()); return SessionManager; } // ====================== คุกกี้และการจัดการเซสชันสิ้นสุด ========================= /endregion /*** ความปลอดภัย: ผู้จัดการธุรกรรมความปลอดภัยหลัก, การจัดการการอนุญาต* มันเป็นคลาสที่ค่อนข้างสำคัญ */ @Bean (name = "SecurityManager") Public DefaultWebsecurityManager SecurityManager () {DefaultWebsecurityManager SecurityManager = ใหม่ defaultWebSecurityManager (); SecurityManager.setRealm (Shirorealm ()); SecurityManager.SetCacheManager (EhcacheManager ()); ///// แคชข้อมูลการอนุญาต/การรับรองความถูกต้องของผู้ใช้โดยใช้ EHCache Cache // การจัดการเซสชันที่กำหนดเองใช้ Redis SecurityManager.SetSessionManager (SessionManager ()); // ฉีดจำผู้จัดการของฉัน SecurityManager.SetRememberMemanager (RememberMemanager ()); Return SecurityManager; } /** * shirorealm นี่คือคลาสการตรวจสอบความถูกต้องที่กำหนดเองที่สืบทอดมาจาก AuthorizingRealm, * รับผิดชอบการตรวจสอบสิทธิ์ของผู้ใช้และการประมวลผลการอนุญาตคุณสามารถอ้างถึงการใช้งาน JDBCrealm */ @bean @dependson ("LifecyclebeanpostProcessor") Shirorealm สาธารณะ Shirorealm (CredentialSmatcher Matcher) {Shirorealm Realm = New Shirorealm (); RealM.SetCredentialSmatcher (matcher); // การตรวจสอบรหัสผ่านใช้การส่งคืน Realm; } /*** EhcacheManager, CacheManager* หลังจากผู้ใช้เข้าสู่ระบบในความสำเร็จให้แคชข้อมูลผู้ใช้และข้อมูลการอนุญาตจากนั้นทุกครั้งที่ผู้ใช้ร้องขอให้ใส่ลงในเซสชันของผู้ใช้ หากไม่ได้ตั้งค่าถั่วนี้ฐานข้อมูลจะถูกสอบถามหนึ่งครั้งสำหรับแต่ละคำขอ */ @bean @dependson ("LifecyclebeanpostProcessor") สาธารณะ ehcachemanager ehcachemanager () {ehcachemanager em = ใหม่ ehcachemanager (); Em.SetCacheManagerConFigFile ("classpath: config/ehcache.xml"); // เส้นทางการกำหนดค่าพา ธ ส่งกลับ EM; } /** * LifeCycleBeanPostProcessor นี่คือคลาสย่อยของ DestructionAwareBeanPostProcessor * มีหน้าที่รับผิดชอบวงจรชีวิตของ org.apache.shiro.util.initializable ถั่วชนิด * ส่วนใหญ่เป็นคลาสย่อยของคลาส AuthorizedRealm เช่นเดียวกับคลาส EhcacheManager */ @bean (name = "LifecycleBeanPostProcessor") Public LifecycleBeanPostProcessor LifecyclecyBeanPostProcessor () {ส่งคืน LifecycleBeanPostProcessor ใหม่ (); } /** * HashedCredentialSmatcher คลาสนี้ใช้สำหรับการเข้ารหัสรหัสผ่าน * ป้องกันไม่ให้รหัสผ่านถูกเก็บไว้ในฐานข้อมูลอย่างชัดเจน แน่นอนเมื่อเข้าสู่ระบบการตรวจสอบสิทธิ์ * คลาสนี้ยังรับผิดชอบในการเข้ารหัสรหัสผ่านที่ป้อนในโปรเซสเซอร์การจับคู่การตรวจสอบความถูกต้องของการประมวลผล: หากการปรับแต่งต้องมีการสืบทอดการสืบทอดของ HashedCredentialSmatcher */ @Bean = new hashedCredentialSmatcher (); CredentialSmatcher.sethashalgorithmname ("MD5"); // ระบุวิธีการเข้ารหัสและคุณยังสามารถเพิ่มแคชได้ที่นี่ เมื่อผู้ใช้เข้าสู่ระบบในข้อผิดพลาดในการเข้าสู่ระบบมากกว่าห้าข้อผู้ใช้จะถูกล็อค ผู้ใช้ถูกห้ามไม่ให้เข้าสู่ระบบ CredentialSmatcher.Sethashiterations (2); CredentialSmatcher.SetStoredCredentialShexencoded (จริง); ส่งคืน CredentialSmatcher; } /** * AuthorizationAttributesourceAdvisor, คลาสที่ปรึกษาที่ใช้ใน Shiro, * ใช้ AopallianceannotationsauthorizingMethodinterceptor ภายในเพื่อสกัดกั้นวิธีการด้วยคำอธิบายประกอบต่อไปนี้ */ @Bean Public AuthorizationAtTributesourceAdVisor AuthorizationAtTributesourceAdVisor () {AuthorizationAtTributesourceAdVisor Advisor = ใหม่ AuthorizationAtTributesourceAdVisor (); ที่ปรึกษา SetSecurityManager (SecurityManager ()); ที่ปรึกษากลับ; } // @Bean สาธารณะ ShiroDialect ShiroDialect () {ส่งคืน Shirodialect ใหม่ (); - 3. ปรับแต่งคลาสการตรวจสอบอาณาจักร
แพ็คเกจ com.yiyun.web.system.config; นำเข้า com.yiyun.dao.master.userdao; นำเข้า com.yiyun.domain.userdo; นำเข้า com.yiyun.web.common.utils.shiroutils; นำเข้า com.yiyun.web.system.service.menuservice; นำเข้า org.apache.shiro.securityutils; นำเข้า org.apache.shiro.authc.*; นำเข้า org.apache.shiro.authz.authorizationInfo; นำเข้า org.apache.shiro.authz.simpleauthorizationInfo; นำเข้า org.apache.shiro.realm.authorizingRealm; นำเข้า org.apache.shiro.session.session; นำเข้า org.apache.shiro.subject.principalcollection; นำเข้า org.springframework.beans.factory.annotation.autowired; นำเข้า Java.util.*; / *** รับข้อมูลผู้ใช้และข้อมูลการอนุญาต*/ Shirorealm ระดับสาธารณะขยาย AuthorizingRealm {// โดยทั่วไปสิ่งที่เขียนไว้ที่นี่คือ servic @autowired UserDao Usermapper; @autowired เมนูเมนูส่วนตัวส่วนตัว; /*** การตรวจสอบล็อกอินโดยทั่วไปพูดหลังจากเข้าสู่ระบบผู้ใช้ปัจจุบันจะได้รับอนุญาต ขั้นตอนนี้ขึ้นอยู่กับ doGetauthenticationInfo หลังจากข้อมูลผู้ใช้คุณสามารถตรวจสอบได้ว่าจะอนุญาตให้ผู้ใช้ตามบทบาทของผู้ใช้และข้อมูลการอนุญาตหรือไม่ ดังนั้นบทบาทและการอนุญาตที่นี่เป็นสองพื้นฐานการตัดสินที่สำคัญสำหรับผู้ใช้ * @param AuthenticationToken * @return * @throws AuthenticationException */ @Override Protected AuthenticationInfo DoGetauthenticationInfo (AuthenticationToken userdo user = usermapper.findbyName (token.getUserName ()); // ตรวจสอบว่ามีผู้ใช้รายนี้หรือไม่ (ผู้ใช้! = null) {// ถ้ามีให้เก็บผู้ใช้นี้ไว้ในข้อมูลการตรวจสอบเข้าสู่ระบบและไม่จำเป็นต้องเปรียบเทียบรหัสผ่านด้วยตัวคุณเอง Shiro จะทำการเปรียบเทียบรหัสผ่านและการตรวจสอบสำหรับรายการ US <urole> rlist = uroledao.findrolebyuid (user.getId ()); // รับรายการบทบาทผู้ใช้ <upermission> plist = upermissionDao.findperMissionByUid (user.getId (); รายการ <String> PermissionStrList = new ArrayList <String> (); /// การรวบรวมการอนุญาตของผู้ใช้สำหรับ (บทบาท UROL: RLIST) {ROLESTLIST.ADD (ROLE.GETNAME ()); } สำหรับ (upermission upermission: plist) {perminsstrlist.add (uperMission.getName ()); } user.setRolestLlist (rolestlist); user.setPerminsstrlist (perminSstrlist); เซสชันเซสชัน = SecurityUtils.getSubject (). getSession (); session.setAttribute ("ผู้ใช้", ผู้ใช้); // หากประสบความสำเร็จให้ใส่ไว้ในเซสชัน // หากมีอยู่ให้เก็บผู้ใช้นี้ไว้ในข้อมูลการตรวจสอบเข้าสู่ระบบและคุณไม่จำเป็นต้องเปรียบเทียบรหัสผ่านของคุณด้วยตัวเอง ชิโร่จะทำการตรวจสอบการเปรียบเทียบรหัสผ่านสำหรับเรา ส่งคืน SimpleAuthEnticationInfo ใหม่ (ผู้ใช้, user.getPassword (), getName ()); } return null; } / *** การรับรองความถูกต้องของการอนุญาต* รับข้อมูลการอนุญาตของผู้ใช้ซึ่งคือการตัดสินสำหรับขั้นตอนต่อไปของการอนุญาตเพื่อให้ได้บทบาทของผู้ใช้ปัจจุบันและข้อมูลการอนุญาตที่เป็นเจ้าของโดยบทบาทเหล่านี้* @Param principalcollection* @return* / @override เทียบเท่ากับ (สตริง) principalcollection.fromrealm (getName ()). iterator (). next (); // สตริง loginName = (สตริง) super.getAvailablePrincipal (principalcollection); userdo user = (userdo) principalcollection.getPrimaryPrincipal (); // // ไปที่ฐานข้อมูลเพื่อตรวจสอบว่าวัตถุนี้มีอยู่ // ผู้ใช้หรือไม่ = null; // ในโครงการจริงคุณสามารถแคชตามสถานการณ์จริง หากคุณไม่ทำเช่นนั้นชิโร่เองก็มีกลไกช่วงเวลาและจะไม่ดำเนินการวิธีการซ้ำ ๆ ภายใน 2 นาที // user = usermapper.findbyName (ชื่อเข้าสู่ระบบ); if (user! = null) {// ข้อมูลวัตถุที่ได้รับอนุญาตใช้เพื่อจัดเก็บบทบาททั้งหมด (บทบาท) และสิทธิ์ (การอนุญาต) ของผู้ใช้ที่พบได้ง่ายต่อการกำหนดค่าข้อมูล INFO = ใหม่ SimpleAdeAdizationInfo (); // ข้อมูลการรวบรวมบทบาทของผู้ใช้ Addroles (user.getRolestList ()); // ข้อมูลการรวบรวมสิทธิ์ของผู้ใช้ข้อมูล AddSstringPermissions (user.getPerminSstrlist ()); ข้อมูลส่งคืน; } // return null มันจะทำให้ผู้ใช้ใด ๆ สามารถเข้าถึงคำขอที่ถูกสกัดกั้นเพื่อข้ามไปยังที่อยู่ที่ระบุโดย UnauthorizedUrl โดยอัตโนมัติ -4. ในที่สุดลองดูที่ไฟล์การกำหนดค่าของ ehcache
<? xml เวอร์ชัน = "1.0" การเข้ารหัส = "utf-8"?> <ehcache xmlns: xsi = "http://www.w3.org/2001/xmlschema-instance" xsi: nonamespaceschemalocation = "http:/ehcache.orgcache.org <diskstore path = "java.io.tmpdir /tmp_ehcache" /> <!- ชื่อ: ชื่อแคช MaxElementsInMemory: จำนวนสูงสุดของแคช MaxElementsOndisk: จำนวนแคชสูงสุดสำหรับฮาร์ดดิสก์ นิรันดร์: วัตถุนั้นถูกต้องอย่างถาวรเมื่อตั้งค่าการหมดเวลาจะไม่ทำงาน OverflowTodisk: ไม่ว่าจะบันทึกลงในดิสก์ timetoidleseconds: ตั้งค่าเวลาว่าง (หน่วย: วินาที) ของวัตถุก่อนที่จะหมดอายุ ใช้เฉพาะในกรณีที่วัตถุนิรันดร์ = เท็จไม่ถูกต้องอย่างถาวรแอตทริบิวต์ตัวเลือกค่าเริ่มต้นคือ 0 ซึ่งหมายความว่าเวลาว่างไม่มีที่สิ้นสุด Timetoliveseconds: ตั้งค่าเวลา (หน่วย: วินาที) ของวัตถุก่อนที่จะหมดอายุ เวลาสูงสุดคือระหว่างเวลาการสร้างและเวลาล้มเหลว ใช้เฉพาะเมื่อวัตถุนิรันดร์ = เท็จไม่ถูกต้องอย่างถาวรค่าเริ่มต้นคือ 0 ซึ่งหมายความว่าเวลาการอยู่รอดของวัตถุนั้นไม่มีที่สิ้นสุด Diskpersistent: ร้านค้าดิสก์ยังคงอยู่ระหว่างรีสตาร์ทเครื่องเสมือนจริงหรือไม่ ค่าเริ่มต้นเป็นเท็จ DiskspoolBuffersizemb: พารามิเตอร์นี้ตั้งค่าขนาดแคชของดิสก์สโตร์ (แคชดิสก์) ค่าเริ่มต้นคือ 30MB แต่ละแคชควรมีบัฟเฟอร์ของตัวเอง diskexpirythreadIntervalseconds: ช่วงเวลาการรันเธรดดิสก์ความล้มเหลวค่าเริ่มต้นคือ 120 วินาที MemoryStoreEvictionPolicy: เมื่อถึงขีด จำกัด MaxElementsInMemory Ehcache จะทำความสะอาดหน่วยความจำตามนโยบายที่ระบุ นโยบายเริ่มต้นคือ LRU (ใช้งานล่าสุด) คุณสามารถตั้งค่าเป็น FIFO (ก่อนออกก่อน) หรือ LFU (ใช้น้อยกว่า) ClearOnflush: จะล้างเมื่อจำนวนหน่วยความจำสูงสุดหรือไม่ MemoryStoreEvictionPolicy: สามกลยุทธ์การล้างสำหรับ Ehcache; FIFO เป็นครั้งแรกในครั้งแรกนี่เป็นสิ่งที่คุ้นเคยที่สุดสำหรับทุกคนก่อนอื่นในครั้งแรก LFU ที่ใช้บ่อยน้อยกว่าเป็นกลยุทธ์ที่ใช้ในตัวอย่างข้างต้น ที่จะทื่อก็คือการบอกว่ามันเป็นสิ่งที่ใช้น้อยที่สุด ดังที่ได้กล่าวไว้ข้างต้นองค์ประกอบแคชมีแอตทริบิวต์ HIT และค่าฮิตต่ำสุดจะถูกลบออกจากแคช LRU ที่ใช้อย่างน้อยเมื่อเร็ว ๆ นี้องค์ประกอบแคชมีการประทับเวลา เมื่อความจุแคชเต็มและคุณจำเป็นต้องมีที่ว่างสำหรับแคชองค์ประกอบใหม่องค์ประกอบที่มีเวลาที่ไกลที่สุดในองค์ประกอบแคชที่มีอยู่จะถูกล้างออกจากแคช -> <defaultCache Eternal = "false" maxElementsInMemory = "1000" overflowTodisk = "false" diskpersistent = "false" timetoidleseconds = "0" timetoliveseconds = "600" MemoryStoreEvictionPolicy = "LRU" /> <! maxentriesLocalHeap = "2000" Eternal = "false" timetoidleseconds = "3600" timetoliveseconds = "0" overflowTodisk = "false" สถิติ = "true"> </cache> </ehcache>
ข้างต้นเป็นเนื้อหาทั้งหมดของบทความนี้ ฉันหวังว่ามันจะเป็นประโยชน์ต่อการเรียนรู้ของทุกคนและฉันหวังว่าทุกคนจะสนับสนุน wulin.com มากขึ้น