Hackathon Starter:NodeJS Web开发脚手架

导读:Hackathon Starter可以看做是express的升级版,在Github上已经有超过7000的star。它内置了一些有用的包和前端库,还包括账号认证等,并且提供了几个初始化的web页面,为Web App开发节省精力。本文翻译自Hackathon Starter的Github页面。当前版本2.3.2。

在线demo: http://hackathonstarter.herokuapp.com

Hackathon Starter是专门为NodeJS Web开发而准备的一个样板。

如果你以前参加过黑客马拉松(hackathon),那么你一定会意识到项目准备阶段会花费大量时间:比如决定制作什么、选择编程语言、选择web框架,以及选择css框架。一段时间过后,你好不容易在Github上建立起初始化的项目,然后其他成员才终于能够开始工作。或者考虑一个更简单的情景,使用Facebook账户登录。如果你不熟悉OAuth 2.0的话,这将耗费你大量的时间。

当我开始本项目时,我首要考虑的是简单易用。我也试着让它尽量的兼容以及可复用,使它能够在大多数hackathon web app上使用。在最坏的情况,比如你只对使用Google账户登录感兴趣,你也可以将它当做一个学习指南。

你很可能不需要使用所有的账号登录认证功能,不用担心,这些在Hackathon Starter 2.1版本之后是可选择的。

现代风格

扁平化Bootstrap主题

默认主题

Hackathon Starter生成器界面

特性

  • 本地登录认证(使用Email与密码)
  • OAuth 1.0a认证( Twitter)
  • OAuth 2.0认证(Facebook、Google、Github、Linkedin等)
  • 快速提示
  • MVC项目结构
  • Nodejs 集群支持
  • Rails 3.1风格的 Asset pipeline,由connect-assets提供
  • LESS样式表(自动编译无需Gulp/Grunt)
  • Bootstrap 3 + Flat UI + iOS 7
  • 联系表单(支持Mailgun、Sendgrid、Mandrill)
  • 账户管理
    Gravatar头像
    用户详细资料
    改密码
    找回密码
    重置密码
    绑定社交账号
    注销账号
  • CSRF保护
  • API案例(Facebook等)

环境依赖

  • MongoDB
  • NodeJS
  • 命令行工具
    Mac OS X:Xcode
    Windows:Visual Studio
    Ubuntu:sudo apt-get install build-essential
    Fedora:sudo yum groupinstall "Development Tools"
    OpenSUSE:sudo zypper install --type pattern devel_basis

注意:如果你是NodeJS新手,建议阅读教程Getting Started With Node.js, Express, MongoDB

入门指南

最简单的开始方法就是克隆Github仓库:

# Get the latest snapshot
git clone --depth=1 https://github.com/sahat/hackathon-starter.git myproject

cd myproject

# Install NPM dependencies
npm install

node app.js

注意:强烈建议安装Nodemon,它能监控你的NodeJS App的任何改动并自动重启,从长远来看着将节省你大量时间。

生成器(Generator)

Hackathon Starter生成器目前还在实验阶段,它与目前的代码紧密相连,一旦移动或改变项目代码,生成器将有可能不可用,因此建议在下载HS后第一时间使用。

生成器能够选择账号认证、改变发送邮件的服务商。

使用生成器请使用命令node setup.js

获得API密钥(略过)

本部分讲如何从Facebook、Google等服务提供商处获取API密钥。

项目结构

Name Description
config/passport.js 本地与OAuth的账号认证策略,包括登录
config/secrets.js API密钥、密码、数据库地址等
controllers/api.js /api 路由控制器,包括所有api示例
controllers/contact.js 联系表单的控制器
controllers/home.js 主页(index)的控制器
controllers/user.js 用户账号管理的控制器
models/User.js Mongoose中用户的schema与model
public/ 静态资源 (fonts, css, js, img)
public/js/application.js 指定客户端JS依赖
public/js/main.js 你所编写的客户端JS
public/css/styles.less 你的App的主样式表
public/css/themes/default.less 一些Bootstrap默认样式
views/account/ 账号管理模板
views/api/ API示例模板
views/partials/flash.jade 错误、信息与成功的提示
views/partials/navigation.jade 导航栏部分的模板
views/partials/footer.jade Footer部分的模板
views/layout.jade 基础模板
views/home.jade 主页模板
app.js 主要的App文件
setup.js 移除账号认证等的工具

