Doodle博客的开发过程

标签:Python, Google App Engine

码了差不多一个月的代码,这款基于Google App Engine/Python的Doodle博客终于差不多完成了,其中感触最深的就是PyCharm真是好用得让我泪流满面啊…

主要功能算是全部达成了(见wiki),接下来的重点就是完善一下用户管理和后台界面。可恨我的UI sense实在太烂,估计后者是比较艰难的了,好在访客是看不到的~
所以想尝鲜的可以去下载试试了,有Hg的更推荐clone下来,这样更新更快。
此外,程序主体是以MIT许可发布的,你可以随意使用和更改;但koi主题是以GPL许可发布的,再次发布请仍保持GPL许可。

顺便放点截图:

导航条是无限层的:


管理员可直接删除评论:


评论的顺序可切换:


鼠标移至回复上会显示被引用的评论:



接下来说说Doodle的开发过程吧,对一般的用户来说,下面的可以无视了,如果想自己修改Doodle,或者想明白为什么Doodle会有一些奇怪的限制的话,看完应该会有所帮助。

任何软件开发都离不开需求的,所以我也是由确定需求开始的。
其实起因就是目前这个论坛我不想再弄下去了,中国的网络环境实在太烂了,服务器动不动就挂掉或被有关部门和谐。但是自己写的那么多东西总不能白写了,因此就决定在GAE上建个博客,再把数据导入过来。既然如此,这个博客需要能导入Discuz!的数据。此外,之前我还从自己的QZone按照WordPress的RSS格式导出了全部日志,因此也要支持导入RSS。
数据有了,还需要显示出来,那么外观自然不能太糟糕,因此找个开源的WordPress主题修改下应该不错。展示的部分主要包括首页、文章页、导航条、分类、标签、友情链接和评论等。而富文本编辑器自然也不能少,不然发表文章和评论就不给力了。再加上我是用Discuz!的数据,因此要支持显示BBCode。同时还有不少代码,因而代码高亮也是必不可少的。
而除了展示给访客,还需要支持供稿,毕竟我也是个Google Reader控。而不提供PubSubHubbub和XML-RPC ping也不给力,因此也得实现了。
其余的应该就是些可以慢慢去弄的功能了,比如评分、投票、垃圾评论审核之类的。

大致的需求确定下来后,就得考虑设计了。我习惯先从数据库模型定义下手,毕竟GAE的datastore限制太多,直接决定了哪些功能是可行的,哪些是不可行或不可取的。

最先考虑的就是用户(User)类。毕竟博客不只是博主一个人写而已,还要和其他人交流。为了避免垃圾评论,强制要求登录自然是很稳妥的。不过Google提供了Google账户和OAuth这2种登录方式,想了下还是觉得前者好,毕竟格式统一,不需要去研究多种,而且Discuz!论坛的用户都是有记录邮箱的,我导入数据时会方便很多。
而字段必然要有email和name这2项。考虑到UserProperty比StringProperty占用的空间更多,而且查询时可能有bug存在,用户的Google账号即使更改了邮箱,也不会造成弄错用户(顶多多了个马甲,之前的账号暂时不能用而已),因此UserProperty的优势不能体现。
考虑到email是唯一的,也不会对它做相等之外的查询和排序,为了节省空间,可以当作key name;而name则不唯一,这也符合多数博客的习惯。
与此同时,用户还可以有site属性来标识其网站,由于不想让每条评论保存一个site,因此放到用户类里更为节省空间。为了区分用户等级,还需要一个flag来标明用户有哪些权限。当然,这2者都是无需索引的。
考虑到如果支持多作者的话,管理逻辑和权限分配将很麻烦,文章也需要多加一个作者字段,于是我便果断放弃了这个功能。
至于用户头像,我自然也不会保存在本地,就交给gravatar了,我只需计算用户email的md5值即可。
属性定义好后,还得定义相应的方法。其中最重要的就是根据email获取User实体,以及获取当前用户的User实体。由于有get_or_insert的存在,很容易就能封装好了。

