鬆哥手把手教你定製 Spring Security 中的表單登錄

{"type":"doc","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Spring Security 系列繼續。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"前面的視頻+文章,鬆哥和大家簡單聊了 Spring Security 的基本用法,並且我們一起自定義了一個登錄頁面,讓登錄看起來更炫一些!"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"今天我們來繼續深入這個表單配置,挖掘一下這裏邊常見的其他配置。學習本文,強烈建議大家看一下前置知識("},{"type":"link","attrs":{"href":"https://mp.weixin.qq.com/s/Q0GkUb1Nt6ynV22LFHuQrQ","title":""},"content":[{"type":"text","text":"鬆哥手把手帶你入門 Spring Security,別再問密碼怎麼解密了"}]},{"type":"text","text":"),學習效果更佳。"}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"1.登錄接口"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"很多初學者分不清登錄接口和登錄頁面,這個我也很鬱悶。我還是在這裏稍微說一下。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"登錄頁面就是你看到的瀏覽器展示出來的頁面,像下面這個:"}]},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/33/3310f0ffa76e77b37d355808e7e966de.png","alt":null,"title":null,"style":null,"href":null,"fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"登錄接口則是提交登錄數據的地方,就是登錄頁面裏邊的 form 表單的 action 屬性對應的值。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在 Spring Security 中,如果我們不做任何配置,默認的登錄頁面和登錄接口的地址都是 "},{"type":"codeinline","content":[{"type":"text","text":"/login"}]},{"type":"text","text":",也就是說,默認會存在如下兩個請求:"}]},{"type":"bulletedlist","content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"GET http://localhost:8080/login"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"POST http://localhost:8080/login"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"如果是 GET 請求表示你想訪問登錄頁面,如果是 POST 請求,表示你想提交登錄數據。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在"},{"type":"link","attrs":{"href":"https://mp.weixin.qq.com/s/Q0GkUb1Nt6ynV22LFHuQrQ","title":""},"content":[{"type":"text","text":"上篇文章"}]},{"type":"text","text":"中,我們在 SecurityConfig 中自定定義了登錄頁面地址,如下:"}]},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":".and()\n.formLogin()\n.loginPage(\"/login.html\")\n.permitAll()\n.and()"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"當我們配置了 loginPage 爲 "},{"type":"codeinline","content":[{"type":"text","text":"/login.html"}]},{"type":"text","text":" 之後,這個配置從字面上理解,就是設置登錄頁面的地址爲 "},{"type":"codeinline","content":[{"type":"text","text":"/login.html"}]},{"type":"text","text":"。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"實際上它還有一個隱藏的操作,就是登錄接口地址也設置成 "},{"type":"codeinline","content":[{"type":"text","text":"/login.html"}]},{"type":"text","text":" 了。換句話說,新的登錄頁面和登錄接口地址都是 "},{"type":"codeinline","content":[{"type":"text","text":"/login.html"}]},{"type":"text","text":",現在存在如下兩個請求:"}]},{"type":"bulletedlist","content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"GET http://localhost:8080/login.html"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"POST http://localhost:8080/login.html"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"前面的 GET 請求用來獲取登錄頁面,後面的 POST 請求用來提交登錄數據。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"有的小夥伴會感到奇怪?爲什麼登錄頁面和登錄接口不能分開配置呢?"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"其實是可以分開配置的!"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在 SecurityConfig 中,我們可以通過 loginProcessingUrl 方法來指定登錄接口地址,如下:"}]},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":".and()\n.formLogin()\n.loginPage(\"/login.html\")\n.loginProcessingUrl(\"/doLogin\")\n.permitAll()\n.and()"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"這樣配置之後,登錄頁面地址和登錄接口地址就分開了,各是各的。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"此時我們還需要修改登錄頁面裏邊的 action 屬性,改爲 "},{"type":"codeinline","content":[{"type":"text","text":"/doLogin"}]},{"type":"text","text":",如下:"}]},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"
\n\n
"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"此時,啓動項目重新進行登錄,我們發現依然可以登錄成功。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"那麼爲什麼默認情況下兩個配置地址是一樣的呢?"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我們知道,form 表單的相關配置在 FormLoginConfigurer 中,該類繼承自 AbstractAuthenticationFilterConfigurer ,所以當 FormLoginConfigurer 初始化的時候,AbstractAuthenticationFilterConfigurer 也會初始化,在 AbstractAuthenticationFilterConfigurer 的構造方法中,我們可以看到:"}]},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"protected AbstractAuthenticationFilterConfigurer() {\nsetLoginPage(\"/login\");\n}"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"這就是配置默認的 loginPage 爲 "},{"type":"codeinline","content":[{"type":"text","text":"/login"}]},{"type":"text","text":"。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"另一方面,FormLoginConfigurer 的初始化方法 init 方法中也調用了父類的 init 方法:"}]},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"public void init(H http) throws Exception {\nsuper.init(http);\ninitDefaultLoginFilter(http);\n}"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"而在父類的 init 方法中,又調用了 updateAuthenticationDefaults,我們來看下這個方法:"}]},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"protected final void updateAuthenticationDefaults() {\nif (loginProcessingUrl == null) {\nloginProcessingUrl(loginPage);\n}\n//省略\n}"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"從這個方法的邏輯中我們就可以看到,如果用戶沒有給 loginProcessingUrl 設置值的話,默認就使用 loginPage 作爲 loginProcessingUrl。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"而如果用戶配置了 loginPage,在配置完 loginPage 之後,updateAuthenticationDefaults 方法還是會被調用,此時如果沒有配置 loginProcessingUrl,則使用新配置的 loginPage 作爲 loginProcessingUrl。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"好了,看到這裏,相信小夥伴就明白了爲什麼一開始的登錄接口和登錄頁面地址一樣了。"}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"2.登錄參數"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"說完登錄接口,我們再來說登錄參數。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在"},{"type":"link","attrs":{"href":"https://mp.weixin.qq.com/s/Q0GkUb1Nt6ynV22LFHuQrQ","title":""},"content":[{"type":"text","text":"上篇文章"}]},{"type":"text","text":"中,我們的登錄表單中的參數是 username 和 password,注意,默認情況下,這個不能變:"}]},{"type":"codeblock","attrs":{"lang":"html"},"content":[{"type":"text","text":"
\n\n\n\n
"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"那麼爲什麼是這樣呢?"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"還是回到 FormLoginConfigurer 類中,在它的構造方法中,我們可以看到有兩個配置用戶名密碼的方法:"}]},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"public FormLoginConfigurer() {\nsuper(new UsernamePasswordAuthenticationFilter(), null);\nusernameParameter(\"username\");\npasswordParameter(\"password\");\n}"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在這裏,首先 super 調用了父類的構造方法,傳入了 UsernamePasswordAuthenticationFilter 實例,該實例將被賦值給父類的 authFilter 屬性。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"接下來 usernameParameter 方法如下:"}]},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"public FormLoginConfigurer usernameParameter(String usernameParameter) {\ngetAuthenticationFilter().setUsernameParameter(usernameParameter);\nreturn this;\n}"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"getAuthenticationFilter 實際上是父類的方法,在這個方法中返回了 authFilter 屬性,也就是一開始設置的 UsernamePasswordAuthenticationFilter 實例,然後調用該實例的 setUsernameParameter 方法去設置登錄用戶名的參數:"}]},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"public void setUsernameParameter(String usernameParameter) {\nthis.usernameParameter = usernameParameter;\n}"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"這裏的設置有什麼用呢?當登錄請求從瀏覽器來到服務端之後,我們要從請求的 HttpServletRequest 中取出來用戶的登錄用戶名和登錄密碼,怎麼取呢?還是在 UsernamePasswordAuthenticationFilter 類中,有如下兩個方法:"}]},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"protected String obtainPassword(HttpServletRequest request) {\nreturn request.getParameter(passwordParameter);\n}\nprotected String obtainUsername(HttpServletRequest request) {\nreturn request.getParameter(usernameParameter);\n}"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"可以看到,這個時候,就用到默認配置的 username 和 password 了。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"當然,這兩個參數我們也可以自己配置,自己配置方式如下:"}]},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":".and()\n.formLogin()\n.loginPage(\"/login.html\")\n.loginProcessingUrl(\"/doLogin\")\n.usernameParameter(\"name\")\n.passwordParameter(\"passwd\")\n.permitAll()\n.and()"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"配置完成後,也要修改一下前端頁面:"}]},{"type":"codeblock","attrs":{"lang":"html"},"content":[{"type":"text","text":"
\n
\n\n\n\n
\n
\n\n\n\n
\n
\n\n
\n
"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"注意修改 input 的 name 屬性值和服務端的對應。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"配置完成後,重啓進行登錄測試。"}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"3.登錄回調"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在登錄成功之後,我們就要分情況處理了,大體上來說,無非就是分爲兩種情況:"}]},{"type":"bulletedlist","content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"前後端分離登錄"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"前後端不分登錄"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"兩種情況的處理方式不一樣。本文我們先來卡第二種前後端不分的登錄,前後端分離的登錄回調我在下篇文章中再來和大家細說。"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"3.1 登錄成功回調"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在 Spring Security 中,和登錄成功重定向 URL 相關的方法有兩個:"}]},{"type":"bulletedlist","content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"defaultSuccessUrl"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"successForwardUrl"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"這兩個咋看沒什麼區別,實際上內藏乾坤。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"首先我們在配置的時候,defaultSuccessUrl 和 successForwardUrl 只需要配置一個即可,具體配置哪個,則要看你的需求,兩個的區別如下:"}]},{"type":"numberedlist","attrs":{"start":"1","normalizeStart":1},"content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":1,"align":null,"origin":null},"content":[{"type":"text","text":"defaultSuccessUrl 有一個重載的方法,我們先說一個參數的 defaultSuccessUrl 方法。如果我們在 defaultSuccessUrl 中指定登錄成功的跳轉頁面爲 "},{"type":"codeinline","content":[{"type":"text","text":"/index"}]},{"type":"text","text":",此時分兩種情況,如果你是直接在瀏覽器中輸入的登錄地址,登錄成功後,就直接跳轉到 "},{"type":"codeinline","content":[{"type":"text","text":"/index"}]},{"type":"text","text":",如果你是在瀏覽器中輸入了其他地址,例如 "},{"type":"codeinline","content":[{"type":"text","text":"http://localhost:8080/hello"}]},{"type":"text","text":",結果因爲沒有登錄,又重定向到登錄頁面,此時登錄成功後,就不會來到 "},{"type":"codeinline","content":[{"type":"text","text":"/index"}]},{"type":"text","text":" ,而是來到 "},{"type":"codeinline","content":[{"type":"text","text":"/hello"}]},{"type":"text","text":" 頁面。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":2,"align":null,"origin":null},"content":[{"type":"text","text":"defaultSuccessUrl 還有一個重載的方法,第二個參數如果不設置默認爲 false,也就是我們上面的的情況,如果手動設置第二個參數爲 true,則 defaultSuccessUrl 的效果和 successForwardUrl 一致。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":3,"align":null,"origin":null},"content":[{"type":"text","text":"successForwardUrl 表示不管你是從哪裏來的,登錄後一律跳轉到 successForwardUrl 指定的地址。例如 successForwardUrl 指定的地址爲 "},{"type":"codeinline","content":[{"type":"text","text":"/index"}]},{"type":"text","text":" ,你在瀏覽器地址欄輸入 "},{"type":"codeinline","content":[{"type":"text","text":"http://localhost:8080/hello"}]},{"type":"text","text":",結果因爲沒有登錄,重定向到登錄頁面,當你登錄成功之後,就會服務端跳轉到 "},{"type":"codeinline","content":[{"type":"text","text":"/index"}]},{"type":"text","text":" 頁面;或者你直接就在瀏覽器輸入了登錄頁面地址,登錄成功後也是來到 "},{"type":"codeinline","content":[{"type":"text","text":"/index"}]},{"type":"text","text":"。"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"相關配置如下:"}]},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":".and()\n.formLogin()\n.loginPage(\"/login.html\")\n.loginProcessingUrl(\"/doLogin\")\n.usernameParameter(\"name\")\n.passwordParameter(\"passwd\")\n.defaultSuccessUrl(\"/index\")\n.successForwardUrl(\"/index\")\n.permitAll()\n.and()"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong"}],"text":"注意:實際操作中,defaultSuccessUrl 和 successForwardUrl 只需要配置一個即可。"}]},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"3.2 登錄失敗回調"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"與登錄成功相似,登錄失敗也是有兩個方法:"}]},{"type":"bulletedlist","content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"failureForwardUrl"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"failureUrl"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong"}],"text":"這兩個方法在設置的時候也是設置一個即可"},{"type":"text","text":"。failureForwardUrl 是登錄失敗之後會發生服務端跳轉,failureUrl 則在登錄失敗之後,會發生重定向。"}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"4.註銷登錄"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"註銷登錄的默認接口是 "},{"type":"codeinline","content":[{"type":"text","text":"/logout"}]},{"type":"text","text":",我們也可以配置。"}]},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":".and()\n.logout()\n.logoutUrl(\"/logout\")\n.logoutRequestMatcher(new AntPathRequestMatcher(\"/logout\",\"POST\"))\n.logoutSuccessUrl(\"/index\")\n.deleteCookies()\n.clearAuthentication(true)\n.invalidateHttpSession(true)\n.permitAll()\n.and()"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"註銷登錄的配置我來說一下:"}]},{"type":"numberedlist","attrs":{"start":"1","normalizeStart":1},"content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":1,"align":null,"origin":null},"content":[{"type":"text","text":"默認註銷的 URL 是 "},{"type":"codeinline","content":[{"type":"text","text":"/logout"}]},{"type":"text","text":",是一個 GET 請求,我們可以通過 logoutUrl 方法來修改默認的註銷 URL。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":2,"align":null,"origin":null},"content":[{"type":"text","text":"logoutRequestMatcher 方法不僅可以修改註銷 URL,還可以修改請求方式,實際項目中,這個方法和 logoutUrl 任意設置一個即可。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":3,"align":null,"origin":null},"content":[{"type":"text","text":"logoutSuccessUrl 表示註銷成功後要跳轉的頁面。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":4,"align":null,"origin":null},"content":[{"type":"text","text":"deleteCookies 用來清除 cookie。"}]}]},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":5,"align":null,"origin":null},"content":[{"type":"text","text":"clearAuthentication 和 invalidateHttpSession 分別表示清除認證信息和使 HttpSession 失效,默認可以不用配置,默認就會清除。"}]}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"好了,今天就先說這麼多,這塊還剩一些前後端分離交互的,鬆哥在下篇文章再來和大家細說。"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong"}],"text":"如果感覺有收穫,記得點一下右下角在看哦"}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}}]}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章