注意:这里没有规定你应该如何处理你的视图,你可以将你的视图模板放在你喜欢的地方,只要记住更新extends ../layout并且与控制器中的res.render()路径一致。

使用包列表

Package Description
async 提供同步控制流的工具库
bcrypt-nodejs 哈希并盐化用户密码的库
cheerio 提供服务器端处理web页面能力的库
clockwork Clockwork SMS API库
connect-assets 处理和编译LESS样式和JS文件的工具
connect-mongo MongoDB连接Express的库
csso connect-assets库的依赖
express Node.js web框架
body-parser Express 4.0 中间件
cookie-parser Express 4.0 中间件
express-session Express 4.0 中间件
morgan Express 4.0 中间件
compression Express 4.0 中间件
errorhandler Express 4.0 中间件
method-override Express 4.0 中间件
express-flash 提供Express的快速提示
express-validator Express的简单表单验证
fbgraph Facebook Graph API 库
github-api GitHub API 库
jade Express的模板引擎
lastfm Last.fm API 库
instagram-node Instagram API 库
less LESS编译器. 在connect-assets中使用.
lusca CSRF 中间件
mongoose MongoDB ODM.
node-foursquare Foursquare API 库
node-linkedin LinkedIn API 库
nodemailer Node.js发送邮件的库
passport node.js简单优雅的账号认证库
passport-facebook Sign-in with Facebook 插件
passport-github Sign-in with GitHub 插件
passport-google-oauth Sign-in with Google 插件
passport-twitter Sign-in with Twitter 插件
passport-instagram Sign-in with Instagram 插件
passport-local 本地登录的插件
passport-linkedin-oauth2 Sign-in with LinkedIn 插件
passport-oauth 设定你自己的OAuth1.0a与OAuth2.0策略
request 简化的HTTP请求库
stripe 官方 Stripe API 库
tumblr.js Tumblr API 库
twilio Twilio API 库
twit Twitter API 库
lodash 方便的JS工具库
uglify-js connect-assets的依赖
validator 在 controllers/api.js中与express-validator联合使用
mocha 测试框架
chai BDD/TDD 声明库
supertest HTTP 声明库
multiline 生成器使用的Multi-line 字符串
blessed 生成器使用的互动式命令行界面
yui Yahoo API 示例中使用

有用的工具与资源

推荐的设计资源

推荐的NodeJS库

  • Nodemon – 代码改动时自动重启Node.js服务
  • geoip-lite – 根据IP地址库的地理位置定位
  • Filesize.js – 格式化文件大小,如 filesize(265318); // "265.32 kB".
  • Numeral.js – 格式化并操作数字的库
  • Node Inspector – 基于Chrome开发者工具的Node.js调试器
  • node-taglib – 读取常用音频格式的meta-data的库
  • sharp – 调整图片大小的库,支持JPEG, PNG, WebP 和 TIFF

推荐的客户端JS库

  • Framework7 – 包含构建iOS7风格App完整特性的HTML框架
  • InstantClick – 在鼠标指上时预下载,加快页面加载速度
  • NProgress.js – 简练的进度显示条
  • Hover – 非常棒的鼠标hover css3动画效果
  • Magnific Popup – 响应式的jQuery弹出框插件
  • jQuery Raty – 星星打分插件
  • Headroom.js – 隐藏你的header直到你需要它
  • X-editable – 直观的修改表单元素
  • Offline.js – 探测用户是否在线
  • Alertify.js – 可爱的弹出警告与浏览器对话框
  • selectize.js – 可调整样式的select元素与input标签
  • drop.js – 强大的JS与CSS库用于创建下拉菜单与其他浮动显示层
  • scrollReveal.js – 提供滚动时的动画效果

高级Tips

  1. 当安装NPM包时,添加–save标签,它将自动添加到package.json文件中。如:npm install –save moment
  2. 当你需要多个同步工作,并当它们都完成后才渲染页面时,使用async.parallel() 。比如你需要爬取三个页面的数据,爬取完成后将结果填充到模板中。
  3. 想要从队列中寻找特定的对象?试试Lodash里的_.find 函数。比如,这段代码提供了检索Twitter token的能力:
var token = _.find(req.user.tokens, { kind: 'twitter' });

FAQ

为什么我在提交表单时显示403错误?