接下来是文章(Article)类。这是博客最重要的数据,自然马虎不得。想了想后,决定使用title、url、content、format、published、time、mod_time、keywords、tags、category、hits、replies、like、hate和rating这些属性。
其中title和url是必需的,url虽然唯一,但由于作者可能会去更改它,因此不适合作为key name,还是使用id比较好。
content就是内容部分了。我觉得还是直接保存用户编辑的格式比较好,存成HTML虽然显示时无需解析,但是再次编辑时就不爽了(我非常不喜欢HTML的标签)。此外,本来我还写了个summary字段,它主要是用来在首页或RSS展示摘要用的。虽然个人推荐RSS全文输出,但是Google不建议在首页显示文章的全文,因为这样会降低真正的文章页的权重。但后来觉得其实可以和content放在一起,加个分隔符就能分开了。
format主要有纯文本、BBCode、HTML和BBCode+HTML这4种方式,我也用掩码的方式去实现它们的组合了。
keywords是列表,为了让搜索时大小写不敏感,就强制保存为小写了。
tags也是列表。最初是想保存标签的key的,后来觉得没有任何用处,还不如保存标签名节省空间。
category最初也是想保存分类的key,结果发现取一批文章的分类变得更麻烦了。不过这和分类的实现有关,所以暂时先搁置。
replies比较麻烦,因为关联到评论的实现,放到后面去研究。
like、hate和rating主要是给用户来评分的。看过一篇分析,说顶、踩比分成5级、10级什么的更好,因为很少有人评中间的等级,而且计算平均分也麻烦。

Article的方法就很多了,包括获取首页的文章、获取feed的文章、根据url和id来获文章、获取文章的评论、获取上一篇和下一篇文章、生成HTML格式的内容和摘要等。
其中不少方法都要和数据库打交道,因此是比较耗时的,那么采用缓存是个比较好的办法。不过每个方法都去手动写memcache.get和memcache.set太累了,于是我实现了一个memcached装饰器,将方法名及其参数作为key来缓存其结果。不过这就要求函数返回的结果不为None了,于是又写了个EntityNotFound类,用来表示没有获取到实体。
实际上后来我又修正了datastore_cache无效的bug,但代码已经懒得再改了,而且datastore_cache的实现并不好控制缓存时间。

此外还实现了一个特殊的计算文章查看数的方法。如果每次查看都立刻去修改文章的hits属性的话,多刷新几次就数据库吃不消了。为此要么就采用Sharding Counters技术,要么就用memcache实现
个人不喜欢Sharding Counters的复杂度,而memcache不适合访问频率较低的博客,于是我又自行设计了采用memcache + task queue的方法,也就是先将访问次数保存在memcache里,然后创建一个1分钟后执行的task,用于将这1分钟内的访问计数写入数据库,并且将memcache清零,这也就保证了这1分钟之内只会有一次写入,而且不影响用户访问速度。
不过超过1分钟后,仍然有可能多次写入,很遗憾memcache.grab_tail()已经消失了很久,否则直接用计划任务来实现更方便。只是这种情况的几率比较小,访问量实在太大的话,可以做2次memcache,其中一个保存上一次更新的时间,以及一分钟内是否还有即将运行更新它的task,这样就不会频繁更新了。

现在最重要的2个模型已经差不多定义好了,但还有个复杂的分类(Category)类不好处理。
如果说我只要实现一级分类的话,那么自然一个key name就够了。但是要实现多级的话,应该怎么方便地表示分类之间的层级关系呢?
最传统的做法自然是每个分类定义一个父分类属性,可是这样查询分类的子类时就得递归查询了,这种时间消耗是我完全不能接受的。另一种办法就是一次获取所有的分类,然后再在内存里根据其继承关系排一个树形结构出来。而在展示的时候,再转成ul和li元素即可。不过我写到一半觉得这种实现太复杂了,断然放弃了。
还有一种方法就是采用实体集,这样要查询子类直接用个ancestor查询即可,但是仍然只是稍微简化而已,而且带来了不能更改分类的层级关系的缺点。
最终我想到一个好办法,直接将分类的路径作为一个属性,写成“分类1/分类2/分类3/”这样的形式,这样要找分类1的之类,只需要查询路径以“分类1/”开头的分类即可。
而在展示的时候,我也不需要构造一个树形结构,直接按路径排序,这样分类1的子类肯定就紧接着分类1,我再根据“/”的数目就能判断相邻分类的层级关系。
当然也不能把分隔符定死,毕竟有人可能会写“C/C++”这种分类,所以还需要写在配置文件中,似乎逗号和分号都不错。
路径确定好后,还需要一个分类名。为了省事,我把它设为key name了,因此不能有同名分类存在。其实把路径作为key name,或者干脆使用自动生成的id就不会有这种限制了;只不过路径作为key name就没法用特殊方法查询子类了,因此它仍然还得作为属性出现一次;平白无故的id也是我不喜欢的,毕竟白白占用空间,而且直接用key name来get肯定比查询属性快(因为datastore查询属性索引后才能获得一个key,还要再用这个key去取对应的实体)。
至于分类所含的文章数,我就懒得统计了,毕竟算出来也没用,显示出来只是个习惯而已。

