#前端篇: 前端演進史 細細整理了過去接觸過的那些前端技術(shù),發(fā)現(xiàn)前端演進是段特別有意思的歷史。人們總是在過去就做出未來需要的框架,而現(xiàn)在流行的是過去的過去發(fā)明過的。如,響應(yīng)式設(shè)計不得不提到的一個缺點是:他只是將原本在模板層做的事,放到了樣式(CSS)層來完成。 復(fù)雜度同力一樣不會消失,也不會憑空產(chǎn)生,它總是從一個物體轉(zhuǎn)移到另一個物體或一種形式轉(zhuǎn)為另一種形式。 如果六、七年前的移動網(wǎng)絡(luò)速度和今天一樣快,那么直接上的技術(shù)就是響應(yīng)式設(shè)計,APP、SPA就不會流行得這么快。盡管我們可以預(yù)見未來這些領(lǐng)域會變得更好,但是更需要的是改變現(xiàn)狀。改變現(xiàn)狀的同時也需要預(yù)見未來的需求。 ###什么是前端? 維基百科是這樣說的:前端Front-end和后端back-end是描述進程開始和結(jié)束的通用詞匯。前端作用于采集輸入信息,后端進行處理。計算機程序的界面樣式,視覺呈現(xiàn)屬于前端。 這種說法給人一種很模糊的感覺,但是他說得又很對,它負責視覺展示。在MVC結(jié)構(gòu)或者MVP中,負責視覺顯示的部分只有View層,而今天大多數(shù)所謂的View層已經(jīng)超越了View層。前端是一個很神奇的概念,但是而今的前端已經(jīng)發(fā)生了很大的變化。 你引入了Backbone、Angluar,你的架構(gòu)變成了MVP、MVVM。盡管發(fā)生了一些架構(gòu)上的變化,但是項目的開發(fā)并沒有因此而發(fā)生變化。這其中涉及到了一些職責的問題,如果某一個層級中有太多的職責,那么它是不是加重了一些人的負擔? ##前端演進史 過去一直想整理一篇文章來說說前端發(fā)展的歷史,但是想著這些歷史已經(jīng)被人們所熟知。后來發(fā)現(xiàn)并非如此,大抵是幸存者偏見——關(guān)注到的都知道這些歷史。 ###數(shù)據(jù)-模板-樣式混合 在有限的前端經(jīng)驗里,我還是經(jīng)歷了那段用Table來作樣式的年代。大學期間曾經(jīng)有償幫一些公司或者個人開發(fā)、維護一些CMS,而Table是當時幫某個網(wǎng)站更新樣式接觸到的——ASP.Net(maybe)。當時,我們啟動這個CMS用的是一個名為 <TABLE cellSpacing=0 cellPadding=0 width=910 align=center border=0> <TBODY> <TR> <TD vAlign=top width=188><TABLE cellSpacing=0 cellPadding=0 width=184 align=center border=0> <TBODY> <TR> <TD><IMG src="Images/xxx.gif" width=184></TD></TR> <TR> <TD> <TABLE cellSpacing=0 cellPadding=0 width=184 align=center background=Images/xxx.gif border=0> 雖然,我也已經(jīng)在HEAD里找到了現(xiàn)代的雛形——DIV + CSS,然而這仍然是一個Table的年代。 <LINK href="img/xxx.css" type=text/css rel=stylesheet> 人們一直在說前端很難,問題是你學過么??? 人們一直在說前端很難,問題是你學過么??? 人們一直在說前端很難,問題是你學過么??? 也許,你也一直在說CSS不好寫,但是CSS真的不好寫么?人們總在說JS很難用,但是你學過么?只在需要的時候才去學,那肯定很難。你不曾花時間去學習一門語言,但是卻能直接寫出可以work的代碼,說明他們?nèi)菀咨鲜?/span>。如果你看過一些有經(jīng)驗的Ruby、Scala、Emacs Lisp開發(fā)者寫出來的代碼,我想會得到相同的結(jié)論。有一些語言可以讓寫程序的人Happy,但是看的人可能就不Happy了。做事的方法不止一種,但是不是所有的人都要用那種方法去做。 過去的那些程序員都是真正的全棧程序員,這些程序員不僅僅做了前端的活,還做了數(shù)據(jù)庫的工作。 Set rs = Server.CreateObject("ADODB.Recordset") sql = "select id,title,username,email,qq,adddate,content,Re_content,home,face,sex from Fl_Book where ispassed=1 order by id desc" rs.open sql, Conn, 1, 1 fl.SqlQueryNum = fl.SqlQueryNum + 1 在這個ASP文件里,它從數(shù)據(jù)庫里查找出了數(shù)據(jù),然后Render出HTML。如果可以看到歷史版本,那么我想我會看到有一個作者將style=""的代碼一個個放到css文件中。 在這里的代碼里也免不了有動態(tài)生成JavaScript代碼的方法: show_other = "<SCRIPT language=javascript>" show_other = show_other & "function checkform()" show_other = show_other & "{" show_other = show_other & "if (document.add.title.value=='')" show_other = show_other & "{" 請盡情嘲笑,然后再看一段代碼: import React from "react"; import { getData } from "../../common/request"; import styles from "./style.css"; export default class HomePage extends React.Component { componentWillMount() { console.log("[HomePage] will mount with server response: ", this.props.data.home); } render() { let { title } = this.props.data.home; return ( <div className={styles.content}> <h1>{title}</h1> <p className={styles.welcomeText}>Thanks for joining!</p> </div> ); } static fetchData = function(params) { return getData("/home"); } } 10年前和10年后的代碼,似乎沒有太多的變化。有所不同的是數(shù)據(jù)層已經(jīng)被獨立出去了,如果你的component也混合了數(shù)據(jù)層,即直接查詢數(shù)據(jù)庫而不是調(diào)用數(shù)據(jù)層接口,那么你就需要好好思考下這個問題。你只是在追隨潮流,還是在改變。用一個View層更換一個View層,用一個Router換一個Router的意義在哪? ###Model-View-Controller 人們在不斷地反思這其中復(fù)雜的過程,整理了一些好的架構(gòu)模式,其中不得不提到的是我司Martin Folwer的《企業(yè)應(yīng)用架構(gòu)模式》。該書中文譯版出版的時候是2004年,那時對于系統(tǒng)的分層是
化身于當時最流行的Spring,就是MVC。人們有了iBatis這樣的數(shù)據(jù)持久層框架,即ORM,對象關(guān)系映射。于是,你的package就會有這樣的幾個文件夾:
在mappers這一層,我們所做的莫過于如下所示的數(shù)據(jù)庫相關(guān)查詢: @Insert( "INSERT INTO users(username, password, enabled) " + "VALUES (#{userName}, #{passwordHash}, #{enabled})" ) @Options(keyProperty = "id", keyColumn = "id", useGeneratedKeys = true) void insert(User user); model文件夾和mappers文件夾都是數(shù)據(jù)層的一部分,只是兩者間的職責不同,如: public String getUserName() { return userName; } public void setUserName(String userName) { this.userName = userName; } 而他們最后都需要在Controller,又或者稱為ModelAndView中處理: @RequestMapping(value = {"/disableUser"}, method = RequestMethod.POST) public ModelAndView processUserDisable(HttpServletRequest request, ModelMap model) { String userName = request.getParameter("userName"); User user = userService.getByUsername(userName); userService.disable(user); Map<String,User> map = new HashMap<String,User>(); Map <User,String> usersWithRoles= userService.getAllUsersWithRole(); model.put("usersWithRoles",usersWithRoles); return new ModelAndView("redirect:users",map); } 在多數(shù)時候,Controller不應(yīng)該直接與數(shù)據(jù)層的一部分,而將業(yè)務(wù)邏輯放在Controller層又是一種錯誤,這時就有了Service層,如下圖: 然而對于Domain相關(guān)的Service應(yīng)該放在哪一層,總會有不同的意見: Domain(業(yè)務(wù))是一個相當復(fù)雜的層級,這里是業(yè)務(wù)的核心。一個合理的Controller只應(yīng)該做自己應(yīng)該做的事,它不應(yīng)該處理業(yè)務(wù)相關(guān)的代碼: if (isNewnameEmpty == false && newuser == null){ user.setUserName(newUsername); List<Post> myPosts = postService.findMainPostByAuthorNameSortedByCreateTime(principal.getName()); for (int k = 0;k < myPosts.size();k++){ Post post = myPosts.get(k); post.setAuthorName(newUsername); postService.save(post); } userService.update(user); Authentication oldAuthentication = SecurityContextHolder.getContext().getAuthentication(); Authentication authentication = null; if(oldAuthentication == null){ authentication = new UsernamePasswordAuthenticationToken(newUsername,user.getPasswordHash()); }else{ authentication = new UsernamePasswordAuthenticationToken(newUsername,user.getPasswordHash(),oldAuthentication.getAuthorities()); } SecurityContextHolder.getContext().setAuthentication(authentication); map.clear(); map.put("user",user); model.addAttribute("myPosts", myPosts); model.addAttribute("namesuccess", "User Profile updated successfully"); return new ModelAndView("user/profile", map); } 我們在Controller層應(yīng)該做的事是:
業(yè)務(wù)是善變的,昨天我們可能還在和對手競爭誰先推出新功能,但是今天可能已經(jīng)合并了。我們很難預(yù)見業(yè)務(wù)變化,但是我們應(yīng)該能預(yù)見Controller是不容易變化的。在一些設(shè)計里面,這種模式就是Command模式。 View層是一直在變化的層級,人們的品味一直在更新,有時甚至可能因為競爭對手而產(chǎn)生變化。在已經(jīng)取得一定市場的情況下,Model-Service-Controller通常都不太會變動,甚至不敢變動。企業(yè)意識到創(chuàng)新的兩面性,要么帶來死亡,要么占領(lǐng)更大的市場。但是對手通常都比你想象中的更聰明一些,所以這時開創(chuàng)新的業(yè)務(wù)是一個更好的選擇。 高速發(fā)展期的企業(yè)和發(fā)展初期的企業(yè)相比,更需要前端開發(fā)人員。在用戶基數(shù)不夠、業(yè)務(wù)待定的情形中,View只要可用并美觀就行了,這時可能就會有大量的業(yè)務(wù)代碼放在View層: <c:choose> <c:when test="${ hasError }"> <p class="prompt-error"> ${errors.username} ${errors.password} </p> </c:when> <c:otherwise> <p class="prompt"> Woohoo, User <span class="username">${user.userName}</span> has been created successfully! </p> </c:otherwise> </c:choose> 不同的情形下,人們都會對此有所爭議,但只要符合當前的業(yè)務(wù)便是最好的選擇。作為一個前端開發(fā)人員,在過去我需要修改JSP、PHP文件,這期間我需要去了解這些Template: {foreach $lists as $v} <li itemprop="breadcrumb"><span{if(newest($v['addtime'],24))} style="color:red"{/if}>[{fun date('Y-m-d',$v['addtime'])}]</span><a href="{$v['url']}" style="{$v['style']}" target="_blank">{$v['title']}</a></li> {/foreach} 有時像Django這一類,自稱為Model-Template-View的框架,更容易讓人理解其意圖: {% for blog_post in blog_posts.object_list %} {% block blog_post_list_post_title %} <section class="section--center mdl-grid mdl-grid--no-spacing mdl-shadow--2dp mdl-cell--11-col blog-list"> {% editable blog_post.title %} <div class="mdl-card__title mdl-card--border mdl-card--expand"> <h2 class="mdl-card__title-text"> <a href="{{ blog_post.get_absolute_url }}" itemprop="headline">{{ blog_post.title }} ? </a> </h2> </div> {% endeditable %} {% endblock %} 作為一個前端人員,我們真正在接觸的是View層和Template層,但是MVC并沒有說明這些。 ###從桌面版到移動版 Wap出現(xiàn)了,并帶來了更多的挑戰(zhàn)。隨后,分辨率從1024x768變成了176×208,開發(fā)人員不得不面臨這些挑戰(zhàn)。當時所需要做的僅僅是修改View層,而View層隨著iPhone的出現(xiàn)又發(fā)生了變化。 這是一個短暫的歷史,PO還需要為手機用戶制作一個怎樣的網(wǎng)站?于是他們把桌面版的網(wǎng)站搬了過去變成了移動版。由于網(wǎng)絡(luò)的原因,每次都需要重新加載頁面,這帶來了不佳的用戶體驗。 幸運的是,人們很快意識到了這個問題,于是就有了SPA。如果當時的移動網(wǎng)絡(luò)速度可以更快的話,我想很多SPA框架就不存在了。 先說說jQuery Mobile,在那之前,先讓我們來看看兩個不同版本的代碼,下面是一個手機版本的blog詳情頁: <ul data-role="listview" data-inset="true" data-splittheme="a"> {% for blog_post in blog_posts.object_list %} <li> {% editable blog_post.title blog_post.publish_date %} <h2 class="blog-post-title"><a href="{% url "blog_post_detail" blog_post.slug %}">{{ blog_post.title }}</a></h2> <em class="since">{% blocktrans with sometime=blog_post.publish_date|timesince %}{{ sometime }} ago{% endblocktrans %}</em> {% endeditable %} </li> {% endfor %} </ul> 而下面是桌面版本的片段: {% for blog_post in blog_posts.object_list %} {% block blog_post_list_post_title %} {% editable blog_post.title %} <h2> <a href="{{ blog_post.get_absolute_url }}">{{ blog_post.title }}</a> </h2> {% endeditable %} {% endblock %} {% block blog_post_list_post_metainfo %} {% editable blog_post.publish_date %} <h6 class="post-meta"> {% trans "Posted by" %}: {% with blog_post.user as author %} <a href="{% url "blog_post_list_author" author %}">{{ author.get_full_name|default:author.username }}</a> {% endwith %} {% with blog_post.categories.all as categories %} {% if categories %} {% trans "in" %} {% for category in categories %} <a href="{% url "blog_post_list_category" category.slug %}">{{ category }}</a>{% if not forloop.last %}, {% endif %} {% endfor %} {% endif %} {% endwith %} {% blocktrans with sometime=blog_post.publish_date|timesince %}{{ sometime }} ago{% endblocktrans %} </h6> {% endeditable %} {% endblock %} 人們所做的只是重載View層。這也是一個有效的SEO策略,上面這些代碼是我博客過去的代碼。對于桌面版和移動版都是不同的模板和不同的JS、CSS。 在這一時期,桌面版和移動版的代碼可能在同一個代碼庫中。他們使用相同的代碼,調(diào)用相同的邏輯,只是View層不同了。但是,每次改動我們都要維護兩份代碼。 隨后,人們發(fā)現(xiàn)了一種更友好的移動版應(yīng)用——APP。 ###APP與過渡期API 這是一個艱難的時刻,過去我們的很多API都是在原來的代碼庫中構(gòu)建的,即桌面版和移動版一起。我們已經(jīng)在這個代碼庫中開發(fā)了越來越多的功能,系統(tǒng)開發(fā)變得臃腫。如《Linux/Unix設(shè)計思想》中所說,這是一個偉大的系統(tǒng),但是它臃腫而又緩慢。 我們是選擇重新開發(fā)一個結(jié)合第一和第二系統(tǒng)的最佳特性的第三個系統(tǒng),還是繼續(xù)臃腫下去。我想你已經(jīng)有答案了。隨后我們就有了APP API,構(gòu)建出了博客的APP。 最開始,人們越來越喜歡用APP,因為與移動版網(wǎng)頁相比,其響應(yīng)速度更快,而且更流暢。對于服務(wù)器來說,也是一件好事,因為請求變少了。 但是并非所有的人都會下載APP——有時只想看看上面有沒有需要的東西。對于剛需不強的應(yīng)用,人們并不會下載,只會訪問網(wǎng)站。 有了APP API之后,我們可以向網(wǎng)頁提供API,我們就開始設(shè)想要有一個好好的移動版。 ###過渡期SPA Backbone誕生于2010年,和響應(yīng)式設(shè)計出現(xiàn)在同一個年代里,但他們似乎在同一個時代里火了起來。如果CSS3早點流行開來,似乎就沒有Backbone啥事了。不過移動網(wǎng)絡(luò)還是限制了響應(yīng)式的流行,只是在今天這些都有所變化。 我們用Ajax向后臺請求API,然后Mustache Render出來。因為JavaScript在模塊化上的缺陷,所以我們就用Require.JS來進行模塊化。 下面的代碼就是我在嘗試對我的博客進行SPA設(shè)計時的代碼: define([ 'zepto', 'underscore', 'mustache', 'js/ProductsView', 'json!/configure.json', 'text!/templates/blog_details.html', 'js/renderBlog' ],function($, _, Mustache, ProductsView, configure, blogDetailsTemplate, GetBlog){ var BlogDetailsView = Backbone.View.extend ({ el: $("#content"), initialize: function () { this.params = '#content'; }, getBlog: function(slug) { var getblog = new GetBlog(this.params, configure['blogPostUrl'] + slug, blogDetailsTemplate); getblog.renderBlog(); } }); return BlogDetailsView; }); 從API獲取數(shù)據(jù),結(jié)合Template來Render出Page。但是這無法改變我們需要Client Side Render和Server Side Render的兩種Render方式,除非我們可以像淘寶一樣不需要考慮SEO——因為它不那么依靠搜索引擎帶來流量。 這時,我們還是基于類MVC模式。只是數(shù)據(jù)的獲取方式變成了Ajax,我們就犯了一個錯誤——將大量的業(yè)務(wù)邏輯放在前端。這時候我們已經(jīng)不能再從View層直接訪問Model層,從安全的角度來說有點危險。 如果你的View層還可以直接訪問Model層,那么說明你的架構(gòu)還是MVC模式。之前我在Github上構(gòu)建一個Side Project的時候直接用View層訪問了Model層,由于Model層是一個ElasticSearch的搜索引擎,它提供了JSON API,這使得我要在View層處理數(shù)據(jù)——即業(yè)務(wù)邏輯。將上述的JSON API放入Controller,盡管會加重這一層的復(fù)雜度,但是業(yè)務(wù)邏輯就不再放置于View層。 如果你在你的View層和Model層總有一層接口,那么你采用的就是MVP模式——MVC模式的衍生(PS:為了區(qū)別別的事情,總會有人取個表意的名稱)。 一夜之前,我們又回到了過去。我們離開了JSP,將View層變成了Template與Controller。而原有的Services層并不是只承擔其原來的責任,這些Services開始向ViewModel改變。 一些團隊便將Services抽成多個Services,美其名為微服務(wù)。傳統(tǒng)架構(gòu)下的API從下圖 變成了直接調(diào)用的微服務(wù): 對于后臺開發(fā)者來說,這是一件大快人心的大好事,但是對于應(yīng)用端/前端來說并非如此。調(diào)用的服務(wù)變多了,在應(yīng)用程序端進行功能測試變得更復(fù)雜,需要Mock的API變多了。 ###Hybird與ViewModel 這時候遇到問題的不僅僅只在前端,而在App端,小的團隊已經(jīng)無法承受開發(fā)成本。人們更多的注意力放到了Hybird應(yīng)用上。Hybird應(yīng)用解決了一些小團隊在開發(fā)初期遇到的問題,這部分應(yīng)用便交給了前端開發(fā)者。 前端開發(fā)人員先熟悉了單純的JS + CSS + HTML,又熟悉了Router + PageView + API的結(jié)構(gòu),現(xiàn)在他們又需要做手機APP。這時候只好用熟悉的jQuer Mobile + Cordova。 隨后,人們先從Cordova + jQuery Mobile,變成了Cordova + Angular的 Ionic。在那之前,一些團隊可能已經(jīng)用Angular代換了Backbone。他們需要更好的交互,需要data binding。 接著,我們可以直接將我們的Angular代碼從前端移到APP,比如下面這種博客APP的代碼: .controller('BlogCtrl', function ($scope, Blog) { $scope.blogs = null; $scope.blogOffset = 0; // $scope.doRefresh = function () { Blog.async('https://www./api/v1/app/?format=json').then(function (results) { $scope.blogs = results.objects; }); $scope.$broadcast('scroll.refreshComplete'); $scope.$apply() }; Blog.async('https://www./api/v1/app/?format=json').then(function (results) { $scope.blogs = results.objects; }); $scope.loadMore = function() { $scope.blogOffset = $scope.blogOffset + 1; Blog.async('https://www./api/v1/app/?limit=10&offset='+ $scope.blogOffset * 20 + '&format=json').then(function (results) { Array.prototype.push.apply($scope.blogs, results.objects); $scope.$broadcast('scroll.infiniteScrollComplete'); }) }; }) 結(jié)果時間軸又錯了,人們總是超前一個時期做錯了一個在未來是正確的決定。人們遇到了網(wǎng)頁版的用戶授權(quán)問題,于是發(fā)明了JWT——Json Web Token。 然而,由于WebView在一些早期的Android手機上出現(xiàn)了性能問題,人們開始考慮替換方案。接著出現(xiàn)了兩個不同的解決方案:
開發(fā)人員開始歡呼React Native這樣的框架。但是,他們并沒有預(yù)見到人們正在厭惡APP,APP在我們的迭代里更新著,可能是一星期,可能是兩星期,又或者是一個月。誰說APP內(nèi)自更新不是一件壞事,但是APP的提醒無時無刻不在干擾著人們的生活,噪聲越來越多。不要和用戶爭奪他們手機的使用權(quán) ###一次構(gòu)建,跨平臺運行 在我們需要學習C語言的時候,GCC就有了這樣的跨平臺編譯。 在我們開發(fā)桌面應(yīng)用的時候,QT就有了這樣的跨平臺能力。 在我們構(gòu)建Web應(yīng)用的時候,Java就有了這樣的跨平臺能力。 在我們需要開發(fā)跨平臺應(yīng)用的時候,Cordova就有了這樣的跨平臺能力。 現(xiàn)在,React這樣的跨平臺框架又出現(xiàn)了,而響應(yīng)式設(shè)計也是跨平臺式的設(shè)計。 響應(yīng)式設(shè)計不得不提到的一個缺點是:他只是將原本在模板層做的事,放到了樣式(CSS)層。你還是在針對著不同的設(shè)備進行設(shè)計,兩種沒有什么多大的不同。復(fù)雜度不會消失,也不會憑空產(chǎn)生,它只會從一個物體轉(zhuǎn)移到另一個物體或一種形式轉(zhuǎn)為另一種形式。 React,將一小部分復(fù)雜度交由人來消化,將另外一部分交給了React自己來消化。在用Spring MVC之前,也許我們還在用CGI編程,而Spring降低了這部分復(fù)雜度,但是這和React一樣降低的只是新手的復(fù)雜度。在我們不能以某種語言的方式寫某相關(guān)的代碼時,這會帶來諸多麻煩。 ##RePractise 如果你是一只辛勤的蜜蜂,那么我想你應(yīng)該都玩過上面那些技術(shù)。你是在練習前端的技術(shù),還是在RePractise?如果你不花點時間整理一下過去,順便預(yù)測一下未來,那么你就是在白搭。 前端的演進在這一年特別快,Ruby On Rails也在一個合適的年代里出現(xiàn),在那個年代里也流行得特別快。RoR開發(fā)效率高的優(yōu)勢已然不再突顯,語法靈活性的副作用就是運行效率降低,同時后期維護難——每個人元編程了自己。 如果不能把Controller、Model Mapper變成ViewModel,又或者是Micro Services來解耦,那么ES6 + React只是在現(xiàn)在帶來更高的開發(fā)效率。而所謂的高效率,只是相比較而意淫出來的,因為他只是一層View層。將Model和Controller再加回View層,以后再拆分出來? 現(xiàn)有的結(jié)構(gòu)只是將View層做了View層應(yīng)該做的事。 首先,你應(yīng)該考慮的是一種可以讓View層解耦于Domain或者Service層。今天,桌面、平板、手機并不是唯一用戶設(shè)備,雖然你可能在明年統(tǒng)一了這三個平臺,現(xiàn)在新的設(shè)備的出現(xiàn)又將設(shè)備分成兩種類型——桌面版和手機版。一開始桌面版和手機版是不同的版本,后來你又需要合并這兩個設(shè)備。 其次,你可以考慮用混合Micro Services優(yōu)勢的Monolithic Service來分解業(yè)務(wù)。如果可以舉一個成功的例子,那么就是Linux,一個混合內(nèi)核的“Service”。 最后,Keep Learning。我們總需要在適當?shù)臅r候做出改變,盡管我們覺得一個Web應(yīng)用代碼庫中含桌面版和移動版代碼會很不錯,但是在那個時候需要做出改變。 對于復(fù)雜的應(yīng)用來說,其架構(gòu)肯定不是只有純MVP或者純MVVM這么簡單的。如果一個應(yīng)用混合了MVVM、MVP和MVC,那么他也變成了MVC——因為他直接訪問了Model層。但是如果細分來看,只有訪問了Model層的那一部分才是MVC模式。 模式,是人們對于某個解決方案的描述。在一段代碼中可能有各種各樣的設(shè)計模式,更何況是架構(gòu)。 |
|