你需要在表单中添加下面的隐藏元素,这是一项CSRF保护措施。

input(type='hidden', name='_csrf', value=_csrf)

注意:CSRF现在支持白名单了,这意味着你可以提交一些URL链接,他们可以被CSRF忽略。

注意2:如需忽略的URL是动态的,可以用正则表达式匹配。

cluster_app.js是什么?

Node.js官方文档的解释

一个Node实例在单个线程中运行。为了充分利用多内核系统的性能,用户会希望启动一个Node的进程簇来处理负载。cluster模块让你能够简单创建共享服务器端口的子进程。

cluster_app.js是app.js的多进程版本,它能为每个被探测到的CPU创建一个进程。为了最大化的满足HTTP请求,这是一个很好的功能。但是,cluster模块仍处於实验阶段,因此请小心使用,确保你正确理解了它的意图和行为。要使用它,只需要运行node cluster_app.js,它与app.js是完全分离的,无任何依赖关系。需要提醒的是,如果你用cluster_app.js替代app.js,你需要在package.json里做出声明。

什么是Rails 3.1风格的asset pipeline?

下面是你如何在HTML里定义静态文件,使用Jade或其他的模板引擎:

link(href='/css/styles.css', rel='stylesheet')
script(src='/js/lib/jquery-2.1.0.min.js')
script(src='/js/lib/bootstrap.min.js')
script(src='/js/main.js')

看起来足够简单?在开发环境下也行是这样。但如果当你将app部署到生产环境时,它们能够被自动的压缩到单个的文件呢?

link(href='/css/styles.css', rel='stylesheet')
script(src='/js/application.js')

当你引入的JS库越多,自动连接并压缩JS文件带来的好处就越大。connect-assets库能够让你简单的完成这一操作,只需两行代码:

!= css('styles')      // expects public/css/styles.less
!= js('application')  // expects public/js/application.js

你只需要记住在public/js/application.js中定义你的JS文件。语法从Rails中借鉴而来:

//= require lib/jquery-2.1.0.min
//= require lib/bootstrap.min
//= require main

使用这个方法,当在开发模式时,它会加载各个独立的JS文件,而当部署到生产环境,它会自动形成单个JS文件。你可以看Sprockets-style concatenation 来了解更多。

我出现了MongoDB Connection Error,该如何修复它?

这是一个在app.js中自定义的错误信息,用来表示连接到MongoDB时出现了问题。

mongoose.connection.on('error', function() {
  console.error('✗ MongoDB Connection Error. Please make sure MongoDB is running.');
});

它提示你应该在启动app.js之前先启动MongoDB,你可以在这里下载MongoDB,也可以从包管理器来安装,如果你是Windows用户,可以按照在Windows上安装MongoDB的说明来做。

Tip:如果你一直连接着网络,也可以试着使用 MongoLab 或者 MongoHQ 等在线数据库服务,你只需要更新config/secrets.js中的db信息。

当我部署我的app时提示错误,为什么?

有可能是你没有在secrets.js中正确的设置数据库路径。当你在本地运行你的app时,数据库路径是localhost,但当你部署app时,你需要在网上找到一个运行的MongoDB,并将连接地址正确的填写在secrets.js中。你也可以申请MongoLab 或者 MongoHQ 等免费服务。

为什么采用Jade代替Handlebars模板引擎?

当我开始这个项目的时候我并不熟悉Handlebars,后来我开发了一些Ember.js apps并且熟悉了Handlebars的语法。Handlebars的确更简单一些,因为它就像HTML一样,但我并不后悔选择了Jade。理由有三,第一因为Jade是Express的默认模板引擎,所以以前开发过Express应用的人已经对它很熟悉了;第二,我发现在Handlebars里extends和block是必不可少的,它实际上并没有达到即开即用的程度,你仍然需要编写一些扩展函数;第三,客观的说,Jade看上去比Handlebars更简洁干净,这点与其他非HAML风格的引擎相比也是一样。

为什么你在app.js里定义了所有的路由(route)?

一言以蔽之,为了简洁。也许有其他更好的方法,比如在这篇博文中将app上下文按照概述传给每一个控制器,但我发现这种方法对初学者来说说容易搞混的。我花了大量时间来理解exports和module.exports的概念,保证有一个单独的全局性app文件作为参考。这是我的背景想法。app.js对我来说是“app的心脏”,它应该成为其他所有模型、控制器、路由等的参考。