Category的方法自然就是查询子类及其文章了。此外还有更改分类的一些操作,同时需要涉及到文章的category属性的修改。
其中移动分类是最麻烦的,为了简化逻辑,就规定了不能移动包含子类的分类。同时也暴露出一个问题,由于分类名不能重复,所以如果移动目标要保持相同名称的话,必须创建一个临时分类来移动2次。好在这种操作并不多,我也就懒得去管了。

接下来是标签(Tag)类。这个类非常好办,只要记录标签名和文章数即可。
没有层级关系,自然没有分类那么麻烦,不过修改标签时仍然需要更改文章的tags属性,而且这玩意是个列表,得检查列表的每个元素和处理空列表的问题。

另一个棘手的就是评论(Comment)类。
它需要存储所属的文章、评论者的邮箱、内容、格式、时间和是否通过审核了。其中所属文章和评论者都能作为parent,考虑到文章的replies属性和它相关,而用户却没什么相关的(不需要记录用户的评论数),因此似乎把文章和评论放在一个实体集里比较方便。不过这也就意味着一篇文章不能在短时间内被多次评论(多于每秒5次),好在一般的博客流量不会大到这种地步,我也就接受了这种方案。而key自然不能用用户的email,否则一个用户只能对一篇文章做一次评论了,于是id就是必须的了。
还有个严重的问题就是评论嵌套了,这意味着必须一次取出所有的评论,而我有的文章有上千条评论,这谁受得了啊。再说这种嵌套的逻辑也很复杂,我还是懒得去弄了。而折中的办法就是@回复,然后用JavaScript获取对应的帖子,用特效显示出来。
其实我也想过用DISQUS,这样就无需自己维护评论了,甚至连用户信息都无需维护。可最后还是放弃了,原因就是不支持HTML代码,这样表格啊颜色啊什么的都浮云了。
至于评论者甚至浏览者的信息也有想过保存,这样可以标识他用的什么浏览器之类的。不过这个需求代价太高,分析ua是件很麻烦的事,而且还占用数据库空间,模板也得进行修改,实在有需要的就自己去改吧。
剩下的方法就很简单了,基本上就是查询文章的评论和获取最新评论而已。

最后一个就是评分(Point)类了。
虽说它只存储一个数值,但也关系到评分者和被评分的文章这2个类。考虑了一番,还是觉得将User email作为key name,Article作为parent比较合适,这样评分时可以用到事务。而如果将Article id作为key id的话,查询一篇文章的Point是不能直接拿到的,必须还得加个id字段,这样就显得累赘了。

当然,如果你的blog达到10万pv以上,你也可以将评论和评分改成以用户为根实体的方式实现,不过这种分布式事务就需要任务队列的辅助了。而如果达到百万pv,建议无需时时更新,每个小时跑个计划任务,修正一下这些数值即可,只不过评分最好再加个动态的时间字段,查询带有时间的评分及其所属文章,然后删除这些动态字段,并统计评分结果即可。


MVC中的Model算是搞定了,接下来转到View和Controller。这2个东西结合得比较密切,不太方便分开说。

在框架方面我采用了自行开发的YUI,也算是为之做个demo吧。之前实际上也用这个框架写过一个应用,不过做到一半由于时间不够,就懒得做下去了,于是没能好好展示一下这个框架的功能…
其实它用起来很简单,如果接触过webapp的话,会觉得风格非常像,因为很多地方我都借鉴了webapp的实现。

而模板引擎则采用了Tenjin。这个模板引擎我比较喜欢,因为可以直接在模板里嵌入Python代码,而且性能非常好(我读了源码,发现是直接利用Python的compile函数编译成字节码);不足之处就是Python代码强制要求缩进,于是无法像PHP那样直接在一句话中间来嵌入Python代码。另外,这类模板也不像Django模板一样被PyCharm所支持,于是可能会提示很多其实正常的警告和错误。

为了省事,我就将YUI和Tenjin整合了起来(实际上就是封装了一层而已),放在BaseHandler里。
而如果要对不同用户输出不同页面的话,就必须用到UserHandler了,这个Handler会自动获取当前用户。
有个tip顺便分享一下,Tenjin编译一个模板是毫秒级,访问memcache也是毫秒级,但访问内存中的dict却是微秒级的,因此模板缓存应该采用tenjin.MemoryCacheStorage,而不要用tenjin.GaeMemcacheCacheStorage。

做好这步后,就可以开始实现首页了。它的逻辑很简单,只要按时间顺序倒序取10篇文章,然后遍历展示出来即可。
于是我找到koi主题,依葫芦画瓢地把PHP代码替换成Python代码,模板就这样移植过来了,只不过侧边栏之类的就暂时不去实现了,先把主要部分搞定再说。出于对IE的鄙视,我顺便还改成了HTML 5,让IE见鬼去吧~(后来发现这个抉择太惬意了,Chrome 6 + Firefox3.5 + Opera 10.5这个测试组合相当给力,大多数CSS 3样式都支持,于是我怎样华丽就怎样写。)
考虑到导航条一般不会去改,如果放到数据库里的话,读取会占用时间,而且并不好管理层级关系(其实是因为不好做这个管理界面),所以我还是干脆直接放在配置文件里了。不过说实话,多级导航确实麻烦,好在我是用Python实现的,否则逻辑不知道要写多长。在这里真想郑重地鄙视一下HTML的ul、ol和li标签。
而由于数据库里还没有文章,所以手动录入了一个实体,首页就华丽地展示出来了。
不过做好后还得重用这个模板,毕竟我是个懒人啊。最初我是用了layout模板的,但发现并不实用,还不如拆成更小的部分来组合方便,改了几次以后,渐渐地发现风格和Discuz!的模板很像了,都是在header里判断是哪个页面调用的…
考虑到后续页也得套用首页的模板,那么URL的规则就得想想了。

按照大多数网站的风格,列出页号并用页数来定位是最为正统的。可是这在GAE上却不太高效,原因就是当你取第991~1000篇这10篇文章时,datastore实际上是取出了1000篇文章,然后返回最后10篇。用我目前实测的例子来说:Article.all().fetch(10)大概0.01~0.03秒,Article.all().fetch(10, 990)大概0.14秒,Article.all().fetch(10, 991)大概0.2秒(超过1000篇会产生第2次查询),而Comment.all().fetch(10, 10000)需要3秒多(没有那么多文章,所以只能测评论了)。目前我的文章接近2000篇,如果有人点最后一页,在这上面花费大量时间明显不划算。
再仔细一想,大多数人都会先去看首页的文章,觉得好才会往下翻页,没人会一开始选个喜欢的数,直接跑到第xxx页开始看的,那么页号也就没有其存在的意义了。当然也不排除有人喜欢从最老的文章看起。如果有这种需求的话,加个正向的排列方式,用户自己选择就行了。想到这里,我就干脆地舍弃了这种页号定位的分页方式。

既然没有页号,那么用户就只能一页页地翻页了。而由于是按时间排序,那么用时间来分隔是很合理的,毕竟正常人不可能1秒内发2篇文章。不过Python的datetime是精确到微秒的,在前后台交互时,处理这部分就比较麻烦了(JavaScript只能精确到秒或毫秒),而丢失这部分有可能导致数据重复出现。
于是我又想到了游标,刚出这玩意时感觉很好用,因为返回None游标时表示已经找完所有实体了;可现在不同了,不管后面还有没有数据,一律返回一个看似正常的游标,必须在下次查询时才会告诉你有没有更多数据。然而当时我没有测试,直接就用上游标了,结果发现存在这个问题。一般的模式是取N+1个实体来判断是否有下一页的,可这样游标就乱掉了,于是我只好再count(1)一次来判断是否有更多数据。另一个问题就是游标会记录查询的order,所以没法回到上一页,于是就做成了只能往下翻页的形式了。