我不需要一个绝对底部(sticky footer),我能删除它吗?

当然可以。不过不像一个常规footer,你还需要做一些额外的工作。首先,从styles.less里删除#wrap#footer,以及html, body { height: 100%; }。然后,从layout.jade中删除#wrap#footer所在的行(顺便说下,如果Jade没有检测到class或id,它会默认其是一个div元素)。不要忘了调整#wrap下面的缩进,本项目使用两个空格表示块级缩进。

我能够使用Sass代替LESS吗?

Yes you can!虽然你需要手动的转换现有的样式表到Sass,考虑到Sass和LESS的相似程度,这不会太难。然后你只需要重命名styles.less为styles.scss,connect-assets会自动的采用Sass预处理器。

你甚至可以同时的使用Sass和LESS,在layout.jade里分别指定了LESS和Sass样式表文件:

!= css('styles') # public/css/styles.less
!= css('my_sass_styles') # public/css/my_sass_styles.scss

注意:项目的package.json不包含Sass,所以你需要自己安装它,使用以下命令:

npm install --save node-sass

迷你指南

这一部分将提供单一特定功能的细节解释。也许你很好奇这个项目是如何工作的,也许你已经迷失在代码当中,我希望它能给你一些指引。

定制HTML与CSS设计入门

HTML5 UP提供许多漂亮的模板,并且可以免费下载。

当你下载了一个zip文件,里面有index.html、images、css和js文件夹。那么,如何把它拿到Hackathon Starter里面来呢?Hackathon Starter使用Bootstrap CSS框架,但那些模板没有使用。将它们放到一起会出现很多意想不到的情况。

注意:使用定制模板的方法,你应当理解你不能重用我创建的所有视图:layout、主页、登录、注册、账号管理、联系页面。这些视图使用Bootstrap栅格风格创建。你需要用新模板里的语法手动更新这些栅格。不过你也可以用另一种方法,在大多数界面使用Bootstrap,而在landing page使用另一种风格的模板。

让我们从头开始,在这个例子里我将使用Escape Velocity 模板。

注意:为了简洁起见我将只考虑index.html,忽略left-sidebar.html、 no-sidebar.html和 right-sidebar.html。

将所有的js文件从html5up-escape-velocity/js移动到public/js,并将所有css文件从html5up-escape-velocity/css移动到public/css,最后将所有图片文件从html5up-escape-velocity/images移动到public/images,复制index.html里的代码,并将它们粘贴到HTML To Jade 进行转换。

创建一个新文件escape-velocity.jade,将转换到的Jade代码粘贴进去。将!!! 5修改为doctype html,这是Jade最近的一个改动之一,但是http://html2jade.aaron-powell.com 还没有跟进这个改动。

在controllers/home.js里创建一个新的控制器escapeVelocity:

exports.escapeVelocity = function(req, res) {
  res.render('escape-velocity', {
    title: 'Landing Page'
  });
};

然后在app.js里创建一个路由,我将它放在index控制器的后面:

app.get('/escape-velocity', homeController.escapeVelocity);

重启服务器(如果你没有使用nodemon),然后你就可以在http://localhost:3000/escape-velocity 来查看新模板了。

我的讲解将在这里打住,如果你想在更多的页面使用这个模板,下面是你需要关注的Jade文件:

  • layout.jade – 基本模板
  • index.jade – 主页
  • partials/navigation.jade – Bootstrap导航栏
  • partials/footer.jade – 绝对底部(sticky footer)

你需要手动的将新模板分解为更小的部分。弄清楚模板的哪些部分你想在所有的页面中保留——那将是你的新layout.jade,其他页面将通过 block content来共享代码。如果有不清楚的地方,你可以使用已有的模板作为参考。

这是一个枯燥无味的过程,如果你下载的模板有一套新的栅格系统,那么需要更加谨慎了。这是我为什么使用Bootstrap的原因。很多人已经熟悉Bootstrap了,即使没用过学起来也很简单。你还可以从Themeforest购买一些漂亮的Bootstrap模板。然后你可以很方便的将它放到Hackathon Starter里。如果你需要完全的定制HTML与CSS,上面这些内容将帮助你。

快速提示是如何工作的?