更让我不爽的是游标实在太长了,我本以为有个20字节也就将就下吧,结果居然超过了100字节,显示在地址栏里太不美观了。
正常的想法应该是压缩(例如取hash值),然后在服务器端保存相应的key。可memcache是不持久的,我可不希望用户过了很长一段时间,决定看下一页时发现memcache里找不到相应的key;而如果保存到数据库,那代价又太高了点(虽说只是一次get(key)而已,而且数目不超过文章数),如果memcache+datastore结合起来倒是还过得去。
不过我还是不想浪费这点资源,于是就想了一个让用户无需点击,翻到页底时自动用JavaScript载入下一页的方法。让我更欣慰的是,jQuery省却了我专门写个AJAX页面的麻烦,可以直接用$.load()函数载入下一页中指定的元素。而服务端为了更节省时间,判断是AJAX请求时就不输出其他部分,只生成主体了。
于是又生成了一批文章,测试了一下效果,感觉还不错。

还有一点想说的就是,我本来是将游标直接放在path中的,不过这样URL就要多个'page/'路径了,长度实际上和用query string差不多,索性就改成query string了,毕竟后续页本身我并不希望Google去收录。
考虑到某些人的网站本身就只是一个blog,可以直接放在根路径下;还有的人可能同时做些其他的事,会将其放在'/blog/'下,于是后来我干脆写了个配置文件来配置这些路径。缺点自然是效率没硬编码高了…

接下来就是显示文章本身了。这是最复杂的页面,虽说主要功能就是根据URL或id获取文章及其评论,然后展示出来。
考虑到URL不统一不利于SEO,因此我就直接将id方式的访问重定向到URL方式了。此外URL格式也限定为“yyyy/mm/dd/标题”,加上日期主要是避免标题相同时,URL不得不写个2、3什么的,而且搜索引擎也更容易判断文章的发表时间。由于是中文博客,所以URL当然也要支持中文。只不过我只去处理UTF-8,懒得照顾百度的gbk了,有需要的在UTF-8解码失败时再用gbk解码一下就行了。

获取到文章后就得显示了,不过我还有很多是BBCode的文章,因此得去弄个解析BBCode的玩意出来。最后找到个Post Markup,改了不少地方后,终于基本符合我要的功能了,不过隐藏功能自然没去弄,感觉不是那么容易定制出来的。
此外还用Highlight.js做了个代码高亮,主要是喜欢它的这款皮肤,不过不能显示行号比较麻烦。

此外还显示了上一篇和下一篇文章的标题和链接,可惜datastore不能指定返回的字段,于是只能取出整个实体了。
以前也试过把文章内容和元数据分成2个实体来保存,不过性能也很一般,复杂度还提高了不少,所以放弃了。
但优化还是得做的,于是把上一篇和下一篇都放入了缓存,至少保证点击它们时可以少2次数据库查询。

顺便还做了个AJAX评分的功能,只不过要求用户登录而已,而且为此还不得不访问数据库以判断用户是否已经评过分了。好在做个cache也不难,也就没去吝啬这点了。
由于用户的浏览器可能不支持JavaScript,于是只能设置成一个链接,让其直接GET访问时也能评分。

至于评论部分,由于我的部分文章评论多达上千条,不可能一次全部取出来。为了节省数据库时间,我就也做成了AJAX方式获取,并且拖到页底自动载入更多评论。
我的想法是AJAX方式的话,Google应该不会去索引,也就不用刻意去给评论的链接加nofollow了,没想到Google还是去索引了…有需要的就写个robots.txt屏蔽吧=。=
当然,有的人可能是用手机,不支持JavaScript,因此还得在noscript里写上评论的链接,然后那个页面就不需要考虑JavaScript特效了。
有人可能注意到我还用了yui.server_cache这个装饰器。这个算是YUI里最让我调试得吐血的功能了,不过其缓存性能非常强大,以至于无需访问数据库或memcache来获取数据,甚至连页面都不用生成,直接就从内存中取出已经用过的response对象来再次输出,因此理论上cpu时间不会超过10ms。
此外,对博客作者来说,最新回复更为重要,所以又加了个切换排序的功能,方便管理新评论。
并且当以管理员登录时,会在每条评论上显示一个删除按钮。不过为了避免误操作,鼠标移上去后会在右侧显示一个确认删除的按钮,点那里才能真正删除,不知道这个设计大家是否满意。

评论显示出来后,还得支持用户发表评论才行。于是还得找个富文本编辑器,最终选择了markItUp!,因为同时支持BBCode和HTML,还能很方便地自定义扩展。提交时也没忘了用AJAX特效更新,并且发email提醒管理员,只不过暂时没去管黑名单、垃圾评论什么的。
为了支持@回复,我还不得不绑定回复按钮的click事件,让它自动添加一个链接,指向被回复的评论。而在这个链接上,我又绑定了一个hover事件,在页面中根据评论id查找被引用的帖,在找到的情况下(顺序显示一般都能找到),就把它浮动显示出来。
此外,我还计划提供对评论的修改,应该也是直接AJAX更新了,毕竟前台效果能炫点就尽量炫,占用的反正不是服务器的资源。
另外还得增加几个选项,让用户决定是否开启BBCode解析之类的,功能不难做,只是UI比较费时而已。我不准备为用户提供HTML格式的评论,因为权限不好控制,而限制标签的话,还不如BBCode方便。

做完文章页后,终于得把侧边栏补上了。最初只想做分类、标签云的和友情链接的,看上去还算容易。
拜之前的设计所赐,多级分类和多级导航的实现差不多,不到10行代码就完成了WordPress上百行代码做的事。
标签云要用到文章数,所以不得不维护一个count字段。然后我把font-size设为了(int(5 *log(max(tag.count + 1, 1))) + 12)像素,这个就随便自己去调节了,文章少的可以适当增大一点。
之后又找到一个3D球面标签云,觉得很不错就用上了。不过虽然在Chrome上很流畅,但在Firefox这种浏览器上就不给力了,CPU占用率直接飚到50%。优化了半天性能后,速度终于提升了50%,可惜仍然不够满意。于是我加入了测速功能,在不限速的情况下循环调用5次,将这个时间作为一次循环的间隔时间,那么理论上CPU占用率应该在20%左右。而间隔时间变了,步进距离也要相应地变化。此外还得限制最小和最大的间隔时间,测试感觉超过150ms就很难看了,于是就让这部分老爷机不看特效,直接恢复成原始的标签云了。
友情链接我虽然是放在数据库中,不过暂时用不到,或许以后也会考虑直接写在配置文件里吧,毕竟是不怎么改动的玩意。
后来我又觉得显示一个最新评论更方便管理,毕竟有时没空去查看邮件,不过为此又多加了一条索引。所以在考虑去掉这个玩意,直接改成订阅评论算了,不然索引数太多了…甚至都觉得approved字段可以删掉,只要自己管理勤快点就行了,这样又可以删掉2条索引了。
而且由于本地测试时评论数超过1万条,载入5条最新评论居然要用掉5秒,可见开发服务器的datastore实现是很低效的。
最后在每个模板中嵌入一下sidebar.html就搞定了。此时就体现出Tenjin的强大了,因为可以在模板中写Python语句来获取这些信息,所以不载入sidebar的页面就不会去访问数据库了;而且顺便还用上了Fragment Cache功能,这样就直接保存到内存里,连mamcache都不用访问了。

至此,前台基本上就算完成了,因为分类和标签页可以直接照抄首页,甚至JavaScript都能通用…
管理界面我就没空去弄个主题了,因此UI是非常糟糕的,但是功能做出来就行了,反正访客不关心=。=
很多功能都涉及到批量更改数据的,所以用到了任务队列。后来发现deferred模块很好用,就直接拿来用了,于是就出现了2种实现混用的情况…
此外,发表和编辑文章时涉及到标签、分类和关键字的改动,不得不处理相关实体和列表,所以代码就变得很复杂了。如果发文的作者小心点的话,倒是可以去掉很多判断和错误处理逻辑的…
顺便在发文时更新了ATOM供稿,也发出了几个ping通知。

然后就准备上线测试了,花了好几几天才搞定了Bulk Loader的一个莫名其妙的bug,于是又省去了我用map-reduce删除多余索引和清除空列表的麻烦了。

最后我又心血来潮地移植了一个半成品的手机主题,于是很多页面就不能用yui.server_cache了,不然手机用户可能会收到缓存里的电脑用户所看到的页面…
看来把手机页面分离到另外的URL也有好处。

其实现在想来,很多东西我最初都没打算做成这样,而是做到一半临时改变想法,然后就开始天马行空地实现了。
果然还是为自己写东西惬意,需求什么的完全由自己确定。唯一遗憾的就是不能问自己要工资=。=

108条评论 你不来一发么↓ 顺序排列 倒序排列

    向下滚动可载入更多评论,或者点这里禁止自动加载

    想说点什么呢?