快速提示(Flash messages)允许你在一个请求的结尾,以及当且仅当下一个请求之前显示一段信息。比如,当登录失败时,你可以输出一些警告信息,但一旦刷新页面或者再次进入登录页面后,这个警告信息不应该再次出现,它只会被显示一次。本项目使用express-flash模块来显示快速提示,这个模块在我创建Hackathon Starter项目时就被包含在connect-flash里面。有了express-flash你无需发送快速提示到每一个视图,它一开始就是可用的,感谢express-flash。

使用快速提示需要两个步骤。使用下面的代码在你的控制器里创建一个快速提示:

req.flash('errors', { msg: 'Error messages goes here' }

然后在你的视图里显示它们:

if messages.errors
  .alert.alert-danger.fade.in
    for error in messages.errors
      div= error.msg

在第一步里,'errors'是你定义的快速提示的名称,它应该和你的视图里的messages里的属性名称相匹配。将警告信息放在if message.errors以让它们出错时才显示。错误信息采用{ msg: 'Error messages goes here' }的形式而不是像'Error messages goes here'一样的字符串是为了一致性。express-validator模块会验证用户的输入,当发生错误时返回一个数组对象,每个对象都含有一个msg属性。下面是express-validator所返回信息的一个示例:

[
  { param: "name", msg: "Name is required", value: "<received input>" },
  { param: "email", msg: "A valid email is required", value: "<received input>" }
]

如果使用字符串,你会发现信息框里面是空的。上面的错误信息也可以应用在info或者success的场合。

partials/flash.jade是控制快速提示格式化显示的子模板。它使用一个叫做DRY的方法,将散落在各个视图的快速提示归到一处。

和导航栏与footer子模板一样,flash子模板被包含在layout.jade里。

body
  #wrap
    include partials/navigation
    .container
      include partials/flash
      block content
  include partials/footer

如何创建一个新页面?

更正确的说法应该是“如何创建一个新路由”。主文件app.js包含所有的路由,每一个路由都伴随着一个callback函数,你会在某些路由里发现3个以上的参数,在这种情况里,第一个参数仍然是URL字符串,中间的参数是中间件,你可以将它们想象成门禁,如果它阻止你前进,你将不能得到callback函数。一个例子是需要认证的路由:

app.get('/account', passportConf.isAuthenticated, userController.getAccount);

它的顺序总是从左到右。当用户访问/account页面,isAuthenticated中间件将检查用户是否认证:

exports.isAuthenticated = function(req, res, next) {
  if (req.isAuthenticated()) {
    return next();
  }
  res.redirect('/login');
};

如果认证检查通过,你通过呼叫return next()的方式让用户通过门禁。它会依次通过剩下的中间件直到最后一个参数,即callback函数,它通常在接受到GET请求时渲染页面,或在收到POST请求时跳转页面。在这个例子里,你将被跳转到用户账号管理页面,如果认证没有通过,你将被跳转到登录页面。

exports.getAccount = function(req, res) {
  res.render('account/profile', {
    title: 'Account Management'
  });
};

Express.js里有app.get、app.post、app.put、app.delete四种HTTP动作。但在大多数情况你只需要前两种,除非你想创建一个RESTful API。如果你只想显示一个页面,使用GET,如果你想提交表单,使用POST。

下面是一个添加路由到你的app里的典型工作流。我们的目的是创建一个页面显示数据库里的书籍列表。

第一步:定义一个路由。

app.get('/books', bookController.getBooks);

在express 4.0以上你可以这样定义你的路由:

app.route('/books')
  .get(bookController.getBooks)
  .post(bookController.createBooks)
  .put(bookController.updateBooks)
  .delete(bookController.deleteBooks)

而下面是一个需要认证中间件的路由:

app.route('/api/twitter')
  .all(passportConf.isAuthenticated)
  .all(passportConf.isAuthorized)
  .get(apiController.getTwitter);
  .post(apiController.postTwitter)

上面三种方式都是可接受的,你可以使用它们中的任何一种。我认为app.route里HTTP动词的这种链式写法非常干净优雅,不过缺点是当每个路由占一行的时候,你不能一眼看到所有的动作。

第二步:创建一个叫book.js的新控制器文件。

/**
 * GET /books
 * List all books.
 */

exports.getBooks = function(req, res) {
  Book.find(function(err, docs) {
    res.render('books', { books: docs });
  });
};

第三步:将控制器加入到app.js里。

var bookController = require('./controllers/book');

第四步:创建book.jade模板。

extends layout

block content
  .page-header
    h3 All Books

  ul
    for book in books
      li= book.name

到这里就完成了。

当然,你可以将1到3步合在一起放到app.js里:

app.get('/books', function(req, res) {
  Book.find(function(err, docs) {
    res.render('books', { books: docs });
  });
});

是的,这样更简单些,但当你在app.js中加入1000行代码时,浏览起来会变得比较麻烦。这个项目的初衷本来就是为了分解关注点,这样你可以与你的队员一起工作而不是忙碌于解决冲突。

上面的内容就是一切了,Express.js非常易于使用。大部分时间其实用在处理其他API,让它们来干真正的工作,比如查询数据库的Mongoose,使用websocket收发信息的socket.io,发送邮件的 Nodemailer,表单验证的express-validator 库,以及处理web页面的Cheerio 等。

如何在Hackathon Starter里使用Socket.io?

Dan Stroot曾提交了一个非常棒的pull以在Hackathon Starter里添加一个实时的仪表盘。但我认为它违反了非特定化的原则,因此并未接受。但你仍然可以在Hackathon Starter里使用Socket.io。下面是一般的操作步骤。

npm install socket.io --save

var app = express();替换为下面的代码:

var app = express();
var http = require('http');
var server = http.createServer(app);
var io = require('socket.io').listen(server);

将下面的代码添加到app.js的末尾:

io.configure(function() {
  io.set('transports', ['websocket']);
});

io.sockets.on('connection', function(socket) {
  socket.emit('greet', { hello: 'Hey, Mr.Client!' });
  socket.on('respond', function(data) {
    console.log(data);
  });
  socket.on('disconnect', function() {
    console.log('Socket disconnected');
  });
});

最后,将

app.listen(app.get('port'), function() {

改为

server.listen(app.get('port'), function() {

后端的工作到这里就完成了。

你有两种方式将前端部分的js加到app中,一种是直接加到layout.jade中,将下面的代码加到head块中。

script(src='/socket.io/socket.io.js')
script.
    var socket = io.connect(window.location.href);
    socket.on('greet', function (data) {
      console.log(data);
      socket.emit('respond', { message: 'Hello to you too, Mr.Server!' });
    });

注意代码中socket.io的路径,你并不需要实际的在项目中包含socket.io.js,它将在运行时自动生成。

另一种方法是将js部分加入到独立的的main.js文件中,并将它们包含在jQuery的ready函数下。

$(document).ready(function() {

  // Place JavaScript code here...
  var socket = io.connect(window.location.href);
  socket.on('greet', function (data) {
    console.log(data);
    socket.emit('respond', { message: 'Hello to you too, Mr.Server!' });
  });

});

到这里所有工作就完成了。

这里有一个实时仪表盘的在线演示。你可以查看这里它是如何添加到工程里的。

Mongoose Cheatsheet

查询所有用户:

User.find(function(err, users) {
  console.log(users);
});

通过email查询用户:

var userEmail = 'example@gmail.com';
User.findOne({ email: userEmail }, function(err, user) {
  console.log(user);
});

查询5个最近的用户账号:

User
  .find()
  .sort({ _id: -1 })
  .limit(5)
  .exec(function(err, users) {
    console.log(users);
  });

从所有文档中查询指定列的总数:

假设每个用户有一个叫做votes的列,你希望统计所有用户的votes总数。一个笨办法是循环查找所有的文档并手动的将结果加起来。另一个方法是使用MongoDB Aggregation Framework 来代替:

User.aggregate({ $group: { _id: null, total: { $sum: '$votes' } } }, function(err, votesCount) {
  console.log(votesCount.total);
});

应用部署(略过)

本部分介绍了应用MongoLab、Heroku、OpenShift等在线服务来部署应用,由于网络环境的不同,在国内可能难以用到,所以不予翻译。

(正文完)

译者注:翻译完成后觉得Hackathon Starter的确是个好东西,不过就像这篇文章所说的,每个开发者都需要有自己的Project Starter,这个Hackathon Starter也不适合直接拿来用,吃透它,将它本地化才是最好的做法。

EOF
  • 文章信息
  • 评论交流
2014年08月14日 1:13 发布。字数:14717
本文被收录于下面的话题: