Giter Club home page Giter Club logo

nodejh.github.io's Introduction

Blog

nodejh.github.io's People

Contributors

nodejh avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

nodejh.github.io's Issues

Serverless 掀起新的前端技术变革

最近关于 Serverless 的讨论越来越多。看似与前端关系不大的 Serverless,其实早已和前端有了颇深渊源,并且将掀起新的前端技术变革。本文主要就根据个人理解和总结,从前端开发模式的演进、基于 Serverless 的前端开发案例以及 Serverless 开发最佳实践等方面,与大家探讨 Serverless 中的前端开发模式。本人也有幸在 QCon2019 分享了这一主题。

前端开发模式的演进

首先回顾一下前端开发模式的演进,我觉得主要有四个阶段。

  1. 基于模板渲染的动态页面
  2. 基于 AJAX 的前后端分离
  3. 基于 Node.js 的前端工程化
  4. 基于 Node.js 的全栈开发

基于模板渲染的动态页面

在早起的互联网时代,我们的网页很简单,就是一些静态或动态的页面,主要目的是用来做信息的展示和传播。这个时候开发一个网页也很容易,主要就是通过 JSP、PHP 等技术写一些动态模板,然后通过 Web Server 将模板解析成一个个 HTML 文件,浏览器只负责渲染这些 HTML 文件。这个阶段还没有前后端的分工,通常是后端工程师顺便写了前端页面。

基于 AJAX 的前后端分离

2005 年 AJAX 技术的正式提出,翻开了 Web 开发的新篇章。基于 AJAX,我们可以把 Web 分为前端和后端,前端负责界面和交互,后端负责业务逻辑的处理。前后端通过接口进行数据交互。我们也不再需要在各个后端语言里面写着难以维护的 HTML。网页的复杂度也由后端的 Web Server 转向了浏览器端的 JavaScript。也正因如此,开始有了前端工程师这个职位。

基于 Node.js 的前端工程化

2009年 Node.js 的出现,对于前端工程师来说,也是一个历史性的时刻。随着 Node.js 一同出现的还有 CommonJS 规范和 npm 包管理机制。随后也出现了 Grunt、Gulp、Webpack 等一系列基于 Node.js 的前端开发构建工具。

在 2013 年前后,前端三大框架 React.js/Angular/Vue.js 相继发布第一个版本。我们可以从以往基于一个个页面的开发,变为基于一个个组件进行开发。开发完成后使用 webpack 等工具进行打包构建,并通过基于 Node.js 实现的命令行工具将构建结果发布上线。前端开发开始变得规范化、标准化、工程化。

基于 Node.js 的全栈开发

Node.js 对前端的重要意义还有,以往只能运行在浏览器中的 JavaScript 也可以运行在服务器上,前端工程师可以用自己最熟悉的语言来写服务端的代码。于是前端工程师开始使用 Node.js 做全栈开发,开始由前端工程师向全栈工程师的方向转变。这是前端主动突破自己的边界。

另一方面,前端在发展,后端也在发展。也差不多在 Node.js 诞生那个时代,后端普遍开始由巨石应用模式由微服务架构转变。这也就导致以往的前后端分工出现了分歧。随着微服务架构的兴起,后端的接口渐渐变得原子性,微服务的接口也不再直接面向页面,前端的调用变得复杂了。于是 BFF(Backend For Frontend)架构应运而生,在微服务和前端中间,加了一个 BFF 层,由 BFF 对接口进行聚合、裁剪后,再输出给前端。而 BFF 这层不是后端本质工作,且距离前端最近和前端关系最大,所以前端工程师自然而然选择了 Node.js 来实现。这也是当前 Node.js 在服务端较为广泛的应用。

下一代前端开发模式

可以看到,每一次前端开发模式的变化,都因某个变革性的技术而起。先是 AJAX,而后是 Node.js。那么下一个变革性的技术是什么?不言而喻,就是 Serverless。

Serverless 服务中的前端解决方案

Serverless 简介

根据 CNCF 的定义,Serverless 是指构建和运行不需要服务器管理的应用程序的概念。(serverless-overview)

Serverless computing refers to the concept of building and running applications that do not require server management.
--- CNCF

其实 Serverless 早已和前端产生了联系,只是我们可能没有感知。比如 CDN,我们把静态资源发布到 CDN 之后,就不需要关心 CDN 有多少个节点、节点如何分布,也不需要关心它如何做负载均衡、如何实现网络加速,所以 CDN 对前端来说是 Serverless。再比如对象存储,和 CDN 一样,我们只需要将文件上传到对象存储,就可以直接使用了,不需要关心它如何存取文件、如何进行权限控制,所以对象存储对前端工程师来说是 Serverless。甚至一些第三方的 API 服务,也是 Serverless,因为我们使用的时候,不需要去关心服务器。

当然,有了体感还不够,我们还是需要一个更精确的定义。从技术角度来说,Serverless 就是 FaaS 和 BaaS 的结合。

Serverless = FaaS + BaaS。

简单来讲,FaaS(Function as a Service) 就是一些运行函数的平台,比如阿里云的函数计算、AWS 的 Lambda 等。

BaaS(Backend as a Service)则是一些后端云服务,比如云数据库、对象存储、消息队列等。利用 BaaS,可以极大简化我们的应用开发难度。

Serverless 则可以理解为运行在 FaaS 中的,使用了 BaaS 的函数。

Serverless 的主要特点有:

  • 事件驱动
    • 函数在 FaaS 平台中,需要通过一系列的事件来驱动函数执行。
  • 无状态
    • 因为每次函数执行,可能使用的都是不同的容器,无法进行内存或数据共享。如果要共享数据,则只能通过第三方服务,比如 Redis 等。
  • 无运维
    • 使用 Serverless 我们不需要关心服务器,不需要关心运维。这也是 Serverless **的核心。
  • 低成本
    • 使用 Serverless 成本很低,因为我们只需要为每次函数的运行付费。函数不运行,则不花钱,也不会浪费服务器资源

Serverless 服务中的前端解决方案架构图

上图是当前主要的一些 Serverless 服务,以及对应的前端解决方案。

从下往上,分别是基础设施和开发工具。

基础设施主要是一些云计算厂商提供,包括云计算平台和各种 BaaS 服务,以及运行函数的 FaaS 平台。

前端主要是 Serverless 的使用者,所以对前端来说,最重要的开发工具这一层,我们需要依赖开发工具进行 Serverless 开发、调试和部署。

框架(Framework)

如今还没有一个统一的 Serverless 标准,不同云计算平台提供的 Serverless 服务很可能是不一样的,这就导致我们的代码,无法平滑迁移。Serverless 框架一个主要功能是简化 Serverless 开发、部署流程,另一主要功能则是屏蔽不同 Serverless 服务中的差异,让我们的函数能够在不改动或者只改动很小一部分的情况下,在其他 Serverless 服务中也能运行。

常见的 Serverless 框架有 Serverless FrameworkZEIT NowApex 等。不过这些基本都是国外公司做的,国内还没有这样的平台。

Web IDE

和 Serverless 紧密相关的 Web IDE 主要也是各个云计算平台的 Web IDE。利用 Web IDE,我们可以很方便地在云端开发、调试函数,并且可以直接部署到对应的 FaaS 平台。这样的好处是避免了在本地安装各种开发工具、配置各种环境。常见的 Web IDE 有 AWS 的 Cloud9、阿里云的函数计算 Web IDE、腾讯云的 Cloud Studio。从体验上来说,AWS Cloud9 最好。

命令行工具

当然,目前最主要的开发方式还是在本地进行开发。所以在本地开发 Serverless 的命令行工具也必不可少。

命令行工具主要有两类,一类是云计算平台提供的,如 AWS 的 aws、 Azure 的 az、阿里云的 fun;还有一类是 Serverless 框架提供的,如 serverlessnow

大部分工具如 serverlessfun 等,都是用 Node.js 实现的。

下面是几个命令行工具的例子。

创建
# serverless
$ serverless create --template aws-nodejs --path myService
# fun
$ fun init -n qcondemo helloworld-nodejs8

部署
# serverless
$ serverless deploy
# fun
$ fun deploy

调试
# serverless
$ serverless invoke [local] --function functionName
# fun
$ fun local invoke functionName

应用场景

在开发工具上面一层,则是 Serverless 的一些垂直应用场景。除了使用传统的服务端开发,目前使用 Serverless 技术的还有小程序开发,未来可能还会设计物联网领域(IoT)。

不同 Serverless 服务的对比

上图从支持语言、触发器、价格等多个方面对不同 Serverless 服务进行了对比,可以发现有差异,也有共性。

比如几乎所有 Serverless 服务都支持 Node.js/Python/Java 等语言。

从支持的触发器来看,几乎所有服务也都支持 HTTP、对象存储、定时任务、消息队列等触发器。当然,这些触发器也与平台自己的后端服务相关,比如阿里云的对象存储触发器,是基于阿里云的 OSS 产品的存取等事件触发的;而 AWS 的对象存储触发器,则是基于 AWS 的 S3 的事件触发的,两个平台并不通用。这也是当前 Serverless 面临的一个问题,就是标准不统一。

从计费的角度来看,各个平台的费用基本一致。在前面也提到,Serverless 的计费是按调用次数计费。对于各个 Serverless,每个月都有 100 万次的免费调用次数,之后差不多 ¥1.3/百万次;以及 400,000 GB-s 的免费执行时间,之后 ¥0.0001108/GB-s。所以在应用体量较小的时候,使用 Serverless 是非常划算的。

基于 Serverless 的前端开发模式

在本章节,主要以几个案例来说明基于 Serverless 的前端开发模式,以及它和以往的前端开发有什么不一样。

在开始具体的案例之前,先看一下传统开发流程。

在传统开发流程中,我们需要前端工程师写页面,后端工程师写接口。后端写完接口之后,把接口部署了,再进行前后端联调。联调完毕后再测试、上线。上线之后,还需要运维工程师对系统进行维护。整个过程涉及多个不同角色,链路较长,沟通协调也是一个问题。

而基于 Serverless,后端变得非常简单了,以往的后端应用被拆分为一个个函数,只需要写完函数并部署到 Serverless 服务即可,后续也不用关心任何服务器的运维操作。后端开发的门槛大幅度降低了。因此,只需要一个前端工程师就可以完成所有的开发工作。

当然,前端工程师基于 Serverless 去写后端,最好也需要具备一定的后端知识。涉及复杂的后端系统或者 Serverless 不适用的场景,还是需要后端开发,后端变得更靠后了。

基于 Serverless 的 BFF

一方面,对不同的设备需要使用不同的 API,另一方面,由于微服务导致前端接口调用的复杂,所以前端工程师开始使用 BFF 的方式,对接口进行聚合裁剪,以得到适用于前端的接口。

下面是一个通用的 BFF 架构。


BFF @ SoundCloud

最底层的就是各种后端微服务,最上层就是各种前端应用。在微服务和应用之前,就是通常由前端工程师开发的 BFF。

这样的架构解决了接口协调的问题,但也带来了一些新的问题。

比如针对每个设备开发一个 BFF 应用,也会面临一些重复开发的问题。而且以往前端只需要开发页面,关注于浏览器端的渲染即可,现在却需要维护各种 BFF 应用。以往前端也不需要关心并发,现在并发压力却集中到了 BFF 上。总的来说运维成本非常高,通常前端并不擅长运维。

Serverless 则可以帮我们很好的解决这些问题。基于 Serverless,我们可以使用一个个函数来实各个接口的聚合裁剪。前端向 BFF 发起的请求,就相当于是 FaaS 的一个 HTTP 触发器,触发一个函数的执行,这个函数中来实现针对该请求的业务逻辑,比如调用多个微服务获取数据,然后再将处理结果返回给前端。这样运维的压力,就由以往的 BFF Server 转向了 FaaS 服务,前端再也不用关心服务器了。

上图则是基于 Serverless 的 BFF 架构。为了更好的管理各种 API,我们还可以添加网关层,通过网关来管理所有 API(比如阿里云的网关),比如对 API 进行分组、分环境。基于 API 网关,前端就不直接通过 HTTP 触发器来执行函数,而是将请求发送至网关,再由网关去触发具体的函数来执行。

基于 Serverless 的服务端渲染

基于当下最流行的三大前端框架(React.js/Anguler/Vue.js),现在的渲染方式大部分都是客户端渲染。页面初始化的时候,只加载一个简单 HTML 以及对应的 JS 文件,再由 JS 来渲染出一个个页面。这种方式最主要的问题就是白屏时间和 SEO。

为了解决这个问题,前端又开始尝试服务端渲染。本质**其实和最早的模板渲染是一样的。都是前端发起一个请求,后端 Server 解析出一个 HTML 文档,然后再返回给浏览器。只不过以往是 JSP、PHP 等服务端语言的模板,现在是基于 React、Vue 等实现的同构应用,这也是如今的服务端渲染方案的优势。

但服务端渲染又为前端带来了一些额外的问题:运维成本,前端需要维护用于渲染的服务器。

Serverless 最大的优点就是可以帮我们减少运维,那 Serverless 能不能用于服务端渲染呢?当然也是可以的。

传统的服务端渲染,每个请求的 path 都对应着服务端的每个路由,由该路由实现对应 path 的 HTML 文档渲染。用于渲染的服务端程序,就是这些集成了这些路由的应用。

使用 Serverless 来做服务端渲染,就是将以往的每个路由,都拆分为一个个函数,再在 FaaS 上部署对应的函数。这样用户请求的 path,对应的就是每个单独的函数。通过这种方式,就将运维操作转移到了 FaaS 平台,前端做服务端渲染,就不用再关心服务端程序的运维部署了。

ZEITNext.js 就对基于 Serverless 的服务端渲染做了很好的实现。下面就是一个简单的例子。

代码结构如下:

.
├── next.config.js
├── now.json
├── package.json
└── pages
    ├── about.js
    └── index.js
// next.config.js
module.exports = {
  target: 'serverless'
}

其中 pages/about.jspages/index.js 就是两个页面,在 next.config.js 配置了使用 Zeit 提供的 Serverless 服务。

然后使用 now 这个命令,就可以将代码以 Serverless 的方式部署。部署过程中,pages/about.jspages/index.js 就分别转换为两个函数,负责渲染对应的页面。

基于 Serverless 的小程序开发

目前国内使用 Serverless 较多的场景可能就是小程开发了。具体的实现就是小程序云开发,支付宝小程序和微信小程序都提供了云开发功能。

在传统的小程序开发中,我们需要前端工程师进行小程序端的开发;后端工程师进行服务端的开发。小程序的后端开发和其他的后端应用开发,本质是是一样的,需要关心应用的负载均衡、备份冗灾、监控报警等一些列部署运维操作。如果开发团队人很少,可能还需要前端工程师去实现服务端。

但基于云开发,就只需要让开发者关注于业务的实现,由一个前端工程师就能够完成整个应用的前后端开发。因为云开发将后端封装为了 BaaS 服务,并提供了对应的 SDK 给开发者,开发者可以像调用函数一样使用各种后端服务。应用的运维也转移到了提供云开发的服务商。

下面分别是使用支付宝云开发(Basement)的一些例子,函数就是定义在 FaaS 服务中的函数。

操作数据库

// `basement` 是一个全局变量
// 操作数据库
basement.db.collection('users')
	.insertOne({
		name: 'node',
		age: 18,
	})
	.then(() => {
		resolve({ success: true });
	})
	.catch(err => {
		reject({ success: false });
	});

上传图片

// 上传图片
basement.file
	.uploadFile(options)
	.then((image) => {
		this.setData({
			iconUrl: image.fileUrl,
		});
	})
	.catch(console.error);

调用函数

// 调用函数
basement.function
	.invoke('getUserInfo')
	.then((res) => { 
		this.setData({ 
			user: res.result
		});
	})
	.catch(console.error}

通用 Serverless 架构

基于上述几个 Serverless 开发的例子,就可以总结出一个通用的 Serverless 架构。

其中最底层就是实现复杂业务的后端微服务(Backend)。然后 FaaS 层通过一系列函数实现业务逻辑,并为前端直接提供服务。对于前端开发者来说,前端可以通过编写函数的方式来实现服务端的逻辑。对于后端开发者来说,后端变得更靠后了。如果业务比较较淡,FaaS 层能够实现,甚至也不需要微服务这一层了。

同时不管是在后端、FaaS 还是前端,我们都可以去调用云计算平台提供的 BaaS 服务,大大降低开发难度、减少开发成本。小程序云开发,就是直接在前端调用 BaaS 服务的例子。

Serverless 开发最佳实践

基于 Serverless 开发模式和传统开发模式最大的不同,就是传统开发中,我们是基于应用的开发。开发完成后,我们需要对应用进行单元测试和集成测试。而基于 Serverless,开发的是一个个函数,那么我们应该如何对 Serverless 函数进行测试?Serverless 函数的测试和普通的单元测试又有什么区别?

还有一个很重要的点是,基于 Serverless 开发的应用性能如何?应该怎么去提高 Serverless 应用的性能?

本章主要就介绍一下,基于 Serverless 的函数的测试和函数的性能两个方面的最佳实践。

函数的测试

虽然使用 Serverless 我们可以简单地进行业务的开发,但它的特性也给我们的测试带来了一些挑战。主要有以下几个方面。

Serverless 函数是分布式的,我们不知道也无需知道函数是部署或运行在哪台机器上,所以我们需要对每个函数进行单元测试。Serverless 应用是由一组函数组成的,函数内部可能依赖了一些别的后端服务(BaaS),所以我们也需要对 Serverless 应用进行集成测试。

运行函数的 FaaS 和 BaaS 在本地也难以模拟。除此之外,不同平台提供的 FaaS 环境可能不一致,不平台提供的 BaaS 服务的 SDK 或接口也可能不一致,这不仅给我们的测试带来了一些问题,也增加了应用迁移成本。

函数的执行是由事件驱动的,驱动函数执行的事件,在本地也难以模拟。

那么如何解决这些问题呢?

根据 Mike Cohn 提出的测试金字塔,单元测试的成本最低,效率最高;UI 测试(集成)测试的成本最高,效率最低,所以我们要尽可能多的进行单元测试,从而减少集成测试。这对 Serverless 的函数测试同样适用。


图片来源: https://martinfowler.com/bliki/TestPyramid.html

为了能更简单对函数进行单元测试,我们需要做的就是将业务逻辑和函数依赖的 FaaS(如函数计算) 和 BaaS (如云数据库)分离。当 FaaS 和 BaaS 分离出去之后,我们就可以像编写传统的单元测试一样,对函数的业务逻辑进行测试。然后再编写集成测试,验证函数和其他服务的集成是否正常工作。

一个糟糕的例子

下面是一个使用 Node.js 实现的函数的例子。该函数做的事情就是,首先将用户信息存储到数据库中,然后给用户发送邮件。

const db = require('db').connect();
const mailer = require('mailer');

module.exports.saveUser = (event, context, callback) => {
  const user = {
    email: event.email,
    created_at: Date.now()
  }

  db.saveUser(user, function (err) {
    if (err) {
      callback(err);
    } else {
      mailer.sendWelcomeEmail(event.email);
      callback();
    }
  });
};

这个例子主要存在两个问题:

  1. 业务逻辑和 FaaS 耦合在一起。主要就是业务逻辑都在 saveUser 这个函数里,而 saveUser 参数的 eventconent 对象,是 FaaS 平台提供的。
  2. 业务逻辑和 BaaS 耦合在一起。具体来说,就是函数内使用了 dbmailer 这两个后端服务,测试函数必须依赖于 dbmailer

编写可测试的函数

基于将业务逻辑和函数依赖的 FaaS 和 BaaS 分离的原则,对上面的代码进行重构。

class Users {
  constructor(db, mailer) {
    this.db = db;
    this.mailer = mailer;
  }

  save(email, callback) {
    const user = {
      email: email,
      created_at: Date.now()
    }

    this.db.saveUser(user, function (err) {
      if (err) {
        callback(err);
      } else {
        this.mailer.sendWelcomeEmail(email);
        callback();
      }
  });
  }
}

module.exports = Users;
const db = require('db').connect();
const mailer = require('mailer');
const Users = require('users');

let users = new Users(db, mailer);

module.exports.saveUser = (event, context, callback) => {
  users.save(event.email, callback);
};

在重构后的代码中,我们将业务逻辑全都放在了 Users 这个类里面,Users 不依赖任何外部服务。测试的时候,我们也可以不传入真实的 dbmailer,而是传入模拟的服务。

下面是一个模拟 mailer 的例子。

// 模拟 mailer
const mailer = {
  sendWelcomeEmail: (email) => {
	console.log(`Send email to ${email} success!`);
  },
};

这样只要对 Users 进行充分的单元测试,就能确保业务代码如期运行。

然后再传入真实的 dbmailer,进行简单的集成测试,就能知道整个函数是否能够正常工作。

重构后的代码还有一个好处是方便函数的迁移。当我们想要把函数从一个平台迁移到另一个平台的时候,只需要根据不同平台提供的参数,修改一下 Users 的调用方式就可以了,而不用再去修改业务逻辑。

小结

综上所述,对函数进行测试,就需要牢记金字塔原则,并遵循以下原则:

  1. 将业务逻辑和函数依赖的 FaaS 和 BaaS 分离
  2. 对业务逻辑进行充分的单元测试
  3. 将函数进行集成测试验证代码是否正常工作

函数的性能

使用 Serverless 进行开发,还有一个大家都关心的问题就是函数的性能怎么样。

对于传统的应用,我们的程序启动起来之后,就常驻在内存中;而 Serverless 函数则不是这样。

当驱动函数执行的事件到来的时候,首先需要下载代码,然后启动一个容器,在容器里面再启动一个运行环境,最后才是执行代码。前几步统称为冷启动(Cold Start)。传统的应用没有冷启动的过程。

下面是函数生命周期的示意图:


图片来源: https://www.youtube.com/watch?v=oQFORsso2go&feature=youtu.be&t=8m5s

冷启动时间的长短,就是函数性能的关键因素。优化函数的性能,也就需要从函数生命周期的各个阶段去优化。

不同编程语言对冷启动时间的影响

在此之前,已经有很多人测试过不同编程语言对冷启动时间的影响,比如:


图片来源: Cold start / Warm start with AWS Lambda

从这些测试中能够得到一些统一的结论:

  • 增加函数的内存可以减少冷启动时间
  • C#、Java 等编程语言的能启动时间大约是 Node.js、Python 的 100 倍

基于上述结论,如果想要 Java 的冷启动时间达到 Node.js 那么小,可以为 Java 分配更大的内存。但更大的内存意味着更多的成本。

函数冷启动的时机

刚开始接触 Serverless 的开发者可能有一个误区,就是每次函数执行,都需要冷启动。其实并不是这样。

当第一次请求(驱动函数执行的事件)来临,成功启动运行环境并执行函数之后,运行环境会保留一段时间,以便用于下一次函数执行。这样就能减少冷启动的次数,从而缩短函数运行时间。当请求达到一个运行环境的限制时,FaaS 平台会自动扩展下一个运行环境。

以 AWS Lambda 为例,在执行函数之后,Lambda 会保持执行上下文一段时间,预期用于另一次 Lambda 函数调用。其效果是,服务在 Lambda 函数完成后冻结执行上下文,如果再次调用 Lambda 函数时 AWS Lambda 选择重用上下文,则解冻上下文供重用。

下面以两个小测试来说明上述内容。

我使用阿里云的函数计算实现了一个 Serverless 函数,并通过 HTTP 事件来驱动。然后使用不同并发数向函数发起 100 个请求。

首先是一个并发的情况:

可以看到第一个请求时间为 302ms,其他请求时间基本都在 50ms 左右。基本就能确定,第一个请求对应的函数是冷启动,剩余 99 个请求,都是热启动,直接重复利用了第一个请求的运行环境。

接下来是并发数为 10 的情况:

可以发现,前 10 个请求,耗时基本在 200ms-300ms,其余请求耗时在 50ms 左右。于是可以得出结论,前 10 个并发请求都是冷启动,同时启动了 10 个运行环境;后面 90 个请求都是热启动。

这也就印证了之前的结论,函数不是每次都冷启动,而是会在一定时间内复用之前的运行环境。

执行上下文重用

上面的结论对我们提高函数性能有什么帮助呢?当然是有的。既然运行环境能够保留,那就意味着我们能对运行环境中的执行上下文进行重复利用。

来看一个例子:

const mysql = require('mysql');

module.exports.saveUser = (event, context, callback) => {

	// 初始化数据库连接
	const connection = mysql.createConnection({ /* ... */ });
	connection.connect();
  
	connection.query('...');

};

上面例子实现的功能就是在 saveUser 函数中初始化一个数据库连接。这样的问题就是,每次函数执行的时候,都会重新初始化数据库连接,而连接数据库又是一个比较耗时的操作。显然这样对函数的性能是没有好处的。

既然在短时间内,函数的执行上下文可以重复利用,那么我们就可以将数据库连接放在函数之外:

const mysql = require('mysql');

// 初始化数据库连接
const connection = mysql.createConnection({ /* ... */ });
connection.connect();


module.exports.saveUser = (event, context, callback) => {

	connection.query('...');

};

这样就只有第一次运行环境启动的时候,才会初始化数据库连接。后续请求来临、执行函数的时候,就可以直接利用执行上下文中的 connection,从而提后续高函数的性能。

大部分情况下,通过牺牲一个请求的性能,换取大部分请求的性能,是完全可以够接受的。

给函数预热

既然函数的运行环境会保留一段时间,那么我们也可以通过主动调用函数的方式,隔一段时间就冷启动一个运行环境,这样就能使得其他正常的请求都是热启动,从而避免冷启动时间对函数性能的影响。

这是目前比较有效的方式,但也需要有一些注意的地方:

  1. 不要过于频繁调用函数,至少频率要大于 5 分钟
  2. 直接调用函数,而不是通过网关等间接调用
  3. 创建专门处理这种预热调用的函数,而不是正常业务函数

这种方案只是目前行之有效且比较黑科技的方案,可以使用,但如果你的业务允许“牺牲第一个请求的性能换取大部分性能”,那也完全不必使用该方案,

小结

总体而言,优化函数的性能就是优化冷启动时间。上述方案都是开发者方面的优化,当然还一方面主要是 FaaS 平台的性能优化。

总结一下上述方案,主要是以下几点:

  1. 选用 Node.js / Python 等冷启动时间短的编程语言
  2. 为函数分配合适的运行内存
  3. 执行上下文重用
  4. 为函数预热

总结

作为前端工程师,我们一直在探讨前端的边界是什么。现在的前端开发早已不是以往的前端开发,前端不仅可以做网页,还可以做小程序,做 APP,做桌面程序,甚至做服务端。而前端之所以在不断拓展自己的边界、不断探索更多的领域,则是希望自己能够产生更大的价值。最好是用我们熟悉的工具、熟悉的方式来创造价值。

而 Serverless 架构的诞生,则可以最大程度帮助前端工程师实现自己的理想。使用 Serverless,我们不需要再过多关注服务端的运维,不需要关心我们不熟悉的领域,我们只需要专注于业务的开发、专注于产品的实现。我们需要关心的事情变少了,但我们能做的事情更多了。

Serverless 也必将对前端的开发模式产生巨大的变革,前端工程师的职能也将再度回归到应用工程师的职能。

如果要用一句话来总结 Serverless,那就是 Less is More。

MySQL 启动报错问题排查

今天使用 MySQL 的时候,莫名奇妙除了很多问题。在 Google 和 StackOverflow 搜索了一大堆,也没有找到很好解决办法。Anyway,最终机智的我还是把问题解决。

在此记录下整个排错过程。

0. 系统环境

  • 操作系统 OS X EI Caption 10.11.6 (15G31)
  • MySQL 5.7.13
  • /usr/local/mysql/bin/usr/local/mysql/support-files 都已经加入到了系统环境变量

1. 进入 MySQL 控制台报错

# 使用 mysql 命令进入 mysql 控制台
$ mysql -uroot -p
Enter password:
ERROR 2002 (HY000): Can't connect to local MySQL server through socket '/tmp/mysql.sock' (2)

出现这个错误,第一时间想到的原因就是 MySQL 并没有启动。为了验证这个猜测,所以我们接下来看看 MySQL 的运行情况。

$ mysql.server status
/usr/local/mysql/support-files/mysql.server: line 365: pidof: command not found
 ERROR! MySQL is not running

可以发现, MySQL 没有运行。

这里使用的是 mysql.server status 命令来查看 MySQL 的运行状态。如果你没有将该命令加入到系统环境变量,则需要加上相应的路径才行,不然会有类似于 zsh: command not found: mysql.server 的错误,也就是说,没有这个命令。

如果没有将 MySQL 相应命令加入系统环境变量,一般需要这么来启动 MySQL:/usr/local/mysql/support-files/mysql.server status,进入 MySQL 控台则需要这样:/usr/local/mysql/bin/mysql -uroot -p。也就是在 mysql.servermysql 等命令前加上路径。

2. MySQL 启动报错

既然 MySQL 没有运行,那么我们肯定要启动 MySQL。

$ mysql.server start
Starting MySQL
. ERROR! The server quit without updating PID file (/usr/local/mysql/data/jh.local.pid).
$ mysql.server status
/usr/local/mysql/support-files/mysql.server: line 365: pidof: command not found
 ERROR! MySQL is not running

启动报错了。会不会是权限不够呢?于是我们再试试使用 sudo 来启动 MySQL:

Starting MySQL
...........................

... 一直在增长,MySQL 一直是正在启动,但就是无法启动成功。

3. 排查 MySQL 启动报错原因

既然 MySQL 启动报错了,那么我们可以通过查看一些日志来推断 MySQL 的报错原因。
在 MySQL 的安装目录里面,有一个 data 目录 (/usr/local/mysql/data),这里吗有一个名为 your_computer_name.local.err 的文件(例如我的该文件名为 jh.local.err ),这个文件里面记录了 MySQL 启动的详细日志信息。

为了更准地得到我们想要的信息,我们可以先删掉改文件,然后再启动 MySQL。

$ sudo rm /usr/local/mysql/data/jh.local.err
$ sudo mysql.server start
Starting MySQL
..................
# 然后 Ctrl+C 停止进程

这个时候,再来看看 jh.local.err 里面的内容:

$ sudo tail /usr/local/mysql/data/jh.local.err
2016-07-31T15:31:35.414440Z 0 [ERROR] InnoDB: Unable to lock ./ibdata1 error: 35
2016-07-31T15:31:35.414531Z 0 [Note] InnoDB: Check that you do not already have another mysqld process using the same InnoDB data or log files.
2016-07-31T15:31:36.417804Z 0 [ERROR] InnoDB: Unable to lock ./ibdata1 error: 35
2016-07-31T15:31:36.418940Z 0 [Note] InnoDB: Check that you do not already have another mysqld process using the same InnoDB data or log files.
2016-07-31T15:31:37.422609Z 0 [ERROR] InnoDB: Unable to lock ./ibdata1 error: 35
2016-07-31T15:31:37.422654Z 0 [Note] InnoDB: Check that you do not already have another mysqld process using the same InnoDB data or log files.
2016-07-31T15:31:38.427437Z 0 [ERROR] InnoDB: Unable to lock ./ibdata1 error: 35
2016-07-31T15:31:38.427670Z 0 [Note] InnoDB: Check that you do not already have another mysqld process using the same InnoDB data or log files.
2016-07-31T15:31:39.431165Z 0 [ERROR] InnoDB: Unable to lock ./ibdata1 error: 35
2016-07-31T15:31:39.431205Z 0 [Note] InnoDB: Check that you do not already have another mysqld process using the same InnoDB data or log files.

第一行就出现了 [ERROR] InnoDB: Unable to lock ./ibdata1 error: 35。然后第二行有个 [Note] InnoDB: Check that you do not already have another mysqld process using the same InnoDB data or log files.

大概意思就是,启动 MySQL 的进程无法给 ibdata1 这个文件加锁。可能是因为有其他 mysqld 进程已经在使用该文件了。

然后后面一直重复这两行。所以我们大概可以猜测,当我们使用 sudo mysql.server start 这个命令来启动 MySQL 的时候,MySQL 一直在尝试启动,但由于进程占用,一直无法启动成功。

4. 查看 mysqld 进程

既然可能是 mysqld 进程已经启动,那么就列出 mysqld 的进程看看:

$ ps xa | grep mysqld
8238   ??  Ss     0:03.74 /usr/local/mysql/bin/mysqld --user=_mysql --basedir=/usr/local/mysql --datadir=/usr/local/mysql/data --plugin-dir=/usr/local/mysql/lib/plugin --log-error=/usr/local/mysql/data/mysqld.local.err --pid-file=/usr/local/mysql/data/mysqld.local.pid
10291 s003  R+     0:00.00 grep --color=auto --exclude-dir=.bzr --exclude-dir=CVS --exclude-dir=.git --exclude-dir=.hg --exclude-dir=.svn mysqld

果然是有进程启动了,那么我们结束该进程,再重新启动 MySQL 试试:

$ sudo kill 8238
$ sudo mysql.server start
Starting MySQL
..................

然而还是和之前一样的情况。再看看进程启动情况?

10341   ??  Ss     0:00.38 /usr/local/mysql/bin/mysqld --user=_mysql --basedir=/usr/local/mysql --datadir=/usr/local/mysql/data --plugin-dir=/usr/local/mysql/lib/plugin --log-error=/usr/local/mysql/data/mysqld.local.err --pid-file=/usr/local/mysql/data/mysqld.local.pid
10343 s003  R+     0:00.00 grep --color=auto --exclude-dir=.bzr --exclude-dir=CVS --exclude-dir=.git --exclude-dir=.hg --exclude-dir=.svn mysqld

mysqld 的进程依然存在,只是进程 ID 由之前的 8238 变成了 10341。多试几次,就会发现,当我们 kill 掉 mysqld 的进程后,它会自动重启。

5. 查看进程占用文件

为了弄清楚已经启动的 mysqld 进程到底是为什么重启,我们可以先看看进程打开了哪些文件。

$ lsof -c mysqld

什么都没有。lsof 只会列出当前用户启动的进程打开的文件。那么加上 sudo 试试?

$ sudo lsof -c mysqld
COMMAND   PID   USER   FD     TYPE             DEVICE  SIZE/OFF     NODE NAME
mysqld  10634 _mysql  cwd      DIR                1,4       510 38686058 /usr/local/mysql-5.7.13-osx10.11-x86_64/data
mysqld  10634 _mysql  txt      REG                1,4  30794000 38685611 /usr/local/mysql-5.7.13-osx10.11-x86_64/bin/mysqld
mysqld  10634 _mysql  txt      REG                1,4    643792 38194604 /usr/lib/dyld
mysqld  10634 _mysql  txt      REG                1,4 558201098 39151946 /private/var/db/dyld/dyld_shared_cache_x86_64h
mysqld  10634 _mysql    0r     CHR                3,2       0t0      302 /dev/null
mysqld  10634 _mysql    1w     REG                1,4     71906 39198339 /usr/local/mysql-5.7.13-osx10.11-x86_64/data/mysqld.local.err
mysqld  10634 _mysql    2w     REG                1,4     71906 39198339 /usr/local/mysql-5.7.13-osx10.11-x86_64/data/mysqld.local.err
mysqld  10634 _mysql    3u  KQUEUE                                       count=0, state=0xa
.......

果然 mysqld 打开了很多文件!说明 mysqld 这个进程真实存在,并且占用了 MySQL 文件,而且是以 root 权限运行的。

6. MySQL 到底为何而启动

继续推断,root 权限启动,并且自动重启,那估计和系统启动有关了。

在 Linux 系统下,/etc/rc.* 或者 /etc/init 目录下包含着系统里各个服务启动和停止的脚本,而 OS X 中则是由 launchd 做进程的管理和控制。

launchd 是OS X 从 10.4开始引入,用于用于初始化系统环境的关键进程,它是内核装载成功之后在OS环境下启动的第一个进程。采用这种方式配置启动进程,只需要一个 plist 文件。/Library/LaunchDaemons 目录下的 plist 文件都是系统启动后立即启动进程。使用 launchctl 命令加载/卸载 plist 文件,加载配置文件后,程序启动,卸载配置文件后程序关闭。

进入 /Library/LaunchDaemons 查看一下,确实是有一个和 MySQL 相关的 plist 文件 com.oracle.oss.mysql.mysqld.plist

那么卸载该文件,然后再查看进程,验证是不是因为该 plist,所以 mysqld 才会不断重启:

$ sudo launchctl unload -w /Library/LaunchDaemons/com.oracle.oss.mysql.mysqld.plist

查看进程:

$ ps xa | grep mysqld
10924 s005  S+     0:00.00 grep --color=auto --exclude-dir=.bzr --exclude-dir=CVS --exclude-dir=.git --exclude-dir=.hg --exclude-dir=.svn mysqld

果然 mysqld 进程已经不存在了。说明推测是正确的,mysqld 之所以会以 root 权限启动,就是因为有一个启动 mysqldplist 配置文件。

因为是修通了系统的进程启动管理配置,所以需要重启一下,launchd 才会在下次启动中生效。

重启后,再通过 mysql.server 来启动 MySQL,就会发现一切正常了!

$ sudo mysql.server start
Password:
Starting MySQL
. SUCCESS!

7. 总结

加载 plist 配置的命令:

$ sudo launchctl load -w /Library/LaunchDaemons/com.oracle.oss.mysql.mysqld.plist

卸载 plist 配置的命令:

$ sudo launchctl unload -w /Library/LaunchDaemons/com.oracle.oss.mysql.mysqld.plist

所以折腾了那么久,其实就只是因为 OS X 特殊的启动管理机制造成的 mysqld 自动启动并占用进程,造成我们期望的 MySQL 启动方式没有正常运行。

卸载掉 plsit 配置并重启系统就好了!

Linux 用户和用户组的管理

Linux系统是一个多用户多任务的分时操作系统,任何一个要使用系统资源的用户,都必须首先向系统管理员申请一个账号,然后以这个账号的身份进入系统。

用户的账号一方面可以帮助系统管理员对使用系统的用户进行跟踪,并控制他们对系统资源的访问;另一方面也可以帮助用户组织文件,并为用户提供安全性保护。

每个用户账号都拥有一个惟一的用户名和各自的口令。

用户在登录时键入正确的用户名和口令后,就能够进入系统和自己的主目录。

关于用户账号的管理,主要有以下几大方面:

  • 用户管理。
  • 用户组管理。
  • 用户账号相关系统文件。

1. 用户管理

1.1. 添加用户

useradd 选项 用户名

参数说明:

  • 选项:
    • -c comment 指定一段注释性描述。
    • -d 目录 指定用户主目录,如果此目录不存在,则同时使用-m选项,可以创建主目录。
    • -g 用户组 指定用户所属的用户组。
    • -G 用户组,用户组 指定用户所属的附加组。
    • -s Shell文件 指定用户的登录Shell。默认的是 /bin/bash
    • -u 用户号 指定用户的用户号,如果同时有-o选项,则可以重复使用其他用户的标识号。
  • 用户名:指定新账号的登录名。

实例1

# useradd -d /home/jh -m jh

此命令创建了一个用户 jh,其中 -d-m 选项用来为用户 jh 创建一个主目录 /home/jh,用户登录服务器之后,就处于该目录。

创建用户时,如果没有给用户指定用户组,则会自动创建一个同名的用户组。

实例2

# useradd -s /bin/sh -g group -G admin,root nodejh

此命令新建了一个用户 nodejh,该用户的登录 Shell 是 /bin/sh,它属于 group 用户组,同时又属于 admroot 用户组,其中 group 用户组是其主组。

这里可能新建组:groupadd groupgroupadd admin

增加用户账号就是在 /etc/passwd 文件中为新用户增加一条记录,同时更新其他系统文件如 /etc/shadow/etc/group 等。

1.2. 删除用户

如果一个用户的账号不再使用,可以从系统中删除。删除用户账号就是要将/etc/passwd等系统文件中的该用户记录删除,必要时还删除用户的主目录。

删除一个已有的用户账号使用userdel命令,其格式如下:

userdel 选项 用户名

常用的选项是-r,它的作用是把用户的主目录一起删除。

例如:

# userdel jh

此命令删除用户 jh 在系统文件中(主要是 /etc/passwd/etc/shadow/etc/group 等)的记录,同时删除用户的主目录。

1.3 修改账户

修改用户账号就是根据实际情况更改用户的有关属性,如用户号、主目录、用户组、登录Shell等。

修改已有用户的信息使用 usermod 命令,其格式如下:

usermod 选项 用户名

常用的选项包括 -c, -d, -m, -g, -G, -s, -u 等,这些选项的意义与 useradd 命令中的选项一样,可以为用户指定新的资源值。

另外,有些系统可以使用选项:-l 新用户名。这个选项指定一个新的账号,即将原来的用户名改为新的用户名。

例如:

# usermod -s /bin/ksh -d /home/jh1 –g developer jh

此命令将用户 jh 的登录 Shell 修改为 ksh,主目录改为 /home/jh,用户组改为developer

1.4 用户口令管理

用户管理的一项重要内容是用户口令的管理。用户账号刚创建时没有口令,但是被系统锁定,无法使用,必须为其指定口令后才可以使用,即使是指定空口令。

指定和修改用户口令的 Shell 命令是 passwd。超级用户可以为自己和其他用户指定口令,普通用户只能用它修改自己的口令。命令的格式为:

passwd 选项 用户名

可使用的选项:

  • -l 锁定口令,即禁用账号。
  • -u 口令解锁。
  • -d 使账号无口令。
  • -f 强迫用户下次登录时修改口令。

如果默认用户名,则修改当前用户的口令。

例如,假设当前用户是 jh,则下面的命令修改该用户自己的口令:

$ passwd
Old password:****** 
New password:******* 
Re-enter new password:*******

如果是超级用户,可以用下列形式指定任何用户的口令:

# passwd jh
New password:******* 
Re-enter new password:*******

普通用户修改自己的口令时,`passwd 命令会先询问原口令,验证后再要求用户输入两遍新口令,如果两次输入的口令一致,则将这个口令指定给用户;而超级用户为用户指定口令时,就不需要知道原口令。

为了系统安全起见,用户应该选择比较复杂的口令,例如最好使用8位长的口令,口令中包含有大写、小写字母和数字,并且应该与姓名、生日等不相同。

为用户指定空口令时,执行下列形式的命令:

# passwd -d jh

此命令将用户 jh 的口令删除,这样用户 jh 下一次登录时,系统就不再询问口令。

passwd 命令还可以用 -l(lock) 选项锁定某一用户,使其不能登录,例如:

# passwd -l jh

2. 用户组的管理

每个用户都有一个用户组,系统可以对一个用户组中的所有用户进行集中管理。不同Linux 系统对用户组的规定有所不同,如 Linux 下的用户属于与它同名的用户组,这个用户组在创建用户时同时创建。

用户组的管理涉及用户组的添加、删除和修改。组的增加、删除和修改实际上就是对/etc/group文件的更新。

2.1 添加用户组

添加用户组使用 groupadd 命令,其基本格式如下:

groupadd 选项 用户组

可以使用的选项有:

  • -g GID 指定新用户组的组标识号(GID)。
  • -o 一般与-g选项同时使用,表示新用户组的GID可以与系统已有用户组的GID相同。

实例1

# groupadd group1

此命令向系统中增加了一个新组 group1,新组的组标识号是在当前已有的最大组标识号的基础上加 1。

实例2

# groupadd -g 1001 group2

此命令向系统中增加了一个新组 group2,同时指定新组的组标识号是 1001。

2.2 删除用户组

如果要删除一个已有的用户组,使用 groupdel 命令,其格式如下:

groupdel 用户组

例如:

# groupdel group1

此命令从系统中删除组group1。

2.3 修改用户组

修改用户组的属性使用 groupmod 命令。其语法如下:

groupmod 选项 用户组

常用的选项有:

  • -g GID 为用户组指定新的组标识号。
  • -o 与-g选项同时使用,用户组的新GID可以与系统已有用户组的GID相同。
  • -n 新用户组 将用户组的名字改为新名字

实例1

# groupmod -g 102 group2

此命令将组group2的组标识号修改为102。

实例2

# groupmod –g 10000 -n group3 group2

此命令将组 group2 的标识号改为 10000,组名修改为 group3

2.4 切换用户组

如果一个用户同时属于多个用户组,那么用户可以在用户组之间切换,以便具有其他用户组的权限。

用户可以在登录后,使用命令 newgrp 切换到其他用户组,这个命令的参数就是目的用户组。

例如:

$ newgrp root

这条命令将当前用户切换到 root 用户组,前提条件是 root 用户组确实是该用户的主组或附加组。

3. 用户账号相关系统文件

完成用户管理的工作有许多种方法,但是每一种方法实际上都是对有关的系统文件进行修改。
与用户和用户组相关的信息都存放在一些系统文件中,这些文件包括 /etc/passwd/etc/shadow/etc/group 等。

下面分别介绍这些文件的内容。

3.1 /etc/passwd

Linux系统中的每个用户都在 /etc/passwd 文件中有一个对应的记录行,它记录了这个用户的一些基本属性。

这个文件对所有用户都是可读的。它的内容类似下面的例子:

# cat /etc/passwd
dnsmasq:x:109:65534:dnsmasq,,,:/var/lib/misc:/bin/false
sshd:x:110:65534::/var/run/sshd:/usr/sbin/nologin
pollinate:x:111:1::/var/cache/pollinate:/bin/false
ntp:x:112:115::/home/ntp:/bin/false
jh:x:1000:1000::/home/jh:

从上面的例子我们可以看到,/etc/passwd 中一行记录对应着一个用户,每行记录又被冒号(:)分隔为7个字段,其格式和具体含义如下:

用户名:口令:用户编号:用户组编号:用户注释信息:用户主目录:shell类型

用户名

是代表用户账号的字符串。通常长度不超过8个字符,并且由大小写字母和/或数字组成。登录名中不能有冒号(:),因为冒号在这里是分隔符。为了兼容起见,登录名中最好不要包含点字符(.),并且不使用连字符(-)和加号(+)打头。

口令

虽然这个字段存放的只是用户口令的加密串,不是明文,但是由于 /etc/passwd 文件对所有用户都可读,所以这仍是一个安全隐患。因此,现在许多 Linux 系统(如SVR4)都使用了 shadow 技术,把真正的加密后的用户口令字存放到 /etc/shadow 文件中,而在 /etc/passwd 文件的口令字段中只存放一个特殊的字符,例如 x 或者 *

用户编号

用户编号是一个整数,系统内部用它来标识用户。

般情况下它与用户名是一一对应的。如果几个用户名对应的用户标识号是一样的,系统内部将把它们视为同一个用户,但是它们可以有不同的口令、不同的主目录以及不同的登录 Shell 等。
通常用户标识号的取值范围是 0~65 5350 是超级用户 root 的标识号,1~99 由系统保留,作为管理账号,普通用户的标识号从 100 开始。在Linux系统中,这个界限是500

用户组编号

用户组编号字段记录的是用户所属的用户组。它对应着 /etc/group 文件中的一条记录。

用户注释信息

例如用户的真实姓名、电话、地址等,这个字段并没有什么实际的用途。在不同的 Linux 系统中,这个字段的格式并没有统一。在许多 Linux 系统中,这个字段存放的是一段任意的注释性描述文字,用做 finger 命令的输出。

用户主目录

它是用户在登录到系统之后所处的目录。在大多数系统中,各用户的主目录都被组织在同一个特定的目录下,而用户主目录的名称就是该用户的登录名。各用户对自己的主目录有读、写、执行(搜索)权限,其他用户对此目录的访问权限则根据具体情况设置。

shell类型

用户登录后,要启动一个进程,负责将用户的操作传给内核,这个进程是用户登录到系统后运行的命令解释器或某个特定的程序,即 Shell。

Shell 是用户与 Linux 系统之间的接口。Linux 的 Shell 有许多种,每种都有不同的特点。常用的有:

  • sh(Bourne Shell)
  • csh(C Shell)
  • ksh(Korn Shell)
  • tcsh(TENEX/TOPS-20 type C Shell)
  • bash(Bourne Again Shell)

系统管理员可以根据系统情况和用户习惯为用户指定某个 Shell。如果不指定 Shell,那么系统使用 sh 为默认的登录 Shell,即这个字段的值为 /bin/sh。而 /bin/sh 一般链接到 /bin/dash

用户的登录 Shell 也可以指定为某个特定的程序(此程序不是一个命令解释器)。

利用这一特点,我们可以限制用户只能运行指定的应用程序,在该应用程序运行结束后,用户就自动退出了系统。有些Linux 系统要求只有那些在系统中登记了的程序才能出现在这个字段中。

3.2 /etc/shadow

/etc/shadow 中的记录行与 /etc/passwd 中的一一对应,它由 pwconv 命令根据 /etc/passwd 中的数据自动产生。

它的文件格式与 /etc/passwd 类似,由若干个字段组成,字段之间用 : 隔开。这些字段是:

登录名:加密口令:最后一次修改时间:最小时间间隔:最大时间间隔:警告时间:不活动时间:失效时间:标志
  • 登录名 是与 /etc/passwd 文件中的登录名相一致的用户账号
  • 口令 字段存放的是加密后的用户口令字,长度为13个字符。如果为空,则对应用户没有口令,登录时不需要口令;如果含有不属于集合 { ./0-9A-Za-z } 中的字符,则对应的用户不能登录。
  • 最后一次修改时间 表示的是从某个时刻起,到用户最后一次修改口令时的天数。时间起点对不同的系统可能不一样。例如在 SCO Linux 中,这个时间起点是1970年1月1日。
  • 最小时间间隔 指的是两次修改口令之间所需的最小天数。
  • 最大时间间隔 指的是口令保持有效的最大天数。
  • 警告时间 字段表示的是从系统开始警告用户到用户密码正式失效之间的天数。
  • 不活动时间 表示的是用户没有登录活动但账号仍能保持有效的最大天数。
  • 失效时间 字段给出的是一个绝对的天数,如果使用了这个字段,那么就给出相应账号的生存期。期满后,该账号就不再是一个合法的账号,也就不能再用来登录了。

3.3 /etc/group

用户组的所有信息都存放在 /etc/group 文件中。

用户分组是Linux 系统中对用户进行管理及控制访问权限的一种手段。

每个用户都属于某个用户组;一个组中可以有多个用户,一个用户也可以属于不同的组。

当一个用户同时是多个组中的成员时,在 /etc/passwd 文件中记录的是用户所属的主组,也就是登录时所属的默认组,而其他组称为附加组。

用户要访问属于附加组的文件时,必须首先使用 newgrp 命令使自己成为所要访问的组中的成员。
用户组的所有信息都存放在 /etc/group 文件中。此文件的格式也类似于 /etc/passwd 文件,由冒号 : 隔开若干个字段,这些字段有:

组名:口令:组标识号:组内用户列表
  • 组名 是用户组的名称,由字母或数字构成。与 /etc/passwd 中的登录名一样,组名不应重复。
  • 口令 字段存放的是用户组加密后的口令字。一般 Linux 系统的用户组都没有口令,即这个字段一般为空,或者是 *
  • 组标识号 与用户标识号类似,也是一个整数,被系统内部用来标识组。
  • 组内用户列表 是属于这个组的所有用户的列表,不同用户之间用逗号 , 分隔。这个用户组可能是用户的主组,也可能是附加组。

4. 伪用户 psuedo users

统中有一类用户称为伪用户(psuedo users)。

这些用户在 /etc/passwd 文件中也占有一条记录,但是不能登录,因为它们的登录Shell为空。它们的存在主要是方便系统管理,满足相应的系统进程对文件属主的要求。

常见的伪用户如下所示:

  • bin 拥有可执行的用户命令文件
  • sys 拥有系统文件
  • adm 拥有帐户文件
  • uucp UUCP使用
  • lp lp或lpd子系统使用
  • nobody NFS使用

除了上面列出的伪用户外,还有许多标准的伪用户,例如:auditcronmail usenet 等,它们也都各自为相关的进程和文件所需要。

5. 添加批量用户

添加和删除用户对每位 Linux 系统管理员都是轻而易举的事,比较棘手的是如果要添加几十个、上百个甚至上千个用户时,我们不太可能还使用 useradd 一个一个地添加,必然要找一种简便的创建大量用户的方法。Linux系统提供了创建大量用户的工具,可以让您立即创建大量用户,方法如下:

a) 先编辑一个文本用户文件

每一列按照 /etc/passwd 文件的格式书写,要注意每个用户的用户名、UID、宿主目录都不可以相同,其中密码栏可以留做空白或输入 x 号。一个范例文件 user.txt 内容如下:

user001::600:100:user:/home/user001:/bin/bash
user002::601:100:user:/home/user002:/bin/bash
user003::602:100:user:/home/user003:/bin/bash
user004::603:100:user:/home/user004:/bin/bash
user005::604:100:user:/home/user005:/bin/bash
user006::605:100:user:/home/user006:/bin/bash

b) 导入用户

root 身份执行命令 /usr/sbin/newusers,从刚创建的用户文件user.txt中导入数据,创建用户:

# newusers < user.txt

然后可以执行 cat /etc/passwd 等命令检查 /etc/passwd 文件是否已经出现这些用户的数据,并且用户的宿主目录是否已经创建。

c) 解码密码

/etc/shadow 产生的 shadow 密码解码,然后回写到 /etc/passwd 中,并将 /etc/shadow 的shadow密码栏删掉。这是为了方便下一步的密码转换工作,即先取消 shadow password 功能。

执行命令 /usr/sbin/pwunconv

# pwunconv

d) 编辑密码文件

编辑每个用户的密码对照文件,范例文件 passwd.txt 内容如下:

user001:密码
user002:密码
user003:密码
user004:密码
user005:密码
user006:密码

e) 创建用户密码

创建用户密码,chpasswd 会将经过 /usr/bin/passwd 命令编码过的密码写入 /etc/passwd 的密码栏。

# chpasswd < passwd.txt

f) 编码密码

确定密码经编码写入 /etc/passwd 的密码栏后,执行命令 /usr/sbin/pwconv 将密码编码为 shadow password,并将结果写入 /etc/shadow

# pwconv

这样就完成了大量用户的创建了,之后就可以到 /home 下检查这些用户宿主目录的权限设置是否都正确,并登录验证用户密码是否正确。

6. 赋予用户 root 权限

如果普通用户不在 /etc/sudoers 文件中,则使用 sudo 命令的时候就会报错,如:

$ sudo apt-get install git
jh is not in the sudoers file.  This incident will be reported.

首先看看 /etc/suders 文件的内容,里面有如下几行:

# User privilege specification
root	ALL=(ALL:ALL) ALL

# Members of the admin group may gain root privileges
%admin ALL=(ALL) ALL

# Allow members of group sudo to execute any command
%sudo	ALL=(ALL:ALL) ALL

其中,root ALL=(ALL) ALL

  • root 表示被授权的用户,这里是 root 用户。
  • 第一个ALL表示所有计算机。
  • 第二个ALL表示所有用户。
  • 第三个ALL表示所有命令。

全句的意思是:授权根用户在所有计算机上以所有用户的身份运行所有文件。

%admin ALL=(ALL) ALL 同上面一样,只不过被授权的成了 admin 这个组。

所以在 root ALL=(ALL:ALL) ALL 下面加一行:

jh ALL=(ALL:ALL) ALL

然后 jh 用户登录后,使用 sudo 就可以获得 root 权限了。

当然,在其他两个地方添加类似的内容,也是可以的。

7. 其他

  • whoami 查看当前用户
  • id users 查看用户编号及用户组编号

在 Oracle 中设置自增列

如果你经常使用 MySQL,你肯定对 AUTO_INCREMENT 非常熟悉,因为经常要用到它。

一、什么是自增列?

自增列是数据库中值随插入的每个行自动增加的一列。它最常用于主键或 ID 字段,这样每次增加一行时,不用指该字段的值,它就会自动增加,而且是唯一的。

当在 MySQL 中定义列时,我们可以指定一个名为 AUTO_INCREMENT 的参数。然后,每当将新值插入此表中时,放入此列的值比最后一个值加 1

但很不幸,Oracle 没有 AUTO_INCREMENT 功能。 那要如何在Oracle中做到这一点呢?

二、在 Oracle 11g 中设置自增字段

1. 创建表

首先创建一张用于测试的表:

CREATE TABLE "TEST" (
    ID NUMBER(11) PRIMARY KEY,
    NAME VARCHAR2(50BYTE) NOT NULL
);

2. 创建序列

然后创建一个名为 TEST_ID_SEQ 的序列(序列名称自己随意设定):

CREATE SEQUENCE TEST_ID_SEQ
INCREMENT BY 1
START WITH 100
MAXVALUE 999999999
NOCYCLE
NOCACHE;

如果要删除序列,可以使用下面的 SQL 命令:

DROP SEQUENCE TEST_ID_SEQ;

SEQUENCE 的一些说明:

  • INCREMENT BY 用于指定序列增量(默认值:1),如果指定的是正整数,则序列号自动递增,如果指定的是负数,则自动递减。
  • START WITH 用于指定序列生成器生成的第一个序列号,当序列号顺序递增时默认值为序列号的最小值,当序列号顺序递减时默认值为序列号的最大值。
  • MAXVALUE 用于指定序列生成器可以生成的组大序列号(必须大于或等于 START WITH,并且必须大于 MINVALUE),默认为 NOMAXVALUE
  • MINVALUE 用于指定序列生成器可以生成的最小序列号(必须小于或等于 START WITH,并且必须小于 MAXVALUE),默认值为 NOMINVALUE
  • CYCLE 用于指定在达到序列的最大值或最小值之后是否继续生成序列号,默认为 NOCYCLE
  • CACHE 用于指定在内存中可以预分配的序列号个数(默认值:20)。

到这一步其实就已经可以实现字段自增,只要插入的时候,将 ID 的值设置为序列的下一个值 TEST_ID_SEQ.NEXTVAL 就可以了:

SQL> INSERT INTO "TEST" ("ID", "NAME") VALUES (TEST_ID_SEQ.NEXTVAL, 'name1');
SQL> INSERT INTO "TEST" ("ID", "NAME") VALUES (TEST_ID_SEQ.NEXTVAL, 'name2');
SQL> INSERT INTO "TEST" ("ID", "NAME") VALUES (TEST_ID_SEQ.NEXTVAL, 'name3');
SQL> SELECT * FROM "TEST";

ID   NAME
---  ------
100	name1
101	name2
102	name3

为了简化插入操作,我们还可以创建一个触发器,当将数据插入到 "TEST" 表的时候,自动将最新的 ID 插入进去。

3. 创建触发器

CREATE OR REPLACE TRIGGER TEST_ID_SEQ_TRG
BEFORE INSERT ON "TEST"
FOR EACH ROW
WHEN (NEW."ID" IS NULL)
BEGIN
  SELECT TEST_ID_SEQ.NEXTVAL
  INTO :NEW."ID"
  FROM DUAL;
END;

这样的话,每次写插入语句,只需要将 ID 字段的值设置为 NULL 它就会自动递增了:

SQL> INSERT INTO "TEST" ("ID", "NAME") VALUES (NULL, 'name4');
SQL> INSERT INTO "TEST" ("ID", "NAME") VALUES (NULL, 'name5');
SQL> INSERT INTO "TEST" ("ID", "NAME") VALUES (NULL, 'name6');
SQL> SELECT * FROM "TEST";

ID   NAME
---  ------
100	name1
101	name2
102	name3
103	name4
104	name5
105	name6

4. 一些值得注意的地方

4.1 插入指定 ID

如果某条插入语句指定了 ID 的值如:

SQL> INSERT INTO "TEST" ("ID", "NAME") VALUES (1000, 'name1001');
SQL> SELECT * FROM "TEST";

ID   NAME
---  ------
100	name1
101	name2
102	name3
103	name4
104	name5
1000	name1001

那么下次 ID 还是会在原来的基础上继续增加:

SQL> INSERT INTO "TEST" ("ID", "NAME") VALUES (NULL, 'name1001');
SQL> SELECT * FROM "TEST";

ID   NAME
---  ------
100	name1
101	name2
102	name3
103	name4
104	name5
1000	name1001

但当序列的值到了 1000 的时候,如果 ID 允许重复,就会有两行记录 ID 都为 1000

但如果 ID 设置为了主键,如本文的例子 ID NUMBER(11) PRIMARY KEY,则插入就会报错:

Error : ORA-00001: unique constraint (SOFTWARE.SYS_C0014995) violated

4.2 字段加引号

在 SQL 语句中,字段最好都加上引号,不然可能会报错:

Error : ORA-00900: invalid SQL statement

或:

ORA-24344: Success with Compilation Error

4.3 SQUENCE

  • 第一次 NEXTVAL 返回的是初始值;随后的 NEXTVAL 会自动增加 INCREMENT BY 对应的值,然后返回增加后的值。
  • CURRVAL 总是返回当前 SEQUENCE 的值,但是在第一次 NEXTVAL 初始化之后才能使用 CURRVAL ,否则会出错。
  • 一次 NEXTVAL 会增加一次 SEQUENCE 的值,所以如果在同一个语句里面使用多个NEXTVAL,其值就是不一样的。
  • 如果指定 CACHE 值,Oracle 就可以预先在内存里面放置一些 SEQUENCE,这样存取的快些。 CACHE 里面的取完后,Oracle 自动再取一组到 CACHE
  • 但使用 CACHE 或许会跳号,比如数据库突然不正常关闭(shutdown abort), CACHE 中的 SEQUENCE 就会丢失。所以可以在 CREATE SEQUENCE 的时候用 NOCACHE 防止这种情况。

4.4 性能

在数据库操作中,触发器的使用耗费系统资源相对较大。如果对于表容量相对较小的表格我们可以忽略触发器带来的性能影响。

考虑到大表操作的性能问题,需要尽可能的减少触发器的使用。对于以上操作,就可以抛弃触发器的使用,直接手动调用序列函数即可,但这样可能在程序维护上稍微带来一些不便。

三、在 Oracle 12c 中设置自增字段

在 Oracle 12c 中设置自增字段就简单多了,因为 ORacle 12c 提供了 IDENTITY 属性:

CREATE TABLE "TEST" (
    ID NUMBER(11) GENERATED BY DEFAULT ON NULL AS IDENTITY,
    NAME VARCHAR2(50BYTE) NOT NULL
);

这样就搞定了!和 MySQL 一样简单!🤣🤣🤣

四、总结

所以如上所属,在 Oracle 中设置自增字段,需要根据不同的版本使用不同的方法:

  • 在 Oracle 11g 中,需要先创建序列(SQUENCE)再创建一个触发器(TRIGGER)。
  • 在 Oracle 12c 中,只需要使用 IDENTITY 属性就可以了。

Github Issues #33

求给定数组的对等差分(symmetric difference) (△ or ⊕)数组

题目

创建一个函数,接受两个或多个数组,返回所给数组的 对等差分(symmetric difference) (△ or ⊕)数组.

给出两个集合 (如集合 A = {1, 2, 3} 和集合 B = {2, 3, 4}), 而数学术语 "对等差分" 的集合就是指由所有只在两个集合其中之一的元素组成的集合(A △ B = C = {1, 4}). 对于传入的额外集合 (如 D = {2, 3}), 你应该安装前面原则求前两个集合的结果与新集合的对等差分集合 (C △ D = {1, 4} △ {2, 3} = {1, 2, 3, 4}).

解题思路

  • 使用 Array.prototype.reduce 对数组进行遍历。传入的两个参数分别是 prev (对等差分后数组),curr(当前数组)
  • 将 sym() 的第一个参数作为初始对等差分数组,即第一个 prev
  • 对 prev 数组和 curr 数组去重,防止重复元素影响对等差分的结果
  • 遍历 curr 数组,以此判断 curr 数组中每个元素是否在 prev 中出现。如果出现,则从 prev 中删除该元素;如果没有出现,则将其加入到 prev 中

代码如下:

// 对等差分
function sym(args) {
  var arr = Array.prototype.slice.call(arguments);
  return arr.reduce(function(prev, curr) {
      prev = unique(prev);
      curr = unique(curr);
      for (var i=0; i<curr.length; i++) {
          if (prev.indexOf(curr[i]) === -1) {
             prev.push(curr[i]);
          } else {
              prev.splice(prev.indexOf(curr[i]), 1);
          }
          console.log('new: ', prev);
      }
      return prev.sort();
  });
}


// 数组去重
function unique(arr) {
    var res = [];
    for (var i=0; i<arr.length; i++) {
        if (res.indexOf(arr[i]) === -1) {
            res.push(arr[i]);
        }
    }
    return res;
}


// sym([1, 2, 3], [5, 2, 1, 4]);
sym([1, 1, 2, 5], [2, 2, 3, 5], [3, 4, 5, 5])

运行效果:Symmetric Difference

从 Hexo 迁移到 Hugo

把博客从 Hexo 迁移到了 Hugo。主要原因有二:

  • Hexo 中文乱码。当中文大概超过1000字,就出现奇怪的乱码。看了 Hexo 的 ISSUE,这个问题也不只我遇到。
  • Hexo 生成静态文件太慢了,等上十几二十秒是常有的事。

遂弃用 Hexo。

1. 将 Hexo 的 YAML 转换为 Hugo 的 TOML

Hugo 的配置文件是 TOML 格式的,其生成的 Markdown 文件的 front matter(不知如何翻译)也是 TOML 结构:

+++
date = "2016-09-30T17:14:37+08:00"
description = ""
title = "Migrate to Hugo from Hexo"
tags = ["hugo", "hexo", "blog"]

+++

而 Hexo 的配置文件是 YAML 格式,其 Markdown 文件的 front matter 如下:

title: 'Start React with Webpack'
date: 2016-09-09 04:11:13
tags:
  - webpack
  - react

---

所以直接将 Hexo 的 Markdown 文件复制到 Hugo 中是不行的,必须得转换一下格式。虽然 Hugo 也可以直接在 config.toml 中配置 metaDataFormat:"yaml",这样 Hugo 就会生成 YAML 结构的 front matter ,但原 Hexo 的 Markdown 文件依旧不能正常的转换,因为 date 的格式不一样。

在 Hexo 中,文件里面的 date: 2016-09-09 04:11:13 并没有存储时区信息,而是把时区放在了 _config.yml 配置文件中。

By the way,TOML 是 GitHub 觉得 YAML 不够简洁优雅,所以捣鼓出来的一个东西。既然现在在用 Hugo,就没有理由不用 TOML 代替原来的 YAML。

其实两者之间的转换也很简单,我用 JS 写了一个脚本来将 YAML 转换为 TOML:

// yaml_to_toml.js
const readline = require('readline');
const fs = require('fs');
const os = require('os');
const moment = require('moment-timezone');  // 需要通过 npm install moment-timezone 来安装


const timezone = 'Asia/Shanghai';  // 时区
const src = 'hexo';  // hexo .md 文件源目录
const target = 'post';  // 目标文件目录


// 开始转换
readDir();


// 遍历目录
function readDir() {
    // read all files in src
    fs.readdir(src, function(err, files) {
        files.map((filename) => {
            // get the file extension
            const extension = filename.substr(filename.lastIndexOf('.', filename.length));
            if (extension === '.md') {
              readFile(`${filename}`);
            }
        });
    });

}


function readFile(filename) {
  fs.readFile(`${src}/${filename}`, { encoding: 'utf8' }, function(err, data) {
      if (err) {
          return console.log('err: ', err);
      }

      const content = data.split('---');
      const head = content[0].split('\n');
      // console.log('head: ', head);

      let newHead = head.map((item, index) => {
        // console.log('slpitHead: ', slpitHead(item, index, head));
        return slpitHead(item, index, head);
      });
      newHead = newHead.filter((item) => {return item;});
      // console.log('newHead: ', newHead);
      const newContent = `+++${os.EOL}${newHead.join(os.EOL)}${os.EOL}${os.EOL}+++${os.EOL}${content[1]}`;
      fs.writeFile(`${target}/${filename}`, newContent, {
          encoding: 'utf8'
      }, function(err) {
          if (err) {
            throw err;
          }
          console.log(`${filename}  生成成功!`);
      });
  });
}



function slpitHead(item, index, head) {
  // title
  if (item.indexOf('title:') !== -1) {
    return `title = "${item.split('title:')[1].trim()}"`;
  }

  // date
  if (item.indexOf('date:') !== -1) {
    return `date = "${(moment.tz(item.split('date:')[1], timezone)).format()}"`;
  }

  // tags
  if (item.indexOf('tags:') !== -1) {
    // console.log('tags...');
    const tags = [];
    for (let i=index+1; i<head.length; i++) {
      if (head[i].indexOf('-') !== -1) {
        // console.log('head[i].split('-')[1]: ', head[i].split('-')[1]);
        tags.push(head[i].split('-')[1].trim());
      } else {
        break;
      }
    }
    // console.log('tags: ', tags);
    return `tags = ${JSON.stringify(tags)}`;
  }

  // categories
  if (item.indexOf('categories:') !== -1) {
    const categories = [];
    for (let i=index+1; i<head.length; i++) {
      if (head[i].indexOf('-') !== -1) {
        categories.push(head[i].split('-')[1].trim());
      } else {
        break;
      }
    }
    // console.log('categories: ', categories);
    return `categories = ${JSON.stringify(categories)}`;
  }

  return false;
}

先配置好 timezone src target 三个参数。然后 npm install moment-timezone 安装需要的第三方包,最后 node yaml_to_toml.js 即可。

2. 图片目录的迁移

本地图片迁移也非常简单。

我之前使用 Hexo 的时候,是将所有图片都放在 source/images/ 目录里面的,在 Markdown 文件中引入图片是这样的: ![image title](/iamges/image_name.png)

Hugo 的图片可以直接放在其 static/ 目录里面,其路径就是 /iamges/image_name.png,所以我只需要将 Hexo 中的 images 目录复制到 Hugo 的 static/ 目录下即可。

3. 主题

看遍了 Hugo 给出的所有主题,都不满意。很多主题都超级简洁,这种风格还是很喜欢的。所以决定自己写一个。

最终 Fork 了 https://github.com/digitalcraftsman/hugo-cactus-theme 这个主题,然后改成了自己想要的样子。

主页

Migrate-to-Hugo-from-Hexo-1.png

文章页

Migrate-to-Hugo-from-Hexo-2.png

改后的主题源码:https://github.com/nodejh/hugo-theme-cactus-plus

4. 部署

由于 Github Pages 国内访问速度慢,所以同时把静态页面部署到了 Github Page 和 Coding Pages。

// 添加两个仓库
git remote add all https://github.com/nodejh/nodejh.github.io
git remote set-url origin --push --add https://git.coding.net/nodejh/nodejh.git
git remote set-url origin --push --add https://github.com/nodejh/nodejh.github.io

然后只需要执行 git push all branch 就可以同时向两个仓库 push 代码了。但暂时不这样做。而是在 public 目录外创建一个 deploy.sh 目录,用来自动部署:

#!/bin/bash

echo -e "\033[0;32mDeploying updates to GitHub...\033[0m"

# Build the project.
hugo # if using a theme, replace by `hugo -t <yourtheme>`

# Go To Public folder
cd public
# Add changes to git.
git add -A

# Commit changes.
msg="rebuilding site `date`"
if [ $# -eq 1 ]
  then msg="$1"
fi
git commit -m "$msg"

# Push source and build repos.
git push origin master

# Come Back
cd ..

接下来再给 deploy.sh 添加可执行权限 chmod +x deploy.sh。然后每次写完东西只需要 ./deploy.sh 'commit message' 即可。

使用 SQL *Plus 管理 Oracle 数据库

SQL *Plus 是基于命令行的 Oracle 管理工具,可以用来执行 SQLPL/SQL、 和 SQL*Plus 命令:

  • 支持查询、插入和更新数据
  • 执行 PL/SQL 程序
  • 查看表和对象的定义
  • 开发和执行批处理脚本
  • 进行数据库管理

登录 SQL *PLUS

直接登录,输入命令后会提示输入用户名密码:

$ sqlplus

使用用户名和密码:

$ sqlplus [username]/[user_password]

操作系统权限认证的 Oracle SYS 管理员登陆:

$ sqlplus / as sysdba

不在终端暴露密码登录:

$ sqlplus /nolog
SQL> conn [username]/[user_password]
# 或者
SQL > conn / as sysdba

退出登录:

SQL> exit

数据库信息

查看数据库名

通常情况了我们称的 数据库,并不仅指物理的数据集合,而是物理数据、内存、操作系统进程的组合体。

SQL> select name from v$database; 

查询当前数据库实例名

实例是访问Oracle数据库所需的一部分计算机内存和辅助处理后台进程,是由进程和这些进程所使用的内存(SGA)所构成一个集合。

SQL> select instance_name from v$instance;    

数据库实例名用于对外部连接。在操作系统中要取得与数据库的联系,必须使用数据库实例名。比如我们作开发,要连接数据库,就得连接数据库实例名,orcl 就为数据库实例名:

jdbc:oracle:thin:@localhost:1521:orcl

一个数据库可以有多个实例,在作数据库服务集群的时候可以用到。

用户管理

Oracle 使用 PROFILE 文件对用户访问资源的权限进行控制。

若不做特殊指定,创建用户时用户默认使用的 PROFILE 就是 DEFAULT

查看当前用户:

SQL> show user

查看数据库用户:

SQL> select * from dba_users;  

解锁用户

默认当密码输错 10 次之后,用户就会被锁定:

ORA-28000: the account is locked

这个时候就需要管理员来解锁:

$ sqlplus / as sysdba
SQL> alter user [username] account unlock;

有解锁肯定就有锁定:

SQL> alter user [username] account lock;

密码错误次数

当然,也可以自己修改最大密码错误次数,最大错误次数存储在 dba_profiles 表中。

首先根据 username 查看用户使用的 PROFILE

SQL> SELECT PROFILE FROM DBA_USERS WHERE USERNAME='[username]'

然后根据 username 以及查询到的 PROFILE 查看该用户的最大密码错误次数 FAILED_LOGIN_ATTEMPTS

SQL> SELECT * FROM DBA_PROFILES WHERE PROFILE='DEFAULT' AND RESOURCE_NAME='FAILED_LOGIN_ATTEMPTS';

将错误次数修改为无限次:

SQL> ALTER PROFILE DEFAULT LIMIT FAILED_LOGIN_ATTEMPTSUNLIMITED;

密码有效期

Oracle 11g 默认用户每三个月(180 天)就要修改一次密码,快到密码过期时间就会提醒:

ORA-28002: the password will expire within 7 days

这里同样要先查找到 PROFILE 再查看用户密码剩余过期时间:

SQL> SELECT * FROM DBA_PROFILES WHERE PROFILE='DEFAULT' AND RESOURCE_NAME='PASSWORD_LIFE_TIME';

修改密码有效期(不受限):

SQL> ALTER PROFILE DEFAULT LIMIT PASSWORD_LIFE_TIMEUNLIMITED;

设置密码过期:

SQL> alter user [username] password expire;

修改密码

修改当前登录用户密码:

SQL> password

修改某个用户的密码:

SQL> alter user [username] identified by [password];

表管理

Oracle 的表都是存储在表空间里面的。创建表之前需要先创建一个表空间。

查看用户所拥有的表

查看用户所拥有的表:

SQL> SELECT TABLE_NAME FROM USER_TABLES; 

查看用户可存取的表:

SQL> SELECT TABLE_NAME FROM ALL_TABLES; 

数据库中所有表:

SQL> SELECT TABLE_NAME FROM DBA_TABLES;

查看表空间

查看表空间详细数据文件:

SQL> SELECT FILE_NAME,TABLESPACE_NAME from DBA_DATA_FILES;

创建表空间

create tablespace [表空间名称]
datafile [表空间数据文件路径 ]
size [表空间大小]
autoextend on;

例如:

SQL> create tablespace SoftwareManagement
  2  datafile '/data/oracle/oradata/orcl/SoftwareManagement.dbf'
  3  size 50m
  4  autoextend on;

创建新用户

CREATE USER [用户名]  
IDENTIFIED BY [密码]  
DEFAULT TABLESPACE [表空间] (默认USERS)  
TEMPORARY TABLESPACE [临时表空间] (默认TEMP)  

例如:

SQL> create USER software
  2  identified by 123456
  3  default tablespace Softwaremanagement;

分配权限

SQL> GRANT CONNECT TO [username];  
SQL> GRANT RESOURCE TO [username];  
SQL> GRANT DBA TO [username];  -- DBA为最高级权限,可以创建数据库、表等。 

到这里,数据库中的表空间、用户以及用户权限都创建并分配好了,接下来用户就可以在自己的表空间中创建表,然后进行开发。

权限管理

在给用户分配权限的时候,分配了 CONNECTRESOURCE 权限给用户。这两个权限到底是什么呢?

oracle中的权限

Oracle 中的权限分为两类:

  • 系统权限:系统规定用户使用数据库的权限,系统权限是对用户而言。
  • 实体权限:某种权限的用户对其他用户的表或视图的存取权限,是针对表或者视图而言。如 selectupdateinsertdeletealterindexall,其中 all 包含所有的实体权限。

系统权限分类

  • DBA:拥有全部特权,是系统最高权限,只有DBA才可以创建数据库结构。
  • RESOURCE:拥有resource权限的用户只可以创建实体,不可以创建数据库结构。
  • CONNECT:拥有connect权限的用户只可以登录oracle,不可以创建实体,不可以创建数据库结构。

建议:
对于普通用户,授予 CONNECTRESOURCE 权限;
对于 DBA 管理用户,授予 CONNECTRESOURCEDBA 权限。

导入导出

数据库的导入导出也是一个很常见的需求。

导出

$ exp [username]/[password]@[orcl] file=./database.dmp  full=y
  • username 是数据库用户名
  • password 是数据库用户密码
  • orcl 是数据库实例名称
  • file 后面的参数是导出的数据库文件存放位置及文件名
  • full 其值为 y 表示全部导出,默认为 no

如果只需导出某几张表,可以指定 tables 参数:tables='(tableName, tableName1)'

导入

$ imp [username]/[password]@[orcl] file=./database.dmp

和导出数据库语法一样,只是关键字不一样。

执行 SQL 文件

执行 SQL 文件的方法有很多种。如下:

使用 SQL PLUS 命令

$ sqlplus [username]/password@[orcl] @path/file.name

或者远程执行:

$ sqlplus [username]/password@server_IP/service_name @path/file.name

如果sql脚本文件比较复杂,包含了begin end语句,就会不断显示行号,解决办法就是在 sql 脚本的最后用 / 符号结尾。

在 SQL PLUS 中执行

SQL>start file_path
SQL>@ file_path

其中 file_path 是文件路径。


参考

使用 Serverless 实现日志报警

最近尝试将应用的页面 JS 错误报警功能通过 Serverless 来实现。本文主要介绍一下具体实现过程,以及遇到的一些问题。

报警功能的需求也很简单,就是定时(如每隔 1 分钟)去读取 ARMS 的错误日志,如果有错误日志,则通过钉钉消息发送错误详情进行报警。

在这之前,我通过定时任务实现了该功能。从成本上来说,这种方案就需要单独申请一台服务器资源;而且定时任务只在对应的时间才执行,这件意味着,服务器有很长的时间都是空闲的,这就造成了资源的浪费。而使用 Serverless,就不需要再申请服务器,函数只需要在需要的时候执行,这就大大节省了成本。

总的来说,我觉得函数计算的优势就是:

  • 对于开发者,只需要关系业务逻辑的实现,不需要关心代码所运行的环境、硬件资源、以及运维
  • 节省成本

通过 Serverless 实现前端日志报警,依赖的云服务是阿里云函数计算,依赖的其他工具还有:

  • 函数计算的命令行工具 fun,用于本地调试、部署函数
  • 函数计算的可交互式工具 fcli,用于本地测试
  • 阿里云 JS SDK aliyun-sdk-js,用于读取 SLS 日志,ARMS 的日志是存储在 SLS 中的
  • 编程语言使用 Node.js

安装和配置 fun

初次使用需要先安装 fun

$ npm install @alicloud/fun -g

安装完成之后,需要通过 fun config 配置一下账号信息 Aliyun Account ID Aliyun Access Key ID Aliyun Secret Access Key 以及默认的地域。地域这里有个需要注意的是,如果需要使用 SLS 记录函数日志,则需要 SLS 和函数服务在同一个地域。这里稍后会详细介绍。

$ fun config
? Aliyun Account ID ******
? Aliyun Access Key ID ******
? Aliyun Secret Access Key ******
? Default region name cn-shanghai
? The timeout in seconds for each SDK client invoking 60
? The maximum number of retries for each SDK client 6

Aliyun Account ID Aliyun Access Key ID Aliyun Secret Access Key 可以在阿里云的控制台中查找和设置。

Aliyun Account ID

![Aliyun Account ID]
accountid

Aliyun Access Key ID Aliyun Secret Access Key

accesskey

函数初始化

先通过 fun 创建一个 Node.js 的 demo,之后可以在这个 demo 的基础上进行开发。

$ fun init -n alarm helloworld-nodejs8
Start rendering template...
+ /Users/jh/inbox/func/code/alarm
+ /Users/jh/inbox/func/code/alarm/index.js
+ /Users/jh/inbox/func/code/alarm/template.yml
finish rendering template.

执行成功后,分别创建了两个文件 index.jstemplate.yml

其中 template.yml 是函数的规范文档,在里面定义了函数需要的资源、触发函数的事件等等。

template.yml

接下来简单看看生成的默认的 template.yml 配置文件。

ROSTemplateFormatVersion: '2015-09-01'
Transform: 'Aliyun::Serverless-2018-04-03'
Resources:
  alarm:
    Type: 'Aliyun::Serverless::Service'
    Properties:
      Description: 'helloworld'
    alarm:
      Type: 'Aliyun::Serverless::Function'
      Properties:
        Handler: index.handler
        Runtime: nodejs8
        CodeUri: 'index.js'

首先定义了规范文档的版本 ROSTemplateFormatVersionTransform,这两个都不用修改。

Resources 里面定义了一个名为 alarm 的函数服务(Type: Aliyun::Serverless::Service 表示该属性为函数服务),并且该服务里面定义了名为 alarm 的函数(Type: 'Aliyun::Serverless::Function'表示该属性为函数)。

函数服务里面可以包含多个函数,就相当于是一个函数组。后面我们会提到的函数日志,是配置到函数服务上的。函数服务里面的所有函数,都用同一个日志。

可以根据实际情况修改函数服务名和函数名。下面就将函数服务名称改为 yunzhi,函数名依旧保留为 alarm

ROSTemplateFormatVersion: '2015-09-01'
Transform: 'Aliyun::Serverless-2018-04-03'
Resources:
  yunzhi: # 函数服务的名称
    Type: 'Aliyun::Serverless::Service' # 表示 yunzhi 是一个函数服务
    Properties:
      Description: 'helloworld' # 函数服务的描述
    alarm: # 函数的名称
      Type: 'Aliyun::Serverless::Function' # 表示 alarm 是一个函数
      Properties:
        Handler: index.handler # 函数的调用入口
        Runtime: nodejs8 # 函数的运行环境
        CodeUri: 'index.js' # 代码的目录

alarm 函数里面的 Properties 定义了函数的调用入口、运行环境等,如上面的注释所示。

关于 template.yml 的配置详见 Serverless Application Model

index.js

index.js 文件就是函数的调用入口了。index.handler 就表示,函数的调用的是 index.[extension] 文件中的 handler 函数。

module.exports.handler = function(event, context, callback) { 
  console.log('hello world');
  callback(null, 'hello world'); 
};

初始化之后的代码就上面这几行,很简单。主要是理解上面的几个参数。

  • event 调用函数时传入的参数
  • context 函数运行时的一些信息
  • callback 函数执行之后的回调
      1. 必须要要调用 callback 函数,才会被认为函数执行结束。如果没有调用,则函数会一直运行到超时
      1. callback 调用之后,函数就结束了
      1. callback 的第一个参数是 error 对象,这和 JS 回调编程的**一致

关于 eventcontext,详见 Nodejs 函数入口

实现报警功能的主要逻辑,就写在 index.js 里面。具体的实现,就不细说,下面用伪代码来描述:

alarm/alarm.js

// alarm/alarm.js 
// 实现报警功能
module.exports = function() {
	return new Promise((resolve, reject) => {
		// 查询 SLS 日志
		// - 如果没有错误日志,则 resolve
		// - 如果有错误日志,则发送钉钉消息
		// 		- 如果钉钉消息发送失败,则 reject
		// 		- 如果钉钉消息发送成功,则 resolve
		resolve();
	})
}

alarm/index.js

// alarm/index.js 
// 调用报警函数
const alarm = require('./alarm');

module.exports.handler = function(event, context, callback) { 
  alarm()
  	.then(() => {
			callback(null, 'success');   
  	})
  	.catch(error => {
			callback(error); 
		})
};

CodeUri

如果函数里面引入了自定义的其他模块,比如在 index.js 里面引入了 alarm.js const alarm = require('./alarm');,则需要修改默认的 codeUri 为当前代码目录 ./。否则默认的 codeUri 只定义了 index.js,部署的时候只会部署 index.js

ROSTemplateFormatVersion: '2015-09-01'
Transform: 'Aliyun::Serverless-2018-04-03'
Resources:
  yunzhi: # 函数服务的名称
    Type: 'Aliyun::Serverless::Service' # 表示 yunzhi 是一个函数服务
    Properties:
      Description: 'helloworld' # 函数服务的描述
    alarm: # 函数的名称
      Type: 'Aliyun::Serverless::Function' # 表示 alarm 是一个函数
      Properties:
        Handler: index.handler # 函数的调用入口
        Runtime: nodejs8 # 函数的运行环境
        CodeUri: './' # 代码的目录

如果没有修改 CodeUri,则会有类似下面的报错

$ fun local invoke alarm
FC Invoke End RequestId: 16e3099e-6a40-43cb-99a0-f0c75f3422c6
{
  "errorMessage": "Cannot find module './alarm'",
  "errorType": "Error",
  "stackTrace": [
    "Error: Cannot find module './alarm'",
    "at Module._resolveFilename (module.js:536:15)",
    "at Module._load (module.js:466:25)",
    "at Module.require (module.js:579:17)",
    "at require (internal/module.js:11:18)",
    "at (/code/index.js:9:15)",
    "at Module._compile (module.js:635:30)",
    "at Module._extensions..js (module.js:646:10)",
    "at Module.load (module.js:554:32)",
    "at tryModuleLoad (module.js:497:12)",
    "at Module._load (module.js:489:3)"
  ]
}

fun local invoke alarm 是本地调试的命令,接下来会讲到。

本地调试

在开发过程中,肯定需要本地调试。fun 提供了 fun local 支持本地调试。

fun local 的命令格式为 fun local invoke [options] <[service/]function>,其中 optionsservice 都可以忽略。比如调试上面的报警功能的命令就是 fun local invoke alarm

需要注意的是,本地调试需要先安装 docker。

$ brew cask install docker

安装成功后启动 docker。

如果 docker 没有启动,运行 fun local 可能会有如下报错

$ fun local invoke alarm
Reading event data from stdin, which can be ended with Enter then Ctrl+D
(you can also pass it from file with -e)
connect ENOENT /var/run/docker.sock

正常的输出如下

$ fun local invoke alarm
Reading event data from stdin, which can be ended with Enter then Ctrl+D
(you can also pass it from file with -e)
skip pulling image aliyunfc/runtime-nodejs8:1.5.0...
FC Invoke Start RequestId: 9360768c-5c52-4bf5-978b-774edfce9e40
load code for handler:index.handler
FC Invoke End RequestId: 9360768c-5c52-4bf5-978b-774edfce9e40
success

RequestId: 9360768c-5c52-4bf5-978b-774edfce9e40          Billed Duration: 79 ms          Memory Size: 1998 MB    Max Memory Used: 54 MB

第一次调试的话,会安装 runtime 的镜像,可能需要点时间。默认的 Docker 镜像下载会很慢,可以使用国内的加速站点加速下载。

出现 Reading event data from stdin, which can be ended with Enter then Ctrl+D 的提示时,如果不需要输入,可以按 ctrl+D 跳过。

函数部署

开发完成之后,就需要将函数部署到阿里云的函数计算上面了。部署可以通过 fun deploy 命令。

前面已经在安装 fun 之后,通过 fun config 命令配置了阿里云的账号和地域信息,fun deploy 会将函数自动部署到对应的账号和地域下。

template.yml 中,也配置了函数的服务名和函数名。如果在函数计算中没有对应的服务或函数,fun deploy 会自动创建;如果已经存在,则会更新。

$ fun deploy
using region: cn-shanghai
using accountId: ***********4698
using accessKeyId: ***********UfpF
using timeout: 60

Waiting for service yunzhi to be deployed...
        Waiting for function alarm to be deployed...
                Waiting for packaging function alarm code...
                package function alarm code done
        function alarm deploy success
service yunzhi deploy success

部署成功之后,就可以在函数计算的控制台中看到对应的函数服务和函数了。目前还没有配置触发器,可以手动在控制台中点击“执行”按钮来执行函数。

fcshow2

触发器

对于应用到生产环境的函数,肯定不会像上面一样手动去执行它,而是通过配置触发器去执行。触发器就相当于是一个特定的事件,当函数计算接收到该事件的时候,就去调用对应的函数。

阿里云的函数计算支持 HTTP 触发器(接收到 HTTP 请求之后调用函数)、定时触发器(定时调用函数)、OSS 触发器等等。详见 触发器列表

对于报警功能,需要用到的是定时触发器,因为需要间隔一定的时间就调用函数。

触发器是配置到函数中的,可以通过函数的 Event 属性去配置

ROSTemplateFormatVersion: '2015-09-01'
Transform: 'Aliyun::Serverless-2018-04-03'
Resources:
  yunzhi:
    Type: 'Aliyun::Serverless::Service'
    Properties:
      Description: 'helloworld'
    alarm: 
      Type: 'Aliyun::Serverless::Function'
      Properties:
        Handler: index.handler
        Runtime: nodejs8 
        CodeUri: './' 
      Events: # 配置 alarm 函数的触发器
        TimeTrigger: # 触发器的名称
          Type: Timer # 表示该触发器是定时触发器
          Properties: 
            CronExpression: "0 0/1 * * * *"  # 每 1 分钟执行一次
            Enable: true # 是否启用该定时触发器

上面的配置,就为 alarm 配置了一个名为 TimeTrigger 的定时触发器,触发器每隔 1 分钟执行一次,也就是每隔 1 分钟调用一次函数。

配置完成之后,再执行 fun deploy 就可以发布函数及触发器到函数计算上。

trigger

这里需要注意的是,阿里云函数计算服务目前支持的触发器,最小的间隔时间为 1 分钟。如果小于 1 分钟,则无法设置成功。定时触发器的详细介绍可参考文档 定时触发函数

函数日志

对于 serverless 应用,虽然不用关心运维了,其实我们也并不知道我们的函数运行在哪台服务器上。这个时候,函数的日志就尤为重要了。没有日志,我们很难知道程序运行状态,遇到问题更是无从下手。

所以接下来需要对函数配置日志。阿里云的函数计算可以使用阿里云日志服务 SLS来存储日志。如果要存储日志,则需要先开通 日志服务

不存在日志库

如果是第一次使用日志服务,则肯定不存在日志库。可以在 template.yml 像定义函数服务一样,通过 Resource 来定义日志资源。

前面也提到,函数日志是配置到对应的服务上的,具体配置也很简单,就是通过函数服务的 LogConfig 属性来配置。

完整的 template.yml 如下

ROSTemplateFormatVersion: '2015-09-01'
Transform: 'Aliyun::Serverless-2018-04-03'
Resources:
  log-yunzhi: # 日志项目名称为 log-yunzhi
    Type: 'Aliyun::Serverless::Log' # 表示该资源是阿里云的日志服务
    Properties:
      Description: 'yunzhi function service log project'
    log-yunzhi-store: # 日志的 logstore
      Type: 'Aliyun::Serverless::Log::Logstore'
      Properties:
        TTL: 10
        ShardCount: 1
    log-another-logstore: # 日志的另一个 logstore
      Type: 'Aliyun::Serverless::Log::Logstore'
      Properties:
        TTL: 10
        ShardCount: 1
  yunzhi:
    Type: 'Aliyun::Serverless::Service'
    Properties:
      Description: 'helloworld' 
      LogConfig: # 配置函数的日志
        Project: 'log-yunzhi' # 存储函数日志 SLS 项目: log-yunzhi
        Logstore: 'log-yunzhi-store' # 存储函数日志的 SLS logstore: log-yunzhi-store
    alarm:
      Type: 'Aliyun::Serverless::Function'
      Properties:
        Handler: index.handler 
        Runtime: nodejs8 
        CodeUri: './' 
      Events: 
        TimeTrigger: 
          Type: Timer 
          Properties: 
            CronExpression: "0 0/1 * * * *"  
            Enable: true 

在上面的配置中,就定义了名为 log-yunzhi 的日志项目(Project),并且在该 Project 中创建了两个日志仓库(LogStore):log-yunzhi-storelog-yunzhi-store。一个 Project 可以包含多个 LogStore。

注意:日志项目的名称必须全局唯一。 即配置中,og-yunzhi 这个项目名称是全局唯一的。

执行 fun deploy 之后,就会自动在函数服务对应的地域创建日志 Project 及日志 logstore,同时也会自动为 logstore 加上全文索引,然后自动为函数服务配置日志仓库。

之后函数的运行日志都会存储在对应的 logstore 里。

$ fun deploy
using region: cn-shanghai
using accountId: ***********4698
using accessKeyId: ***********UfpF
using timeout: 60

Waiting for log service project log-yunzhi to be deployed...
        Waiting for log service logstore log-yunzhi-store to be deployed...
                retry 1 times
                Waiting for log service logstore log-yunzhi-store default index to be deployed...
                log service logstore log-yunzhi-store default index deploy success
        log serivce logstore log-yunzhi-store deploy success
        Waiting for log service logstore log-another-logstore to be deployed...
                Waiting for log service logstore log-another-logstore default index to be deployed...
                log service logstore log-another-logstore default index deploy success
        log serivce logstore log-another-logstore deploy success
log serivce project log-yunzhi deploy success

Waiting for service yunzhi to be deployed...
        Waiting for function alarm to be deployed...
                Waiting for packaging function alarm code...
                package function alarm code done
                Waiting for Timer trigger TimeTrigger to be deployed...
                function TimeTrigger deploy success
        function alarm deploy success
service yunzhi deploy success

slslog

如果日志库已经存在,且定义了日志资源,则 fun deploy 会按照 template.yml 中的配置更新日志库。

存在日志库

如果日志库已经存在,即已经在日志服务中创建了日志项目 Project 和日志库 Logstore ,就可以直接为函数服务添加 LogConfig,不用再定义日志资源。

注意,日志库需要和函数服务在同一个地域 Region。否则不能部署成功。

下面是一个配置函数日志到已经存在的 Project 和 Logstore 中的例子。

ROSTemplateFormatVersion: '2015-09-01'
Transform: 'Aliyun::Serverless-2018-04-03'
Resources:
  yunzhi:
    Type: 'Aliyun::Serverless::Service'
    Properties:
      Description: 'helloworld' 
      LogConfig: # 配置函数的日志
        Project: 'log-yunzhi-exist' # 存储函数日志到已经存在的 Project: log-yunzhi-exist
        Logstore: 'logstore-exist' # 存储函数日志到已经存在的 logstore: logstore-exist
    alarm:
      Type: 'Aliyun::Serverless::Function'
      Properties:
        Handler: index.handler 
        Runtime: nodejs8 
        CodeUri: './' 
      Events: 
        TimeTrigger: 
          Type: Timer 
          Properties: 
            CronExpression: "0 0/1 * * * *"  
            Enable: true 

如果日志库和函数服务不在同一个地域,函数服务就会找不到日志库,fun deploy 也会报错。如下所示,yunzhi-log-qingdao 是我创建的一个青岛地域的日志 Project。

$ fun deploy
using region: cn-shanghai
using accountId: ***********4698
using accessKeyId: ***********UfpF
using timeout: 60

Waiting for service yunzhi to be deployed...
        retry 1 times
        retry 2 times
        retry 3 times
        retry 4 times
        retry 5 times
        retry 6 times
        retry 7 times
PUT /services/yunzhi failed with 400. requestid: 6af2afb8-cbd9-0d3e-bf16-fe623834b4ee, message: project 'yunzhi-log-qingdao' does not exist.

其他问题

子账号 AccessDenied

如果是使用 RAM 子账号来开发、部署函数计算,则 fun 工具的配置中 Aliyun Access Key ID Aliyun Secret Access Key
是对应子账户的信息,但 Aliyun Account ID 还是主账号的信息。RAM 子账号有一个 UID,这个不是 Account ID。

如果 Aliyun Account ID 写错了,则使用 funfcli 的时候,可能会遇到下面的错误

Error: {
  "HttpStatus": 403,
  "RequestId": "b8eaff86-e0c1-c7aa-a9e8-2e7893acd545",
  "ErrorCode": "AccessDenied",
  "ErrorMessage": "The service or function doesn't belong to you."
}

代码版本的管理

在实现报警功能的过程中,我依旧使用了 GitLab 来存储代码。每次开发完成之后,将代码 push 到 GitLab,然后再将代码部署到函数计算上。不过这两个过程是独立的,还是不那么方便。

环境问题

一般我们开发的时候,需要日常、预发、线上多个环境部署、测试。阿里云函数计算是一个云产品,没有环境的区分。但对于报警整个功能,我也没有去区分环境,只是本地开发的时候,将报警消息发到一个测试的钉钉群,所以也没有特别去关注。

经济成本

使用函数计算的经济成本,相比于购买云服务器部署应用,成本低了非常多。

本文中涉及到函数计算和日志服务两个云产品,都有一定的免费额度。其中函数计算每月前 100 万次函数调用免费,日志服务每月也有 500M 的免费存储空间和读写流量。所以只用来测试或者实现一些调用量很小的功能,基本是免费的。

总结

配置了函数的日志之后,将函数部署到函数计算上,就算是正式发布上线了。

现在回过头来看,整个流程还算比较简单。但从零开始一步一步到部署上线的过程,还是遇到了很多问题。比如文中的许多注意事项,都是在不断尝试中得出的结论。

最近 serverless 这个话题也很火热,也期待这个技术即将带来的变革。

在 VPS 上安装 Shadowsocks Server

首先关于 Shadowsocks 的使用说明在这里:Shadowsocks 使用说明

使用说明中描述的也非常详细。我主要是记录 在 Vultr 的 VPS 上安装 shadowsocks 安装使用过程中遇到的错误,以及错误解决办法。

我的 VPS 系统版本是 Ubuntu 16.10:

$ cat /etc/issue
Ubuntu 16.10 \n \l

# 或
$ sudo lsb_release -a
No LSB modules are available.
Distributor ID:	Ubuntu
Description:	Ubuntu 16.10
Release:	16.10
Codename:	yakkety

首先安装 pip:

$ sudo apt-get install python-pip

然后通过 pip 安装 shadowsocks,结果就报错了:

$ sudo pip install shadowsocks
Could not import setuptools which is required to install from a source distribution.
Please install setuptools.

这是因为没有安装 setuptools,所以安装一下:

$ sudo pip install -U setuptools
# 然后再安装 shadowsocks
$ sudo pip install shadowsocks

更具体的内容可在 Github Issues 查看:ImportError: No module named setuptools (add details to docs)

接下来配置 shadowsocks:

$ sudo vim /etc/shadowsocks.json

写入下面的配置:

{
    "server":"my_server_ip",
    "server_port":8388,
    "local_address": "127.0.0.1",
    "local_port":1080,
    "password":"mypassword",
    "timeout":300,
    "method":"chacha20",
    "fast_open": false
}

Shadowsocks 的配置文件描述在 Configuration via Config File

我的加密算法设置的是 chacha20。之前主流的SS有两种加密算法:RC4-MD5aes-256-cfb

aes-256-cfb 是各种一键包默认的加密方法,但是由于路由器和手机性能的问题,这种算法还是多少会影响到一些速度的。RC4-MD5 主要是加密太简单了,在 GFW 面前加密不加密已经没有什么区别...所以我们需要密码强度比 RC4-MD5 高,但是速度比 aes-256-cfb 快的加密算法,那就是 chacha20 了。可以说是目前性价比比较高的加密算法。

然后后台启动 shadowsocks:

$ sudo ssserver -c /etc/shadowsocks.json -d start

又报错了:

INFO: loading config from /etc/shadowsocks.json
Traceback (most recent call last):
  File "/usr/local/bin/ssserver", line 11, in <module>
    load_entry_point('shadowsocks==2.8.2', 'console_scripts', 'ssserver')()
  File "/usr/local/lib/python2.7/dist-packages/shadowsocks/server.py", line 34, in main
    config = shell.get_config(False)
  File "/usr/local/lib/python2.7/dist-packages/shadowsocks/shell.py", line 262, in get_config
    check_config(config, is_local)
  File "/usr/local/lib/python2.7/dist-packages/shadowsocks/shell.py", line 124, in check_config
    encrypt.try_cipher(config['password'], config['method'])
  File "/usr/local/lib/python2.7/dist-packages/shadowsocks/encrypt.py", line 44, in try_cipher
    Encryptor(key, method)
  File "/usr/local/lib/python2.7/dist-packages/shadowsocks/encrypt.py", line 83, in __init__
    random_string(self._method_info[1]))
  File "/usr/local/lib/python2.7/dist-packages/shadowsocks/encrypt.py", line 109, in get_cipher
    return m[2](method, key, iv, op)
  File "/usr/local/lib/python2.7/dist-packages/shadowsocks/crypto/sodium.py", line 62, in __init__
    load_libsodium()
  File "/usr/local/lib/python2.7/dist-packages/shadowsocks/crypto/sodium.py", line 42, in load_libsodium
    raise Exception('libsodium not found')
Exception: libsodium not found

因为没有 libsodiumlibsodiumchacha20 加密算法所需要的一个包。所以接下来就安装它:

$ wget https://download.libsodium.org/libsodium/releases/LATEST.tar.gz
tar zxf LATEST.tar.gz
cd libsodium*
./configure
sudo make && sudo make install

编译的时候又报错了:

configure: error: no acceptable C compiler found in $PATH

这是因为没有 C 编译器。所以继续安装:

$ sudo apt-get install build-essential
# 安装成功之后再编译
$ sudo make && sudo make install

然后将下面的代码加入到 /etc/ld.so.conf

include ld.so.conf.d/*.conf"
/lib
/usr/lib64
/usr/local/lib

再重新载入配置:

$ sudo ldconfig

接下来再启动 shadowsocks:

$ sudo ssserver -c /etc/shadowsocks.json -d start

查看 shadowsocks 日志:

$ tail -f /var/log/shadowsocks.log

然后只需要配置好客户端,就可以愉快地番茄了。

最后分享一下我的 vultr 邀请链接,通过该链接注册您将获得 $20vultr

分别使用 XHR、jQuery 和 Fetch 实现 AJAX

本文详细讲述如何使用原生 JS、jQuery 和 Fetch 来实现 AJAX。

AJAX 即 Asynchronous JavaScript and XML,异步的 JavaScript 和 XML。使用 AJAX 可以无刷新地向服务端发送请求接收服务端响应,并更新页面。

一、原生 JS 实现 AJAX

JS 实现 AJAX 主要基于浏览器提供的 XMLHttpRequest(XHR)类,所有现代浏览器(IE7+、Firefox、Chrome、Safari 以及 Opera)均内建 XMLHttpRequest 对象。

1. 获取XMLHttpRequest对象

// 获取XMLHttpRequest对象
var xhr = new XMLHttpRequest();

如果需要兼容老版本的 IE (IE5, IE6) 浏览器,则可以使用 ActiveX 对象:

var xhr;
if (window.XMLHttpRequest) { // Mozilla, Safari...
  xhr = new XMLHttpRequest();
} else if (window.ActiveXObject) { // IE
  try {
    xhr = new ActiveXObject('Msxml2.XMLHTTP');
  } catch (e) {
    try {
      xhr = new ActiveXObject('Microsoft.XMLHTTP');
    } catch (e) {}
  }
}

2. 发送一个 HTTP 请求

接下来,我们需要打开一个URL,然后发送这个请求。分别要用到 XMLHttpRequest 的 open() 方法和 save() 方法。

// GET
var xhr;
if (window.XMLHttpRequest) { // Mozilla, Safari...
  xhr = new XMLHttpRequest();
} else if (window.ActiveXObject) { // IE
  try {
    xhr = new ActiveXObject('Msxml2.XMLHTTP');
  } catch (e) {
    try {
      xhr = new ActiveXObject('Microsoft.XMLHTTP');
    } catch (e) {}
  }
}
if (xhr) {
  xhr.open('GET', '/api?username=admin&password=root', true);
  xhr.send(null);
}
// POST
var xhr;
if (window.XMLHttpRequest) { // Mozilla, Safari...
  xhr = new XMLHttpRequest();
} else if (window.ActiveXObject) { // IE
  try {
    xhr = new ActiveXObject('Msxml2.XMLHTTP');
  } catch (e) {
    try {
      xhr = new ActiveXObject('Microsoft.XMLHTTP');
    } catch (e) {}
  }
}
if (xhr) {
  xhr.open('POST', '/api', true);
  // 设置 Content-Type 为 application/x-www-form-urlencoded
  // 以表单的形式传递数据
  xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
  xhr.send('username=admin&password=root');
}

open() 方法有三个参数:

  • open() 的第一个参数是 HTTP 请求方式 – GET,POST,HEAD 或任何服务器所支持的您想调用的方式。按照HTTP规范,该参数要大写;否则,某些浏览器(如Firefox)可能无法处理请求。有关HTTP请求方法的详细信息可参考 https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html
  • 第二个参数是请求页面的 URL。由于同源策略(Same origin policy)该页面不能为第三方域名的页面。同时一定要保证在所有的页面中都使用准确的域名,否则调用 open() 会得到 permission denied 的错误提示。
  • 第三个参数设置请求是否为异步模式。如果是 TRUE,JavaScript 函数将继续执行,而不等待服务器响应。这就是 AJAX 中的 A。

如果第一个参数是 GET,则可以直接将参数放在 url 后面,如:http://nodejh.com/api?name=admint&password=root

如果第一个参数是 POST,则需要将参数写在 send() 方法里面。send() 方法的参数可以是任何想送给服务器的数据。这时数据要以字符串的形式送给服务器,如:name=admint&password=root。或者也可以传递 JSON 格式的数据:

// 设置 Content-Type 为 application/json
xhr.setRequestHeader('Content-Type', 'application/json');
// 传递 JSON 字符串
xhr.send(JSON.stringify({ username:'admin', password:'root' }));

如果不设置请求头,原生 AJAX 会默认使用 Content-Type 是 text/plain;charset=UTF-8 的方式发送数据。

关于 Content-Type 更详细的内容,将在以后的文章中解释说明。

3. 处理服务器的响应

当发送请求时,我们需要指定如何处理服务器的响应,我们需要用到 onreadystatechange 属性来检测服务器的响应状态。使用 onreadystatechange 有两种方式,一是直接 onreadystatechange 属性指定一个可调用的函数名,二是使用一个匿名函数:

// 方法一 指定可调用的函数
xhr.onreadystatechange = onReadyStateChange;
function onReadyStateChange() {
  // do something
}

// 方法二 使用匿名函数
xhr.onreadystatechange = function(){
    // do the thing
};

接下来我们需要在内部利用 readyState 属性来获取当前的状态,当 readyState 的值为 4,就意味着一个完整的服务器响应已经收到了,接下来就可以处理该响应:

// readyState的取值如下
// 0 (未初始化)
// 1 (正在装载)
// 2 (装载完毕)
// 3 (交互中)
// 4 (完成)
if (xhr.readyState === 4) {
    // everything is good, the response is received
} else {
    // still not ready
}

完整代码如下:

// POST
var xhr;
if (window.XMLHttpRequest) { // Mozilla, Safari...
  xhr = new XMLHttpRequest();
} else if (window.ActiveXObject) { // IE
  try {
    xhr = new ActiveXObject('Msxml2.XMLHTTP');
  } catch (e) {
    try {
      xhr = new ActiveXObject('Microsoft.XMLHTTP');
    } catch (e) {}
  }
}
if (xhr) {
  xhr.onreadystatechange = onReadyStateChange;
  xhr.open('POST', '/api', true);
  // 设置 Content-Type 为 application/x-www-form-urlencoded
  // 以表单的形式传递数据
  xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
  xhr.send('username=admin&password=root');
}


// onreadystatechange 方法
function onReadyStateChange() {
  // 该函数会被调用四次
  console.log(xhr.readyState);
  if (xhr.readyState === 4) {
    // everything is good, the response is received
    if (xhr.status === 200) {
      console.log(xhr.responseText);
    } else {
      console.log('There was a problem with the request.');
    }
  } else {
    // still not ready
    console.log('still not ready...');
  }
}

当然我们可以用onload来代替onreadystatechange等于4的情况,因为onload只在状态为4的时候才被调用,代码如下:

xhr.onload = function () {    // 调用onload
    if (xhr.status === 200) {    // status为200表示请求成功
        console.log('执行成功');
    } else {
        console.log('执行出错');
    }   
}

然而需要注意的是,IE对 onload 属性的支持并不友好。除了 onload 还有以下几个属性也可以用来监测响应状态:

  • onloadstart
  • onprogress
  • onabort
  • ontimeout
  • onerror
  • onloadend

二、 jQuery 实现 AJAX

jQuery 作为一个使用人数最多的库,其 AJAX 很好的封装了原生 AJAX 的代码,在兼容性和易用性方面都做了很大的提高,让 AJAX 的调用变得非常简单。下面便是一段简单的 jQuery 的 AJAX 代码:

$.ajax({
  method: 'POST',
  url: '/api',
  data: { username: 'admin', password: 'root' }
})
  .done(function(msg) {
    alert( 'Data Saved: ' + msg );
  });

对比原生 AJAX 的实现,使用 jQuery 就异常简单了。当然我们平时用的最多的,是下面两种更简单的方式:

// GET
$.get('/api', function(res) {
  // do something
});

// POST
var data = {
  username: 'admin',
  password: 'root'
};
$.post('/api', data, function(res) {
  // do something
});

三、Fetch API

使用 jQuery 虽然可以大大简化 XMLHttpRequest 的使用,但 XMLHttpRequest 本质上但并不是一个设计优良的 API:

  • 不符合关注分离(Separation of Concerns)的原则
  • 配置和调用方式非常混乱
  • 使用事件机制来跟踪状态变化
  • 基于事件的异步模型没有现代的 Promise,generator/yield,async/await 友好

Fetch API 旨在修正上述缺陷,它提供了与 HTTP 语义相同的 JS 语法,简单来说,它引入了 fetch() 这个实用的方法来获取网络资源。

Fetch 的浏览器兼容图如下:

ajax-js-jquery-and-fetch-api-0.png

原生支持率并不高,幸运的是,引入下面这些 polyfill 后可以完美支持 IE8+:

  • 由于 IE8 是 ES3,需要引入 ES5 的 polyfill: es5-shim, es5-sham
  • 引入 Promise 的 polyfill: es6-promise
  • 引入 fetch 探测库:fetch-detector
  • 引入 fetch 的 polyfill: fetch-ie8
  • 可选:如果你还使用了 jsonp,引入 fetch-jsonp
  • 可选:开启 Babel 的 runtime 模式,现在就使用 async/await

1. 一个使用 Fetch 的例子

先看一个简单的 Fetch API 的例子 🌰 :

fetch('/api').then(function(response) {
  return response.json();
}).then(function(data) {
  console.log(data);
}).catch(function(error) {
  console.log('Oops, error: ', error);
});

使用 ES6 的箭头函数后:

fetch('/api').then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.log('Oops, error: ', error))

可以看出使用Fetch后我们的代码更加简洁和语义化,链式调用的方式也使其更加流畅和清晰。但这种基于 Promise 的写法还是有 Callback 的影子,我们还可以用 async/await 来做最终优化:

async function() {
  try {
    let response = await fetch(url);
    let data = response.json();
    console.log(data);
  } catch (error) {
    console.log('Oops, error: ', error);
  }
}

使用 await 后,写代码就更跟同步代码一样。await 后面可以跟 Promise 对象,表示等待 Promise resolve() 才会继续向下执行,如果 Promise 被 reject() 或抛出异常则会被外面的 try...catch 捕获。

Promise,generator/yield,await/async 都是现在和未来 JS 解决异步的标准做法,可以完美搭配使用。这也是使用标准 Promise 一大好处。

2. 使用 Fetch 的注意事项

  • Fetch 请求默认是不带 cookie,需要设置 `fetch(url, {credentials: 'include'})``
  • 服务器返回 400,500 错误码时并不会 reject,只有网络错误这些导致请求不能完成时,fetch 才会被 reject

接下来将上面基于 XMLHttpRequest 的 AJAX 用 Fetch 改写:

var options = {
    method: 'POST',
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ username: 'admin', password: 'root' }),
    credentials: 'include'
  };

fetch('/api', options).then(response => response.json())
  .then(data => console.log(data))
  .catch(error => console.log('Oops, error: ', error))

在 Mac OS X 上安装 Opencv3 (Python3.5)

通过 homebrew 安装 opencv

通过 homebrew 安装在 Mac OS X 上安装为 Python3.5 安装 Opencv3:

$ brew install opencv3 --with-python3
......
......
This formula is keg-only, which means it was not symlinked into /usr/local.

opencv3 and opencv install many of the same files.

Generally there are no consequences of this for you. If you build your
own software and it requires this formula, you'll need to add to your
build variables:

    LDFLAGS:  -L/usr/local/opt/opencv3/lib
    CPPFLAGS: -I/usr/local/opt/opencv3/include

==> Summary

安装完成后,会有如上提示。接下来需要做的事,将 opencv3site-packegs 链接到 Python3site-packegs:

echo /usr/local/opt/opencv3/lib/python3.5/site-packages >> /usr/local/lib/python3.5/site-packages/opencv3.pth

检查是否安装成功

$ python
>>> import cv2
>>> cv2.__version__
'3.1.0'

2018 年度总结

春节即将来临,2019 年的第一个月也快结束了,拖延了好久的年度总结,始终没想好怎么去总结 2018 年。

但确定的是,2018 年对自己来说是一个重要的时间点。因为去年,有两个重要的身份转变:

  • 从实习生变成了正式员工
  • 从学生变成了社会人员

先回头看去年总结中的几个 TODO:

  • 顺利毕业
  • 完成 GitHub 上的空项目
  • 深入 JS/Node.js,熟悉 Golang
  • 其他

其中顺利毕业完成了,并且幸运地以优秀毕业生的身份毕业了。算是对大学画上了一个圆满的句号。

至于 Github,去年的 commit 只有一百多个,相比前两年少了非常多。当然,只要是因为开始工作了,工作之余,没怎么写其他代码。对于 Node.js,算是更加熟悉了,但感觉还不够。Golang 只看了一下语法,没有实践。

2018 年,去过十多个城市,但现在想起来,好像已经非常遥远了。基本都是上半年去的。工作了就再没有那么多时间出去玩了。

此外还有一些变化:

  • 写博客少了
  • 看技术书籍/文章少了
  • 关注新技术也少了

这些都需要在新的一年做出些改变。

在 2018 的下半年,团队的需求量暴增,尤其是去年 11 月、12月,需求多、业务复杂,每个同学都加班加点。最近终于有所好转。

正式工作的这半年的时间,自己除了业务之外,持续做的一件事情就是质量提升。从最开始的编码规范的确定、到团队 Code Review 机制,再到应用的监控,这一系列事情做下来,遇到了不少技术与非技术的困难与麻烦,现在终于有了点成效。

在阿里,也经常听到一个词叫 “产出”。就是说,不仅要做业务,还要有产出。高质量完成业务,只是一个员工的基本要求。业务开发快,需求做得好,也就仅此而已。不管业务有多繁忙。要想变得更优秀,就需要基于业务或业务之外更多的 “产出”。同时作为一名技术人员,技术成长是自己的事情。所以有时候也会思考,如何在完成业务的同时也提升自己的技术。虽然目前还没有结论。

如何找到业务至于的产出呢?我目前的一些想法是,要善于发现工作中的问题,并且找到方案去解决。并且把这个方案抽象成一种通用的解决方案,抽象为解决这一类问题的方案。

算上实习,在阿里工作已经快两年了,想想真是时间过的飞快。最近也越来越觉得,一些不经意的决定,可能当时漫不经心,但回过头来看,就会对以后有非常多的影响。

比如自己现在的职位是一名前端开发。但我真的就很喜欢前端开发吗?其实也不一定。那我现在为什么会做前端开发呢?主要还是因为,校招的时候,我更擅长 JavaScript/Node.js,所以投了前端岗位。再往回看,为什么我会擅长 JS 呢?其实最开始我是写 PHP的,大概 13、14年的时候,因为 Node.js 开始有了大量的关注,自己开始接触 Node.js,再到后来 React.js 诞生,学习了 React.js,所以对 JS 更加熟悉了。当然,你要问我是不是不想做前端开发?那肯定不是的。主要还是因为自己比较喜欢 Coding,然后现在擅长前端。

有时候也会思考,作为一名前端工程师,其实更愿意称自己为全栈工程师,未来职业应该向哪个方向发展?其实对这个问题还是很疑惑。可能这个问题暂时也不用过于纠结。

2018 年就这么过去了。和 2017 年相比,感觉少了一些激情,希望 2019 年找回来。

至于 2019 年的目标,大概还是朝着一个优秀的全栈工程师的方向去发展。

说到全栈,经常听到一句话说是,很多前端以为自己学个 Node.js 就是全栈了。其实我觉得要看学 Node.js 学的到底是什么。Node.js 也好,其他编程语言也好,只是一个工具。更多的是这些工具之后的服务端技术,比如数据库、网络、操作系统等。

也简单列一些 2019 的 TODO List:

  • 多写点技术总结
  • 多看些开源项目源码
  • 学习 Go

断续成文,不知所言。

使用 husky+lint-staged+prettier 优化代码格式

使用 husky+lint-staged+prettier 优化代码格式


背景

团队协作中经常遇到这样的问题:

  1. 代码风格不统一

可以使用 linter 使代码风格统一。

  1. 使用 linter 检测之后有非常多的格式问题,手动修改很麻烦

可以使用 linter 命令来修复错误。比如 eslint 就可以 eslint --fix

  1. 使用命令修复的时候,不小心修改了他人代码,很容易造成冲突。

  2. 如果一个项目以前没有用过 linter,如何最方便地开始使用并且尽量不改动之前的代码?

解决以上问题的一个方案就是,在提交代码的时候,自动修复格式问题,并且只修复自己改动的代码。

具体实现可以借助于 husky + lint-staged + prettier 这几个工具。

最终效果如下图所示:


使用

首先安装包 eslint 相关的包,如:

# Node.js 可以使用  eslint-config-egg
$ tnpm i eslint eslint-config-egg babel-eslint -D
# 或 React 项目使用 eslint-config-airbnb
$ tnpm i eslint eslint-plugin-import eslint-plugin-jsx-a11y eslint-plugin-react eslint-config-airbnb -D

然后在项目根目录创建 .eslintrc 文件,并写入对应的配置,以 eslint-config-airbnb 为例:

.eslintrc

{
  "extends": "airbnb"
}

接下来再安装 prettier lint-stagedhusky

$ tnpm i prettier lint-staged husky -D

简单介绍一下几个工具:

  • prettier 用来优化代码格式,比如缩进、空格、分号等等

  • husky 用于实现各种 Git Hook。这里主要用到 pre-commit这个 hook,在执行 commit 之前,运行一些自定义操作

  • lint-staged 用于对 Git 暂存区中的文件执行代码检测

接下来配置 package.json

"scripts": {
    "lint": "eslint .  --ext .js",
    "prettier": "prettier --trailing-comma es5 --single-quote --write '**/*.{js,json,md}'",
  },
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged"
    }
  },
  "lint-staged": {
    "**/*.js": [
      "prettier --trailing-comma es5 --single-quote --write",
      "eslint",
      "git add"
    ]
  },

scripts

  • lint 检测 js 代码的 eslint 错误

  • prettier 使用 prettier 优化代码的格式

如果一个项目之前没有使用过 linter,现在加上了,并且需要处理所有的代码格式,就可以使用 tnpm run prettier

--trailing-comma es5 表示使用 ES5 支持的拖尾逗号;--single-quote 表示使用单引号。prettier 的所有参数详见 https://prettier.io/docs/en/options.html

这些参数即可以在命令中配置,也可以使用配置文件 .prettierrc 来指定,如:

{
    "singleQuote": true,
     "trailingComma": "es5"
}

在优化代码格式的时候,有些文件不需要进行处理,则可以通过 .prettierignore 文件来配置:

dist/
node_modules
*.log
run
logs/
coverage/

husky

husky 里面定义了一些 Git 的钩子。

上面的示例中,"pre-commit": "lint-staged" 的含义就是在 pre-commit 阶段(也就是 commit 之前)执行 lint-staged 命令。

lint-staged

如上所述, lint-stagedpre-commit 的时候执行。lint-staged 里面定义了需要对 Git 暂存区中的文件执行的任务。

在该 package.json 示例中主要有一个任务: **/*.js, 即对暂存区中所有 js 文件依次执行下面的操作:

  • prettier --trailing-comma es5 --single-quote --write

  • eslint

  • git add

也就是先优化暂存区中的 js 代码格式,再进行 eslint 检测,最后再执行 git add,将优化后的代码添加到暂存区。暂存区中的代码文件,就是这几个命令的参数。

如果 eslint 步骤抛错了,则表示代码格式不符合 eslint 规范,进而导致 pre-commit 这个钩子就会抛错,最终导致 commit 操作失败。

因为 eslint 也只会检测 lint-staged 中的代码,也就是自己修改过的代码。所以即避免了影响他人代码,同时也避免了因他人代码格式问题造成自己的代码不能提交。


其他

huskylint-staged 配合起来还有非常多的用处。

比如删除已经被 Git 追踪的文件。

比如 run/ 目录是代码运行时生成的目录。如果一开始没有将 run/ 添加到 .gitignore 里面,则每个人提交代码的时候都会提交自己的 run/ 目录。由于每个人 run/ 目录里面的文件很可能不一致,所以很容易造成冲突。

而且一旦文件已经被 Git 追踪,再将其添加到 .gitignore 里面,也无法在提交的时候忽略它。

这个时候,就可以在 pre-commit 阶段,利用 "lint-staged" 的任务删除暂存区里面的 run/

如下所示:

"lint-staged": {
    "**/*.js": [
      "prettier --trailing-comma es5 --single-quote --write",
      "eslint",
      "git add"
    ],
    "run/*": [
      "git rm --cached"
    ]
  },

然后再在 .gitignore 里面添加上 run/,这样以后再提交代码的时候,就再也不会将 run/ 提交到 Git 仓库了。

Ubuntu下JAVA开发环境的配置

1. 下载 JDK

建JAVA开发环境,第一步就是要安装JDK。在这里下载:Java SE Development Kit 8 - Downloads

下载 JDK

2. 解压 JDK 压缩文件

首先在 /opt 新建 java 目录,并进入该目录:

sudo mkdir /opt/java && cd /opt/java

然后将下载的软件移动到 /opt/java 目录:

sudo mv ~/Downloads/jdk-8u73-linux-x64.tar.gz .

再然后解压压缩包:

 sudo tar xvf jdk-8u73-linux-x64.tar.gz --strip-components=1

完整命令截图如下:

how-to-install-java-on-ubuntu-2

解压后大概是这样的:

how-to-install-java-on-ubuntu-3

3. 配置环境变量

Ubuntu 系统环境变量的配置方法有很多种。而 JAVA 的环境变量一般情况下是需要对系统的每个用户都要生效。所以我们选择对 /etc/profile 文件进行配置。

在终端输入:sudo vim /etc/profile,使用 vim 对该文件进行编辑。注意这里需要 root 权限。如果你的 Ubuntu 还没有安装 vim,那么可以通过命令 sudo apt-get install vim 进行安装。

如图所示,需要在 /etc/profile 的末尾加入下面几行(其中#后面是注释):

export JAVA_HOME=/opt/java
export JRE_HOME=$JAVA_HOME/jre
export CLASSPATH=.:$CLASS_PATH:$JAVA_HOME/lib:$JRE_HOME/lib
export PATH=$JAVA_HOME/bin:$JRE_HOME/bin:$PATH

how-to-install-java-on-ubuntu-4

然后 :wq 保存修改并退出 vim。

最后需要使用 source /etc/profile 使变量设置在当前窗口立即生效。需注销/重启之后,才能对每个新终端窗口都生效。然后输入 java -version 命令,如图所示,如果出现 JAVA 的版本号,则说明安装成功。

how-to-install-java-on-ubuntu-5

4. 安装 Eclipse

下载 Eclipse: Eclipse IDE for Java EE Developers

然后同样在 /opt/ 目录下新建一个 eclipse 的子目录,并将 Eclispe 解压到该目录。命令如下:

sudo mkdir /opt/eclipse
sudo tar zxvf ~/Downloads/eclipse-jee-mars-2-linux-gtk-x86_64.tar.gz -C /opt/eclipse/ --strip-components=1

how-to-install-java-on-ubuntu-6

Eclipse 解压后即可使用。启动命令为:

/opt/eclispe/eclipse

敲下回车后如果出现 eclispe 启动界面,则安装成功。如图:

how-to-install-java-on-ubuntu-7

在 Atom 中使用 ESlint

ESlint 主要是用来帮助我们规范书写 JavaScript 代码。通过使用 Atom 的 ESLit 插件,并配合 Airbnb 的 ESLint 规则,我们可以一边编码一边检测语法。关于 ESLint 的详细介绍可看官方文档

1. 在 Atom 中安装 ESLint

$ apm install linter-eslint

2. 在项目中使用 eslint-config-airbnb

[eslint-config-airbnb](https://www.npmjs.com/package/eslint-config-airbnb#improving-this-config)是一个基于 Airbnb's Javascript styleguide 的 ESLint 配置。安装方法如下:

$ npm install --save-dev eslint eslint-plugin-import eslint-plugin-react eslint-plugin-jsx-a11y eslint-config-airbnb

然后在项目根目录添加一个 .eslintrc 文件,并在其中添加如下代码:

{
	"extends": "airbnb"
}

3. 自定义 ESLint 规则

eslint-config-airbnb 中的规则,可能并不完全符合自己的编码习惯,我们还可以在 .eslintrc 中添加一些自定义的规则。

4. ESLint 规则说明

"rules": {

        /*Possible Errors*/

        // 数组和对象键值对最后一个逗号,

        // never参数:不能带末尾的逗号,

        // always参数:必须带末尾的逗号,  

        // always-multiline:多行模式必须带逗号,单行模式不能带逗号  

        "comma-dangle": [2, "never"],

        //禁止在条件表达式中使用赋值语句

        "no-cond-assign": 2,

        //禁止使用console

        "no-console": 2,

        //禁止在条件中使用常量表达式 if(true) if(1)

        "no-constant-condition": 2,

        //禁止在正则表达式中使用控制符

        "no-control-regex": 2,

        //禁止使用debugger语句

        "no-debugger": 2,

        //函数参数禁止重名

        "no-dupe-args": 2,

        //在创建对象字面量时不允许键重复

        "no-dupe-keys": 2,

        //在switch语句中禁止重复的case

        "no-duplicate-case": 2,

        //代码块的内容不能为空,禁止空代码块

        "no-empty": 2,

        //正则表达式的内容不能为空,禁止使用不匹配任何字符串的正则表达式

        "no-empty-character-class": 2,

        //禁止对catch语句中的异常进行赋值

        "no-ex-assign": 2,

        //禁止不必要的bool转换

        "no-extra-boolean-cast": 2,

        //禁止使用多余的圆括号

        "no-extra-parens": 2,

        //禁止多余的冒号

        "no-extra-semi": 2,

        //禁止重复的函数声明

        "no-func-assign": 2,

        //禁止在块语句中声明变量或函数

        "no-inner-declarations": 2,

        //禁止使用无效的正则语句

        "no-invalid-regexp": 2,

        //禁止使用不合法或者不规则的空白符

        "no-irregular-whitespace": 2,

        //在in操作符左边的操作项不能用! 例如这样写不对的:if ( !a in b) { //dosomething }

        "no-negated-in-lhs": 2,

        //禁止把全局对象当函数调用,比如下面写法错误的:Math(), JSON()

        "no-obj-calls": 2,

        //禁止在正则表达式字面量中使用多个空格 /foo bar/

        "no-regex-spaces": 2,

        //禁止稀疏数组,清除多余的逗号申明  比如[1,,2]

        "no-sparse-arrays": 2,

        //为了保证两行不相关的代码不会意外的被当做一行代码来解析

        "no-unexpected-multiline": 2,

        //禁止有执行不到的代码

        "no-unreachable": 2,

        //禁止和NaN作比较,推荐使用isNaN方法

        "use-isnan": 2,

        //用来检测JSDoc是否完整和合法

        "valid-jsdoc": 2,

        //typeof操作符返回的结果会是 "undefined",  "object",  "boolean", "number", "string", 和  "function"之一。

        //保证typeof 操作符返回的结果必须和上面六个字符串作比较

        "valid-typeof": 2,



        /*Best Practices*/

        //在声明对象时getter和setter需成对出现

        "accessor-pairs": 2,

        //数值方法的回调函数中强制写return语句

        "array-callback-return": 2,

        //当在代码块中用var声明变量,并在代码块外使用时报错

        "block-scoped-var": 0,

        //用来控制函数的复杂度,分支超过5时报错

        "complexity": [2, 5],

        //不同分支的return语句不能返回不同的类型,要么一致要么都没有  

        "consistent-return": 0,

        // if else while for do后面的代码块是否需要{ }包围,参数:  

        // multi         只有块中有多行语句时才需要{ }包围  

        // multi-line    只有块中有多行语句时才需要{ }包围, 但是块中的执行语句只有一行时,块中的语句只能跟和if语句在同一行。

        //                if (foo) foo++; else doSomething();  

        // multi-or-nest 只有块中有多行语句时才需要{ }包围, 如果块中的执行语句只有一行,执行语句可以另起一行也可以跟在if语句后面    

        // [2, "multi", "consistent"] 保持前后语句的{ }一致  

        // default: [2, "all"] 全都需要{ }包围  

        "curly": 2,

        //所有的switch语句都必须要有一个default分支

        "default-case": 2,

        // 在书写对象的属性或方法时,新的一行代码可以以. 开头,也可以以. 结束。

        // 强制统一object.key中 . 的位置,参数:  

        //      property,'.'号应与属性在同一行  

        //      object, '.' 号应与对象名在同一行  

        "dot-location": [2, "property"],

        // 强制使用.号取属性  

        // 参数: allowKeywords:true  使用保留字做属性名时,只能使用.方式取属性  

        //                       false 使用保留字做属性名时, 只能使用[]方式取属性

        //                       e.g [2, {"allowKeywords": false}]  

        //        allowPattern:  当属性名匹配提供的正则表达式时,允许使用[]方式取值,否则只能用.号取值

        //                       e.g [2, {"allowPattern": "^[a-z]+(_[a-z]+)+$"}]  

        "dot-notation": [2, { "allowKeywords": true }],

        //在进行比较时,必须使用全等=== 和完全不等!==

        "eqeqeq": [2, "allow-null"],

        //在for-in 循环中要使用if语句

        "guard-for-in": 2,

        //代码中禁止使用alert, confirm, and prompt

        "no-alert": 2,

        //禁止使用arguments.caller和arguments.callee

        "no-caller": 2,

        //禁止在case/default语句中使用lexical declarations,例如let, const, function and class

        //因为在case/default中的声明,在整个switch语句中都能够访问到,如果需要声明变量,可以加大括号。

        "no-case-declarations": 2,

        //不能使用看起来像除法的正则表达式

        //用来消除/ (除号)操作符对程序员的迷惑,比如在正则表达式/=foo/中,我们并不能够确定第一个/是除号还是正则表达式,因此我们需要在等号前面加一个转移符/\=foo/

        "no-div-regex": 2,

        //在if else语句中,如果else语句中只含有一个return语句,那么完全可以不使用else语句,直接return。

        "no-else-return": 2,

        //不允许空函数

        "no-empty-function": 2,

        //在结构赋值时,模式不能为空。在ECMAScript2015的结构赋值中,模式为空是不会报错的,只是这样的结构赋值没有任何效果,该条规则就保证了模式不能为空,也就保证了结构赋值的有效性。

        "no-empty-pattern": 2,

        //保证了在和null比较时使用===和!==,而不能够使用==和!=

        "no-eq-null": 2,

        //禁止使用eval函数

        "no-eval": 2,

        //禁止扩展native对象,不能向native的对象上面添加属性

        "no-extend-native": 2,

        //保证了调用bind方法的函数体内有this对象。规避了不必要的使用bind方法的情况。

        //箭头函数中没有this对象,也就不能够使用bind()方法。该规则保证了在所有的箭头函数中使用bind方法将被视为错误。

        "no-extra-bind": 2,

        //如果 loop中没有内嵌的loops或switches, loop标签是不必要的.

        "no-extra-label": 2,

        //在case语句中尽量加break,避免不必要的fallthrough错误,消除从一个case到另一个case的非故意的「fall through」。

        //如果没有添加break等终止语句或者没有添加注释语句,将会抛出错误

        "no-fallthrough": 2,

        //在使用浮点小数时,不能够省略小数点前面的数或者后面的数,必须写。比如.2 2. 应该写2.2 2.0

        "no-floating-decimal": 2,

        //禁止隐式转换,为了消除简写的类型转换

        "no-implicit-coercion": 2,

        //禁止在全局作用域里声明变量或函数

        "no-implicit-globals": 2,

        //在setTimeout(), setInterval() or execScript()中消除隐式eval的使用

        "no-implied-eval": 2,

        //禁止无效的this,只能用在构造器,类,对象字面量

        "no-invalid-this": 2,

        //禁止使用__iterator__属性

        "no-iterator": 2,

        //禁止使用label语句,以避免无限循环

        "no-labels": [2, { "allowLoop": false, "allowSwitch": false }],

        //禁止使用不必要的嵌套代码块

        "no-lone-blocks": 2,

        //禁止在循环体中定义函数并且函数引用了外部变量

        //在循环中定义了函数,但是函数内部没有引用外部变量,或者使用let定义的代码块变量,视为合法

        "no-loop-func": 2,

        //禁止使用魔法数字,建议使用常量来代替

        "no-magic-numbers": 2,

        //保证了在逻辑表达式、条件表达式、申明语句、数组元素、对象属性、sequences、函数参数中不使用超过一个的空白符。

        "no-multi-spaces": 2,

        //该规则保证了字符串不分行书写。

        "no-multi-str": 2,

        //该规则保证了不重写原生对象。

        "no-native-reassign": 2,

        //在使用new来调用构造函数后,必须把生成的实例赋值给一个变量

        "no-new": 2,

        //禁止使用new Function(); 语句。

        "no-new-func": 2,

        //禁止使用new创建String,Number, and Boolean实例

        "no-new-wrappers": 2,

        //禁止使用八进制数字

        "no-octal": 2,

        //禁止使用八进制转义序列,比如 var foo = "Copyright \251";

        "no-octal-escape": 2,

        //禁止对函数的参数重新进行无意义的赋值

        "no-param-reassign": 2,

        //禁止使用__proto__属性

        "no-proto": 2,

        //避免重复声明一个变量

        "no-redeclare": [2, { "builtinGlobals": true }],

        //不要在return语句中使用赋值语句

        "no-return-assign": [2, "always"],

        //禁止代码中使用类似javascript:void(0)的javascript: urls.

        "no-script-url": 2,

        //禁止给自身赋值

        "no-self-assign": 2,

        //禁止和自身作比较

        "no-self-compare": 2,

        //禁止可能导致结果不明确的逗号操作符

        "no-sequences": 2,

        //通过throw语句抛出的对象必须是Error对象本身或者通过Error对象定义的对象。有些情况除外,见官网

        "no-throw-literal": 2,

        //禁止使用不被修改的循环条件

        "no-unmodified-loop-condition": 2,

        //禁止在代码中出现没有被使用到的表达式或值

        "no-unused-expressions": [2, { "allowShortCircuit": true, "allowTernary": true }],

        //禁止在代码中出现没有被使用到的标签

        "no-unused-labels": 2,

        //避免使用没有意义的call() 和 apply()

        "no-useless-call": 2,

        //避免使用不必要的字符串拼接

        "no-useless-concat": 2,

        //不要使用void操作符

        "no-void": 2,

        //生产代码中不能出现warning-comments包含的注释

        "no-warning-comments": [2, { "terms": ["todo", "fixme", "any other term"], "location": "anywhere" }],

        //不要使用with语句

        "no-with": 2,

        //在使用parseInt()方法时,必须要传递第二个参数来帮助解析。

        "radix": 2,

        //在通过var声明变量时,应该放在代码所在作用域的顶部

        "vars-on-top": 2,

        //立即执行函数需要通过圆括号包围

        "wrap-iife": 2,

        //yoda条件语句就是对象字面量应该写在比较操作符的左边,而变量应该写在比较操作符的右边

        //默认的规则要求,变量写在左边而字面量写在右边

        "yoda": 2,



        /*Strict Mode*/

        //使用严格模式

        "strict": 2,





        /*Variables*/

        //变量声明时必须赋初值

        "init-declarations": 2,

        //In IE 8 and earlier,禁止catch子句参数与外部作用域变量同名

        "no-catch-shadow": 2,

        //禁止使用delete删除var声明的变量

        "no-delete-var": 2,

        //防止label和声明的变量重名

        "no-label-var": 2,

        //禁止使用某些全局变量

        "no-restricted-globals": [2, "event"],

        //禁止声明外部作用域中已定义的变量

        "no-shadow": 2,

        //声明变量时禁止覆盖JavaScript中的一些保留关键字,比如NaN、Infinity、undefined、eval、arguments等。

        "no-shadow-restricted-names": 2,

        //禁止使用未被定义的变量,除非已在配置文件的global中进行了说明。

        "no-undef": 2,

        //禁止初始化变量为undefined

        "no-undef-init": 2,

        //禁止把undefined作为变量名

        "no-undefined": 2,

        //不允许定义的变量在后面的代码中没有被使用到

        "no-unused-vars": 2,

        //所有的变量都应该先定义后使用

        "no-use-before-define": 2,







        /*Node.js and CommonJS*/

        //强制回调后return,避免多次调用回调

        "callback-return": 2,

        //强制require()出现在模块作用域的顶部

        "global-require": 2,

        // 如果函数有err入参(err或者error),在函数体内必须进行处理

        "handle-callback-err": [2, "^(err|error)$"],

        //声明时不能混用声明类型

        "no-mixed-requires": 2,

        //禁止把require方法和new操作符一起使用。

        "no-new-require": 2,

        //不能使用__dirname或__filename做路径拼接

        "no-path-concat": 2,

        //禁止使用process.env

        "no-process-env": 2,

        //禁止使用process.exit()

        "no-process-exit": 2,

        //禁用使用指定模块,使用了就会报错

        "no-restricted-modules": [2, "fs"],

        //禁止使用同步方法,建议使用异步方法

        "no-sync": 2,





        /*Stylistic Issues*/

        // 用数组字面量定义数组时数组元素前后是否加空格,

        // never参数: 数组元素前后不能带空格,

        // always参数:数组元素前后必须留空格  

        "array-bracket-spacing": [2, "never"],

        //在单行代码块中,代码块前后是否需要留空格

        // always参数:默认,前后必须留空格

        // never参数: 前后不能带空格  

        "block-spacing": [2, "always"],

        //大括号的样式,比如下面的大括号语法采用『1tbs』,允许单行样式

        "brace-style": [2, "1tbs", { "allowSingleLine": true }],

        //强制使用驼峰命名  

        "camelcase": 2,

        //规定了逗号前后的空白,默认配置规定逗号前面没有空白,而逗号后面需要留空白

        "comma-spacing": [2, { "before": false, "after": true }],

        //规定了逗号放的位置,默认配置逗号应该放在行末,如果设置为first,逗号就应放在行首

        "comma-style": [2, "last"],

        //是否在对象的动态属性(computed properties: ES6引入)中添加空白,默认配置不添加空白

        "computed-property-spacing": [2, "never"],

        //统一this的别名(this赋值的变量名)保证整个应用程序代码的统一。

        //如果一个变量被指定为this对象的别名,那么这个变量就不能够用来赋其他值,只能够用来保存this对象。

        //如果this对象明确被赋值给了一个变量,那么这个变量应该是配置中指定的那个变量名。     

        "consistent-this": [2, "self"],

        //该规则规定文件最后强制换行,仅需留一空行

        "eol-last": 2,

        //要求给函数表达式命名,便于debug

        "func-names": 2,

        //在JavaScript中有两种方式定义函数:函数声明和函数表达式。

        //函数声明就是把function关键词写在最前面,后面跟一个函数名。我们可以在函数申明代码前调用函数

        //函数表达式是通过var等声明变量的关键字开头,然后跟函数名,再后面是function本身。在使用函数表达式定义函数前调用函数会报错

        // 统一定义函数是所采用的方式,参数:  

        //    declaration: 强制使用方法声明的方式,function f(){} e.g [2, "declaration"]  

        //    expression:强制使用方法表达式的方式,默认方式,var f = function() {}  e.g [2, "expression"]  

        //    allowArrowFunctions: declaration风格中允许箭头函数。 e.g [2, "declaration", {"allowArrowFunctions":true}]  

        "func-style": [2, "expression"],

        //规定了标识符命名的黑名单

        "id-blacklist": [2, "data", "err", "e", "cb", "callback"],

        //规定标识符的长度,默认配置标识符最少两个字符

        "id-length": [2, { "min": 2 }],

        //命名检测,标识符命名需和配置中的正则表达式匹配,但是该规则对函数调用无效。

        "id-match": [2, "^[a-z]+([A-Z][a-z]+)*$", { "properties": false }],

        // 统一代码缩进方式,默认值是4 spaces.

        "indent": 2,

        //规定了在JSX中的属性值是使用单引号还是双引号,默认使用双引号

        "jsx-quotes": [2, "prefer-double"],

        //该规则规定了在对象字面量语法中key和value之间的空白,冒号前不要留空格,冒号后面需留一个空格

        "key-spacing": [2, { "beforeColon": false, "afterColon": true }],

        // 规定了keyword前后是否需要留一个空格

        "keyword-spacing": [2, { "before": true, "after": true, "overrides": {} }],

        //统一换行符,"\n" unix(for LF) and "\r\n" for windows(CRLF),默认unix

        "linebreak-style": 2,

        //规定注释和代码块之间是否留空行

        "lines-around-comment": 2,

        //规定代码最多可以嵌套多少层

        "max-depth": [2, 4],

        //规定了代码单行的最大长度

        "max-len": [2, 80, 4],

        //规定了回调的最大嵌套层数

        "max-nested-callbacks": [2, 10],

        //规定了函数参数的最大个数

        "max-params": [2, 3],

        //规定了函数中代码不能够超过多少行

        "max-statements": [2, 10],

        //使用构造函数(new)时首字母需大写,首字母大写的函数需用new操作符

        "new-cap": 2,

        //使用构造函数(new)时必须圆括号不能省略

        "new-parens": 2,

        //规定了变量声明后是否需要空行

        "newline-after-var": 2,

        //规定了return语句前是否是否需要空行

        "newline-before-return": 2,

        //规定了方法链式调用时是否需换行

        "newline-per-chained-call": 2,

        //禁止使用Array构造函数

        "no-array-constructor": 2,

        //禁止使用位操作符

        "no-bitwise": 2,

        //禁止使用continue

        "no-continue": 2,

        //禁止使用行内注释

        "no-inline-comments": 2,

        //禁止在if-else控制语句中,else代码块中仅包含一个if语句

        "no-lonely-if": 2,

        //禁止混用tab和空格

        "no-mixed-spaces-and-tabs": 2,

        //不要留超过规定数目的空白行

        "no-multiple-empty-lines": [2, { "max": 2 }],

        //在if语句中使用了否定表达式,同时else语句又不为空,那么这样的if-else语句将被视为不合法,为什么不将其反过来这样代码更容易理解,该规则同样适用于三元操作符

        "no-negated-condition": 2,

        //三元操作符禁止嵌套

        "no-nested-ternary": 2,

        //禁止使用new Object()来构造对象

        "no-new-object": 2,

        //禁止使用++,--

        "no-plusplus": 2,

        //禁止使用某些特定的JavaScript语法,例如FunctionDeclaration 和 WithStatement

        "no-restricted-syntax": [2, "FunctionExpression", "WithStatement"],

        //函数调用时,函数名和圆括号之间不能有空格

        "no-spaced-func": 2,

        //禁止使用三元操作符

        "no-ternary": 2,

        //禁止行末加空格

        "no-trailing-spaces": 2,

        //禁止在标识符前后使用下划线

        "no-underscore-dangle": 2,

        //禁止使用没有必要的三元操作符,因为用有些三元操作符可以使用其他语句替换

        "no-unneeded-ternary": [2, { "defaultAssignment": false }],

        //禁止属性操作符.的前后和[之前有空格

        "no-whitespace-before-property": 2,

        //规定对象字面量中大括号内是否允许加空格,也适用于ES6中的结构赋值和模块import和export

        "object-curly-spacing": [2, "never"],

        //规定了在每个函数中声明变量是否只使用一次var,该规则同样适用于let和const

        "one-var": [2, { "initialized": "never" }],

        //规定了使用赋值操作符的简写形式

        "operator-assignment": [2, "always"],

        //在换行时操作符应该放在行首还是行尾。还可对某些操作符进行重写。

        "operator-linebreak": [2, "after", { "overrides": { "?": "before", ":": "before" } }],

        //在代码块中,代码块的开始和结尾是否应该留一个空行

        "padded-blocks": 0,

        //对象的属性名是否强制加双引号

        "quote-props": [2, "always"],

        //在JavaScript中有三种方式定义字符串,双引号、单引号、反义符(ECMAScript2015)。规定了字符串定义的方式

        "quotes": [2, "single", "avoid-escape"],

        //注释格式要求JSDoc格式

        "require-jsdoc": [2, {

            "require": {

                "FunctionDeclaration": true,

                "MethodDefinition": false,

                "ClassDeclaration": false

            }

        }],

        //JavaScript不要求在每行末尾加上分号,这是因为JavaScript引擎会决定是否需要在行末加上分号,然后自动帮我们在行末加上分号,这一特性被成为ASI(automatic semicolon insertion),也是JavaScript语言最富争议的特性之一

        //尽管ASI允许我们使用更加自由的代码风格,但是它也可能使得你的代码并不是按你期许的方式运行

        //两个可选参数,always 和never

        //默认配置always,要求在行末加上分号。

        "semi": [2, "always"],

        //该规则用来规定分号前后是否加空格,默认配置如下

        "semi-spacing": [2, { "before": false, "after": true }],

        //要求对同一个模块里的import声明按字母排序

        "sort-imports": 2,

        //规定在同一个变量声明代码块中,要对变量的声明按字母排序

        "sort-vars": 2,

        //规定了在代码块前是否需要加空格

        "space-before-blocks": [2, "always"],

        //函数定义时,function关键字后面的小括号前是否需要加空格

        "space-before-function-paren": [2, "always"],

        //规定圆括号内部的空格。规定是否需要在(右边,或者)左边加空格。

        "space-in-parens": [2, "never"],

        //中綴操作符左右是否添加空格

        "space-infix-ops": 2,

        //规定在一元操作符前后是否需要加空格,单词类操作符需要加,而非单词类操作符不用加

        //words - applies to unary word operators such as: new, delete, typeof, void, yield

        //nonwords - applies to unary operators such as: -, +, --, ++, !, !!

        "space-unary-ops": [2, { "words": true, "nonwords": false }],

        //规定是否需要在代码注释起始符// or /*后面至少紧跟一个空格

        "spaced-comment": [2, "always", { "markers": ["global", "globals", "eslint", "eslint-disable", "*package", "!", ","] }],

        //要求在正则表达式的双斜杠外面加一个圆括号,来消除歧义

        "wrap-regex": 2,





        /*ECMAScript 6*/

        //箭头函数中,如果函数体里只有一句代码时可以省略大括号

        //规定是否可以省略大括号

        "arrow-body-style": 2,

        //箭头函数中,只有一个参数时可以省略圆括号

        //规定了参数是否需要圆括号包围

        "arrow-parens": [2, "always"],

        //规定了箭头函数的箭头前后是否加空格

        "arrow-spacing": [2, { "before": true, "after": true }],

        //保证constructor函数中super()应正确出现,比如在继承的classes中(派生类)必须使用super,否则(非派生类)不要使用super。

        "constructor-super": 2,

        //规定generator函数中星号前后的空白

        "generator-star-spacing": [2, { "before": true, "after": true }],

        //禁止覆盖class命名,也就是说变量名不要和class名重名

        "no-class-assign": 2,

        //箭头函数的箭头和比较操作符 (>, <, <=, and >=)很相似,该规则要求在和比较操作符容易发生混淆时禁止使用箭头函数语法

        "no-confusing-arrow": 2,

        //禁止修改const声明的变量

        "no-const-assign": 2,

        //class中的成员不允许有相同的名字

        "no-dupe-class-members": 2,

        //禁止在Symbol对象前使用new操作符

        "no-new-symbol": 2,

        //该规则可以定义不允许在应用中导入的模块

        "no-restricted-imports": [2,

            "assert", "buffer", "child_process", "cluster", "crypto", "dgram", "dns", "domain", "events", "freelist", "fs", "http", "https", "module", "net", "os", "path", "punycode", "querystring", "readline", "repl", "smalloc", "stream", "string_decoder", "sys", "timers", "tls", "tracing", "tty", "url", "util", "vm", "zlib"

        ],

        //在构造函数中,禁止在super()调用前使用this/super对象

        "no-this-before-super": 2,

        //ES2015提供了默认的空构造函数,禁止使用不必要的空构造函数

        "no-useless-constructor": 2,

        //禁用var,用let和const代替var

        "no-var": 2,

        //ES6中提供了定义对象字面量的方法和属性的简写形式。强制要求在对象字面量中使用方法和属性的简写形式

        "object-shorthand": 2,

        //函数作为函数的参数传入时,传入的函数需要是箭头函数

        //箭头函数中的this对象直接绑定到了其外面包围的函数的this对象。

        "prefer-arrow-callback": 2,

        //如果一个变量声明后不再被修改,那么应使用const来声明该变量

        "prefer-const": 2,

        //推荐使用Reflect上的方法替代以前老方法

        "prefer-reflect": 2,

        // 在ES2015(ES6)中推荐使用剩余参数(...rest)代替arguments变量

        "prefer-rest-params": 2,

        //在ES2015(ES6)中推荐使用扩展符替代apply()方法

        "prefer-spread": 2,

        //在ES2015(ES6)中推荐使用模板代替以前的字符串拼接

        "prefer-template ": 2,

        //生成器函数中必须有yield关键字,如果没有会报错。

        "require-yield": 2,

        //模板字符串中使用${ 和 } 包含的表达式前后是否需要留空格,默认规则禁止花括号内有空格

        "template-curly-spacing": [2, "never"],

        //yield*表达式中的*号前后是否留空格,默认after,比如yield* other()

        "yield-star-spacing": [2, "after"],









        /*eslint-plugin-standard*/

        "standard/object-curly-even-spacing": [2, "either"],

        "standard/array-bracket-even-spacing": [2, "either"],

        "standard/computed-property-even-spacing": [2, "even"],





        /* eslint-plugin-promise*/

        "promise/param-names": 2,

        "promise/always-return": 2,

        "promise/catch-or-return": 2,



        /*eslint-plugin-react*/

        "react/jsx-boolean-value": 2,

        "react/jsx-quotes": 2,

        "react/jsx-no-undef": 2,

        "react/jsx-sort-props": 0,

        "react/jsx-sort-prop-types": 0,

        "react/jsx-uses-react": 2,

        "react/jsx-uses-vars": 2,

        "react/no-did-mount-set-state": 2,

        "react/no-did-update-set-state": 2,

        "react/no-multi-comp": 2,

        "react/no-unknown-property": 1,

        "react/prop-types": 1,

        "react/react-in-jsx-scope": 2,

        "react/self-closing-comp": 2,

        "react/wrap-multilines": 0

    }

远程登录 VPS 语言错误

当使在 VPS 上安装软件的时候,经常遇到同一个警告,如下:

perl: warning: Setting locale failed.
perl: warning: Please check that your locale settings:
	LANGUAGE = "en_US:",
	LC_ALL = (unset),
	LC_CTYPE = "zh_CN.UTF-8",
	LANG = "en_US"
    are supported and installed on your system.
perl: warning: Falling back to a fallback locale ("en_US").
locale: Cannot set LC_CTYPE to default locale: No such file or directory
locale: Cannot set LC_ALL to default locale: No such file or directory

那是因为安装软件时,都会去执行一个 update-locale 的命令,用来更新 locale

这个命令是一个脚本,用 perl 写的,可以用 whereis update-locale 查到,位置在 /usr/sbin/update-locale

上述报错并不是因为 update-locale 命令而引起,update-locale 这段脚本没有问题,而是因为perl。

可以使用以下命令测试:

perl -e exit

其实,真正的原因是 perl 为系统使用 zh_CN.UTF-8,但系统并没有安装 zh_CN.UTF-8

这种情况一般是vps比较常见,因为一般都是用 ssh 的方式连接到 vps 上的 sshd 有这个机制,会把客户机上的语言环境带到远程的机器上。

客户机一般都会设置 zh_CN.UTF-8 语言,用来显示中文,而远端的vps一般就只有en_US.UTF-8,zh_CN.UTF-8一旦带过去就会报找不到的错误。

所以安装一个中文语言,perl就不会报错了。

$ sudo locale-gen zh_CN zh_CN.UTF-8
Generating locales...
  zh_CN.GB2312... done
  zh_CN.UTF-8... done
Generation complete.
$ sudo dpkg-reconfigure locales
Generating locales...
  en_US.UTF-8... done
  zh_CN.GB2312... up-to-date
  zh_CN.UTF-8... up-to-date
Generation complete.

这个时候,还可以使用 locale 当前有哪些语言:

$ locale

参考

使用 Flexbox 使浮动元素垂直居中

+++
title = "How To Vertically Middle Align Floated Elements With Flexbox"
description = "使用 Flexbox 使浮动元素垂直居中"
date = 2017-07-28T20:53:14+08:00
tags = ["CSS"]
categories = ["CSS"]
draft = false
+++

垂直居中一直是一个很麻烦的问题,但基于 FlexBox 的垂直居中就非常简单了。

考虑下面的场景:

  • 你正在使用网格布局的框架,比如 BootstrapFoundation SkeletonSusy 等。
  • 你有两个包含动态内容的列(每列都是一个盒模型),你并不知道每列的具体尺寸,也不知道哪个更高。
  • 你需要这两列能够垂直居中。

我们希望得到的布局就像下面这样:

http://oh1ywjyqf.bkt.clouddn.com/How-to-Vertically-Middle-Align-Floated-Elements-with-Flexbox-01.png

但默认情况下,这两列将会顶部对齐:

http://oh1ywjyqf.bkt.clouddn.com/How-to-Vertically-Middle-Align-Floated-Elements-with-Flexbox-02.png

所以问题来了,在不改变浮动布局的前提下,我们应该如何是元素垂直居中对齐?到目前为止,这个简单的问题都非常难以解决。

是否可以使用 vertical-align:middle

很不幸,不能,因为一些不同的原因。

首先,在 MDN->CSS 中有关于 vertical-align 的描述 The vertical-align CSS property specifies the vertical alignment of an inline or table-cell box.。我们的元素不是 inlineinline-blocktable-cells,所以在不改变 display 的情况下 vertical-align:middle 并不会生效。

其次,网格布局的框架使用了 float:right 来对我们的两列元素进行定位,在 W3C->CSS->9.5.1 Positioning the float: the 'float' property 第八条有描述 A floating box must be placed as high as possible.。这意味着浮动元素总会被固定在顶部。

第一个问题我们可以通过将 display 改为 inline-block 或者 table-cell 解决,但没有 CSS 技术可以解决第二个问题。我们需要移除浮动规则,但这会破坏基于网格布局的框架的基础。

使用 Flexbox 解决问题

像往常一样,Flexbox 对我们的问题有一个简单的解决方案。只需要简单的两步就行了:

  1. 为元素添加 display: flex 规则。
  2. 为对元素添加 ‘align-items: center 规则。

这样就可以了!下面是一个简单的 HTML 和 CSS 例子:

<div class="container">
 <div class="column-1">[Dynamic content]</div>
 <div class="column-2">[Dynamic content]</div>
</div>
.container {
  display: flex;
  align-items: center;
}

.column-1,
.column-2 {
  float: left;
  width: 50%;
}

See the Pen RZWYZX by Hang Jiang (@nodejh) on CodePen.

<script async src="https://production-assets.codepen.io/assets/embed/ei.js"></script>

你可以从下面的动画中看到,两列元素将会根据内容的变化而始终保持垂直居中对齐:

http://oh1ywjyqf.bkt.clouddn.com/How-to-Vertically-Middle-Align-Floated-Elements-with-Flexbox-03.gif

这种解决方法最好的一点是通过添加两个规则,在没有对两列元素的原本样式做任何修改的前提下就实现了垂直居中对齐。现在大部分浏览器都支持 flex,老的浏览器会忽略该规则,元素将保持顶部对齐。

关于 flexbox 的浏览器兼容性可以在 Can I Use Flexbox 查看得到:

http://oh1ywjyqf.bkt.clouddn.com/How-to-Vertically-Middle-Align-Floated-Elements-with-Flexbox-04.png


参考

一键评教软件设计及代码分析

+++
date = "2016-12-30T18:58:19+08:00"
description = "一键评教软件设计及代码分析"
title = "Architecture And Code Analysis Of Teaching Evaluation"
tags = ["Node.js"]
categories = ["Node.js"]

+++

大到一个企业级应用,小到类似于该一键评教软件,都有自己的软件架构设计。通常来说,对于同一个需求,实现方式是多种多样的。如何设计应用逻辑,如何组织代码模块,如何确定目录结构等等, 都需要在编码之前进行考虑。每个人的编码风格不尽相同,写出来的代码也各有千秋。要想得出一个最佳实践,就要不断总结自己的过往经验,学习别人的优秀设计,并再次将其运用于实践才能真正理解其中的奥义。

本文主要就是介绍一键评教程序的软件结构设计,并对代码进行简要分析,同时也会讲述一些自己遇到的问题。

1. 什么是一键评教

首先声明,该程序的本质目的是用于学习交流。

每学期进行教学评估的时候,都要评很多教师,每个教师都有很多选项,再加上教务系统网站比较老旧,操作不方面,评教总是要花很长时间。

“一键评教,用过都说好”。线上地址 http://pj.fyscu.com,源码 https://github.com/nodejh/teach_evaluation

爱美之心人皆有之,我也很喜欢好用又好看的事物,所以我在写代码的时候也尽量做到好看又好用。软件截图如下,是不是很好看:

输入账号的页面

评估结果页面

2. 功能分析

需求很明确,就是能够在一个网页上实现点击按钮自动评估所有教师。那么要实现这样的需求,该怎么去做呢?

先来想想我们通常手动评教的步骤:

  1. 登录进入教务系统网站
  2. 找到教学评估链接并进入,这个时候就能看到所有需要进行评估的教师列表
  3. 从教师列表中点击某个教师,进入到该教师的教学评估页面
  4. 填写各种需要填写的表单
  5. 填写完毕之后,点击提交按钮进行评教
  6. 一切正常的情况下,则对该教师评教成功
  7. 然后返回到教师列表页面,选择下一个需要评估的教师
  8. 重复 3-7 这五个步骤,直到所有教师评估完毕

要用程序实现一键评教,其实就是用程序模拟上面的步骤。所以程序要实现的主要功能有:

  • 模拟登录,获取 cookie
  • 获取需要评估的教师列表
  • 对列表中的每个教师进行评教

然后我们需要一个用户界面来让用户进行操作。这个界面可以是 APP,也可以是网页。由于网页更方便更利于传播,所以我选择了网页。所以我们就还需要一个 HTTP 服务器,用来提供静态页面资源,并且接收并响应用户操作后发送的 HTTP 请求。

3. 软件设计

软件设计主要从三个方面来说明。一是技术选择,二是软件架构,三是目录结构。

3.1 技术选择

首先是各种技术的选择,包括前后端编程语言(语法)、第三方模块的选择、和服务器部署。

3.1.1 后端语言

该程序后端使用的是 Node.js。我的 Node.js 版本是 7.3。大量使用了 ES6 的语法,比如 Promise、模板字符串、箭头函数。只要你的 Node.js 版本 >= 6.0 应该都是可以运行的。

3.1.2 第三方模块

在开发程序之前,我想要尽量让应用的体积足够小,所以我尽量不使用第三方模块。

最终我只使用了 cheerioicon-lite 这两个第三方包。

  • cheerio 主要用来分析抓取到的 HTML 文档。其实最开始也想不用 cheerio,直接使用正则表达式来分析页面的,但正则表达式编写麻烦,我的能力也有限,所以最终选择了使用 cheerio
  • icon-lite 则是用来对 GBK 文本进行解码。因为教务系统网站使用的是 GBK 的编码,所以直接抓取的结果是乱码的。除了 icon-lite 这个包,我找不到其他的可以解决乱码问题的方案了。

3.1.3 编码规范

然后使用了 ESLint 来规范代码。主要是用的是 airbnb 的 Eslint 规则,并且根据自己的喜好对 .eslintrc 作了配置。具体配置在源码 .eslintrc 中可以看到。

3.1.4 前端技术

为了减小代码体积,提高加载速度,节省带宽,前端没有使用任何第三方 JS 库。并且这只是一个小应用,有没有必须使用庞大(相比该程序而言显得庞大)的第三方库。

前端使用的是 ES5 的语法。最开始想用 ES6 来写的,但 ES6 写好的代码还需要编译成 ES5 才能在浏览器运行,并且还需要引入各种 polyfill,最终还是决定使用 ES5。

前端唯一使用了第三方资源的,只有两个字体图标了。一个是 heart 的图标 ❤️,毕竟是用心写的代码,Made with ❤️ by nodejh;还有一个就是“关闭”小图标。图标使用的是 IcoMoon 的字体图标库,可以自己在里面找到需要的图标然后下载使用。

该程序只有 index.html 这一个 HTML 文件,所以本质上也是一个单页应用。所有的操作和交互都在这一个页面完成。

3.1.5 前端代码压缩

为了进一步压缩前端资源文件的体积,所以对静态资源进行了压缩。

压缩 CSS 使用的是在线压缩工具 CSS Compressor

压缩 JS 使用的是 UglifyJS2

3.1.6 部署

完成编码后,代码是部署在 Ubuntu 16.04 上的,然后使用了 pm2 进行进程的管理。

3.2 软件架构

程序的整体架构主要分为三层,可以就将其理解为 MVC 的三个层次。

MVC 是一种设计模式,设计模式不是一层不变的,我们需要根据自己的实际业务灵活运用。MVC 是一个很经典的设计模式,生活中的很多事物,我们也可以根据 MVC 对其进行定义。就拿人来进行类比,大脑就是 C(Controller),控制着人的一切活动。躯体外表就是 V(View) 层,一方面是表现着一个人的外观,另一方面是人的各种活动的外在表现。体内各种器官比如心脏、肺等就相当于 M(Model),从表面可能并不能直观看到 M 层的作用,但它受大脑控制,进行着血液循环呼吸系统等重要功能,而这些器官可能又跟躯体相互作用,比如影响人的精神面貌或高矮胖瘦。

说正经的。

首先是该一键评教程序的 M 层,包括页面抓取、页面分析和评教等功能模块。

然后是 V 层,主要是前端页面,直接给用户使用,与用户交互的界面。比如用户点击“开始”按钮的时候,就向 C 层发送一个 HTTP 请求。

C 是控制中心,接收 V 层的 HTTP 请求,根据 HTTP 请求决定调用哪些 M 层的模块,然后将模块调用后的处理结果返回给 V。

这样一个事件的处理流程可能就是:

V(HTTP 请求)---> C (调用 M 的对应模块)---> M(返回处理结果) ---> C(HTTP 响应) ---> V

3.3 目录结构

了解了软件的整体架构之后,就来看看代码的目录结构,代码的目录结构也完美地印证了这三层架构。

代码的主要目录/子目录及其功能如下:

|____app.js  # 入口文件
|____controller  # C 层目录,定义了各种控制器
| |____evaluate.js  # 评教的控制器
| |____evaluationList.js  # 获取需要评教列表的控制器
| |____staticServer.js  # 静态服务器控制器
|____helper  # 一些自定义的功能模块
| |____colors.js  # 十六进制颜色代码,主要是为了改变 console.log 的颜色
| |____dateformat.js  # 时间格式化
| |____getContentType.js  # 获取文件后缀名对应的 Content-Type,用于静态服务器
| |____log.js  # 自定义的彩色 console.log() 输出,告别满屏黑白日志
| |____request.js  # HTTP 请求的封装
|____models  # M 层目录,定义了各种模块及实现
| |____evaluate.js  # 评教功能模块
| |____getEvaluationList.js  # 获取需要评教的教师列表
| |____loginZhjw.js  # 模拟登录教务系统
| |____showEvaluatePage.js  # 显示某个具体的评教页面

看完目录结构,再回头看看软件的三层架构,肯定就清晰很多了。

4. 代码分析

接下来再对一些重要的功能模块以及涉及到的代码进行简要分析。相信了解完代码的执行流程之后,对软件的整体架构理解,定会再进一步。

4.1 app.js

app.js 是整个项目的入口文件,启动项目的时候使用 node app.js 即可启动。

在 app.js 里面,主要是创建了 HTTP Server,然后根据请求的路径,调用对应的控制器:

if (method === 'POST' && pathname === '/api/evaluationList') {
    // 模拟登录,获取需要评教的老师列表
    return evaluationListController(req, res);
  }

  if (method === 'POST' && pathname === '/api/evaluate') {
    // 评教
    return evaluateController(req, res);
  }

  if (method === 'GET') {
    // 所有 GET 请求都当作是请求静态资源
    return staticServerController(req, res);
  }

当请求方法是 POST 且路径是 /api/evaluationList 时,就说明前端是发送的一个获取需要评估的教师列表的请求,所以紧接着执行 evaluationListController(req, res);,调用该控制器,并且使用 return 来停止代码的执行。

如果有新的 API 的请求,都可以在这里加。

如果所有的自定义的请求及路径都不满足,并且请求的方法是 GET,那就当作是请求静态资源文件,如 HTML、CSS、JS 或图片等。这里就调用 staticServerController(req, res)staticServerController 是在 Controller 里面定义的返回静态文件的方法。

如果 GET 请求也不是,则返回 400 Bad Request

然后程序监听了 5000 端口,这样发送请求到 5000 端口,代码就能接收到请求并进行处理了。

4.2 静态资源服务器

前面已经提到了,staticServerController 是在 Controller 里面定义的返回静态文件的方法,也就是一个静态资源服务器。

因为我们的软件很简单,所以完全没有必要使用 express 或 koa 等框架,自己写一个简单的静态服务器完全足够应对所有业务需求了。

主要代码如下,代码优美,注释详尽,通俗易懂:

/**
 * 静态服务器
 * @param  {object} req request
 * @param  {object} res response
 * @return {null}   null
 */
const staticServerController = (req, res) => {
  let pathname = url.parse(req.url).pathname;
  if (path.extname(pathname) === '') {
    // 没有扩展名,则指定访问目录
    pathname += '/';
  }

  if (pathname.charAt(pathname.length - 1) === '/') {
    // 如果访问的是目录,则添加默认文件 index.html
    pathname += 'index.html';
  }
  // 拼接实际文件路径
  const filepath = path.join(__dirname, './../public', pathname);
  fs.access(filepath, fs.F_OK, (error) => {
    if (error) {
      res.writeHead(404);
      res.end('<h1>404 Not Found</h1>');
      return false;
    }
    const contentType = getContentType(filepath);
    res.writeHead(200, { 'Content-Type': contentType });
    // 读取文件流并使用管道将文件流传输到HTTP流返回给页面
    fs.createReadStream(filepath)
      .pipe(res);
  });
};

这里需要稍微留意的是 getContentType 这个方法,这个方法的定义和实现被放在了 helper/getContentType.js 里面,其主要作用,就是根据请求路径的后缀名来确定 HTTP Response 里面的 Content-Type 类型,以便浏览器或客户端识别:

/**
 * 获取 Content-Type
 * @param  {string} filepath 文件路径
 * @return {string}          文件对应的 Conent-Type
 */
const getContentType = (filepath) => {
  let contentType = '';
  const ext = path.extname(filepath);
  switch (ext) {
    case '.html':
      contentType = 'text/html';
      break;
    case '.js':
      contentType = 'text/javascript';
      break;
    case '.css':
      contentType = 'text/css';
      break;
    case '.gif':
      contentType = 'image/gif';
      break;
    case '.jpg':
      contentType = 'image/jpeg';
      break;
    case '.png':
      contentType = 'image/png';
      break;
    case '.ico':
      contentType = 'image/icon';
      break;
    case '.manifest':
      contentType = 'text/cache-manifest';
      break;
    default:
      contentType = 'application/octet-stream';
  }
  return contentType;
};

这样我们的一个简单的静态资源文件服务器就成型了,单独把这两段代码拿出去也是完全可以运行的。

当用户请求 localhost:5000 的时候,根据上面的代码,就会去寻找 public/index.html 这个文件然后返回给客户端。

index.html 就是我们的前端页面。

4.3 模拟登录

要想获取评教列表或进行评教,第一步就是登录教务系统。经抓包分析,教务系统使用的是 session cookie 的认证机制,关于如何抓包分析,可以看我的另一篇文章《模拟登录某某大学图书馆系统》[http://nodejh.com/post/Crawler-for-SCU-Libirary/]。这一步我们需要获取登录后的 cookie

登录的时候,是向 http://202.115.47.141/loginAction.do 发送的 POST 请求,请求的 Content-Typeapplication/x-www-form-urlencoded ,参数是 zjh=xx&mm=xx

曾经教务系统可以使用 GET 方式登录,所有有一种快捷登录方式,就是在浏览器地址栏 http://202.115.47.141/loginAction.do?zjh=[你的学号]&&mm=[你的密码]。而且这种方法可以绕过“登录人数已满”的限制。这在选课时期,这种强制的登录方式还是很好用的。不过 GET 方法也有缺点就是,你的学号和密码就直接暴露了,不安全。曾经还通过 Google 搜索,搜到了某个同学的账号及密码。现在教务系统估计是升级了禁止了这个方法。

模拟登录教务系统的程序在 models/loginZhjw.js 里面,详细代码就不贴了,总的来说,就是通过 Node.js 的 HTTP 模块,设置一个自定义的 HTTP Headers 信息,然后发送 HTTP 请求。当然,其他任何编程语言道理都一样。

模拟登录后,教务系统会返回 HTTP Response。HTTP Response 的 Content-Type 都是 text/html,也就是说返回的始终都是 HTML 文本。所以我们就可以根返回的 HTML 文本的内容判断是否登录成功。

如果文本包含下面 errorText 对象的属性字符串之一,都是登录失败:

 const errorText = {
        number: '你输入的证件号不存在,请您重新输入!',
        password: '您的密码不正确,请您重新输入!',
        database: '数据库忙请稍候再试',
        notLogin: '请您登录后再使用',
      };

同时,也经过抓包发现,登录成功后返回的 HTML 文本的 title 部分是:

<title>学分制综合教务</title>

而其他情况都不是。所以就可以大致判断,除了上面几种 errorText 是登录失败之前,只有返回的 HTML 包含 <title>学分制综合教务</title> 才是返回成功。

登录成功后的 HTTP Response Headers 部分含有一个 set-cookie 属性,而这个属性的值就是登录成功后的 cookie。我们抓那么多包,做了那么多准备,找的就是它。所以最终从登录成功的响应中取出 cookie 的代码如下:

const cookie = result.headers['set-cookie'].join().split(';')[0];

之后获取需要评估的教师列表和评教,都需要在发送 HTTP 请求时在 HTTP Headers 里面带上该 cookie。

4.3 获取需要评估的教师列表

获取需要评估的教师列表就简单很多了,发送的是 GET 请求,然后在 HTTP Headers 里面设置 Cookie 即可,其头发送 HTTP 请求的头信息大概如下:

const options = {
    hostname: '202.115.47.141',
    port: 80,
    path: '/jxpgXsAction.do?oper=wjShow',
    method: 'POST',
    headers: {
      Cookie: data.cookie,
      'Content-Type': 'application/x-www-form-urlencoded',
      'Content-Length': Buffer.byteLength(postData),
    },
  };

4.4 进行评教

获取到教师列表之后,就可以进行评教了。但这里有一个坑,就是进行评教之前,必须先访问评教页面,再发送评教请求。不然是无法评教成功的。就是这个问题,导致我纠结了好久。

也就是说,用程序模拟评教的时候,就要发送两个 HTTP 请求了,一是发送请求到某个老师的评教页面,对应的是 models/showEvaluatePage.js 这个文件;二是发送评教请求,对应的是 models/evaluate.js。而且这两个请求都是 POST 类型的。所以代码类逻辑似于下面这样:

// 显示评教页面
showEvalutePage(data)
    .then(() => {
    // 评教
    return evaluate(data)
    })
    .then((result) => {
    // 评教结果
    })
        .catch((exception) => {
            // 捕获异常
        });

4.5 public/js/style.js

前端的 JS 代码都在 style.js 这个文件里面了,主要就是监听了按钮的点击事件,然后发送 HTTP 请求,并根据请求结果增删页面的 DOM。

前端由于没有使用 jQuery 等第三方库,所以操作 DOM 和事件监听都是原生 JS 实现的。发送 AJAX 请求也是自己封装的 XHR 对象。有关于 XHR 的更多内容,可以参考我之前写的 《AJAX: XHR, jQuery and Fetch API》

5. 总结

到这里,这篇文章就基本完成了。本文讲述的实践,可能也不是最佳的实践,也有很多值得继续商讨和改进之处。只有不断实践,不断总结,才能写出更美的代码。人生不也一样?总结过去的教训,才能更好地前行。

JavaScript 是传值调用还是传引用调用?

1. 例子

先来看两个个来自于 《JavaScript 高级程序设计》P70-P71 的两个例子。

1.1. 基本类型参数传递

function addTen(num) {
  num += 10;
  return num;
}

var count = 20;
var result = addTen(count);
alert(count); // 20, 没有变化
alert(result); // 30

书上解释说,JavaScript 参数传递都是按值传参。

所以传递给 addTen 函数的值是 20 这个值,所以函数执行结束原始变量 count 并不会改变。

1.2. 引用类型参数传递

function setName(obj) {
  obj.name = 'Nicholas';
  obj = new Object();
  obj.name = 'Greg';
}

var person = new Object();
setName(person);
alert(person.name); // Nicholas

为什么结果是 Nicholas 呢?

变量存储方式

疑问:如果是传值,那应该是把 person 变量的值(也就是一个指向堆内存中对象的指针)传递到函数中,obj.name = 'Greg'; 改变了堆内存中对象的属性,为什么 person.name 还是 Nicholas

2. 传值还是传引用?

让我们再将上面两个例子综合为下面的例子:

function changeStuff(a, b, c) {
  a = a * 10;
  b.item = "changed";
  c = {item: "changed"};
}

var num = 10;
var obj1 = {item: "unchanged"};
var obj2 = {item: "unchanged"};

changeStuff(num, obj1, obj2);

console.log(num);
console.log(obj1.item);    
console.log(obj2.item);

最终的输出结果是:

10
changed
unchanged

所以 JS 到底是传值调用还是传引用调用呢?要弄清楚这个问题,首先我们要明白到底什么是传值调用(Call-by-value)和传引用调用(Call-by-reference)

2.1. 传值调用(Pass by value)

在传值调用中,传递给函数参数是函数被调用时所传实参的拷贝。在传值调用中实际参数被求值,其值被绑定到函数中对应的变量上(通常是把值复制到新内存区域)。

changeStuff 的参数 a b cnum1 obj1 obj2 的拷贝。所以无论 a b c 怎么变化,num1 obj1 obj2 都保持不变。

问题就在于 obj1 变了。

2.2. 传引用调用(Pass by reference)

在传引用调用调用中,传递给函数的是它的实际参数的隐式引用而不是实参的拷贝。通常函数能够修改这些参数(比如赋值),而且改变对于调用者是可见的。

也就是说 changeStuff 函数内的 a b c 都分别与 num obj1 obj2 指向同一块内存,但不是其拷贝。函数内对 a b c 所做的任何修改,都将反映到 num obj1 obj2 上 。

问题就在于 numobj2 没变。

从上面的代码可以看出,JavaScript 中函数参数的传递方式既不是传值,也不是传引用。主要问题出在 JS 的引用类型上面。

JS 引用类型变量的值是一个指针,指向堆内存中的实际对象。

2.3. 传共享调用(Call by sharing)

还有一种求值策略叫做传共享调用(Call-by-sharing/Call by object/Call by object-sharing)

传共享调用和传引用调用的不同之处是,该求值策略传递给函数的参数是对象的引用的拷贝,即对象变量指针的拷贝。

也就是说, a b c 三个变量的值是 num obj1 obj2 的指针的拷贝。 a b c 的值分别与 num obj1 obj2 的值指向同一个对象。函数内部可以对 a b c 进行修改可重新赋值。

function changeStuff(a, b, c) {
  a = a * 10; // 对 a 赋值,修改 a 的指向,新的值是 a * 10
  b.item = "changed"; // 因为 b 与 obj1 指向同一个对象,所以这里会修改原始对象 obj1.item 的内容
  c = {item: "changed"}; // 对 c 重新赋值,修改 c 的指向,其指向的对象内容是 {item: "changed"}
}

3 代码分析

接下来让我们再来分析一下代码。

3.1 变量初始化

var num = 10;
var obj1 = {item: "unchanged"};
var obj2 = {item: "unchanged"};

变量初始化

3.2 调用函数

changeStuff(num, obj1, obj2);

调用函数

可以看到,变量 a 的值就是 num 值的拷贝,变量 b c 分别是 obj1 obj2 的指针的拷贝。

函数的参数其实就是函数作用域内部的变量,函数执行完之后就会销毁。

3.3 执行函数体

a = a * 10;
b.item = "changed";
c = {item: "changed"};

执行函数体

如图所示,变量 a 的值的改变,并不会影响变量 num

b 因为和 obj1 是指向同一个对象,所以使用 b.item = "changed"; 修改对象的值,会造成 obj1 的值也随之改变。

由于是对 c 重新赋值了,所以修改 c 的对象的值,并不会影响到 obj2

4. 结论

从上面的例子可以看出,对于 JS 来说:

  • 基本类型是传值调用
  • 引用类型传共享调用

传值调用本质上传递的是变量的值的拷贝。

传共享调用本质上是传递对象的指针的拷贝,其指针也是变量的值。所以传共享调用也可以说是传值调用。

所以《JavaScript 高级程序设计》说 JavaScript 参数传递都是按值传参 也是有道理的。


参考

Serverless 将使前后端从分离再度走向融合

本文是本人在之前分享后关于 Serverless 的一个采访稿,其中也有针对上一篇文章《Serverless 掀起新的前端技术变革》评论中相关问题的个人观点,希望能对读者有所收获和思考。

近日,Serverless 作为新兴的架构模式,与其相关的话题被讨论的如火如荼。Serverless 不需部署、配置和管理传统服务器,这一概念的提出打破了前后端的壁垒,使得前端开发者能够真正做到全栈,掀起新一轮的开发模式变革。

Serverless 的背景和发展历程

InfoQ:Serverless 的背景和发展历程是什么?

云计算经过了从物理机到虚拟机、从虚拟机到云计算、从云计算到容器这几个阶段,而容器之后的下一个阶段,则是 Serverless。2009 年 Berkeley 在 《Above the Clouds: A Berkeley View of Cloud Computing》 这篇论文中定义了云计算,并提出了云计算的六个优点:

  • 按需提供无限计算资源。
  • 消除云用户的前期承诺。
  • 根据需要在短期内支付使用计算资源的能力。
  • 由于许多非常大的数据中心,大规模降低成本的规模经济。
  • 通过资源虚拟化简化操作并提高利用率。
  • 通过复用来自不同组织的工作负载来提高硬件利用率。

今天被大家讨论的 Serverless 则最大程度体现了这些优点。从时间上来看,2014 年 11 月,AWS Lambda 的发布,标志则 Serverless 发展的开始。2016 年 Google Cloud Function 和微软 Azure Function 产品的发布,标志着 Serverless 渐渐成熟。2017 年 4 月,阿里云函数计算和腾讯云无服务器函数的发布,则标志着国内 Serverless 发展的成熟。2018 年 9 月,支付宝小程序和微信小程序的云开发功能发布,标志着国内 Serverless 应用场景的落地。

Serverless 的概念及定义

InfoQ: Baas 出现很早,但一直没火起来,包括 Facebook 关闭了 Parse 服务,为什么现在又认为 Serverless 必须依赖它?

现在公认的 Serverless 的定义,是 Serverless = FaaS + BaaS。

传统的编程中,我们定义一个函数,函数针对输入计算输出,这些函数组成了一个应用程序。而 FaaS 则让我们能够在云端编写、运行函数,并由这些云函数组成应用程序。

FaaS 本身提供的只有运行函数的功能,并且每个函数的执行都是孤立和短暂的。但我们的应用程序,往往还需要持久存储和临时存储,以及在存储中进行数据管理,所以我们需要 BaaS。BaaS 就是一些列后端的功能的集合,比如云数据库、对象存储、消息队列、通知服务。没有这些 BaaS,函数的能力是非常有限的。整个 BaaS 也都由云供应商厂商提供,开发者不需要关心具体细节、实现,只需要在 FaaS 中使用 BaaS,这样才能构建整个应用。

Serverless 的优缺点

InfoQ:Serverless 架构与传统架构相比的优势在哪里?

我觉得 Serverless 相比传统 Serverfull 架构主要有以下几个优势:

  • 无需运维
    Serverless 架构的核心**就是,构建和运行程序不需要管理服务器等底层资源。基于 Serverless 架构,应用的部署、扩容、备份、容灾、监控、日志等都不需要开发者关心,这些功能全都由云供应商提供。开发者就可以从以往繁琐的运维工作中解脱出来,专心实现自己的产品。

  • 低成本
    传统的 Serverfull 架构,我们需要为资源付费。很多时候我们的云服务器等资源都是空闲的,但也需要计算费用,这就造成了不必要的浪费。但 Serverless 架构,我们只需要为计算付费。函数每执行一次,付一次的费用。比如阿里云函数计算、AWS Lambda、微软 Azure Function 等产品,定价几乎都是 1.33 元 / 百万次 的执行次数,0.00011108/GB-s 的运行时间,如果你的应用比较轻量,每个月的成本是非常低的。

  • 更简单
    相比传统架构下的开发,基于 Serverless 架构的开发将变得更简单。云计算平台已经为我们提供了一系列的基础设施,我们只需要在此之上进行应用开发。传统架构,就犹如编程语言中的底层语言,如汇编,我们需要关心每一个细节,细致到 CPU 寄存器这样的级别。而基于 Serverless 的开发,就犹如 Node.js、Python 等高级语言,我们只需要专注于业务逻辑的实现,可以很高效地构建一个应用,并且这个应用天然就是弹性可伸缩的。

Serverless 的实践

InfoQ: 依据 Serverless 的概念定义,在实际开发中如何保证性能?有实际的解决方案吗?如果有的话能具体讲一下怎么实现的吗?

Serverless 的性能,也是绝大部分开发者都关注的一个话题。相比传统架构,Serverless 架构下,程序的运行需要经过一些列步骤:

  • 下载代码
  • 启动容器
  • 启动运行环境
  • 执行代码

前三步统称为冷启动。传统应用则完全没有冷启动时间。冷启动时间的长短,直接决定了应用性能的高低。
一方面,冷启动时间需要 Serverless 服务提供商去优化;另一方面,作为开发者,我们也可以从应用的角度去优化。首先就是选用合适的编程语言。因为 Java 等高级语言的冷启动时间大约是 Node.js、Python 等语言的 100 倍。其次是为函数分配合适的内存。一般而言,内存越大,冷启动的时间越短。基于这点,开发者也可以为 Java 分配更大的内存,使其冷启动时间和 Node.js 一样短。但更大的内存意味着更多的支出。所以为函数选用合适的内存很重要。

还有就是重复利用函数的执行上下文。当一个事件来临,函数冷启动并执行之后,运行环境并不会立即被销毁,而是在一定时间内处于冷冻状态继续等待下一次函数执行。这也是 Serverless 服务平台的一个性能优化方案。基于这样的特点,我们就可以将数据库连接、临时文件等保存在执行上下文中,从而使函数无需在每次运行时都创建这些资源。

除此,我们还可以对函数进行预热。可以通过定时对函数进行调用的方式,使函数一直处于“温暖”状态,从而避免真实请求到来时函数进行冷启动,进而达到提高性能的目的。

InfoQ: Serverless 有那些应用场景,实际开发中表现如何?遇到哪些技术痛点?

根据 2018 年的调研,有接近三分之一的用户,将 Serverless 用于接口的开发,还有大部分用来做数据处理、第三方无服务集成或内部工具。

serverless-EECS-2019-3

图片来源:https://www2.eecs.berkeley.edu/Pubs/TechRpts/2019/EECS-2019-3.pdf

理论上来说,传统架构能做的事情,Serverless 都能做。Serverless 比较适合有显著波峰波谷的应用或基于事件处理的一些场景。

比如一个系统白天用户量很大,晚上基本没有用户,使用传统架构,晚上服务器资源就是浪费的;而使用 Serverless 架构,只需要为实际的计算付费。

再比如上传图片后,需要对图谱进行压缩、裁剪然后再存储,使用 Serverless 用户只需要在函数中定义好处理逻辑,具体的执行由云计算平台来做。上传图片就是触发云计算平台执行代码的事件。

对于一般应用,实际开发中的痛点,一方面是函数的测试,因为 Serverless 函数往往依赖于第三方服务,如 FaaS 和 BaaS,我们很难对使用了这些服务器的函数进行测试。同时函数是事件驱动的,触发函数执行的事件,在本地也很难模拟。所以要能够方便地对 Serverless 函数进行测试,就需要我们在开发过程中,将函数的业务逻辑和所依赖的第三方服务分离,这样就可以编写单元测试对函数进行测试。另一方面,是函数的性能,性能问题上面已经提到。

对于一些复杂的大型企业级应用,现在我们还很难基于简单的 FaaS 平台去开发。这可能需要一个能够超越 FaaS 的一个可编程框架。

InfoQ: Serverless 运行在云服务上,这方面会带来什么限制吗?

最主要的限制就是我们基于 Serverless 的应用严重依赖云服务。云服务的稳定性直接决定了业务的稳定性。当然,我觉得这方面也不用过于担心。云服务肯定会有比我们更专业的开发者去维护。

其次,应用的多云部署或应用的迁移,也会比较麻烦。因为目前 Serverless 还没有一个统一标准,各个云供应商的 FaaS 和 BaaS 实现也不一样。所以当我们想要把 Serverless 应用从一个云服务迁移到另一个云服务,就会变得很困难。解决这个问题的方法,就是尽量让我们的业务代码和所依赖的云服务分离。这样迁移的时候,就只需要修改依赖云服务的相关代码。

Serverless 还存在的另一个问题是底层硬件资源的不确定性。由于云供应商可以灵活的选择底层服务器的规格和型号,这就导致了每个云函数运行的物理环境性能不尽相同。这种不确定性其实暴露了云供应商的背后的目的:他们想要最大化的平衡资源的使用和预算。

Serverless 对前端的影响

InfoQ: Serverless 对前端开发模式带来什么的变化?

纵观整个前端开发模式的演进历程,前端开发由最初的基于 JSP、PHP 等后端语言的模板渲染,演变到了基于 AJAX 的前后端分离,进而再演便到了现在的 BFF(Backend For Frontend)架构模式。

因为前后端分离后,前端的应用变得更加复杂,端也由 PC 端扩展到移动端、客户端甚至 IoT;后端应用也由单体应用转变为了微服务应用,接口变得更加原子化,前后端接口协调开始变得困难。所以前端开始寻求使用 BFF 来做接口的聚合、裁剪,甚至使用 Node.js 来做全栈开发。不管 BFF 也好、全栈也好,都会涉及到服务器的运维,这恰好是前端工程师所不擅长的。而 Serverless 正好能解决这一问题。

基于 Serverless,前端工程师将再度回归到 Web 应用工程师这一职能,前后端也将由分离再度走向融合。前后端的协调也不在是基于接口的协调,而是基于 Serverless 函数的协调。前端工程师能够基于 Serverless 去开发函数、实现后端功能。而后端工程师则去实现不适用函数编写的功能,或者供函数使用的一些微服务。对前端工程师来说,后端变得更简单了;对后端工程师来说,后端变得更靠后了。整体而言,应用的开发效率也大幅提升,开发者只需要关注于业务逻辑的实现,我们可以使用更少的技术在更短的时间内得到更多的产出。

InfoQ:随着前端开发模式的不断演进,目前大部分采用 Node.js 开发 Serverless 应用,为什么选用 Node.js? 有什么优势?

  • 第一, Node.js 的足够简单、轻量。
    拿 Node.js 和 Java 来对比,开发同样的功能,Node.js 的代码会比 Java 少很多。Node.js 函数所消耗的内存等资源也比 Java 要少。再如常用的 REST API 接口中,JSON 格式在 Node.js 里面是原生支持的,而 Java 需要使用第三方库来转换。
  • 第二, Node.js 冷启动时间比 Java、C# 等编译型语言要低很多。
    有测试表明,Node.js 的冷启动时间比 Java 大约低 100 倍。对于 Serverless 应用来说,冷启动时间是函数性能的关键因素,因此从性能上来说 Node.js 也是开发的首选。
  • 第三,Serverless 对前端友好。
    前端开发者是 Serverless 的主要使用者和受益者,基于 Serverless 架构,前端开发者可以很容易地开发服务端程序,能够很快速地实现一个完整的应用。而前端开发者最熟悉的服务端语言便是 Node.js,因此很多 Serverless 应用使用 Node.js 实现。

InfoQ: 在 Serverless 的大势所趋下,对前端开发人员的技能有哪些新的要求?

最主要的,当然是对 Serverless 架构的学习和理解。当我们在使用一项新技术的时候,一定要充分了解到它的优缺点、适用和不适用场景。

对于前端工程师来说,Serverless 使开发变得更简单了,前端工程师可以很方便进行后端的开发。但前端开发后端,依旧存在一定门槛。前端工程师依旧需要学习一些基本的后端开发知识。

Serverless 也不等于无服务器,只是我们不再需要关心服务器。虽然 Serverless 可以极大程度帮助我们减少运维工作,但我们还是可以了解一些基本的运维知识,这样遇到问题,可以更快速、高效进行排查。

Serverless 未来的发展

InfoQ: 您怎么看待 Serverless 在未来的发展,在其推广普及的道路上会有怎样的际遇?

Serverless 是一种新的架构模式,还在不断发展和完善中。

未来 Serverless 标准一定会走向统一。目前还没有一个统一的 Serverless 标准,不同的云供应商在实现自己的 Serverless 平台。这给开发者的多云部署或应用迁移带来了极大的挑战,也给 Serverless 发挥其潜力带来了限制。因此社区中需要一个能兼容各个服务供应商、封装好部署函数和管理生命周期的框架。在国外有 Serverless Framework 这样的产品在做这些事情,但它们基本都不支持国内的 Serverless 平台如阿里云、腾讯云,国内现在也没有这样的框架。

Serverless 架构在未来也一定会成为主流。现在的 Serverless 还存在的很多限制,比如缺乏细粒度模式的存储支持、缺乏细粒度的协调、缺乏标准的通信模式、还有性能问题;一些非常复杂的业务场景、大型的企业级系统也很难基于简单的 FaaS 平台去开发。这也就导致了现在大部分 Serverless 使用者,只是将其用在一些非核心的场景中。一种技术架构走向主流,一定是要经过经过大规模的实践、经过复杂系统的验证。引用一句话,“架构级的演进机会不是年年都有,甚至是 5 年、10 年都不一定能碰到一次,所以这也同样意味着这是个巨大的机会。”

参考文献:

原文: 阿里蒋航:Serverless 将使前后端从分离再度走向融合

Webpack 和 React 实战

TL;DR

$ git clone https://github.com/nodejh/start-react-with-webpack react-sample
$ cd react-sample && npm install
$ npm run dev

然后打开浏览器输入 http://localhost:8080,并尝试随意修改一下 app 目录里面的代码,就能看到效果了。

为了避免包版本问题导致程序不能运行,根目录下有一个 npm-shrinkwrap.json 文件,这里面所有包的版本都是固定的。 npm install 时首先会检查在根目录下有没有 npm-shrinkwrap.json,如果 shrinkwrap 文件存在的话,npm 会使用它(而不是 package.json)来确定安装的各个包的版本号信息。

1. 安装并配置 Webpack

首先创建并初始化一个项目目录:

$ mkdir react-sample && cd react-sample
$ npm init

安装 webpack

$ npm i webpack --save-dev

然后配置 webpack.config.js

# 创建一个 webpack.config.js 文件
$ touch webpack.config.js

在该文件中加入下面的内容:

const webpack = require('webpack');
const path = require('path');

// 定义打包目录路径
const BUILD_DIR = path.resolve(__dirname, './build');
// 定义组件目录路径
const APP_DIR = path.resolve(__dirname, './app');

const config = {
  entry: `${APP_DIR}/index.jsx`, // 文件打包的入口点
  output: {
    path: BUILD_DIR, // 输出目录的绝对路径
    filename: 'bundle.js', // 输出的每个包的相对路径
  },
  resolve: {
    extensions: ['', '.js', '.jsx'], // 开启后缀名的自动补全
  },
};

module.exports = config;

这是一个最基本的 webpack 配置文件。

接下来在 build/ 目录中创建一个 index.html 文件:

<html>
  <head>
    <meta charset="utf-8">
    <title>Start React with Webpack</title>
  </head>
  <body>
    <div id="app" />
    <script type="text/javascript" src="./bundle.js"></script>
  </body>
</html>

2. 配置加载器 babel-loader

加载器是把一个资源文件作为入参转换为另一个资源文件的 node.js 函数。

由于我们写 React 的时候使用的是 JSX 语法和 ES6 语法,而浏览器并不完全支持它们。所以需要使用 babel-loader 来让 webpack 加载 JSX 和 ES6 的文件。

babel-loader 的主要作用如下图:

Babel

安装依赖包:

$ npm i babel-core babel-loader babel-preset-es2015 babel-preset-react --save-dev

babel-preset-es2015 是转换 ES6 的包;babel-preset-react 是转换 JSX 的包。

接下来需要修改 webpack.config.js

// Existing Code ....
const config = {
  // Existing Code ....
  module: {
    loaders: [{
      test: /\.(js|jsx)$/,
      exclude: /(node_modules|bower_components)/,
      loader: 'babel-loader',
      query: {
        presets: ['es2015', 'react']
      }
    }]
  }
};

3. Hello React

安装 React:

$ npm i react react-dom --save

app 目录下新建一个 index.jsx 文件,然后将下面的内容添加到 index.jsx 中:

import React from 'react';
import {render} from 'react-dom';

class App extends React.Component {
  render () {
    return <h1> Hello React!</h1>;
  }
}

render(<App/>, document.getElementById('app'));

这个时候,执行下面的命令打包:

webpack -w

-w 参数表示持续监测项目目录,如果文件发生修改,则重新打包。

打包完成后,将 build/index.html 用浏览器打开,就能看到 Hello React!,如下:

hello_world.png

4. 自动刷新和热加载

懒是第一生产力。每次写完代码,都要重新打包,重新刷新浏览器才能看到结果,显然很麻烦。

那有没有能够自动刷新浏览器的方法呢?当然有,这个时候就需要 webpack-dev-server 这个包。

$ npm install webpack-dev-server -g

webpack-dev-server 提供了两种自动刷新模式:

Iframe 模式

  • 不需要额外配置,只用修改路径
  • 应用被嵌入了一个 iframe 内部,页面顶部可以展示打包进度信息
  • 因为 Iframe 的关系,如果应用有多个页面,无法看到当前页面的 URL 信息

inline 模式

  • 需要添加 --inline 配置参数
  • 提示信息在控制台中和浏览器的console中显示
  • 页面的 URL 改变,可以在浏览器地址栏看见

接下来启动 webpack-dev-server:

$ webpack-dev-server --inline --hot --content-base ./build/

--hot 参数就是热加载,即在不刷新浏览器的条件下,应用最新的代码更新。在浏览器中可能看到这样的输出:

[HMR] Waiting for update signal from WDS...
[WDS] Hot Module Replacement enabled.

--content-base ./ 参数表示将当前目录作为 server 根目录。命令启动后,会在 8080 端口创建一个 HTTP 服务,通过访问 http://localhost:8080/index.html 就可以访问我们的项目了,并且修改了项目中的代码后,浏览器会自动刷新并实现热加载。

当然,命令行输入这么长,还是不太方便,所以还有一种更简单的方式,在 package.json 中配置 webpack develop server:

// Existing Code ....
"scripts": {
    "dev": "webpack-dev-server --inline --hot --content-base ./build/"
  }

然后通过 npm start dev 来启动即可。

5. 添加一个新的组件

app 目录中新建一个 AwesomeComponent.jsx 文件,并添加如下代码:

import React, { Component } from 'react';

class AwesomeComponent extends Component {
  constructor(props) {
    super(props);
    this.state = {
      likesCount: 0
    };
    this.onLike = this.onLike.bind(this);
  }


  onLike() {
    let newLikesCount = this.state.likesCount + 1;
    this.setState({
      likesCount: newLikesCount
    });
  }


  render() {
    return (
      <div>
        Likes: <span>{this.state.likesCount}</span>
        <div>
          <button onClick={this.onLike}>Like Me</button>
        </div>
      </div>
    );
  }
}


export default AwesomeComponent;

然后修改 index.jsx

// ...
import AwesomeComponent from './AwesomeComponent.jsx';
// ...
class App extends React.Component {
  render () {
    return (
      <div>
        <p> Hello React!</p>
        <AwesomeComponent />
      </div>
    );
  }
}

// ...

like.png


UPDATE

2016.10.15

  • 更新 webpack-dev-server 的配置方法
设置 webpack-dev-server (old)

上面我们直接通过浏览器浏览的 html 文件,接下来我们需要利用 webpack-dev-server 来创建一个 HTTP Server。

首先安装 webpack-dev-server

$ npm i webpack-dev-server --save-dev

然后在 package.jsonscript 里面加入 builddev 两个命令:

{
  "scripts": {
    "build": "webpack",
    "dev": "webpack-dev-server --devtool eval --progress --colors --hot --content-base build"
  }
}
  • webpack-dev-server - 在 localhost:8080 建立一个 Web 服务器
  • --devtool eval - 为你的代码创建源地址。当有任何报错的时候可以让你更加精确地定位到文件和行号
  • --progress - 显示合并代码进度
  • --colors - 命令行中显示颜色!
  • --content-base build - 指向设置的输出目录

然后就可以使用 npm run dev 的命令来启动项目:

$ npm run dev

在浏览器地址栏输入 localhost:8080 即可看到页面。

如果需要浏览器自动刷新,将 webpack.config.js 中的 entry: APP_DIR + '/index.jsx 改为下面这样:

entry: [
   'webpack-dev-server/client?http://localhost:8080',
   'webpack/hot/dev-server',
    APP_DIR + '/index.jsx'
]

这样的话,每次当代码发生变化之后,webpack 会自动重新打包,浏览器也会自动刷新页面。

2016.11.19 更新

  • 使用 ES6 语法编写 webpack.config.js
  • 修改 babel-loader 加载器的配置方法:将添加 .babelrc 文件改为在 webpack.config.js 中配置
  • 🐛:webpack-dev-server --inline --hot --content-base ./build/ ➡️ webpack-dev-server --inline --hot --content-base ./build/
babel-loader 加载器的配置方法(old)

接下来需要配置 babel-loader,告诉 webpack 我们使用了 ES6 和 JSX 插件。先通过touch .babelrc 创建一个名为 .babelrc 的配置文件。然后加入下面的代码:

{
  "presets" : ["es2015", "react"]
}

然后再修改 webpack.config.js,使 webpack 在打包的时候,使用 babel-loader:

// Existing Code ....
var config = {
  // Existing Code ....
  module: {
    loaders : [
      {
        test : /\.jsx?/,
        include : APP_DIR,
        loader : 'babel'
      }
    ]
  }
}
自动刷新和热加在的配置(old, wrong)

当然,命令行输入这么长,还是不太方便,所以还有一种更简单的方式,直接在 webpack.cofig.js 中配置 webpack develop server:

{
  entry: {
    // ...      
  },
  // ...
  devServer: {
    hot: true,
    inline: true
  }
}

Ubuntu 下 Tomcat 的配置

1. 安装 JAVA

在安装 Tomcat 之前,必须得先安装 JDK 或 JRE。
JRE 是JAVA 程序运行的基础,它提供了 JVM 等 JAVA 运行环境。而 JDK 是给开发JAVA程序的人员提供了包括JRE在内的基本环境以及一些开发工具,例如 JAVAC。
Tomcat 是使用 JAVA 进行 Web 开发的应用服务器(Application Server)软件。当从网络上有请求到达 Tomcat 服务器时,它会根据请求的地址来调用你写的 JAVA 程序模块。

简单来说,假设 http://xxx.com 是一个 Java 写的网站,那么当在浏览器输入网址并敲下回车后,Tomcat 会得到一个希望访问 /(根目录资源)的请求。然后它去配置文件里查根目录对应的模块是什么。假设查到的模块是 root 这个类,那么它会去调用 root.doGet() 方法。而你写一个 Servlet 的时候,必须实现 doGet()方法,此时你的方法被调用,那么你写的代码就运行了。

当然,实际情况会比这个更复杂。

如果还没有安装 JAVA 开发环境,可参考:Ubuntu下JAVA开发环境的配置

2. 创建 tomcat 用户

为了安全考虑,Tomcat 需要以普通用户的权限来运行。所以我们将创建一个用户和用户组,来运行 Tomcat 服务。

首先,创建一个 tomcat 用户组:

sudo groupadd tomcat

然后我们再创建一个 tomcat 用户,该用户有如下条件:

  • tomcat 用户属于 tomcat 用户组;
  • tomcat 用户主目录为 /opt/tomcat,这个目录也是我们将用来安装 tomcat 的目录(当然也可以是你喜欢的其他目录);
  • tomcat 不能登录

所创建 tomcat 用户的命令如下:

sudo useradd -s /bin/false -g tomcat -d /opt/tomcat tomcat

3. 安装 Tomcat

下载 Tomcat 二进制文件

首先下载 Tomcat。目前 Tomcat 的最新版本是 9.0.0.M3。可以在 http://tomcat.apache.org 找到最新版本的 Tomcat,然后拷贝其二进制链接地址,在终端使用 wget 命令进行下载:

cd ~
wget http://apache.fayea.com/tomcat/tomcat-9/v9.0.0.M3/bin/apache-tomcat-9.0.0.M3.tar.gz

how-to-install-tomcat-on-ubuntu-1

然后我们需要将 Tomcat 安装到 /opt/tomcat 目录下面。

接下来创建安装目录,并将下载的二进制包解压到该目录。

sudo mkdir /opt/tomcat
sudo tar zxvf apache-tomcat-9.0.0.M3.tar.gz -C /opt/tomcat --strip-components=1

设置 Tomcat 用户权限

用户 tomcat 需要有合适的权限来运行 Tomcat 服务,所以接下来需要设置用户权限。

首先进入到 Tomcat 的安装目录:

cd /opt/tomcat

然后设置 tomcat 用户对 conf 目录的写权限,以及对其子目录的读权限:

sudo chgrp -R tomcat conf
sudo chmod g+rwx conf

接下来需要设置 tomcat 用户对 work/temp/logs/ 这几个目录的所有权:

sudo chown -R tomcat work/ temp/ logs/

完整命令如下图:

how-to-install-tomcat-on-ubuntu-2

设置环境变量

Tomcat 运行的时候依赖于 JDK 或 JRE。所以还需要为 Tomcat 指定 JDK 或 JRE 的路径。按理说只要有 JRE 就够了,但要使用 Tomcat 的更多功能,如 debug,还需要 JDK。所以建议设置 JDK 的路径。

设置环境变量的方法是在 bin/ 目录下新建一个 setenv.sh 目录,然后将 JAVA_HOME=/opt/java 写入到该文件:

sudo vim bin/setenv.sh

how-to-install-tomcat-on-ubuntu-3

环境变量设置完毕后就可以通过 catalina.sh 命令来启动 Tomcat 了。因为现在是在 /opt/tomcat 目录下,所以可以使用如图所示的命令来启动 Tomcat:

sudo bin/catalina.sh start

how-to-install-tomcat-on-ubuntu-4.png

如果你是在别的目录下,也可以通过 sudo /opt/tomcat/bin/catalina.sh start 命令来启动 Tomcat 服务。

一切正常的话,就会提示 Tomcat 启动成功,那么 Tomcat。然后在浏览器中输入 localhost:8080 就可以看到 Tomcat 的主界面:

how-to-install-tomcat-on-ubuntu-5.png

4. 配置 Web 管理员接口

Tomcat 提供了一个图形用户界面的管理后台,来方便我们管理使用 Tomcat 的 Web 应用。在 Tomcat 主页点击右上角的 Server Status 按钮,会弹出一个登录框,输入用户名和密码就可登录。但 Tomcat 安装后是没有默认的管理员帐户的,需要我们自己去配置。

如果登录的时候密码错误,就会出现一个 401 Unauthorized 的错误页面,这个页面上有提示信息,告诉我们应该怎么去配置管理员帐户:

how-to-install-tomcat-on-ubuntu-6.png

接下来就来配置管理员帐户。Tomcat 后台管理员配置文件是 /opt/tomcat/conf/tomcat-users.xml,所以我们要编辑该文件:

sudo vim /opt/tomcat/conf/tomcat-users.xml

然后在最后一行的前面加上如下两行,username 和 password 可自行设置:

<role rolename="manager-gui" />
<user username="admin" password="password" roles="manager-gui"/>

how-to-install-tomcat-on-ubuntu-7.png

编辑完了后保存,然后再重新启动 Tomcat 服务:

sudo /opt/tomcat/bin/catalina.sh stop
sudo /opt/tomcat/bin/catalina.sh start

重启后,再点击 Server Status 按钮,输入刚设置的用户名和密码就可以登录到后台:

how-to-install-tomcat-on-ubuntu-8.png

至此,Tomcat 就安装成功!

Eclipse JSP/Servlet 环境搭建

Eclipse JSP/Servlet 环境搭建

本文假定你已安装了 JDK 环境,如未安装,可参阅:

1. 踩过的坑

1) Tomcat 版本过新

最近 Tomcat 更新到了 9.0,为了求新我们安装了 Tomcat 9.0,但 Eclipse 目前最高只能识别 Tomcat 8 的版本。如图:

ubuntu-eclipse-1.png

所以为了解决问题,我们就只能再安装一个 Tomcat 8.0 了。因为 Tomcat 的安装只需要解压到指定目录即可,所以安装多少个版本都是互不影响的。如果你还不知道怎么安装,请参考:Ubuntu 下 Tomcat 的配置

安装 8.0 和 9.0 的唯一区别是,下载的源码版本不同,即 wget 这一步不一样,安装 8.0 可以在链接下载:

wget http://mirrors.cnnic.cn/apache/tomcat/tomcat-8/v8.0.32/bin/apache-tomcat-8.0.32.tar.gz

下载后可以解压到 /opt/tomcat8 这个目录,以便和之前的 /opt/tomcat 作区分。然后启动 Tomcat 8 当然就使用 sudo /opt/tomcat8/bin/catalina.sh start 这个命令了。

2) Eclispe 运行权限不够无法识别或执行 Tomcat 服务器

假设我们已经将 Tomcat 8.0 安装到了 /opt/tomcat8 这个目录。

由于最开始我们这个目录给除了 tomcat 用户外的其它用户的权限很小,也就是除了 tomcat 用户,其它用户都不能执行 Tomcat 甚至不能读取该目录。

所以如果我们的 Eclipse 是以普通用户权限运行的,那么它是没办法正确使用 Tomcat 的。

解决问题的办法有两个,第一种是更改 /opt/tomcat8 的权限,让普通用户能够访问并执行它;第二种是使用 root 权限来运行 Eclipse。这里把两种方法都写上。任选一种即可。

方法一:更改 /opt/tomcat8 的权限

打开终端,执行下面的命了即可,就是这么简单!也推荐就用这种做法:

cd /opt/
sudo chmod -R 775 tomcat8

ubuntu-eclipse-2.gif

方法二:使用 root 权限运行 Eclipse

为什么把这个方法也写上呢?是因为使用 root 权限运行 Eclipse 的时候也会遇到问题:

ubuntu-eclipse-3.gif

使用 root 运行 Eclispe 的时候,Eclispe 找不到 JAVA 运行环境了,也就是找不到 JRE 了。这和我们最初的环境变量设置有关。

那么如何解决问题呢?其实方法也有很多,可以修改环境变量,也可以用一种更巧妙的方式,就是将解压后的 jre/ 目录放在 eclipse.ini 所在的目录,也就是 /opt/eclipse/

我们之前已经安装过了 JDK,JDK 的安装目录是 /opt/java,里面就包含了 JRE,也就是 JRE 的目录是 /opt/java/jre。好了,下面就将 jre/ 复制过去试试。

ubuntu-eclipse-4.gif

好了,可以看到现在 Eclipse 已经正常启动了。

更多方法可参考:Eclipse - no Java (JRE) / (JDK) … no virtual machine

之所以举这两个例子,还有一点就是说明,解决一个问题的方法可能有很多种,仁者见仁,智者见智。

2. 关联 Eclispe 和 Tomcat

假设我们上面是通过第一种方法解决的 Eclispe 不能使用 Tomcat 的问题。

接下来启动 Tomcat

/opt/eclispe/eclipse

然后选择菜单栏 Windows-->preferences,弹出如下界面:

ubuntu-eclipse-5.gif

上图中,点击"add"的添加按钮,弹出选择 Tomcat 版本的界面。之前说到的 Eclipse 不能识别 Tomcat 9.0 就是这一步不能识别。我们已经又安装了 Tomcat 8.0,所以这里选择 Tomcat 8.0。

接着点击 Next,选择 Tomcat 的安装目录,并选择我们安装的 Java 环境:

ubuntu-eclipse-6.gif

3. 使用 Eclipse 创建第一个项目

1) 新建动态网站项目

选择 File-->New-->Dynamic Web Project,创建 TomcatTest 项目:

ubuntu-eclipse-7.gif

Eclipse 会自动选择默认的 Tomcat 版本。如果没有默认选择 Tomcat 的版本,则需求点击 New Runtime 按钮,选择我们刚才设置的 Tomcat 版本。

在Eclipse中只要创建一个Dynamic Web Project,就可以根据创建向导创建出一个典型 Java Web 站点的目录结构。除非有特殊需要,在大多数情况下都没有必要修改这个目录结构,这也是 Web 容器的缺省目录结构,我们只要直接使用即可。一般的目录结构如下:

ubuntu-eclipse-8.png

  • Deployment Descriptor:部署描述符。部署描述符描述了组件、模块或应用程序(如Web应用程序或企业级软件)应该如何部署。
  • JAX-WS Web Services:Java API for XML Web Services(JAX-WS)是 Java 程序设计语言一个用来创建 Web 服务的 API。
  • build:放入编译之后的文件。
  • WebContent:站点根目录。
WebContent (站点根目录) 
     |----META-INF (META-INF文件夹) 
     |----|---MANIFEST.MF (MANIFEST.MF配置清单文件) 
     |----WEB-INF (WEB-INF文件夹) 
     |----|----web.xml (站点配置web.xml) 
     |----|----lib (第三方库文件夹) 

WEB-INF:是Java的WEB应用的安全目录。所谓安全就是客户端无法访问,只有服务端可以访问的目录。如果想在页面中直接访问其中的文件,必须通过web.xml文件   对要访问的文件进行相应映射才能访问。

META-INF:文件夹相当于一个信息包,目录中的文件和目录获得Java 2平台的认可与解释,用来配置应用程序、扩展程序、类加载器和服务。

2). 新建一个 JSP 文件

接下来在 WebContent 文件夹下新建一个 test.jsp 文件:

ubuntu-eclipse-9.gif

接着我们修改下 test.jsp 文件。代码如下所示:

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Tomcat Test</title>
</head>
<body>
<%
	out.println("世界,你好!");
%>
</body>
</html>

ubuntu-eclipse-10.gif

然后运行该项目:

ubuntu-eclipse-11.gif

3). 新建一个 Servlet 文件

我们也可以使用以上环境创建 Servlet 文件,选择 File-->New-->Servlet

ubuntu-eclipse-12.gif

文件路径位于 TomcatTest项目的 /TomcatTest/src 目录下创建 "HelloServlet" 类,包为 "com.runoob.test"。

HelloServlet.java 代码如下所示:

package com.test.test;

import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * Servlet implementation class HelloWorld
 */
@WebServlet("/HelloWorld")
public class HelloWorld extends HttpServlet {
	private static final long serialVersionUID = 1L;
       
    /**
     * @see HttpServlet#HttpServlet()
     */
    public HelloWorld() {
        super();
        // TODO Auto-generated constructor stub
    }

	/**
	 * @see HttpServlet#doGet(HttpServletRequest request, HttpServletResponse response)
	 */
	protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
		// TODO Auto-generated method stub
		response.getWriter().append("Served at: ").append(request.getContextPath());
	}

	/**
	 * @see HttpServlet#doPost(HttpServletRequest request, HttpServletResponse response)
	 */
	protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
		// TODO Auto-generated method stub
		doGet(request, response);
	}

}

关于 JSP 和 Servlet 的关系,这里简单作几点说明:

  • JSP 是 HTML 内嵌 JAVA 代码。从本质上讲,JSP 是 Servlet 的扩展,是简易版的 Servlet。Servlet 则完全和 HTML 分离开来,只运用在 JAVA 文件中。

  • JSP引擎从磁盘中载入JSP文件,然后将它们转化为servlet。这种转化只是简单地将所有模板文本改用println()语句,并且将所有的JSP元素转化成Java代码。

  • JSP引擎将servlet编译成可执行类。

然后我们在运行一下 HelloWorld.java

ubuntu-eclipse-13.gif

使用 Ngnix 给 Node.js 应用做反向代理

一般来说使用 node.js 开发的 webapp 都不会是默认的80端口,以官方文档演示为例:

const http = require('http');

const hostname = '127.0.0.1';
const port = 3000;

const server = http.createServer((req, res) => {
  res.statusCode = 200;
  res.setHeader('Content-Type', 'text/plain');
  res.end('Hello World\n');
});

server.listen(port, hostname, () => {
  console.log(`Server running at http://${hostname}:${port}/`);
});

该例子使用的是 3000 端口,需要像 localhost:3000 这样,域名(或IP)加上 :port 才能访问。而一般 Web 应用都是监听的 80 端口。而普通应用一般只能监听 1024 以上的端口号,监听 80 端口需要 root 权限。而且 node.js 监听了 80 端口后,像 nginx 这类 HTTP Server 就只能选择监听其他端口了。

所以一般不使用 node.js 直接监听 80 端口,而是通过 nginx 来做反向代理。

Nginx 的具体配置如下:


upstream nodejs {
    server 127.0.0.1:3000;
    keepalive 64;
}

server {
    listen 80;
    # server_name 后面是域名,这里以 www.domain.com 为例
    server_name www.domain.com;
    # 日志
    access_log /var/log/nginx/test.log;
    location / {
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host  $http_host;
        proxy_set_header X-Nginx-Proxy true;
        proxy_set_header Connection "";
        proxy_pass http://nodejs;
    }
}

纯 CSS 实现响应式导航菜单

+++
title = "CSS Responsive Navigation Menu"
description = "纯 CSS 实现响应式导航菜单"
date = 2017-07-28T17:03:58+08:00
tags = ["CSS"]
categories = ["CSS"]
draft = false
+++

本文介绍如何使不使用 JavaScript 只使用 CSS 实现一个响应式导航菜单。菜单可以左对齐、右对齐或者居中。当用户鼠标移动到菜单上时,菜单就自动显示或隐藏,这样对用户也更加友好。菜单上也会有一个指示器,用来表示当前选中的菜单。该导航菜单兼容所有手机和桌面浏览器,甚至 Internet Explorer。

目标

本文的目标是实现一个导航菜单,当屏幕变小时,导航菜单由列表自动变为下来菜单。具体效果如下:

http://oh1ywjyqf.bkt.clouddn.com/CSS-Responsive-Navigation-Menu-01.gif

Nav 标签

下面是导航菜单的 HTML 标签。当创建绝对定位(absolute)的下拉菜单的时候,nav 标签是必须的。稍后将详细解释。.current 这个类用来表示当前选中的菜单。

<nav class="nav">
  <ul>
		<li class="current"><a href="#">HTML</a></li>
		<li><a href="#">CSS</a></li>
		<li><a href="#">JavaScript</a></li>
		<li><a href="#">Node.js</a></li>
		<li><a href="#">Java</a></li>
	</ul>
</nav>

CSS

导航菜单的 CSS (桌面视图)是非常直观的,所以我不会详细解释。注意下面 nav li 使用了 display:inline-block 代替了 float: left,来实现列表横向排列。然后通过对 ul 标签制定 text-align 属性,来使得菜单居中、居左或居右。其余CSS 都是一些装饰样式。

/* nav */
.nav {
	position: relative;
	margin: 20px 0;
}
.nav ul {
	margin: 0;
	padding: 0;
}
.nav li {
	margin: 0 5px 10px 0;
	padding: 0;
	list-style: none;
	display: inline-block;
}
.nav a {
	padding: 3px 12px;
	text-decoration: none;
	color: #999;
	line-height: 100%;
}
.nav a:hover {
	color: #000;
}
.nav .current a {
	background: #999;
	color: #fff;
	border-radius: 5px;
}

居中和居左对齐

如上面所说,可以通过改变 text-align 属性的值来改变菜单的对齐方式。

/* right nav */
.nav.right ul {
	text-align: right;
}

/* center nav */
.nav.center ul {
	text-align: center;
}

支持 IE

IE8 以及更老的版本不支持HTML5 的 <nav> 标签和媒体查询,可以通过引入 css3-mediaqueries-js (或 respond.js) 和 html5shiv 来支持。如果不想使用 html5shim.js,将 <nav> 标签替换为 <div> 标签就可以了。

<!--[if lt IE 9]>
	<script src="http://css3-mediaqueries-js.googlecode.com/files/css3-mediaqueries.js"></script>
	<script src="http://html5shim.googlecode.com/svn/trunk/html5.js"></script>
<![endif]-->

响应式

接下来就是比较有趣的一部分了:使用媒体查询使菜单能够自适应。

这里以 600px 作为断点,因为 nav 标签的定位是 relative,所以这里可以将 <ul> 菜单设置为 absolute,使其固定在 nav 的定部。然后通过 display:none 隐藏所有的 li 标签,使用 .current 这个类,将当前 li 显示为 block

因前面将 nav 标签定位设置为了 postion,所以将 <ul> 标签定位设置为 absolute 之后它就会排列在 nav 的顶部。然后通过设置 display:none 隐藏所有的 li 标签。然后当鼠标移动到 nav 上时,就将所有 li 标签设置为 display:block,这样来显示所有的下拉选项。我在 .current 类所在的标签添加了一个小图标来标志当前选中到菜单。如果想要让菜单居中或居右,在 ul 上添加 .right 或者 .left 属性就可以了。具体可以查看最终的 demo

@media screen and (max-width: 600px) {
	.nav {
		position: relative;
		min-height: 40px;
	}
	.nav ul {
		width: 180px;
		padding: 5px 0;
		position: absolute;
		top: 0;
		left: 0;
		border: solid 1px #aaa;
		background: #fff url(images/icon-menu.png) no-repeat 10px 11px;
		border-radius: 5px;
		box-shadow: 0 1px 2px rgba(0,0,0,.3);
	}
	.nav li {
		display: none; /* hide all <li> items */
		margin: 0;
	}
	.nav .current {
		display: block; /* show only current <li> item */
	}
	.nav a {
		display: block;
		padding: 5px 5px 5px 32px;
		text-align: left;
	}
	.nav .current a {
		background: none;
		color: #666;
	}

	/* on nav hover */
	.nav ul:hover {
		background-image: none;
	}
	.nav ul:hover li {
		display: block;
		margin: 0 0 5px;
	}
	.nav ul:hover .current {
		background: url(images/icon-check.png) no-repeat 10px 7px;
	}

	/* right nav */
	.nav.right ul {
		left: auto;
		right: 0;
	}

	/* center nav */
	.nav.center ul {
		left: 50%;
		margin-left: -90px;
	}

}

查看 DEMO


参考:

MySQL ibdata1 文件不可写

今天重启电脑后 MySQL 又用不了了!

然后查看了错误日志 :

$ sudo cat /usr/local/mysql/data/jh.local.err
2016-10-01T15:51:09.6NZ mysqld_safe Starting mysqld daemon with databases from /usr/local/mysql/data
2016-10-01T15:51:09.574413Z 0 [Warning] TIMESTAMP with implicit DEFAULT value is deprecated. Please use --explicit_defaults_for_timestamp server option (see documentation for more details).
2016-10-01T15:51:09.574540Z 0 [Warning] 'NO_ZERO_DATE', 'NO_ZERO_IN_DATE' and 'ERROR_FOR_DIVISION_BY_ZERO' sql modes should be used with strict mode. They will be merged with strict mode in a future release.
2016-10-01T15:51:09.574546Z 0 [Warning] 'NO_AUTO_CREATE_USER' sql mode was not set.
2016-10-01T15:51:09.574595Z 0 [Warning] Insecure configuration for --secure-file-priv: Current value does not restrict location of generated files. Consider setting it to a valid, non-empty path.
2016-10-01T15:51:09.574641Z 0 [Note] /usr/local/mysql/bin/mysqld (mysqld 5.7.13) starting as process 7326 ...
2016-10-01T15:51:09.579265Z 0 [Warning] Setting lower_case_table_names=2 because file system for /usr/local/mysql/data/ is case insensitive
2016-10-01T15:51:09.581901Z 0 [Note] InnoDB: Mutexes and rw_locks use GCC atomic builtins
2016-10-01T15:51:09.581934Z 0 [Note] InnoDB: Uses event mutexes
2016-10-01T15:51:09.581943Z 0 [Note] InnoDB: GCC builtin __atomic_thread_fence() is used for memory barrier
2016-10-01T15:51:09.581950Z 0 [Note] InnoDB: Compressed tables use zlib 1.2.3
2016-10-01T15:51:09.582282Z 0 [Note] InnoDB: Number of pools: 1
2016-10-01T15:51:09.582394Z 0 [Note] InnoDB: Using CPU crc32 instructions
2016-10-01T15:51:09.583648Z 0 [Note] InnoDB: Initializing buffer pool, total size = 128M, instances = 1, chunk size = 128M
2016-10-01T15:51:09.594097Z 0 [Note] InnoDB: Completed initialization of buffer pool
2016-10-01T15:51:09.606687Z 0 [ERROR] InnoDB: The innodb_system data file 'ibdata1' must be writable
2016-10-01T15:51:09.606728Z 0 [ERROR] InnoDB: The innodb_system data file 'ibdata1' must be writable
2016-10-01T15:51:09.606753Z 0 [ERROR] InnoDB: Plugin initialization aborted with error Generic error
2016-10-01T15:51:09.913995Z 0 [ERROR] Plugin 'InnoDB' init function returned error.
2016-10-01T15:51:09.914027Z 0 [ERROR] Plugin 'InnoDB' registration as a STORAGE ENGINE failed.
2016-10-01T15:51:09.914035Z 0 [ERROR] Failed to initialize plugins.
2016-10-01T15:51:09.914040Z 0 [ERROR] Aborting

2016-10-01T15:51:09.914056Z 0 [Note] Binlog end
2016-10-01T15:51:09.914142Z 0 [Note] Shutting down plugin 'CSV'
2016-10-01T15:51:09.914491Z 0 [Note] /usr/local/mysql/bin/mysqld: Shutdown complete

其中最关键的当然是 2016-10-01T15:51:09.606687Z 0 [ERROR] InnoDB: The innodb_system data file 'ibdata1' must be writable。说的是 ibdata1 文件不可写。

ibdata1 是InnoDB的共有表空间,默认情况下会把表空间存放在一个文件ibdata1中,(此原因会造成这个文件越来越大)。

所以大概能猜测是 mysql 用户的权限不够了。所以再给 ibdata1 目录分配一下权限即可。

这个时候需要查看一下mysql 安装目录权限。我没有查看,就直接使用 chown 改变了整个 mysql 目录的权限,这是一个非常不好的习惯。

$ cd /usr/local/mysql
$ sudo chown -R _mysql:_mysql *

这里需要注意的是,macOS 系统下,mysql 的用户组和用户名都是 _mysql,Linux 没记错的话应该是 mysql

然后再重启 MySQL:

$ sudo mysql.server start
Password:
Starting MySQL
 SUCCESS!

问题解决!

使用 Python and Keras 构建一个简单的神经网络

本文翻译自 A simple neural network with Python and Keras

a-simple-neural-network-with-python-and-keras-1

1. 使用 Python and Keras 构建 一个简单的神经网络

在开始之前,我们先快速复习一下当前最通用的神经网络架构:前馈网络。

我们接下来将写一个 Python 代码来定义我们的前馈神经网络,然后将其运用到 Kaggle Dogs vs. Cats(https://www.kaggle.com/c/dogs-vs-cats/data) 分类比赛中。这比赛的目标是,给出一张图像,然后区分它是猫还是狗。

最后,我们将检查我们的神经网络程序的区分结果,然后再讨论一下如何继续优化我们的架构,使结构更精确。

2. 前馈神经网络

目前有很多很多的神经网络架构,其中最通用的一种架构是前馈网络。

a-simple-neural-network-with-python-and-keras-2

上图是一个简单的前馈神经网络示意图,该前馈网络具有三个输入节点,一个具有两个节点的隐藏层,一个具有三个节点的隐藏层,还有两个节点的输出层。

在这种类型的架构中,要在这两个节点之间建立连接,必须要求这两个节点是 layer i 中的节点连接到 layer i+1 中的节点(这也是前馈网络这个术语的由来;前馈网络不允许反向连接,或者层内连接)。

并且,layer i 中的节点,必须完全连接(fully connected)到 layer i+1 中的节点。也就是说,layer i 中的每一个节点,必须连接到 layer i+1 中的每个节点。如上图所示,在 layer 0 和 layer 1 中,一共有 2 x 3 = 6 个节点 --- 这就是完全连接(fully connected),或者可以缩写为 FC

我们通常使用一系列的整数来快速简洁地描述每个 layer 中的节点。

例如,上图的前馈网络,我们可以称之为 3-2-3-2前馈神经网络

  • Layer 0 包含 3 个输入,我们可将其定义为 Xi。这些输入值可能是特征矢量的原始像素强度或矢量条目。
  • Layer 1 和 Layer 2 是隐藏层(hidden layers),分别包含两个和三个节点。
  • Layer 3 是输出节层(output layer),或叫可见层(output layer)。在这里网们将获得神经网络输出的分类。输出层可能有很多输出节点,每一个分类都对应着一个潜在的输出。在 Kaggle Dogs vs. Cats 的分类中,我们有两个输出节点,猫和狗。如果还有其他分类,就会有另一个与之对应的输出节点。

3. 使用 Python 和 Keras 实现我们的神经网络

现在我们已经理解了前馈神经网络的基本知识,接下来让我们使用 Python 和 Keras 实现我们用于图像分类的神经网络。

在开始之前,你需要在你的电脑上安装 Keras 这个框架。

接下来,新建一个名为 simple_neural_network.py 的文件,然后开始编码:

# import the necessary packages
from sklearn.preprocessing import LabelEncoder
from sklearn.cross_validation import train_test_split
from keras.models import Sequential
from keras.layers import Activation
from keras.optimizers import SGD
from keras.layers import Dense
from keras.utils import np_utils
from imutils import paths
import numpy as np
import argparse
import cv2
import os

在这段代码中,我们引入我们需要的 Python 的包。我们将使用 scikit-learn 和 Keras 来一起实现我们的函数。在这之前,你最好也需要知道怎么配置开发环境中的 Keras。

同时我们也需要用刀 imutils 这个包,这个包可以方便我们使用 OpenCV。如果你还没有安装 imutils,则先安装:

pip install imutils

接下来,我们需要定义一个函数,来接收图像,并且得到图像的原始像素强度。

为了完成这个目标,我们先定一个名为 image_to_feature_vector 的函数,它接受两个输入,一个是 image,另一个是 size

def image_to_feature_vector(image, size=(32, 32)):
	# resize the image to a fixed size, then flatten the image into
	# a list of raw pixel intensities
	return cv2.resize(image, size).flatten()

我们将调整图像,使我们收集到的图像都具有相同的矢量特征大小。这也是我们使用神经网络的一个先决条件,每张图像都必须由一个矢量来表示。

在这个函数里面,我们需要将图像调整为 32 x 32 像素,然后将图像转化为 32 x 32 x 3 = 3,072-d 的特征矢量。

接下来的代码,将处理解析命令行参数,注意其中的一些初始化操作:

# construct the argument parse and parse the arguments
ap = argparse.ArgumentParser()
ap.add_argument("-d", "--dataset", required=True,
	help="path to input dataset")
args = vars(ap.parse_args())

# grab the list of images that we'll be describing
print("[INFO] describing images...")
imagePaths = list(paths.list_images(args["dataset"]))

# initialize the data matrix and labels list
data = []
labels = []

我们使用 --dataset 参数来定义输入 Kaggle Dogs vs. Cats 图像的目录。这些图像图可以从 https://www.kaggle.com/c/dogs-vs-cats/data 这里下载。

imagePaths = list(paths.list_images(args["dataset"])) 这一行用来收集输入的图像。第 31 行 和第 32 分别初始化 data labels 列表。

现在我们已经有了 imagePaths ,接下来就可以循环处理它们中的每一项,把图像转换为特征矢量,然后将其添加到 datalabels 列表:

# loop over the input images
for (i, imagePath) in enumerate(imagePaths):
	# load the image and extract the class label (assuming that our
	# path as the format: /path/to/dataset/{class}.{image_num}.jpg
	image = cv2.imread(imagePath)
	label = imagePath.split(os.path.sep)[-1].split(".")[0]

	# construct a feature vector raw pixel intensities, then update
	# the data matrix and labels list
	features = image_to_feature_vector(image)
	data.append(features)
	labels.append(label)

	# show an update every 1,000 images
	if i > 0 and i % 1000 == 0:
		print("[INFO] processed {}/{}".format(i, len(imagePaths)))

现在 data 列表包含每个输入图像的扁平化 32 x 32 x 3 = 3,072-d 表示。当然,在我们开始训练我们的神经网络之前,我们首先要做一些预处理:

# encode the labels, converting them from strings to integers
le = LabelEncoder()
labels = le.fit_transform(labels)

# scale the input image pixels to the range [0, 1], then transform
# the labels into vectors in the range [0, num_classes] -- this
# generates a vector for each label where the index of the label
# is set to `1` and all other entries to `0`
data = np.array(data) / 255.0
labels = np_utils.to_categorical(labels, 2)

# partition the data into training and testing splits, using 75%
# of the data for training and the remaining 25% for testing
print("[INFO] constructing training/testing split...")
(trainData, testData, trainLabels, testLabels) = train_test_split(
	data, labels, test_size=0.25, random_state=42)

data = np.array(data) / 255.0 行将输入的数据范围调整到 [0, 1] 之间,labels = np_utils.to_categorical(labels, 2) 这一行将整数转换为矢量(在我们训练神经网络的交叉熵损失函数中需要用到)。

接下来的两行,我们就开始初始化我们的训练和测试代码,75% 的数据用来训练,25% 的数据用来测试。

现在我们将使用 Keras 来定义我们的神经网络:

# define the architecture of the network
model = Sequential()
model.add(Dense(768, input_dim=3072, init="uniform",
	activation="relu"))
model.add(Dense(384, init="uniform", activation="relu"))
model.add(Dense(2))
model.add(Activation("softmax"))

上面的代码就即用来构建我们的神经网络结构,一个 3072-768-384-2 前馈神经网络。

我们的 input layer 有 3072 个节点,在我们输入的扁平化图像厘米,每个节点有 32 x 32 x 3 = 3,072 个原始像素强度。

我们也有两个 hidden layer,分别有 768 和 384 个节点。这些节点的数目通过交叉验证和调整超参数实验来确定。

Output layer 包含两个节点,分别是猫和狗的类标签。

我们将在网络的顶部定义一个名为 softmax 的函数,这个函数将会得出实际输出类标签的概率。

接下来将使用 [Stochastic Gradient Descent (SGD)] (https://en.wikipedia.org/wiki/Stochastic_gradient_descent) 来训练我们的模型:

# train the model using SGD
print("[INFO] compiling model...")
sgd = SGD(lr=0.01)
model.compile(loss="binary_crossentropy", optimizer=sgd,
	metrics=["accuracy"])
model.fit(trainData, trainLabels, nb_epoch=50, batch_size=128,

为了训练我们的模型,我们将SGD的学习率参数设定为0.01。我们将使用 binary_crossentropy 损失函数也是如此。

在大多数情况下,你需要使用交叉熵 crossentropy ,但由于只有两个类标签,所以我们使用 binary_crossentropy。如果类标签数量大于 2,请确保使用交叉熵。

这个网络允许对 50 epochs 进行训练,意味着模型将看到对每个图片单独进行 50 次训练,以便了解底层的图案。

最后的代码块评估我们的Keras神经网络的测试数据:

# show the accuracy on the testing set
print("[INFO] evaluating on testing set...")
(loss, accuracy) = model.evaluate(testData, testLabels,
	batch_size=128, verbose=1)
print("[INFO] loss={:.4f}, accuracy: {:.4f}%".format(loss,
	accuracy * 100))

4. 使用构建的神经网络来分类图片

为了使用 simple_neural_network.py 这个脚本,请确保:

完整代码如下:

# USAGE
# python simple_neural_network.py --dataset kaggle_dogs_vs_cats

# import the necessary packages
from sklearn.preprocessing import LabelEncoder
from sklearn.cross_validation import train_test_split
from keras.models import Sequential
from keras.layers import Activation
from keras.optimizers import SGD
from keras.layers import Dense
from keras.utils import np_utils
from imutils import paths
import numpy as np
import argparse
import cv2
import os

def image_to_feature_vector(image, size=(32, 32)):
	# resize the image to a fixed size, then flatten the image into
	# a list of raw pixel intensities
	return cv2.resize(image, size).flatten()

# construct the argument parse and parse the arguments
ap = argparse.ArgumentParser()
ap.add_argument("-d", "--dataset", required=True,
	help="path to input dataset")
args = vars(ap.parse_args())

# grab the list of images that we'll be describing
print("[INFO] describing images...")
imagePaths = list(paths.list_images(args["dataset"]))

# initialize the data matrix and labels list
data = []
labels = []

# loop over the input images
for (i, imagePath) in enumerate(imagePaths):
	# load the image and extract the class label (assuming that our
	# path as the format: /path/to/dataset/{class}.{image_num}.jpg
	image = cv2.imread(imagePath)
	label = imagePath.split(os.path.sep)[-1].split(".")[0]

	# construct a feature vector raw pixel intensities, then update
	# the data matrix and labels list
	features = image_to_feature_vector(image)
	data.append(features)
	labels.append(label)

	# show an update every 1,000 images
	if i > 0 and i % 1000 == 0:
		print("[INFO] processed {}/{}".format(i, len(imagePaths)))

# encode the labels, converting them from strings to integers
le = LabelEncoder()
labels = le.fit_transform(labels)

# scale the input image pixels to the range [0, 1], then transform
# the labels into vectors in the range [0, num_classes] -- this
# generates a vector for each label where the index of the label
# is set to `1` and all other entries to `0`
data = np.array(data) / 255.0
labels = np_utils.to_categorical(labels, 2)

# partition the data into training and testing splits, using 75%
# of the data for training and the remaining 25% for testing
print("[INFO] constructing training/testing split...")
(trainData, testData, trainLabels, testLabels) = train_test_split(
	data, labels, test_size=0.25, random_state=42)

# define the architecture of the network
model = Sequential()
model.add(Dense(768, input_dim=3072, init="uniform",
	activation="relu"))
model.add(Dense(384, init="uniform", activation="relu"))
model.add(Dense(2))
model.add(Activation("softmax"))

# train the model using SGD
print("[INFO] compiling model...")
sgd = SGD(lr=0.01)
model.compile(loss="binary_crossentropy", optimizer=sgd,
	metrics=["accuracy"])
model.fit(trainData, trainLabels, nb_epoch=50, batch_size=128,
	verbose=1)

# show the accuracy on the testing set
print("[INFO] evaluating on testing set...")
(loss, accuracy) = model.evaluate(testData, testLabels,
	batch_size=128, verbose=1)
print("[INFO] loss={:.4f}, accuracy: {:.4f}%".format(loss,
	accuracy * 100))

然后,就可以像下面这样运行:

python simple_neural_network.py --dataset kaggle_dogs_vs_cats

运行后的程序输出如下:

a-simple-neural-network-with-python-and-keras-3

在我的 Titan X GPU 的电脑上,整个程序的执行,包括特征提取、训练神经网络、评估测试数据,一共花了 1m 15s。训练数据的准确率大约为 76%,测试数据的准确率大约为 67%。

大概 9% 的准确率差异,也是很常见的,这跟训练测试精度,和有限的测试数据相关。

在上面的程序中,我们最终获得了 67.376% 的精度,这也是比较高的精度了。当然,我们还可以很容易地通过使用卷积神经网络获得大于 95% 的准确率。

5. 总结

在上面的内容中,我示范了如何通过 Python and Keras 来构建和训练神经网络。我们将神经网络运用到了 Kaggle Dogs vs. Cats 的测试中,仅仅通过每张图片的原始像素强度,我们就最终得到了 67.376% 的精度。

Promise 的链式调用与中止

Abstract

本文主要讲的是如何实现 Promise 的链式调用。也就是 promise().then().then().catch() 的形式,然后讨论如何在某一个 then() 里面中止 Promise。

在程序中,只要返回了一个 promise 对象,如果 promise 对象不是 Rejected 或 Fulfilled 状态,then 方法就会继续调用。利用这个特性,可以处理多个异步逻辑。但有时候某个 then 方法的执行结果可能会决定是否需要执行下一个 then,这个时候就需中止 promise,主要**就是使用 reject 来中止 promise 的 then 继续执行。

“中止”这个词不知道用得是否准确。这里可能还是 break 的含义更精确,跳出本次 promise,不继续执行后面的 then 方法。但 promise 依旧会继续执行。

Can I use promises

当前浏览器对 Promise 的支持情况见下图:

http://caniuse.com/#search=promise

caniusepromise

Promise

先简单复习一下 Promise。Promise 其实很简单,就是一个处理异步的方法。一般可以通过 new 方法来调用 Promise 的构造器实例化一个 promise 对象:

var promise = new Promise((resolve, reject) => {
    // 异步处理
    // 处理结束后,调用 resolve 或 reject
    //      成功时就调用 resolve
    //      失败时就调用 reject
});

new Promise 实例化的 promise 对象有以下三个状态:

  • "has-resolution" - Fulfilled。resolve(成功)时,此时会调用 onFulfilled

  • "has-rejection" - Rejected。reject(失败)时,此时会调用 onRejected

  • "unresolved" - Pending。既不是resolve也不是reject的状态,也就是promise对象刚被创建后的初始化状态等

关于上面这三种状态的读法,其中左侧为在 ES6 Promises 规范中定义的术语, 而右侧则是在 Promises/A+ 中描述状态的术语。基本上状态在代码中是不会涉及到的,所以名称也无需太在意。

promise state

Promise Chain

先来假设一个业务需求:在系统中使用教务系统账号进行登录。首先用户在登录页面输入用户名(教务系统账号)和密码(教务系统密码);然后判断数据库中是否存在该用户;如果不存在则使用用户名和密码模拟登录教务系统,如果模拟登录成功,则存储用户名和密码,并返回登录成功。

听起来就有点复杂对不对?于是画了个流程图来解释整个业务逻辑:

flow char

上图只是一个简化版本,比如密码加密、session设置等没有表现出来,大家知道就好。图中 (1)(2)(3) 三个地方就是会进行异步处理的地方,一般数据库操作、网络请求都是异步的。

如果用传统的回调函数 callback 来处理上面的逻辑,嵌套的层级就会比较深,上面的业务因为有三个异步操作所以有三层回调,代码大概会是下面的样子:

// 根据 name 查询用户信息
findUserByName(name, function(err, userinfo) {
  if (err) {
    return res.json({
      code: 1000,
      message: '查询用户信息,数据库操作数出现异常',
    });
  }


  if (userinfo.length > 0) {
  // 用户存在
  if (userinfo[0].pwd === pwd)
    // 密码正确
    return res.json({
      code: 0,
      message: '登录成功',
    });
  }

  // 数据库中不存在该用户,模拟登录教务系统
  loginEducationSystem(name, pwd, function(err, result) {
    if (err) {
      return res.json({
        code: 1001,
        message: '模拟登录教务系统出现异常',
      });
    }

    // 约定正确情况下,code 为 0
    if (result.code !== 0) {
      return res.json({
        code: 1002,
        message: '模拟登录教务系统失败,可能是用户名或密码错误',
      });
    }

    // 模拟登录成功,将用户名密码存入数据库
    saveUserToDB(name, pwd, function(err, result) {
      if (err) {
        return res.json({
          code: 1003,
          message: '将用户名密码存入数据库出现异常',
        });
      }
      if (result.code !== 0) {
        return res.json({
          code: 1004,
          message: '将用户名密码存入数据库出现异常',
        });
      }

      return res.json({
        code: 0,
        message: '登录成功!',
      });
    });
  });
});

上面的代码可能存在的不优雅之处:

  • 随着业务逻辑变负责,回调层级会越来越深
  • 代码耦合度比较高,不易修改
  • 每一步操作都需要手动进行异常处理,比较麻烦

接下来再用 promise 实现此处的业务需求。使用 promise 编码之前,可以先思考两个问题。

一是如何链式调用,二是如何中止链式调用。

How to Use Promise Chain

业务中有三个需要异步处理的功能,所以会分别实例化三个 promise 对象,然后对 promise 进行链式调用。那么,如何进行链式调用?

其实也很简单,直接在 promise 的 then 方法里面返回另一个 promise 即可。例如:

function start() {
  return new Promise((resolve, reject) => {
    resolve('start');
  });
}

start()
  .then(data => {
    // promise start
    console.log('result of start: ', data);
    return Promise.resolve(1); // p1
  })
  .then(data => {
    // promise p1
    console.log('result of p1: ', data);
    return Promise.reject(2); // p2
  })
  .then(data => {
    // promise p2
    console.log('result of p2: ', data);
    return Promise.resolve(3); // p3
  })
  .catch(ex => {
    // promise p3
    console.log('ex: ', ex);
    return Promise.resolve(4); // p4
  })
  .then(data => {
    // promise p4
    console.log('result of p4: ', data);
  });

上面的代码最终会输出:

result of start:  start
result of p1:  1
ex:  2
result of p4:  4

代码的执行逻辑如图:

promise chain

从图中可以看出来,代码的执行逻辑是 promise start --> promise p1 --> promise p3 --> promise p4。所以结合输出结果和执行逻辑图,总结出以下几点:

  • promise 的 then 方法里面可以继续返回一个新的 promise 对象
  • 下一个 then 方法的参数是上一个 promise 对象的 resolve 参数
  • catch 方法的参数是其之前某个 promise 对象的 rejecte 参数
  • 一旦某个 then 方法里面的 promise 状态改变为了 rejected,则promise 方法连会跳过后面的 then 直接执行 catch
  • catch 方法里面依旧可以返回一个新的 promise 对象

How to Break Promise Chain

接下来就该讨论如何中止 promise 方法链了。

通过上面的例子,我们可以知道 promise 的状态改变为 rejected 后,promise 就会跳过后面的 then 方法。

也就是,某个 then 里面发生异常后,就会跳过 then 方法,直接执行 catch。

所以,当在构造的 promise 方法链中,如果在某个 then 后面,不需要再执行 then 方法了,就可以把它当作一个异常来处理,返回一个异常信息给 catch,其参数可自定义,比如该异常的参数信息为 { notRealPromiseException: true},然后在 catch 里面判断一下 notRealPromiseException 是否为 true,如果为 true,就说明不是程序出现异常,而是在正常逻辑里面中止 then 方法的执行。

代码大概就这样:

start()
  .then(data => {
    // promise start
    console.log('result of start: ', data);
    return Promise.resolve(1); // p1
    )
  .then(data => {
    // promise p1
    console.log('result of p1: ', data);
    return Promise.reject({
      notRealPromiseException: true,
    }); // p2
  })
  .then(data => {
    // promise p2
    console.log('result of p2: ', data);
    return Promise.resolve(3); // p3
  })
  .catch(ex => {
    console.log('ex: ', ex);
    if (ex.notRealPromiseException) {
      // 一切正常,只是通过 catch 方法来中止 promise chain
      // 也就是中止 promise p2 的执行
      return true;
    }
    // 真正发生异常
    return false;
  });

这样的做法可能不符合 catch 的语义。不过从某种意义上来说,promise 方法链没有继续执行,也可以算是一种“异常”。

Refactor Callback with Promise

讲了那么多道理,现在就改来使用 promise 重构之前用回调函数写的异步逻辑了。

// 据 name 查询用户信息
const findUserByName = (name, pwd) => {
  return new Promise((resolve, reject) => {
    // 数据库查询操作
    if (dbError) {
      // 数据库查询出错,将 promise 设置为 rejected
      reject({
        code: 1000,
        message: '查询用户信息,数据库操作数出现异常',
      });
    }
    // 将查询结果赋给 userinfo 变量
    if (userinfo.length === 0) {
      // 数据库中不存在该用户
      resolve();
    }
    // 数据库存在该用户,判断密码是否正确
    if (pwd === userinfo[0].pwd) {
      // 密码正确,中止 promise 执行
      reject({
        notRealPromiseException: true,
        data: {
          code: 0,
          message: '密码正确,登录成功',
        }
      });
    }
    // 密码不正确,登录失败,将 Promise 设置为 Rejected 状态
    reject({
      code: 1001,
      message: '密码不正确,登录失败',
    });
  });
};


// 模拟登录教务系统
const loginEducationSystem = (name, pwd) => {
  // 登录逻辑...
  // 登录成功
  resolve();
  // 登录失败
  reject({
    code: 1002,
    message: '模拟登录教务系统失败',
  });
};


// 将用户名密码存入数据库
const saveUserToDB(name, pwd) => {
  // 数据库存储操作
  if (dbError) {
    // 数据库存储出错,将 promise 设置为 rejected
    reject({
      code: 1004,
      message: '数据库存储出错,将出现异常',
    });
  }
  // 数据库存储操作成功
  resolve();
};


findUserByName(name)
.then(() => {
  return loginEducationSystem(name, pwd);
})
.then(() => {
  return saveUserToDB(name, pwd);
})
.catch(e => {
  // 判断异常出现原因
  if (e.notRealPromiseException) {
    // 正常中止 promise 而故意设置的异常
    return res.json(e.data);
  }
  // 出现错误或异常
  return res.json(e);
});

在上面的代码中,实例化了三个 promise 对象,分别实现业务需求中的三个功能。然后通过 promise 方法链来调用。相比用回调函数而言,代码结构更加清晰,也更易读易懂耦合度更低更易扩展了。

Promise.all && Promise.race

仔细观察可以发现,在上面的 promise 代码中,loginEducationSystemsaveUserToDB 两个方法执行有先后顺序要求,但没有数据传递。

其实 promise 方法链更好用的一点是,当下一个操作依赖于上一个操作的结果的时候,可以很方便地通过 then 方法的参数来传递数据。前面页提到过,下一个 then 方法的参数就是上一个 then 方法里面 resolve 的参数,所以当然就可以把上一个 then 方法的执行结果作为参数传递给下一个 then 方法了。

还有些时候,可能 then 方法的执行顺序也没有太多要求,只需要 promise 方法链中的两个或多个 promise 全部都执行正确。这时,如果依旧一个一个去写 then 可能就比较麻烦,比如:

function p1() {
  return new Promise((resolve) => {
    console.log(1);
    resolve();
  });
}

function p2() {
  return new Promise((resolve) => {
    console.log(2);
    resolve();
  });
}

function p3() {
  return new Promise((resolve) => {
    console.log(3);
    resolve();
  });
}

现在只需要 p1 p2 p3 这三个 promise 都执行,并且 promise 最终状态都是 Fulfilled,那么如果还是使用方法链,这是这样调用:

p1()
.then(() => {
  return p2();
})
.then(() => {
  return p3();
})
.then(() => {
  console.log('all done');
})
.catch(e => {
  console.log('e: ', e);
});

// 输出结果:
// 1
// 2
// 3
// all done

代码貌似就不那么精炼了。这个时候就有了 Promise.all 这个方法。

Promise.all 接收一个 promise对象的数组作为参数,当这个数组里的所有 promise 对象全部变为 resolve 或 reject 状态的时候,它才会去调用 then 方法。

于是,调用这几个 promise 的代码就可以这样写了:

p1()
.then(() => {
  return Promise.all([
    p2(),
    p3(),
  ]);
})
.then(() => {
  console.log('all done');
})
.catch((e) => {
  console.log('e: ', e);
});

// 输出结果:
// 1
// 2
// 3
// all done

这样看起来貌似就精炼些了。

而对于 Promise.race,其参数也跟 Promise.all 一样是一个数组。只是数组中的任何一个 promise 对象如果变为 resolve 或者reject 的话,该函数就会返回,并使用这个 promise 对象的值进行 resolve 或者 reject。

这里就不举例了。

Conclusion

到目前为止,我们就基本了解了 Promise 的用法及特点,并实现用 Promise 重构用回调函数写的异步操作。现在对 Promise 的使用,应该驾轻就熟了。

完。


Github Issue: #23

MySQL 连接错误 (61)

远程连接 MySQL 的时候出现如下错误:

Can't connect to MySQL server on '*.*.*.*' (61)

这是因为 MySQL 默认的 bind-address127.0.0.1, 也就是限制了只能本地访问.

所以解决方法之一,就是修改 bind-address.

改为 0.0.0.0 ,任何远程 IP 都能访问.你也可以把它改为你的电脑的 IP,只能你自己访问.

修改后,重启就好了:

$ sudo vim /etc/mysql/mysql.conf.d/mysqld.cnf
# 修改 bind-address
$ sudo service mysql restart

基于公众号的微信扫码登陆实现

+++
title = "基于公众号的微信扫码登陆实现"
date = 2017-12-09T21:42:31+08:00
tags = ["微信公众平台", "Node.js"]
categories = ["前端"]
draft = false
+++

注:文中阐述的方案是一个可以解决问题但不是最优的方案。改进之处在文末。

微信扫码登陆的主要目的有两个,一是方便用户,扫码即可登陆,不需再记忆账户密码;二是方便开发者,通过微信获取用户,并进行用户身份认证。其中最简单的一种方式是使用 微信开放平台。但需要 300 元的认证费用。而且如果已有微信公众平台,微信公众平台大部分功能也需要认证才能使用,而且也需要 300 元。所以对于我这种已经认证过微信公众平台,不想再花钱认证开放平台,只能想一个不使用开放平台的办法了,主要还是因为穷。本文就主要详细介绍如何基于微信公众平台实现扫码登陆,并阐述基于 Node.js 的技术方案和代码示例。

1. 场景

扫码登陆的场景很简单,就是用户首先在电脑的浏览器中打开登陆页面,页面上是一个二维码;然后用户使用微信扫一下页面上的二维码;然后就微信中就提示用户登陆成功了,电脑上的网页就自动跳转到登陆后的页面。

2. 设计方案

首先熟悉一个微信公众号的几个功能:

  • 对于每个微信用户,微信公众平台都有一个唯一的 openid 来标识该用户的身份,不同公众平台 openid 也不一样
  • 可以通过微信网页授权来获取用户的 openid 和个人信息

基于这些功能,我的方案如下:

http://oh1ywjyqf.bkt.clouddn.com/Wechat-Scan-QR-Code-to-Login-structure.png

接下来详细介绍图中的几个要点。

2.1. 二维码生成

要扫码登陆,首先就要生成二维码。二维码本质上就是存储数据的图片介质,其中的数据可以是 URL 也可以是文本等。通过二维码扫码工具就能扫出其中的数据。比如扫描上面设计图中的二维码,你就可以得到一段文本。所以基于此原理,我们就可以把 URL 存储在二维码中,微信扫码之后,会自动跳转到该 URL。

上面还提到,微信可以通过授权 URL 获取到用户的 openid,而第三步需要该 openi的,所以我们的 URL 需要是一个授权 URL。

在 node.js 中生成授权 URL 可以借助于 wechat-oauth 这个包。详细步骤如下:

const OAuth = require('wechat-oauth');
const client = new OAuth('wechat_appid', 'wechat_appsecret');
const url = client.getAuthorizeURL('redirectUrl', 'state', 'scope');

其中 redirectUrl 是网页授权回调域名,scope 是网页授权的方式,有 snsapi_basesnsapi_userinfostate 是我们自定义的一个参数,重定向后会带上该参数,所以一般可以用该参数来表示不同的业务。更详细的信息可以参考 微信网页授权

这样用户访问该 URL 之后就会被重定向到上面设置的 redirectUrl,并带上 code 参数。在第三步的时候,就可以根据 code 来获取用户的 openid。

然后由于还需要确定是哪个用户在进行登陆,即将微信和电脑浏览器对应起来,所以还需要一个唯一字符串来标识。即在 URL 中加上一个唯一 token,这样微信就能根据该 token 知道是哪一个客户端(浏览器,也就是用户)在进行登陆了。

所以我们可以生成一个 uuid 作为 token。生成 uuid 可以使用 node-uuid 这个包。然后我们可以将 uuid 作为 state 参数来生成授权 URL。

URL 的行使可能就像下面这样:

const url = client.getAuthorizeURL('http://nodejh.com', '985123a0-7e4f-11e7-9022-fb7190c856e4', 'snsapi_base');
console.log(url);
// https://open.weixin.qq.com/connect/oauth2/authorize?appid=wxb8c83c7bd4ac209f&redirect_uri=http%3A%2F%2Fnodejh.com&response_type=code&scope=snsapi_userinfo&state=985123a0-7e4f-11e7-9022-fb7190c856e4#wechat_redirect

最后根据这个带有 token 的 URL 生成一个二维码。生成二维码最简单的方式是使用 qr-image 这个包。

const fs = require('fs');
const qr = require('qr-image');

const qrSvg = qr.image('I love you!', { type: 'png', ec_level: 'H' });
qrSvg.pipe(fs.createWriteStream('qrcode.png'));

这样就会在当前目录下生成一个名为 qrcode.png 的二维码图片。当用户访问该登陆页面的时候,就返回该图片。

同时还需要做的事情是,将生成二维码的 token 也返回给客户端,因为后面还会用到该 token。可以将其放在 cookie 里面,也可以放在隐藏表单。

2.2 扫描二维码,获取 token

当用户用微信扫描登陆页面的二维码时,就会自动跳转到二维码对应的 URL 上。比如上面的例子,就会跳转到 https://open.weixin.qq.com/connect/oauth2/authorize?appid=wxb8c83c7bd4ac209f&redirect_uri=http%3A%2F%2Fnodejh.com&response_type=code&scope=snsapi_userinfo&state=985123a0-7e4f-11e7-9022-fb7190c856e4#wechat_redirect

接下来微信浏览器还将继续跳转回调域名,并带上 code 参数,可能跳转之后的页面就是 http://nodejh.com/?code=CODE&state=985123a0-7e4f-11e7-9022-fb7190c856e4

于是我们就可以自己的后端通过 state 参数中取得 token。

2.3 获取用户 openid

同时 URL 中还有 code 参数,我们就可以根据 code 来获取到用户的 openid。依旧可以使用 wechat-oauth

client.getAccessToken('code', function (err, result) {
  var accessToken = result.data.access_token;
  var openid = result.data.openid;
});

这样就可以得到 openid 了。

这里获取 token 和 openid 都是在我们自己的服务器中实现的。微信所做的事情就只是扫码获取二维码中的授权 URL,并生成 code 跳转到回调 URL。

有了 openid 我们还可以获取用户信息

client.getUser(openid, function (err, result) {
  var userInfo = result;
});

这个时候就可以做一些用户账号绑定的事情了。比如如果数据库里面没有该用户,就将用户信息存入;如果有用户,则可以更新用户微信信息,比如微信昵称或头像,可能改变了。

2.4 存储 token 和 openid

有了 token 和 openid,我们还需要将其存储,供客户端使用。可以将其存储到数据库里面,也可以存储到缓存。

当用户在客户端打开登陆页面之后,登陆页面就可以带上 token 轮询服务端,判断是否数据库中有该 token 对应的 openid。如果有,则说明用户已经扫码了,登陆成功;如果没有,则说明没有扫码,继续轮询直到有数据。

2.5 轮询

轮询可以循环发送 HTTP 请求,也可以使用 Web Socket。

当服务端发现用户已经扫码之后,就可以将登陆状态设置为已登陆,如设置 session,然后返回给客户端。客户端发现已登陆成功,则跳转到登陆后的页面。

3. 改进

上面的设计方案存在的主要题是,二维码是一直有效的。如果考虑到二维码失效怎么处理?

这个时候就可以简单改变一下思路。

前面是扫码的时候,将 token 和 openid 存储到数据库(或别的存储),客户端根据 token 轮询判断是否有数据。

考虑到二维码的实效性,则生成二维码的时候,就先将 token 存储到数据库,并设置一个 token 的过期时间。

当用户使用微信扫码的时候,获取到 token 和 openid。首先根据 token 判断一下数据库中是否有该 token 对应的数据,如果没有则不存储;如果有,则判断是否过期;如果有且 token 未过期,则更新 该 token 对应的 openid。这样就能达到二维码实效性的问题。

4. 总结

其实文中大部分内容描述的都不是最优的解决方案。是因为自己最开始思考的不够,没想到那么全。当然,改进部分描述的可能也不是最好的方案。但如果没有之前想到的那些,可能更不会想到可以改进的地方,索性就这么在本文中记录了一下。

JS 判断字符串是否是拼音音节

最终函数

最近在抓取 Rice 大学的博士生姓名,并从中获取到**人的姓名。由于博士有**人和外国人,而**人的姓名是由拼音组成,所以最终需求是这样的,判断一个字符串是否是由拼音音节组成。于是写了下面这个函数:

/**
 * 判断输入的一个字符串是不是拼音
 * @param string 需要测试的字符串
 * @returns {*}
 */
function is_pinyin(string) {

  var list = ['a', 'ai', 'an', 'ang', 'ao', 'ba', 'bai', 'ban', 'bang', 'bao', 'bei', 'ben',
    'beng', 'bi', 'bian', 'biao', 'bie', 'bin', 'bing', 'bo', 'bu', 'ca', 'cai', 'can', 'cang',
    'cao', 'ce', 'cen', 'ceng', 'cha', 'chai', 'chan', 'chang', 'chao', 'che', 'chen', 'cheng', 'chi',
    'chong', 'chou', 'chu', 'chua', 'chuai', 'chuan', 'chuang', 'chui', 'chun', 'chuo', 'ci', 'cong',
    'cou', 'cu', 'cuan', 'cui', 'cun', 'cuo', 'da', 'dai', 'dan', 'dang', 'dao', 'de', 'dei', 'den',
    'deng', 'di', 'dia', 'dian', 'diao', 'die', 'ding', 'diu', 'dong', 'dou', 'du', 'duan', 'dui', 'dun',
    'duo', 'e', 'en', 'eng', 'er', 'fa', 'fan', 'fang', 'fei', 'fen', 'feng', 'fiao', 'fo', 'fou', 'fu',
    'ga', 'gai', 'gan', 'gang', 'gao', 'ge', 'gei', 'gen', 'geng', 'gong', 'gou', 'gu', 'gua', 'guai', 'guan',
    'guang', 'gui', 'gun', 'guo', 'ha', 'hai', 'han', 'hang', 'hao', 'he', 'hei', 'hen', 'heng', 'hong', 'hou',
    'hu', 'hua', 'huai', 'huan', 'huang', 'hui', 'hun', 'huo', 'ji', 'jia', 'jian', 'jiang', 'jiao', 'jie',
    'jin', 'jing', 'jiong', 'jiu', 'ju', 'juan', 'jue', 'ka', 'kai', 'kan', 'kang', 'kao', 'ke', 'ken',
    'keng', 'kong', 'kou', 'ku', 'kua', 'kuai', 'kuan', 'kuang', 'kui', 'kun', 'kuo', 'la', 'lai', 'lan',
    'lang', 'lao', 'le', 'lei', 'leng', 'li', 'lia', 'lian', 'liang', 'liao', 'lie', 'lin', 'ling', 'liu',
    'lo', 'long', 'lou', 'lu', 'luan', 'lun', 'luo', 'lv', 'lve', 'ma', 'mai', 'man', 'mang', 'mao', 'me',
    'mei', 'men', 'meng', 'mi', 'mian', 'miao', 'mie', 'min', 'ming', 'miu', 'mo', 'mou', 'mu', 'na', 'nai',
    'nan', 'nang', 'nao', 'ne', 'nei', 'nen', 'neng', 'ni', 'nian', 'niang', 'niao', 'nie', 'nin', 'ning',
    'niu', 'nong', 'nou', 'nu', 'nuan', 'nun', 'nuo', 'nv', 'nve', 'o', 'ou', 'pa', 'pai', 'pan', 'pang', 'pao',
    'pei', 'pen', 'peng', 'pi', 'pian', 'piao', 'pie', 'pin', 'ping', 'po', 'pou', 'pu', 'qi', 'qia', 'qian',
    'qiang', 'qiao', 'qie', 'qin', 'qing', 'qiong', 'qiu', 'qu', 'quan', 'que', 'qun', 'ran', 'rang', 'rao',
    're', 'ren', 'reng', 'ri', 'rong', 'rou', 'ru', 'rua', 'ruan', 'rui', 'run', 'ruo', 'sa', 'sai', 'san',
    'sang', 'sao', 'se', 'sen', 'seng', 'sha', 'shai', 'shan', 'shang', 'shao', 'she', 'shei', 'shen', 'sheng',
    'shi', 'shou', 'shu', 'shua', 'shuai', 'shuan', 'shuang', 'shui', 'shun', 'shuo', 'si', 'song', 'sou',
    'su', 'suan', 'sui', 'sun', 'suo', 'ta', 'tai', 'tan', 'tang', 'tao', 'te', 'tei', 'teng', 'ti', 'tian',
    'tiao', 'tie', 'ting', 'tong', 'tou', 'tu', 'tuan', 'tui', 'tun', 'tuo', 'wa', 'wai', 'wan', 'wang',
    'wei', 'wen', 'weng', 'wo', 'wu', 'xi', 'xia', 'xian', 'xiang', 'xiao', 'xie', 'xin', 'xing', 'xiong',
    'xiu', 'xu', 'xuan', 'xue', 'xun', 'ya', 'yan', 'yang', 'yao', 'ye', 'yi', 'yin', 'ying', 'yo', 'yong',
    'you', 'yu', 'yuan', 'yue', 'yun', 'za', 'zai', 'zan', 'zang', 'zao', 'ze', 'zei', 'zen', 'zeng', 'zha',
    'zhai', 'zhan', 'zhang', 'zhao', 'zhe', 'zhei', 'zhen', 'zheng', 'zhi', 'zhong', 'zhou', 'zhu', 'zhua',
    'zhuai', 'zhuan', 'zhuang', 'zhui', 'zhun', 'zhuo', 'zi', 'zong', 'zou', 'zu', 'zuan', 'zui', 'zun', 'zuo'];
  var lowerString = string.toLowerCase();
  var length = lowerString.length;
  var index = -1;

  for (var i=0; i<length; i++) {
    var name = lowerString.substring(0, i+1);
    index = list.lastIndexOf(name) > index ? list.lastIndexOf(name) : index;
  }
  
  // 判断当前 lowerString 是不是拼音(lowerString 在 list 中就是;不在就不是)
  if (index >= 0) {
    var item = list[index];
     lowerString = lowerString.substring(item.length);
    if (lowerString.length == 0) {
      return string;
    } else {
       return arguments.callee(lowerString);
    }
  } else {
    return false;
  }
}

思路

1. 找到所有拼音音节表

首先是找到所有的拼音列表,将其存入数组 ist

汉语的拼音音节共 399 个,可以在百度百科找到:汉语拼音音节

2. 递归判断字符串

接下来要循环判断传入的字符串是否是拼音音节。也就是说,我们要判断所传入的字符串是否在 lsit 数组中,聪明的可能想到了,可以用 indexOf() 来判断。

那么问题来了,我们所要判断的字符串是姓名,形如 jianghang,是由两个甚至更多的拼音音节组成的。所以不能直接 list.indexOf('jianghang'),而是要不断循环截取 jianghang 这个字符串,判读所截取的字符串是否在 list 数组中。如第一次循环截取的是 j,然后判断 j 是否在 lsit 里面,如果不在,则继续截取 ji 再判断;如果是,则又从字符串的第三位开始继续判断,直到判断完整个字符串。其实也就是递归。

然后可能你又发现了,这么做还是有问题。因为 ji 是拼音音节,jiajianjiang 也都是拼音音节。所以这就涉及到一个最优的问题,我们要找到 jiang 而不是 ji 或其它。

根据 lsit 的规则,排在后面的音节总是最优的音节。所以我们使用 lastIndexOf() 对数组从后往前筛选。

3. 还是递归

这里需要注意的是 return arguments.callee(lowerString); 这一行。arguments 是一个类数组对象,它包含着传入函数的所有参数。这个对象有一个 callee 属性,该属性是一个指针,指向拥有 arguments 对象的函数。

这里我们使用 return arguments.callee(lowerString) 而不是用 return is_pinyin(lowerString) 的好处就是,当我们改变函数名的时候,我们仍然可以正确使用该函数。

找出一个数组中的最大值

本文介绍 JavaScript 的几种从数组中找出最大值的方法。

使用递归函数

var arr = [9,8,55,66,49,68,109,55,33,6,2,1];
var max = arr[0];
function findMax( i ){
  if( i == arr.length ) return max;
  if( max < arr[i] ) max = arr[i];
  findMax(i+1);
}

findMax(1);
console.log(max);

使用 for 循环遍历

var arr = [9,8,55,66,49,68,109,55,33,6,2,1];  
var max = arr[0];
for(var i = 1; i < arr.length; i++){
  if( max < arr[i] ){
    max = arr[i];
  }
}

console.log(max);

使用apply将数组传入max方法中直接返回

Math.max.apply(null,[9,8,55,66,49,68,109,55,33,6,2,1])

除此之外,还有很多数组排序方式,都可以在排序后,根据新数组索引值获取 最大/最小 值。

var a=[1,2,3,5];
console.log(Math.max.apply(null, a));//最大值
console.log(Math.min.apply(null, a));//最小值

多维数组可以这么修改:

var a=[1,2,3,[5,6],[1,4,8]];
var ta=a.join(",").split(",");//转化为一维数组
console.log(Math.max.apply(null,ta));//最大值
console.log(Math.min.apply(null,ta));//最小值

更改 Ubuntu 软件源

1. 软件管理工具 apt-get

Ubuntu 软件源本质上是一个软件仓库,我们可以通过 sudo apt-get install <package-name> 命令来从仓库中下载安装软件。

上面命令中提到的 apt-get 则是 Ubuntu 系统中的一个包管理工具,其常用的几个命令如下:

安装软件

$ sudo apt-cache search <package-name>

卸载软件

$ sudo apt-get remove <package-name>

只卸载软件,不删除配置文件等。

完全卸载

$ sudo apt-get purge <package-name>
# 或
$ sudo apt-get remove <package-name> --purge

删除包括配置文件在内的所有文件。

搜索软件

$ sudo apt-cache <package-name>

更多的关于 apt-get 的用法可使用 man apt-get 命令查看。

2. 软件源的分类

Ubuntu 软件源分为两种:

  • Ubuntu 官方软件源
  • PPA 软件源

Ubuntu 官方软件源中包含了 Ubuntu 系统中所用到的绝大部分的软件,它对应的源列表是 /etc/apt/sources.list

PPA 软件源即 Personal Package Archives(个人软件包档案)。有些软件没有被选入 UBuntu 官方软件仓库,为了方便Ubuntu用户使用,Launchpad 提供了 PPA,允许用户建立自己的软件仓库,自由的上传软件。PPA也被用来对一些打算进入Ubuntu官方仓库的软件,或者某些软件的新版本进行测试。

Launchpad 是 Ubuntu 母公司 Canonical 有限公司所架设的网站,是一个提供维护、支援或联络 Ubuntu 开发者的平台。

3. 修改官方软件源

由于在国内从 Ubuntu 官方源下载软件比较慢,所以我们通常需要更换软件源来加快下载速度。互联网上有很多开源镜像站点,下面列出了一些网站。

选择源列表的时候,可以先使用 ping 命令测试一下网络速度,选择最快的源。

3.1 源列表

Ubuntu Sources List Generator

可以在该网站上根据所处地区、Ubuntu发行版本等条件自动生成 Ubuntu 源。如我的 Ubuntu 版本号是 16.04,生成的源列表是:

#------------------------------------------------------------------------------#
#                            OFFICIAL UBUNTU REPOS                             #
#------------------------------------------------------------------------------#


###### Ubuntu Main Repos
deb http://cn.archive.ubuntu.com/ubuntu/ xenial main restricted universe multiverse
deb-src http://cn.archive.ubuntu.com/ubuntu/ xenial main restricted universe multiverse

###### Ubuntu Update Repos
deb http://cn.archive.ubuntu.com/ubuntu/ xenial-security main restricted universe multiverse
deb http://cn.archive.ubuntu.com/ubuntu/ xenial-updates main restricted universe multiverse
deb http://cn.archive.ubuntu.com/ubuntu/ xenial-proposed main restricted universe multiverse
deb http://cn.archive.ubuntu.com/ubuntu/ xenial-backports main restricted universe multiverse
deb-src http://cn.archive.ubuntu.com/ubuntu/ xenial-security main restricted universe multiverse
deb-src http://cn.archive.ubuntu.com/ubuntu/ xenial-updates main restricted universe multiverse
deb-src http://cn.archive.ubuntu.com/ubuntu/ xenial-proposed main restricted universe multiverse
deb-src http://cn.archive.ubuntu.com/ubuntu/ xenial-backports main restricted universe multiverse

顺便给出查看 Ubuntu 版本号的命令:

Codename:on:ID:axenial 16.04 LTS

Ubuntu Wiki 源列表

该站点提供了大量的开源镜像,可以先根据自己的版本号,点击版本号后面的 [详细] 链接进入 模板: Ubuntu source 页面,该页面提供了很多可用的服务器列表

3.2 修改源列表

  1. 首先备份源列表(for sure)

    $ sudo cp /etc/apt/sources.list /etc/apt/sources.list_backup
    
  2. 而后用gedit或其他编辑器打开

    $ sudo gedit /etc/apt/sources.list
    # 或 vim
    $ sudo vim /etc/apt/sources.list
    
  3. 从上面的列表中选择合适的源,替换掉文件中所有的内容,保存编辑好的文件。注意:一定要选对版

  4. 然后,刷新列表

    $ sudo apt-get update
    

    注意:一定要执行刷新

4. 修改 PPA 源

添加 PPA 软件源的命令:

$ sudo add-apt-repository ppa:user/ppa-name

删除 PPA 软件源的命令:

$ sudo add-apt-repository --remove ppa:user/ppa-name

比如 FireFox PPA 源,https://launchpad.net/~ubuntu-mozilla-daily/+archive/ppa ,我们可以在这里找到 ppa:ubuntu-mozilla-daily/ppa 的字样,然后我们通过以下命令把这个源加入到 source list 中:

$ sudo apt-add-repository ppa:ubuntu-mozilla-daily/ppa

关于 PPA 的源,可以通过 Google 搜索,也可以在 https://launchpad.net 网站搜索(可能效率比较低)。有的软件可能在官网提供了 PPA 源名称。

添加完成之后,就会在 /etc/apt/sources.list.d/ 里面创建一个文件:ubuntu-mozilla-daily-ubuntu-ppa-xenial.list。现在来看看里面的内容:

$ cat /etc/apt/sources.list.d/ubuntu-mozilla-daily-ubuntu-ppa-xenial.list
# deb-src http://ppa.launchpad.net/ubuntu-mozilla-daily/ppa/ubuntu xenial main

可以发现其源列表格式其实和官方源一模一样。之所以把 PPA 和官方源区分开来,是因为第三方源可能没有十足的保障。

添加完成之后,依旧需要使用 sudo apt-get update 来刷新。

实现一个TodoList - Vue2 Tutorials (二)

+++
title = "Vue2 Tutorials 02 TodoList"
description = "实现一个TodoList - Vue2 Tutorials (二)"
date = 2017-07-17T09:57:37+08:00
tags = ["vue"]
categories = ["vue"]
draft = false
+++

在了解了 Vue 的一些基本概念之后,就可以写一个最简单的小项目了 --- TodoList。麻雀虽小,五张俱全。虽然是一个小 demo,但也涉及到了组件化、双向绑定、自定义事件的触发与监听、计算属性等概念。接下来从这个小项目中,对这些基本概念进行实践,从而加深理解。

本文的所有代码在 https://github.com/nodejh/vue2-tutorials/tree/master/02.TodoList

最终实现效果如下:

02.TodoLos�t-3.png

接下来就一一实现。

初始化项目

同样使用 vue-cli 初始化项目,直接回车就好了。

$ vue init webpack todoListDemo
$ cd todoListDemo
$ npm install
$ npm run dev

启动之后,浏览器就会自动现默认的页面。

在进行编码之前,首先要考虑组件怎么设计。在本文中,组件结构如下。

+-----------------------+
|                       |
|  +-----------------+  |
|  |    Todo Add     |  |
|  +-----------------+  |
|  +-----------------+  |
|  |                 |  |
|  |   Todo List     |  |
|  |+---------------+|  |
|  ||   Todo Item   ||  |
|  |+---------------+|  |
|  |+---------------+|  |
|  ||   Todo Item   ||  |
|  |+---------------+|  |
|  |+---------------+|  |
|  ||   Todo Item   ||  |
|  |+---------------+|  |
|  |                 |  |
|  +-----------------+  |
|                       |
+-----------------------+

其中主要包括两个大的组件

  • TodoAdd 添加 Todo 的一个输入框
  • TodoList Todo 列表,里面有每一个 Todo Item

添加 TodoList 组件

src/components 目录下新建一个名为 TodoList.vue 的文件,并添加如下代码:

<template>
  <div id="todoList">
    <h1>Todo List</h1>
    <ul class="todos">
      <li v-for="todo, index in todos" class="todo">
        <input
          type="checkbox"
          name=""
          value=""
          :checked="todo.isCompleted"
        >
        <span
          :class="todo.isCompleted ? 'completed' : ''"
          @
        >
          <em>{{ index + 1 }}.</em>{{ todo.text }}
        </span>
      </li>
    </ul>
  </div>
</template>
<script>
export default {
  name: 'TodoList',
  data: () => ({
    todos: [{
      text: '吃饭',
      isCompleted: false
    }, {
      text: '睡觉',
      isCompleted: false
    }]
  })
}
</script>
<style scoped>
#todoList {
  margin: 0 auto;
  max-width: 350px;
}
.todos li {
  list-style: none;
}
.todo {
  text-align: left;
  cursor: pointer;
}
.completed {
  text-decoration: line-through;
}
</style>

TodoList 中,使用 todos 数组来保存所有的 todo list。其中每一个 todo 都是对象,对象里面有两个属性,分别是 todo 的内容,和 todo 是否完成的标志。默认给数组添加了两个 todo,主要用于演示。

src/components/Hello.vue 在本项目中没什么用,可以随意删除。

然后修改 src/router/index.js

import Vue from 'vue'
import Router from 'vue-router'
import TodoList from '@/components/TodoList'

Vue.use(Router)

export default new Router({
  routes: [
    {
      path: '/',
      name: 'todoList',
      component: TodoList
    }
  ]
})

修改完之后,vue 会自动重新编译并刷新页面,这时浏览器的页面如下:

02.TodoLos�t-1.png

添加完成 Todo 的方法

在该 demo 中,当点击 todo item 或者前面的复选框的时候,就完成 todo。所以现在需要添加完成 todo 的方法,并设置 todo item 的点击事件。

像下面这样修改 src/components/TodoList.vue 中的 template 部分:

<input
  type="checkbox"
  name=""
  value=""
  :checked="todo.isCompleted"
  @click="completed(index)"
>
<span
  :class="todo.isCompleted ? 'completed' : ''"
  @click="completed(index)"
>
  <em>{{ index }}.</em>{{ todo.text }}
</span>

然后在组件里面添加对应的 completed 方法:

<script>
export default {
  // 其他现有代码
  name: 'TodoList',
  methods: {
    completed(index) {
      this.todos[index].isCompleted = !this.todos[index].isCompleted
    }
  }
}
</script>

当点击 check boxspan 的时候,就调用 completed 方法并传入被点击的 todo item 的索引。在 completed 方法里面,更新数据对象 data 里面对应的 todo item 的 isCompleted 属性。这样就实现了完成 todo 和取消完成 todo 的功能。点击之后如图:

02.TodoLos�t-2.png

TodoAdd 组件

接下来就需要完成添加新的 todo 的功能了。

新建一个文件 src/components/TodoAdd.vue,添加如下代码:

<template>
  <div id="addTodo">
    <input
      type="text"
      name=""
      class="input"
      value=""
      v-model="todo"
      @keyup.enter="addTodo"
    >
    <button
      type="button"
      name="button"
      @click="addTodo"
    >
        添加
    </button>
  </div>
</template>
<script>
export default {
  name: 'addTodo',
  data: () => ({
    todo: ''
  }),
  methods: {
    addTodo () {
      if (this.todo) {
        this.$emit('add', this.todo)
        this.todo = ''
      } else {
        alert('内容不能为空')
      }
    }
  }
}
</script>
<style scoped>
.input {
  min-width: 200px;
}
</style>

首先在组件的数据对象 data 里面有一个 todo 属性,用来存储用户输入的内容。然后在 templateinput 输入框里,使用 v-model 实现双向绑定。

当用户按下回车(@keyup.enter="addTodo",详见 键值修饰符)或者点击添加按钮(@click="addTodo")的时候,就调用 methods 里面的 addTodo 方法。

addTodo 方法通过 vm.$emit 触发了一个 add 事件,并将用户输入的内容(即 this.todo)作为参数传递。事件触发之后,将输入框中的内容清空。

接下来就需要监听 add 事件了。监听事件需要在使用组件的模板里面,通过 v-on 来实现。详见 使用-v-on-绑定自定义事件

完成添加 Todo 功能

src/components/TodoList.vue 中使用 AddTodo 这个子组件:

<h1>Todo List</h1>
<!-- 调用子组件,并使用 v-on 监听 add 方法 -->
<!-- 当 add 事件触发时,就调用当前组件 addTodo 这个方法 -->
<todo-add v-on:add="addTodo"></todo-add>
<ul class="todos">
<!-- // 调用子组件 -->

<script>
  // 引入子组件
  import TodoAdd from './TodoAdd.vue'
  export default {
    name: 'TodoList',
    components: {
      TodoAdd
    },
    // ...
    methods: {
      // ...
      // 添加新的 todo
      addTodo() {
        this.todos.push({
          text: todo,
          isCompleted: false
        })
      }
    }
  }
</script>

到此,添加 todo 和完成 todo 功能就实现了。

Todo 统计

接下来还可以做点别的事情,比如显示总共的 todo 数目,以及完成和未完成的数目。

要实现此功能,方法有很多种。最简单的一种是直接在模板中加入 JS 表达式,来显示总共的数目,比如:

<p>总共有 <strong>{{ this.todos.lengt }}</strong> 个待办事项。</p>

对于简单的逻辑可以很方便用表达式写出来,但如果是比较复杂的逻辑,比如统计未完成数目(当然这个也可以用一个表达式搞定),可能一个表达式看起来就不太清晰。这个时候就可以用计算属性

修改 src/components/TodoList.vue

<!-- // ... -->
<ul class="todos">
<!-- // ... -->
</ul>
<div>
  <p v-show="todos.length === 0">
    恭喜!所有的事情都已完成!
  </p>
  <p v-show="todos.length !== 0"><strong>{{ todos.length }}</strong> 个待办事项。{{ completedCounts }} 个已完成,{{ notCompletedCounts }} 个未完成。
  </p>
</div>


<script>
// ...
export default {
  name: 'TodoList',
  // ...
  computed: {
    completedCounts () {
      return this.todos.filter(item => item.isCompleted).length
    },
    notCompletedCounts () {
      return this.todos.filter(item => !item.isCompleted).length
    }
  }
}
</script>

上述代码中通过 completedCountsnotCompletedCounts 两个计算属性,来计算出已完成和未完成的 todo。虽然这两个表达式可以直接放在模板中,但表达式比较复杂,看起来也不是很清晰,所以很多时候就可以用计算属性来计算出一个最终值,然后在模板中使用。

总结

到此,基于 Vue 的 Todo List 就完成了。在该项目中,对组件化、双向绑定、自定义事件的触发与监听、计算属性等概念进行了实践。当然,最重要的不是完成这个 Todo List 的代码,而是从实现功能的过程中举一反三,通过简单的 demo 实现,去思考如何用 vue 开发一个更大更完整的项目。

Electron 快速入门

+++
title = "Electron Quick Start"
description = "Electron 快速入门"
date = 2017-07-06T15:32:26+08:00
tags = ["electron"]
categories = ["electron"]
draft = true
+++

简介

Electron 是一个可以使用 Web 技术如 JavaScript、HTML 和 CSS 来创建跨平台原生桌面应用的框架。借助 Electron,我们可以使用纯 JavaScript 来调用丰富的原生 APIs。

Electron用 web 页面作为它的 GUI,而不是绑定了 GUI 库的 JavaScript。它结合了 Chromium、Node.js 和用于调用操作系统本地功能的 APIs(如打开文件窗口、通知、图标等)。

Electron-Quick-Start-00

现在已经有很多由 Electron 开发应用,比如 AtomInsomniaVisual Studio Code 等。查看更多使用 Electron 构建的项目可以访问 Apps Built on Electron

安装

安装 electron 之前,需要安装 Node.js。如果没有安装,推荐使用 nvm 等 Node.js 版本管理工具进行安装/

然后建议修改 electron 的源为国内源:

$ export ELECTRON_MIRROR="https://npm.taobao.org/mirrors/electron/"

不然会出现如下错误:

Error: connect ETIMEDOUT 54.231.50.42:443
    at Object.exports._errnoException (util.js:1016:11)
    at exports._exceptionWithHostPort (util.js:1039:20)
    at TCPConnectWrap.afterConnect [as oncomplete] (net.js:1138:14)

安装 electron:

$ npm install electron -g

进程

Electron 的进程分为主进程和渲染进程。

主进程

在 electron 里面,运行 package.json 里面 main 脚本的进程成为主进程。主进程控制整个应用的生命周期,在主进程中可以创建 Web 形式的 GUI,而且整个 Node API 是内置其中。

渲染进程

每个 electron 的页面都运行着自己的进程,称为渲染进程。

主进程与渲染进程的联系及区别

主进程使用 BrowserWindow 实例创建页面。每个 BrowserWindow 实例都在自己的渲染进程里运行页面。当一个 BrowserWindow 实例被销毁后,相应的渲染进程也会被终止。

主进程管理所有页面和与之对应的渲染进程。每个渲染进程都是相互独立的,并且只关心他们自己的页面。

在 electron 中,页面不直接调用底层 APIs,而是通过主进程进行调用。所以如果你想在网页里使用 GUI 操作,其对应的渲染进程必须与主进程进行通讯,请求主进程进行相关的 GUI 操作。

在 electron 中,主进程和渲染进程的通信主要有以下几种方式:

  • ipcMain、ipcRender
  • Remote 模块

进程通信将稍后详细介绍。

打造第一个 Electron 应用

以下所有代码可以在 https://github.com/nodejh/electron-quick-start 找到。

一个最简单的 electron 应用目录结构如下:

electron-demo/
├── package.json
├── main.js
└── index.html

package.json 与 Node.js 的完全一致,所以我们可以使用 npm init 来生成。然后将 "main": "index.js" 修改为 "main": "main.js"。之所以命名为 main.js,主要是为了与主进程这个概念对应。

main.js

创建 main.js 文件并添加如下代码:

const electron = require('electron');

const {
  app, // 控制应用生命周期的模块
  BrowserWindow, // 创建原生浏览器窗口的模块
} = electron;

// 保持一个对于 window 对象的全局引用,如果不这样做,
// 当 JavaScript 对象被垃圾回收, window 会被自动地关闭
let mainWindow;

function createWindow() {
  // 创建浏览器窗口。
  mainWindow = new BrowserWindow({width: 800, height: 600});

  // 加载应用的 index.html。
  // 这里使用的是 file 协议,加载当前目录下的 index.html 文件。
  // 也可以使用 http 协议,如 mainWindow.loadURL('http://nodejh.com')。
  mainWindow.loadURL(`file://${__dirname}/index.html`);

  // 启用开发工具。
  mainWindow.webContents.openDevTools();

  // 当 window 被关闭,这个事件会被触发。
  mainWindow.on('closed', () => {
    // 取消引用 window 对象,如果你的应用支持多窗口的话,
    // 通常会把多个 window 对象存放在一个数组里面,
    // 与此同时,你应该删除相应的元素。
    mainWindow = null;
  });
}

// Electron 会在初始化后并准备
// 创建浏览器窗口时,调用这个函数。
// 部分 API 在 ready 事件触发后才能使用。
app.on('ready', createWindow);

// 当全部窗口关闭时退出。
app.on('window-all-closed', () => {
  // 在 macOS 上,除非用户用 Cmd + Q 确定地退出,
  // 否则绝大部分应用及其菜单栏会保持激活。
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

app.on('activate', () => {
  // 在 macOS 上,当点击 dock 图标并且该应用没有打开的窗口时,
  // 绝大部分应用会重新创建一个窗口。
  if (mainWindow === null) {
    createWindow();
  }
});

关于 appBrowserWindow 对象和实例的更多用法可参考 electron 的文档:

index.html

然后编辑需要展示的 index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Hello World!</title>
    <style media="screen">
      .version {
        color: red;
      }
    </style>
  </head>
  <body>
    <h1>Hello World!</h1>
    We are using Node.js
    <span id="version-node" class="version"></span>
    and Electron
    <span id="version-electron" class="version"></span>
    <script type="text/javascript">
      console.log('process: ', process);
      var versionNode = process.version;
      var versionElectron = process.versions['electron'];
      document.getElementById('version-node').innerText = versionNode
      document.getElementById('version-electron').innerText = versionElectron
    </script>
  </body>
</html>

在这个例子中,我们显示出了 electron 使用的 Node.js 版本和 electron 的版本。index.html 跟网页的 HTML 一摸一样,只是多了一些 electron 的全局对象。

运行

因为前面已经全局安装了 electron,所以我们可以使用 electron 命令来运行项目。在 electron-demo/ 目录里面运行下面的命令:

$ electron .

然后会弹出一个 electron 应用客户端,如图所示:

Electron-Quick-Start-01

因为在主进程中启用了开发模式 mainWindow.webContents.openDevTools(),所以默认启动开发者工具。

如果是局部安装的 electron,即 npm install --save electron,则可以运行下面的命令来启动应用:

$ ./node_modules/.bin/electron .

进行通信

对于 electron 来说,主进程和渲染进程直接的通信是必不可少的。

前面提到过 electron 进程间的通信的方式主要有两种,一种是用于发送消息的 ipcMainipcRenderer 模块,一种用于 RPC 的 remote 模块。

现在假设一个业务场景,用户在页面中输入文本消息,渲染进程将消息发送给主进程,主进程处理后将处理结果返回给页面。为了方便起见,主进程的处理就假设为翻转文本。当然,这个功能在前端完全可以实现,这里只是为了演示进程通信。

ipcMain 和 ipcRenderer

首先在渲染进程中添加一个输入框和一个按钮,并实现点击按钮获取输入框的内容。然后使用 ipcRenderer 发送消息。主进程接收到消息并处理之后,会返回处理结果。所以渲染进程中还需要接收主进程的消息。

修改 index.html,添加下面的代码:

<!-- 在 body 部分添加一个输入框和按钮 -->
<div>
  <input type="text" id="message" name="" value="">
  <br/>
  <button type="button" id="button" name="button">click me</button>
</div>

<script type="text/javascript">
  // ...

  // 添加下面的代码。
  // 引入 ipcRenderer 模块。
  var ipcRenderer = require('electron').ipcRenderer;
  document.getElementById('button').onclick = function () {
    var message = document.getElementById('message').value;
    // 使用 ipcRenderer.send 向主进程发送消息。
    ipcRenderer.send('asynchronous-message', message);
  }

  // 监听主进程返回的消息
  ipcRenderer.on('asynchronous-reply', function (event, arg) {
    alert(arg);
  });
</script>

接下来在主进程中接收渲染进程的消息,并进行处理(翻转字符串),然后将处理结果发送给主进程。修改 main.js 如下:

//...

// 监听渲染进程发送的消息
ipcMain.on('asynchronous-message', (event, arg) => {
  const reply = arg.split('').reverse().join('');
  console.log('reply: ', reply);
  // 发送消息到主进程
  event.sender.send('asynchronous-reply', reply);
});

然后重新运行项目。在页面的输入框内输入字符,点击按钮,就能弹出如下的弹出框,说明渲染进程与主进程通信成功:

http://oh1ywjyqf.bkt.clouddn.com/Electron-Quick-Start-02.png

remote

remote 模块提供了一种在渲染进程(网页)和主进程之间进行进程间通讯(IPC)的简便途径。

使用 remote 模块,我们可以很方便地调用主进程对象的方法,而不需要发送消息。

index.html<script> 标签中添加如下代码:

// 引入 remote 模块
var remote = require('electron').remote;
// 获取主进程中的 BrowserWindow 对象
var BrowserWindow = remote.BrowserWindow;
// 创建一个渲染进程
var win = new BrowserWindow({ width: 200, height: 150 });
win.loadURL('http://nodejh.com');

然后使用 ctr + r 组合键刷新应用,就会看到创建出的一个新窗口。

打包

Electron 应用开发完成之后,还需要将其打包成对应平台的客户端。常用的打包工具有 electron-packagerasar

这里以 electron-packager 为例。首先全局安装 electron-packager:

$ npm install electron-packager -g

然后在项目中安装 electron:

$ npm install electron --save-dev

然后打包:

$ electron-packager . electron-demo

总结

本文首先对 electron 做了简单的介绍,然后讲解了 electron 进程的概念,其进程包括主进程和渲染进程。然后创建了一个简单的 electron 应用,并通过实现一个简单的应用场景,对 electron 进程间的通信做了实践。总体来说,使用 electron 创建桌面客户端的开发体验跟写 Node.js 和网页差不多。但本文对内置模块比如 app、ipcMain、ipcRender、remote 等的介绍比较粗浅,涉及到一些内置模块的使用,还需要继续查询 electron 的官方文档,只有实践越多,才能越熟悉。

--

深入理解 Oracle 启动原理

+++
categories = ["Database"]
date = "2017-03-26T00:42:23+08:00"
description = "深入理解 Oracle 启动原理"
draft = false
tags = ["数据库", "Oracle", "Database"]
title = "Understand the Oracle startup process"

+++

一. 常用启动步骤

对于普通用户,如果需要使用 Oracle 数据库,需要两个启动步骤:启动数据库和启动监听器。

如果还需要使用 OEM 来监控数据库服务,则还要启动 OEM。

1.1. 启动监听器

Oracle 监听器是一个独立的后台进程,用于监听客户端向数据库服务器端提出的连接请求,它是客户端和服务器端通讯的桥梁。

启动监听器:

$ lsnrctl start

1.2. 启动数据库

我们可以使用 sqlplus 来启动数据库。关于 sqlplus 的详细使用方法请参考 《使用 SQL *Plus 管理 Oracle 数据库》

进入 sqlplus

$ sqlplus / as sysdba

启动数据库:

>SQL startup

完成这两个步骤,就可以使用数据库了。

1.3. 启动 OEM

Oracle Enterprise Manager(Oracle企业管理器,简称 OEM )是一个图形化数据库管理工具,可同时监控管理多个系统上的多个数据库,因而特别适合分布式环境。

启动 OEM:

$ emctl start dbconsole

启动成功后就可以通过 http://服务器:1158/em 来访问基于 Web 的监控页面。

二. 启动概述

Oracle Serve 由实例(Instance)和数据库(database)组成,每一个运行的 Oracle 数据库都与一个 Oracle 实例关联。

实例是由一组后台进程和一块称为系统全局区 SGA(System Global Area)的共享内存段组成。后台进程是数据库和操作系统进行交互的通道,后台进程的命名由 ORACLE_SID 决定,Oracle 根据 ORACLE_SID 来寻找参数文件启动实例。数据库是指存储在磁盘上的一组物理文件。

Oracle 数据库具有四种状态,启动过程具有三个阶段。

四种状态分别 shutdown nomount mount open,对应三个阶段分别为:

  • 启动实例 shutdown --> nomount
  • 装载数据库 nomount --> mount
  • 打开数据库 mount --> open
                                                    ^
                                           open     |
                                     +--------------|
                                     |   All files
                                     |   opened as
                                     | described by
                              mount  |  the control
                       +-------------+ file for this
                       | Control file    instance
                       |    opened
                       |   for this
               nomount |   instance
           +-----------+
           |  Instance
           |   started
           |
  shutdown |
-----------+

启动实例后,Oracle 软件会将实例与特定的数据库关联,这个过程称为装载数据库。接下来可以打开数据库,以便授权用户访问数据库。在同一台计算机上可以并发执行多个实例,每一个实例只访问自己的物理数据库。

三. 启动详解

为了弄清楚 Oracle 启动过程的详细内容,我们需要用到两个命令:

  • ps 用来查看系统运行了哪些进程
  • ipcs 查询进程间通信设施状态,显示使用了共享内存和信号量

3.1. shutdown 状态

当 Oracle 处于该状态的时候,Oracle 的所有文件都静静的躺在磁盘里,一切都还未开始,属于关机状态。

$ ps -ef | grep oracle
oracle    4524     1  0 00:54 ?        00:00:39 /data/oracle/product/11.2.0/db_1/bin/emagent
root     12825   974  0 13:49 ?        00:00:00 sshd: oracle [priv]
oracle   12832 12825  0 13:49 ?        00:00:00 sshd: oracle@pts/0
oracle   12833 12832  0 13:49 pts/0    00:00:00 -bash
oracle   13825 12833  0 13:54 pts/0    00:00:00 ps -ef
oracle   13826 12833  0 13:54 pts/0    00:00:00 grep --color=auto oracle
$ ipcs -a

------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages

------ Shared Memory Segments --------
key        shmid      owner      perms      bytes      nattch     status
0x011268f0 458753     root       600        1000       8

------ Semaphore Arrays --------
key        semid      owner      perms      nsems

3.2. 启动实例 shutdown --> nomount

总体来说,启动数据库实例包括以下操作:

  1. 读取参数文件 SPFILE
  2. 分配 SGA
  3. 启动后台进程
  4. 打开告警文件和跟踪文件

在启动实例时,将为实例创建一系列后台进程和服务进程,并且在内存中创建 SGA 区等内存结构。在实例启动的过程中只会使用到初始化参数文件,数据库是否存在对实例的启动没有影响。如果初化参数设置有误,实例将无法启动。

启动数据库实例的命令如下:

$ sqlplus / as sysdba

SQL*Plus: Release 11.2.0.1.0 Production on Sun Mar 26 14:05:14 2017

Copyright (c) 1982, 2009, Oracle.  All rights reserved.

Connected to an idle instance.

SQL> startup nomount
ORACLE instance started.

Total System Global Area 3273641984 bytes
Fixed Size		    2217792 bytes
Variable Size		 2432698560 bytes
Database Buffers	  822083584 bytes
Redo Buffers		   16642048 bytes

启动数据库实例后,只会创建实例(即创建 Oracle 实例的各种内存结构与服务进程),并不加载数据库,也不会打开任何数据文件。

测试数据文件能否打开:

SQL> select * from v$datafile;
select * from v$datafile
              *
ERROR at line 1:
ORA-01507: database not mounted

select * from v$datafile 的时候报错,说明数据库文件在 nomount 状态下是无法访问的,因为数据字典需要从控制文件获取文件的信息,而此时控制文件没有打开所以无法查看。

但是在 nomount 状态下可以通过参数文件获得控制文件的位置,因为此时参数文件已经打开:

SQL> show parameter control_files;

NAME				     TYPE
------------------------------------ 
VALUE
------------------------------
control_files			     string
/data/oracle/oradata/orcl/cont
rol01.ctl, /data/oracle/flash_
recovery_area/orcl/control02.c
tl
3.2.1 读取参数文件

启动数据库实例首先会读取 SPFILE 文件中的初始化参数,如果 SPFILE 文件不存在,则会读取初始化文件。Linux 系统的 SPFILE 文件在 $ORACLE_HOME/dbs 目录下,Windows NT 和 Windows 2000 中 SPFILE 文件目录在 %ORACLE_HOME%\database。文件的读取顺序如下:

  • spfile$ORACLE_SID.ora
  • spfile.ora
  • init$ORACLE_SID.ora

其中 spfile$ORACLE_SID.oraspfile.ora 属于 SPFILE 文件,init$ORACLE_SID.ora 是初始化文件。

查看 $ORACLE_HOME$ORACLE_SID 的值可使用 echo 命令,如 echo $ORACLE_HOME

3.2.2 分配 SGA

读取到参数文件之后,Oracle 会根据参数文件分配 SGA(System Global Area)。

SGA 是一个非常庞大的内存区间,这也是为什么开启 Oracle 之后占用了很大内存的原因。SGA 由所有服务进程和后台进程共享。

我们可以通过 show sgaselect * from v$sga 查看 SGA 的大小:

SQL> show sga;
Total System Global Area 3273641984 bytes
Fixed Size		    2217792 bytes
Variable Size		 2432698560 bytes
Database Buffers	  822083584 bytes
Redo Buffers		   16642048 bytes

SQL> select * from v$sga;

NAME					      VALUE
---------------------------------------- ----------
Fixed Size				    2217792
Variable Size				 2432698560
Database Buffers			  822083584
Redo Buffers				   16642048

SGA 分为不同的池,我们可以通过视图 v$sgastat 查看:

SQL> select pool,sum(bytes) bytes from v$sgastat group by pool;

POOL			      BYTES
------------------------ ----------
java pool		   16777216
large pool		   16777216
shared pool		 1258295992
			  840943424
3.2.3 启动后台进程

其中有 5 个进程必须启动, DBWR、LGWR、SMON、PMON、CKPT。

SMON 系统监视器(System Monitor)。如果 Oracle 实例失败,则在 SGA 中的任何没有写到磁盘中的数据都会丢失。有许多情况可能引起 Oracle 实例失败,例如操作系统的崩溃就会引起 Oracle 实例的失败。当实例失败之后,如果重新打开该数据库,则后台进程 SMON 自动执行实例的复原操作。

DBWR 数据库书写器(Database Write)。该服务器进程在缓冲存储区中记录所有的变化和数据。DBWR 把来自数据库的缓冲存储区中的脏数据写到数据文件中,以便确保数据库缓冲存储区中有足够的空闲的缓冲存储区。脏数据就是正在使用但是没有写到数据文件中的数据。

LGWR 日志书写器(Log Write)。LGWR 负责把重做日志缓冲存储区中的数据写入到重做日志文件中。

CKPT 检查点(Checkpoint)。该进程可以用来同步化数据库的文件,它可以把日志中的文件写入到数据库中。

PMON 进程监视器(Process Monitor)。当取消当前的事务,或释放进程占用的锁以及释放其它资源之后,PMON 进程清空那些失败的进程。

查看系统进程和通信设施状态:

$ ps -ef | grep oracle
oracle    4524     1  0 Mar26 ?        00:01:25 /data/oracle/product/11.2.0/db_1/bin/emagent
root      9797   974  0 00:57 ?        00:00:00 sshd: oracle [priv]
oracle    9805  9797  0 00:57 ?        00:00:00 sshd: oracle@pts/0
oracle    9806  9805  0 00:57 pts/0    00:00:00 -bash
oracle   10020     1  0 00:58 ?        00:00:00 ora_pmon_orcl
oracle   10022     1  0 00:58 ?        00:00:01 ora_vktm_orcl
oracle   10026     1  0 00:58 ?        00:00:00 ora_gen0_orcl
oracle   10028     1  0 00:58 ?        00:00:00 ora_diag_orcl
oracle   10030     1  0 00:58 ?        00:00:00 ora_dbrm_orcl
oracle   10032     1  0 00:58 ?        00:00:00 ora_psp0_orcl
oracle   10034     1  0 00:58 ?        00:00:01 ora_dia0_orcl
oracle   10036     1  0 00:58 ?        00:00:00 ora_mman_orcl
oracle   10038     1  0 00:58 ?        00:00:00 ora_dbw0_orcl
oracle   10040     1  0 00:58 ?        00:00:00 ora_lgwr_orcl
oracle   10042     1  0 00:58 ?        00:00:00 ora_ckpt_orcl
oracle   10044     1  0 00:58 ?        00:00:00 ora_smon_orcl
oracle   10046     1  0 00:58 ?        00:00:00 ora_reco_orcl
oracle   10048     1  0 00:58 ?        00:00:00 ora_mmon_orcl
oracle   10050     1  0 00:58 ?        00:00:00 ora_mmnl_orcl
oracle   10052     1  0 00:58 ?        00:00:00 ora_d000_orcl
oracle   10054     1  0 00:58 ?        00:00:00 ora_s000_orcl
oracle   15675  9806  0 01:59 pts/0    00:00:00 ps -ef
oracle   15676  9806  0 01:59 pts/0    00:00:00 grep --color=auto oracle
$ ipcs -a

------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages

------ Shared Memory Segments --------
key        shmid      owner      perms      bytes      nattch     status
0x011268f0 458753     root       600        1000       9
0x00000000 524290     oracle     660        4096       0
0x00000000 557059     oracle     660        4096       0
0x2a1ee740 589828     oracle     660        4096       0

------ Semaphore Arrays --------
key        semid      owner      perms      nsems
0x19ba9600 1310738    oracle     660        154

smonpmon lgwr 等进程可以看出此阶段创建了多个后台进程,并首次报告使用了共享内存和信号量。

3.2.4 打开告警文件和跟踪文件

数据库的启动过程记录在警告追踪文件中,该警告追踪文件中包括数据库启动信息,它存放在参数BACKGOUND_DUMP_DEST 定义的目录下,警告日志的名字为 alert_<sid>.logsid 是实例的名称:

SQL> show parameter background_dump_dest;

NAME			           TYPE               VALUE
-------------------- -------------------------------------------------
background_dump_dest  string  /data/oracle/diag/rdbms/orcl/orcl/trace

进入到目录查看警告日志关于 startup nomount 过程记录:

$ more alert_orcl.log
Fri Nov 11 17:04:51 2016
Starting ORACLE instance (normal)
LICENSE_MAX_SESSION = 0
LICENSE_SESSIONS_WARNING = 0
Shared memory segment for instance monitoring created
Picked latch-free SCN scheme 3
Using LOG_ARCHIVE_DEST_1 parameter default value as USE_DB_RECOVERY_FILE_DEST
Autotune of undo retention is turned on.
IMODE=BR
ILAT =27
LICENSE_MAX_USERS = 0
SYS auditing is disabled
Starting up:
Oracle Database 11g Enterprise Edition Release 11.2.0.1.0 - 64bit Production
With the Partitioning, OLAP, Data Mining and Real Application Testing options.
Using parameter settings in client-side pfile /data/oracle/admin/orcl/pfile/init.

Oracle 实例的后台进程会在遇到问题的时候将日志写入跟踪文件中。数据库的跟踪文件在目录由 BACKGOUND_DUMP_DEST 参数指定,最大大小由 MAX_DUMP_FILE_SIZE 指定,默认为UNLIMITED

SQL> show parameter user_dump_dest;

NAME				     TYPE
------------------------------------ ----------------------
VALUE
------------------------------
user_dump_dest			     string
/data/oracle/diag/rdbms/orcl/o
rcl/trace
SQL> show parameter max_dump_file_size;

NAME				     TYPE
------------------------------------ ----------------------
VALUE
------------------------------
max_dump_file_size		     string
unlimited

3.3 装载数据库 nomunt --> mount

3.3.1 装载数据库概述

装载数据库就是把数据库文件和实例关联起来,包括以下三个步骤:

  • Oracle根据参数文件 SPFILE 中的参数找到控制文件
  • 打开控制文件
  • 从控制文件获得数据字典和重做日志文件的名字及位置

完成以上三步,没有任何错误的情况下,Oracle 就已经把实例和数据库关联起来了。

装载数据库有两种方式:

  • 一是直接启动数据库到 mount 状态:startup mount
  • 二是如果数据库已经启动到 nomount 状态,使用 alter database mount 把数据库切换到mount 状态

alert database mount

SQL> alter database mount;

Database altered.

startup mount

SQL> shutdown immediate;
ORA-01109: database not open


Database dismounted.
ORACLE instance shut down.
SQL> startup mount;
ORACLE instance started.

Total System Global Area 3273641984 bytes
Fixed Size		    2217792 bytes
Variable Size		 2432698560 bytes
Database Buffers	  822083584 bytes
Redo Buffers		   16642048 bytes
Database mounted.
3.3.2 可以查询控制文件

这个时候我们就可以查询控制文件、数据文件和重做日志文件了。

SQL> select status from v$instance;

STATUS
------------------------
MOUNTED

SQL> select name from v$controlfile;

NAME
--------------------------------------------------------------------------------
/data/oracle/oradata/orcl/control01.ctl
/data/oracle/flash_recovery_area/orcl/control02.ctl

SQL> select name from v$datafile;

NAME
--------------------------------------------------------------------------------
/data/oracle/oradata/orcl/system01.dbf
/data/oracle/oradata/orcl/sysaux01.dbf
/data/oracle/oradata/orcl/undotbs01.dbf
/data/oracle/oradata/orcl/users01.dbf
/data/oracle/oradata/orcl/example01.dbf
/data/oracle/oradata/orcl/SoftwareManagement.dbf

6 rows selected.

SQL> select member from v$logfile;

MEMBER
--------------------------------------------------------------------------------
/data/oracle/oradata/orcl/redo03.log
/data/oracle/oradata/orcl/redo02.log
/data/oracle/oradata/orcl/redo01.log
3.3.3 不能查询表、视图

但此时还不能查询数据库文件,如表和视图。所以对于普通用户而言,这个时候数据库还是不可用的。只有等到经历了最后一步 打开数据库 之后,才能使用数据库。

SQL> select * from tab;
select * from tab
              *
ERROR at line 1:
ORA-01219: database not open: queries allowed on fixed tables/views only

SQL> select * from scott.dept;
select * from scott.dept
                    *
ERROR at line 1:
ORA-01219: database not open: queries allowed on fixed tables/views only

3.4 打开数据库 mount --> open

打开数据库时,实例将打开所有处于联机状态的数据文件和重做日志文件。

在此期间,Oracle 服务器将校验所有的数据文件和联机日志文件能否打开,并对数据库作一致性检查。

  • 如果出现一致性错误,SMON 进程将启动实例恢复
  • 如果任一数据文件或联机日志文件丢失,Oracle 服务器将报错

只有将数据库设置为打开状态后,数据库才处于正常状态,这时普通用户才能够访问数据库。

打开数据库也有两种方式。

一是使用 alter 命令。

>SQL alter database open;

Database altered.

SQL> select status from v$instance;

STATUS
------------------------
OPEN

二是直接通过 startup 命令启动。

startup 命令会逐步完成数据库启动的三个步骤(创建实例、装载数据库、打开数据库),将数据库启动到 open 状态:

SQL> startup
ORACLE instance started.

Total System Global Area 3273641984 bytes
Fixed Size		    2217792 bytes
Variable Size		 2432698560 bytes
Database Buffers	  822083584 bytes
Redo Buffers		   16642048 bytes
Database mounted.
Database opened.

启动之后,就可以访问数据文件了:

SQL> select * from scott.dept;

    DEPTNO DNAME			LOC
---------- ---------------------------- --------------------------
	10 ACCOUNTING			NEW YORK
	20 RESEARCH			DALLAS
	30 SALES			CHICAGO
	40 OPERATIONS			BOSTON

四. 关闭数据库

与启动数据库顺序相反,也分三个步骤:

  • CLOSE 关闭数据库(关闭数据文件)
  • DISMOUNT 卸载数据库(关闭控制文件)
  • SHUTDOWN 关闭 Oracle 实例

同时关闭模式也有多种。

4.1. NORMAL

正常的关闭方式。如果对于关闭数据库的时间没有限制,通常采用这种方式。

NORMAL 方式关闭数据库,Oracle 将执行如下操作:

  1. 阻止任何用户建立新的连接
  2. 等待当前所有正在连接的用户主动断开连接
  3. 当前所有用户的都断开连接后,将立即关闭数据库

4.2. TRANSACTION

事务关闭方式,它的首要任务是保证当前所有活动的事务都可以被提交,并在尽可能短的时间内关闭数据库。

以事务方式关闭,Oracle将执行如下操作:

  1. 阻止用户建立新连接和开始新事务
  2. 等待所有活动事务提交后,再断开用户连接
  3. 当所有活动事务提交完毕,用户断开连接后,关闭数据库

4.3. IMMEDIATE

立即关闭方式,可以较快且安全的关闭数据库,是 DBA 经常采用的关闭数据库的方式。

立即关闭方式 Oracle执行如下操作:

  1. 阻止用户建立新的连接和开始新的事务
  2. 中断当前事务,回滚未提交事务
  3. 强制断开所有用户连接和执行检查点把脏数据写到数据文件中
  4. 关闭数据库

五. 为什么分为三步

现在问题来了,明明一步就可以把数据库启动起来,为什么 Oracle 要很麻烦地分为三步呢?

这样的步骤,对于普通用户来说是多余的,但对于 DBA 来说确实非常重要的。因为这三个步骤中,每一步都会启动对应的进程打开对应的文件,所以维护的时候,我们就可以准对具体的问题,在数据库的某种状态下进行数据库维护工作。

nomount 状态

nomount 状态不打开任何的控制文件及数据文件,所以我们可以在此阶段数据库创建、控制文件重建、特定的备份恢复等操作。

mount 状态

在加载完成以后,数据库还不可以被访问,所以在此阶段我们可以:

  • 重命名数据文件,移动数据文件位置等
  • 启用或关闭重做日志文件的归档及非归档模式
  • 实现数据库的完全恢复

六. 常见连接错误

1. 未启动数据库实例

Connection Failed
ORA-12514: TNS:listener does not currently know of service requested in connect descriptor

2. 未启动监听器

Oracle Connection Failed
ORA 12541: TNS:no listener

参考:

创建 MySQL数据库及用户权限

一.创建数据库用户

首先用root账号进入MySQL:

$ mysql -u root -p

然后输入密码即可。

创建数据库并设置编码为 utf8,不然中文可能会乱码:

> create database mydb default character set utf8 defa
ult collate utf8_general_ci;

新建用户有两种方式

1.> create user username@hostname identified by 'yourpassword';

2.> insert into mysql.user(host,user,password) value('hostname','username',password('yourpassword'));
  > flush privileges;

说明:
username:即将创建的用户名;
hostname:主机名。主机名为localhost表示用户可以本地登录;主机名为 % 表示远程访问,该用户可以从任意远程主机登陆。
yourpassword:该用户密码。如果密码为空,则该用户可不通过密码直接登录服务器。密码为空也可以直接写为:

 > create user 'username'@'hostname';

flsu privileges:刷新权限。
方法1设置后不需刷新权限即可生效;方法2需要刷新权限才能生效。

例:

 > create user test@localhost identified by 'testpasswd';
 > create user test@'%' identified by 'testpasswd';

创建一个用户名为test的用户,并设置其本地访问和远程访问。注意通配符 % 需要加引号。

二.查看已有用户

查看用户名,主机名,密码:

> select user,host,password from mysql.user;

三.删除已有用户

删除也有两种方式。

1.> drop user username@hostname;
2.> delete from mysql.user where user='usrname' and host='hostname';

说明:如果未指定主机名,则 drop user username 默认删除username@'%';如果没有 username@'%',则会提示 ERROR 1396 (HY000) 的错误。

四.设置修改用户密码

同样有两种方式。

1.> set password for username@'hostname' = password('newpassword');
2.> update mysql.user password=password('newpassword') where user='username' and host='hostname';

同样,方法一不需要刷新即可生效;方法2需要刷新才能生效。

五.为用户分配数据库及数据库表的权限

MySQL给用户分配数据库(表)权限的命令可概括为

grant 权限 on 数据库对象 to 用户
1.分配某个数据库的所有权限
> grant all privileges on db.* to username@'%';

表示将数据库db中所有表的所有权限赋予用户username,通配符 % 表示可远程访问。

2.分配数据库中一个表的所有权限
> grant all privileges on db.table to username@'%';

表示将数据库db中的table表的所有权限赋予用户username。

3.分配创建、修改、删除 MySQL 数据表结构权限。
> grant create on db.* to username@'%';
> grant alter  on db.* to username@'%';
> grant drop   on db.* to username@'%';

操作 MySQL 外键权限

> grant references on db.* to username@'%';

操作 MySQL 临时表权限

> grant create temporary tables on db.* to username@'%';

操作 MySQL 索引权限

> grant index on db.* to username@'%';

操作 MySQL 视图、查看视图源代码 权限

> grant create view on db.* to username@'%';
> grant show view on db.* to username@'%';

操作 MySQL 存储过程、函数 权限

> grant create routine on db.* to username@'%'; -- now, can show procedure status
> grant alter routine on db.* to username@'%'; -- now, you can drop a procedure
> grant execute on db.* to username@'%';

六.查看用户权限

查看当前用户权限

> show grants;

查看其它用户权限

> show grants for username@'%';

七.撤销已经赋予给 MySQL 用户权限的权限。

revoke 与 grant 的语法类似,只需要把关键字 “to” 换成 “from” 即可:

> grant all on *.* to username@'%';
> revoke all on *.* from username@'%';

八.MySQL grant,revoke 用户权限注意事项

  1. grant, revoke 用户权限后,该用户只有重新连接 MySQL 数据库,权限才能生效。

  2. 如果想让授权的用户,也可以将这些权限 grant 给其他用户,需要选项 “grant option“

    grant select on db.* to username@localhost with grant option;

这个特性一般用不到。实际中,数据库权限最好由 管理员 来统一管理。

在 macOS 中编译安装 MXNet

MXNet 是一个深度学习系统。关于 MXNet 的介绍可以看这篇文章:《MXNet设计和实现简介》

在 macOS 上编译安装 MXNet 的大体步骤都是按照官方文档来进行安装即可。但由于每个人电脑环境不同,所以可能会出现一些依赖库/包的缺失,导致安装失败。

安装依赖软件

在 macOS 上,首先需要具有以下软件:

  • Homebrew (to install dependencies)
  • Git (to pull code from GitHub)
  • Homebrew/science (for linear algebraic operations)
  • OpenCV (for computer vision operations)

如果上述已经安装了,就不需要再安装;如果没有,则按照下面的步骤安装:

# 安装 Homebrew
$ /usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
# 安装 Git 和 OpenCV
$ brew update
$ brew install git
$ brew tap homebrew/science
$ brew info opencv
$ brew install opencv

编译 MXNet

# 下载源码
$ git clone --recursive https://github.com/dmlc/mxnet

然后还需要安装 openblas

# 安装 openblas
$ brew install --fresh -vd openblas
...
Generally there are no consequences of this for you. If you build your
own software and it requires this formula, you'll need to add to your
build variables:

    LDFLAGS:  -L/usr/local/opt/openblas/lib
    CPPFLAGS: -I/usr/local/opt/openblas/include

==> Summary
🍺  /usr/local/Cellar/openblas/0.2.18_2: 20 files, 41.8M, built in 12 minutes 33 seconds

如果没有安装 openblas,则会有类似 fatal error: 'cblas.h' file not found 的错误,详见 apache/mxnet#572

接下来修改配置文件:

$ cd mxnet
$ cp make/osx.mk ./config.mk

用 vim 或其他编辑器打开 config.mk,在 USE_BLAS = apple 下面加入如下 ADD_LDFLAGS = -I/usr/local/opt/openblas/libADD_CFLAGS = -I/usr/local/opt/openblas/include

USE_BLAS = apple
ADD_LDFLAGS = -I/usr/local/opt/openblas/lib
ADD_CFLAGS =  -I/usr/local/opt/openblas/include

最后再编译即可:

$ make -j$(sysctl -n hw.ncpu)

在 Python 中使用 MXNet

编译安装完成之后,若要使用 MXNet 的 Python 接口,还需要将 mxnet/python 添加到 Python 的包搜索路径。至少有三种方式可以实现。

1. python 代码手动加载

import os, sys;
cur_path = os.path.abspath(os.path.dirname(__file__));
mxnet_lib_path = os.path.join(cur_path, 'mxnet/python');
sys.path.append(mxnet_lib_path);
import mxnet as mx;

在没有将 mxnet/python 添加到 PYTHONPATH 之前,依旧可以运行 /example/image-classification 里面的一些测试案例,就是因为案例里面有一行 import find_mxnet,而 find_mxnet 的作用就是手动加载 mxnet/python

# find_mxnet.py
try:
    import mxnet as mx
except ImportError:
    import os, sys
    curr_path = os.path.abspath(os.path.dirname(__file__))
    sys.path.append(os.path.join(curr_path, "../../python"))
    import mxnet as mx

2. 将路径加到环境变量 PYTHONPATH 中

这种方法需要修改 shell 的配置文件。如果使用的 bash,则修改 ~/.bashrc;若使用的是 zsh,则修改 ~/.zshrc;其他类似。

在 bash 配置文件中加入下面这一行:

export PYTHONPATH=path_to_mxnet_root/python

其中 path_to_mxnet_root 是下载的 mxnet 源码目录。

3. 全局安装 mxnet

直接运行 mxnet/python/setup.py,将 mxnet 添加到全局路径即可:

python setup.py install --user

运行上面的命令后,脚本会在 ~/.local 目录下创建一个 lib 目录,里面有一个 python-2.7/site-packages 文件夹。

如果是 sudo python setup.py install,则上面的目录会在 /usr/lib 下。


删除GIT中的.DS_Store

.DS_Store 是什么

使用 Mac 的用户可能会注意到,系统经常会自动在每个目录生成一个隐藏的 .DS_Store 文件.DS_Store (英文全称 Desktop Services Store)是一种由苹果公司的Mac OS X操作系统所创造的隐藏文件,目的在于存贮目录的自定义属性,例如文件们的图标位置或者是背景色的选择。相当于 Windows 下的 desktop.ini

删除 .DS_Store

如果你的项目中还没有自动生成的 .DS_Store 文件,那么直接将 .DS_Store 加入到 .gitignore 文件就可以了。如果你的项目中已经存在 .DS_Store 文件,那就需要先从项目中将其删除,再将它加入到 .gitignore。如下:

# 删除项目中的所有.DS_Store。这会跳过不在项目中的 .DS_Store
find . -name .DS_Store -print0 | xargs -0 git rm -f --ignore-unmatch
# 将 .DS_Store 加入到 .gitignore
echo .DS_Store >> ~/.gitignore
# 更新项目
git add --all
git commit -m '.DS_Store banished!'

如果你只需要删除磁盘上的 .DS_Store,可以使用下面的命令来删除当前目录及其子目录下的所有 .DS_Store 文件:

find . -name '*.DS_Store' -type f -delete

禁用或启用自动生成

  • 禁止.DS_store生成:
defaults write com.apple.desktopservices DSDontWriteNetworkStores -bool TRUE
  • 恢复.DS_store生成:
defaults delete com.apple.desktopservices DSDontWriteNetworkStores

Github Issue: #18

检测 Express 路由中的参数合法性

本文以 Express 框架为基础,讲诉如何通过一个中间件来检测 Express 路由中传输的参数是否合法。

几乎对于任何应用,前后端都需要进行传输数据。不管是通过 HTTP 请求的 POST 方法还是 GET 方法,数据校验都是必要的操作。

对于大部分 API 来说,可能只需要判断传入的参数是否为 undefined 或 null,所以这个时候,为了减少重复代码,我们可以写一个简单的中间件来处理路由中的参数。

这个中间件的需求如下:

  • 检测路由中的一般参数是否为 undefined、null、[]、''
  • 中间件同时还需要能对特殊参数做处理,如一个参数值在 1-100 之间

最终写出来的处理参数的模块:

/**
 * 检测路由中的参数
 * @method checkParameters
 * @param  {object}        req     http请求
 * @param  {object}        res     http响应
 * @param  {Function}      next    
 * @param  {[type]}        special 特殊参数,
 *                                 格式: [{eval: 'req.query.id>0', key:'id', type: 'query'}]
 *                                 key 属性的值必须和eval中对应,且type只能是 query/params/body
 * @return {object}        若参数错误,直接返回json到前端;正确,则next()
 */
function checkParameters(req, res, next, special) {
  // console.log('params: ', req.params);
  // console.log('body: ', req.body);
  // console.log('query: ', req.query);
  console.log('specialParams: ', typeof special);
  // 判断是否传入第四个参数
  if (Array.isArray(special)) {
    // 判断特殊参数
    for (let i = 0; i < special.length; i++) {
      // console.log('special[i]: ', special[i]);
      // console.log('special[i] eval: ', special[i].hasOwnProperty('eval'));
      // console.log('special[i] key: ', special[i].hasOwnProperty('key'));
      // console.log('special[i] type: ', special[i].hasOwnProperty('type'));

      // 判断是否具有 eval, key, type 三个参数
      if (! (special[i].hasOwnProperty('eval')
        && special[i].hasOwnProperty('key')
        && special[i].hasOwnProperty('type'))) {
        return res.json({
          code: 90001,
          msg: '检测参数属否正确时,传入的特殊参数格式不完全',
          detail: special
        });
      }
      // 判断 eval, key, type 三个参数是否为 undefined
      if ( !(typeof special[i]['eval'] !== undefined
      && typeof special[i]['key'] !== undefined
      && typeof special[i]['type'] !== undefined)) {
        return res.json({
          code: 90002,
          msg: '检测参数属否正确时,传入的特殊参数为 undefined',
          detail: special
        });
      }
      const evalString = special[i]['eval'];
      const type = special[i]['type'];
      const key = special[i]['key'];
      // 判断 key 和 eval 是否匹配
      console.log('length: ', evalString
      .split('req.' + type + '.' + key)
      .length);
      const length = evalString.split('req.' + type + '.' + key).length;
      if (length < 1) {
        return res.json({
          code: 90003,
          msg: '检测参数属否正确时,传入的特殊参数为格式不正确',
          detail: special
        });
      }
      // 执行 eval
      if (!eval(evalString)) {
        return res.json({
          code: 90004,
          msg: '检测参数属否正确时,参数不匹配传入的条件 ' + evalString,
          detail: special,
          parameters: req[type]
        });
      }
      // 从普通参数中删除特殊参数
      // console.log('delete: ', req[type][key]);
      delete req[type][key];
    }
  }

  const params = req.params;
  // 去掉 req.params 中 {'0': '', ...} 这一项
  delete params['0'];
  // console.log('params: ', params);
  const body = req.body;
  const query = req.query;
  const common = Object.assign(params, body, query);
  console.log('common: ', common);
  // 检测参数属否为undefined
  for (let i in common) {
    if (typeof common[i] === 'undefined') {
      return res.json({
        code: 90005,
        msg: '检测参数属否正确时,' + i + ' 参数为undefined',
        detail: common
      });
    }
    // 检测参数属否为null
    if (common[i] === null) {
      return res.json({
        code: 90006,
        msg: '检测参数属否正确时,' + i + ' 参数为null',
        detail: common
      });
    }
    // 检测参数是否为空字符串 ''
    if (common[i] === '') {
      return res.json({
        code: 90007,
        msg: '检测参数属否正确时,' + i + ' 参数为空字符串',
        detail: common
      });
    }
    // 检测参数是否为空数组 []
    if (common[i] === '') {
      return res.json({
        code: 90008,
        msg: '检测参数属否正确时,' + i + ' 参数为空数组',
        detail: common
      });
    }
  }

  return next();
}

module.exports = checkParameters;

函数详解

1. 特殊参数的处理

这个模块,首先对特殊参数做处理。special 这个参数是一个数组,格式如下:

[{
  eval: 'parseInt(req.query.id) > 1 && parseInt(req.query.id) < 10',
  key: 'id',
  type: 'query'
}, {
  eval: 'parseInt(req.query.age) === 11',
  key: 'age',
  type: 'query'
}]

key 属性的值必须和eval中对应,且type只能是 query/params/body。

  • 首先判断函数是否传入第四个参数,并且第四个参数是否为数组,如果这两个条件成立,说明有特殊参数需要处理。
  • 然后判断是否具有 eval, key, type 三个参数。eval 参数是一个参数条件表达式,如 'id>0',最终通过 eval() 函数来执行这个表达式。key 表示参数属性名,type 表示是通过何种方式传递的参数,如 query/params/body。
  • 接下来判断 eval, key, type 三个参数自身是否为 undefined 或 null
  • 再然后判断 判断 key 和 eval 是否匹配。key 和 eval 的匹配,主要是为了后面从一般参数中删除特殊参数
  • 再执行 eval 表达式,判断表达式是否成立,即特殊参数值是否合法
  • 如果以上都为真,说明特殊参数合法,最后则使用 delete 从参数中删除特殊参数

2. 普通参数处理

处理完毕特殊参数后,就需要处理普通参数。普通参数只需要满足:

  • 不为 undefined
  • 不为 null
  • 不为空字符串 ''
  • 不为空数组 []

普通参数,即 req.body req.params req.query 中的所有非特殊参数。

除了在判断特殊参数的时候,需要 delete 特殊参数,还需要 delete req.params['0']。因为对于 req.params,当 URL 没有 path 部分的时候,req.params['0']''

处理完毕之后,若所有参数都合法,则返回 next()

模块使用

1. 创建没有挂载路径的中间件

首先在 Express 项目入口文件,创建一个参数处理中间件:

// app.js
const checkParameters = require('./checkParameters');
// ...
app.use(function(req, res, next) {
  checkParameters(req, res, next);
});

注意这里的 app.use() 需要放在创建具体路由中间件如app.use('/', index) 之前。

当 Express 接收到 HTTP 请求后,首先该中间件会检测参数是否合法(只判断是否为 null 或 undefine 或 '' 或 [])。如果不合法,则直接返回;如果合法,则程序继续执行,由匹配该请求路径的路由继续完成该请求。

2. 创建挂载路径的中间件

如果需要对某个路由的特殊参数做处理,则需要创建一个挂载路径的中间件。

比如为们要判断 /index 路由的 GET 请求中的 id 参数,其中 id 的取值范围是 1-100,URL 形式如 http://localhost:3000/index?id=10,则可以这样使用该模块:

// index.js
const checkParameters = require('./checkParameters');

// 挂载路径的中间件
router.get('/index', function(req, res, next) {
  const special = {
    eval: 'parseInt(req.query.id) >= 1 && parseInt(req.query.id) <= 100',
    key: 'id',
    type: 'query'
  };
  checkParameters(req, res, next, special);
});
// 正常逻辑处理
router.get('/index', function(req, res, next) {
  // 正常逻辑
});

当 Express 接收到一个 HTTP 请求的时候,首先还是没有挂载路径的中间件先处理请求,检测参数;然后该挂载路径的中间件再检测参数;最后才执行正常处理逻辑。

使用 Hexo 创建项目文档网站

+++
title = "Project Documentation With Hexo Static Site Generator"
description = "使用 Hexo 创建项目文档网站"
date = 2017-07-05T11:41:43+08:00
tags = [""]
categories = [""]
draft = false
+++

当我们发布一个开源项目的时候,最重要的事情之一就是要创建项目文档。对使用项目的用户来说,文档是非常有必要的,通常我们可以使用下面这些方式来创建文档:

  • GitHub Wiki:在 Github 上我们可以为每个项目都创建一个 wiki。Wiki 是由一系列的 Markdown 文件组成,所以我们可以用 wiki 来做项目文档。但这种方案也有一些缺点:wiki 的贡献者不会出现在项目贡献者列表中;文档的结构和布局都是有限制的,只能是 Github Wikis 的样式;文档存储在第三方平台上。
  • README:我们可以为项目创建一个 README.md 文件,它会直接展示在 Github(或 Gitlab、Coding 等 git 仓库)的项目页面。如果文档非常少,这中方案是非常适合的。但如果文档非常多,这个 README.md 文件就会非常大了。而且通常来说,README.md 是用来介绍项目,而不是展示文档。
  • 自建网站:当然,我们也可以创建一个文档网站,然后放在自己的服务器上。这样我们就可以随意编辑文档。但这种方案的缺点是不便于追踪文档的变化、开发网站和文档维护相比前两种方案麻烦非常多、而且还需要自建主机。
  • Github Pages:Github 也提供了一个托管项目中静态文件的功能。我们可以为项目创建一个 gh-pages 分支,Github 就会将分支中的内容当做静态站点。这种方案好的一方面是文档维护是在一个单独的分支,虽然可能寻找起来比较麻烦。不好的一方面是文档编写是编写成静态文件(html/css/js),修改和维护起来比较麻烦。

以上方案都不完美,所以需要一种综合以上所有优点的方案,简单来说就是:

  • 文档以 MarkDown 文件编写
  • 使用 hexo 将 MarkDown 文件生成成静态文件
  • 将静态文件发布到 github pages

Hexo 简介

Hexo 是一个 Node.js 编写的静态网站生成器。Hexo 主要用来做博客框架,同时 Hexo 也整合了将静态网站部署到 Github 的功能,所以也很适合用来做 Github 项目的文档。

我们可以使用 Hexo,根据写好的 HTML 布局(既 Hexo 的主题),将 MarkDown 文件生成成主题对应的静态 html/css/js 文件。Hexo 提供了将静态文件部署到 Github 分支上的配置。也就是说,我们可以使用 MarkDown 来维护文档,当写好部署配置之后,使用一个命令就可以将文档生成并发布到 Github 的 gh-pages 分支上。

安装 Hexo

Hexo 是通过 Node.js 编译的,所以需要安装 Node.js。Hexo 使用 Git 将文件部署到 Github,所以也需要安装 Git。

安装 Node.js

推荐使用 Node.js 的版本管理器来安装,比如 nvm。当然,也有很多其他的 Node.js 版本管理工具,使用这些工具,我们能很方便地安装 Node.js,以及在不同的 Node.js 的版本中切换。

目前 Node.js 最新的版本是 8.1.3,使用 nvm 来安装:

$ nvm install v8.1.3

安装完 Node.js 的同时也会安装对应的 npm

安装 Git

我们还需要在系统上安装 Git。如果不确定系统中是否已经安装了 Git,使用下面的命令检查:

$ git --version

如果出现了 Git 的版本号,则不需要再安装了。如果没有,则需要安装 Git。

Windows

Windows 系统直接点此连接 https://git-scm.com/download/win 下载 Git 软件,然后运行即可。

macOS

在 macOS 上安装 Git 有多种不同的方式:

我个人推荐使用 Homebrew 来安装软件。当然如果你更喜欢 MacPorts,也没有任何问题。

Linux – Ubuntu or Debian

在 Ubuntu 或 Debian 上,我们可以使用 apt 来安装软件:

$ sudo apt-get install git-core

Linux – Fedora, Red Hat or CentOS

在 Fedora、Red Hat 或 CentOS 上,我们可以使用 yum 来安装软件:

$ sudo yum install git-core

安装 Hexo CLI

在安装完 Node.js 和 Git 之后,我们最后需要安装 Hexo:

$ npm install -g hexo-cli

通过下面的命令来检查 hexo 是否正确安装上了:

$ hexo --version

如果输出了一系列的版本号,说明所有安装工作都以完成,可以正式使用 hexo 了。

配置

安装好 hexo 之后,现在我们就可以在 Github 的主分支上来创建我们的文档了。根据该文章,你可以:

简单起见,假设你是新创建了一个名为 hexo-documentation 的项目,当然你也可以用一个已经存在的项目继续下面的操作。

接下来使用下面的名令在本地 clone 项目:

$ git clone https://github.com/USERNAME/REPOSITORY.git

USERNAME 替换为你的用户名,REPOSITORY 替换为你的项目名称。例如我执行的命令如下:

$ git clone https://github.com/nodejh/hexo-documentation

然后使用 cd 进入项目目录,并创建一个名为 docs 的目录:

$ cd hexo-documentation
$ mkdir docs

docs 目录将存放我们的文档。使用 hexo 初始化 docs 目录:

$ hexo init docs

上面的命令将生成 hexo 的一些配置并安装相关依赖。安装完成之后,docs 的目录结构如下:

  • _config.yml 站点配置文件
  • package.json Node.js 的依赖文化
  • scaffolds hexo 发布文章的时候使用(本文暂不介绍 hexo 的特性)
  • source MarkDown 和各种资源文件
  • themes hexo 的主题

我们可以通过下面的命令来检查网站是否能够正常运行:

$ hexo generate
$ hexo server

第一个命令将根据选用的主题,将 sources 目录中的文件转换成静态网站文件。第二个命令将启动一个 Web 服务器,提供这些静态网站文件,我们可以通过 http://localhost:4000 来访问:

Project-Documentation-with-Hexo-Static-Site-Generator-01

目前我们的网站看起来还是一个博客而不是文档,不过我们将要将其改成文档的样子。

创建一个主题

要改变网站的外观,我们需要创建一个 hexo 的主题。主题确定了 hexo 生成的网站的样式和布局。https://hexo.io/themes/ 这个网站有很多免费的 hexo 主题可以使用。但在这篇文章里,我们要从零开始创建一个 hexo 主题。

Hexo 有一个名为 landscape 的默认主题,在 docs/themes 这个目录里面。你可以在 themes 目录存放多个主题,但每次只能有一个主题被使用。接下来让我们创建自己的主题。在 themes 目录下创建一个名为 documentation 的目录。

Hexo 的主题包含以下文件和目录:

  • _config.yml 主题配置文件
  • languages 国际化的语言包
  • layout 主题布局,即页面结构等
  • scripts 一些 Hexo 插件脚本
  • source 资源文件夹,里面的文件名以 _ 开头外的所有文件都会被当作网站的静态资源

我们将创建一个简单的静态主题,所以我们不需要 scripts 目录。然后目前仅以中文展示,所以也不需要 languages 目录。

我们需要做的就是编写网站的布局,以及一些 CSS 代码。在本文中我将使Sass 来生成 CSS,但 hexo 并不能直接处理 Sass,但幸运的是有 hexo-renderer-sass 这个插件来帮助 hexo 处理 Sass。

使用 npm 来安装 hexo-renderer-sass,在 ./docs(注意不是在 themes 目录里面)运行下面的命令:

$ npm install --save hexo-renderer-sass

然后回到 themes 目录里面,配置 Sass,不然 hexo-renderer-sass 插件不会被加载。在 docs/themes/documentation/_config.yml 文件中加入下面的代码:

node_sass:
  outputStyle: nested
  precision: 4
  sourceComments: false

Sass 的所有可配置在 node-sass

接下来就可以编写 Sass 代码了。不过在本文中我不会详细介绍怎么写 Sass 样式,因为它和本文内容无关,而且范围太大,一时半会儿写不完。你可以在这里 https://github.com/nodejh/hexo-documentation 找到这些文件,然后把他们复制到你的项目中,或者你也可以创建自己的样式。

让我们继续回到布局,开始编写代码之前,还有一个重要的事情就是选择模板引擎,如 swig、ejs 等。Hexo 默认使用的模版引擎是 swig,这也是我们将要使用的。

接下来创建文件 docs/themes/documentation/layout/post.swig,并写入下面的代码:

<!DOCTYPE html>
<html>
<head>
  <meta charSet='utf-8' />
  <title>{{config.title + ' - ' + page.title}}</title>
  <link href='https://cdnjs.cloudflare.com/ajax/libs/normalize/4.0.0/normalize.min.css' rel='stylesheet' type='text/css'>
  <link href='https://fonts.googleapis.com/css?family=Open+Sans:400,600,300,700' rel='stylesheet' type='text/css'>
  <link href='{{ url_for("css/docs.css") }}' rel='stylesheet'>
</head>
<body>
  <div class='menu'>
    <div class='logo'>
      Documentation
    </div>
    <nav class='menu-nav'>
      {% for section in site.data.nav %}
        <ul class='nav'>
          <span>{{ section.title }}</span>
          <ul class='nav'>
            {% for item in section.items %}
              <li>
                <a href='{{item.href || url_for(item.id + ".html") }}'{% if item.id == page.id %} class='active'{% endif %}>{{item.title}}</a>
              </li>
            {% endfor %}
          </ul>
        </ul>
      {% endfor %}
    </nav>
    <a class='footer' href='https://github.com/sitepoint-editors/hexo-documentation'>
      Project on github
    </a>
  </div>
  <div class='page'>
    <div class='page-content'>
      <h1>{{page.title}}</h1>
      {{page.content}}
    </div>
  </div>
  <div class='switch-page'>
    {% if page.prev %}
      <a class='previous' href='{{ url_for(page.prev) }}'>Previous</a>
    {% endif %}
    {% if page.next %}
      <a class='next' href='{{ url_for(page.next) }}'>Next</a>
    {% endif %}
  </div>
</body>
</html>

简单分析一下代码。

<head>
  <meta charSet='utf-8' />
  <title>{{config.title + ' - ' + page.title}}</title>
  <link href='https://cdnjs.cloudflare.com/ajax/libs/normalize/4.0.0/normalize.min.css' rel='stylesheet' type='text/css'>
  <link href='https://fonts.googleapis.com/css?family=Open+Sans:400,600,300,700' rel='stylesheet' type='text/css'>
  <link href='{{ url_for("css/docs.css") }}' rel='stylesheet'>
</head>

头部主要包括两部分:

  • title Hexo 提供了一些列的变量,我们可以使用其中的 config.titlepage.title 来组成我们的 title
  • links 链接里面包括 normalize CSS,使默认的样式保持跨浏览器的一致性;Google Fonts,使文本显示更友好;url_for,这是 Hexo 的一个辅助函数,可以在路径前加上根路径

接下来看 body 部分,大体上还是 HTML。一些重点部分稍后会详细介绍。

<nav class='menu-nav'>
  {% for section in site.data.nav %}
  <ul class='nav'>
    <span>{{ section.title }}</span>
    <ul class='nav'>
      {% for item in section.items %}
        <li>
          <a
            href='{{ item.href || url_for(item.id + ".html") }}'
            {% if item.id == page.id %}
              class='active'
            {% endif %}
          >
            {{ item.title }}
          </a>
        </li>
      {% endfor %}
    </ul>
  </ul>
  {% endfor %}
</nav>

上面的代码会生成网站的菜单部分,菜单项来自于 site.data.nav 这个对象,稍后我们会在 docs/source/_data/nav.yml 中创建。source/_data 是 Hexo 的数据文件site.data.nav_data 目录中的 nav.yml 文件。nav.yml 中是一个包含 titleitems 对象的数组。

接下来比较重要的是文章内容这部分:

<div class="page-content">
  <h1>{{ page.title }}</h1>
  {{ page.content }}
</div>

这里面包括了文章标题和内容两部分。文章内容是根据 MarkDown 文件生成的 HTML。

最后还包括 “上一页” 和 “下一页” 按钮:

{% if page.prev %}
  <a class='previous' href="{{ url_for(page.prev) }}">上一页</a>
{% endif %}
{% if page.next %}
  <a class='next' href="{{ url_for(page.next) }}">下一页</a>
{% endif %}

上面的代码中,我们假设每个页面都有 “上一页” 和 “下一页” 按钮。

然后创建一个首页 documentation/layout/index.swig

<!DOCTYPE html>
<html>
<head>
  <meta charSet='utf-8' />
  <title>{{config.title + ' - ' + page.title}}</title>
  <link href='https://cdnjs.cloudflare.com/ajax/libs/normalize/4.0.0/normalize.min.css' rel='stylesheet' type='text/css'>
  <link href='https://fonts.googleapis.com/css?family=Open+Sans:400,600,300,700' rel='stylesheet' type='text/css'>
  <link href='{{ url_for("css/docs.css") }}' rel='stylesheet'>
</head>
<body>
  <div class='index'>
    <a href="/what-is-it.html">
      Get Start
    </a>
  </div>
</body>
</html>

现在差不多就完成了!不仅是布局文件完成了,我们的主题也制作好了。最后一件事情就是修改 Hexo 生成静态文件的时候使用的主题。修改 docs/_config.yml 文件中的 theme 属性:

theme: documentation

所有事情都做完了!接下来我们就可以创建文档了。

编写文档

接下来就到了整篇文章最重要的部分了,为我们的项目编写文档。我们将在 docs/source/ 目录完成这些事情。这里的文档是网站内容的来源,以及网站的菜单。

首先创建菜单。Hexo 提供了让我们定义一些数据文件,并通过 site.data 来访问。首先在 source 目录里面创建 _data 目录,然后创建名为 nav.yml 的文件:

- title: Introduction
  items:
  - id: what-is-it
    title: What is it?
  - id: how-it-works
    title: How it works
- title: Usage
  items:
  - id: installation
    title: Installation
  - id: using
    title: Using It

这样我们就可以通过 site.data.nav 来访问 nav.yml 中的文件。

在上面创建的菜单中,我们创建了两篇文章,每篇文章有两个部分。最后我们就只需要创建页面了。在编写 MarkDown 之前,先创建以下文件,与菜单对应:

  • what-is-it.md
  • how-it-works.md
  • installation.md
  • using.md

接下来就要往文件中写入内容。文件的开头部分是 Front-matter,里面是页面的一些设置,Front-matter 是包含在两个 --- 之间的 YAML 格式的。

what-is-it.md 所示:

---
layout: default
id: what-is-it
title: What is it?
next: how-it-works.html
---

This is our what it is markdown file

- one
- two
- three

在 front-matter 中有下面这些设置:

  • layout 页面的布局
  • id 页面的唯一标识
  • title 页面标题
  • next 下一页链接

按照类似的方法编写其他几个 MarkDown 文件。当网站创建好之后,这些 MarkDown 内容会被转换为 HTML。

编辑好了之后,就可以生成静态网站了:

$ hexo generate
$ hexo server

然后通过 http://localhost:4000 就可以看到如下页面:

Project-Documentation-with-Hexo-Static-Site-Generator-02

部署到 GitHub Pages

现在我们的文档网站就全部做好了,接下来需要做的就是将其部署到 Github Pages 上。如果我们手动来实现,就需要创建 gh-pages 分支,生成静态网站,复制网站文件到 gp-pages 分支,commit 并且 push 代码到 GitHub。当修改文档之后,又得重复这些工作。

幸运的是,Hexo 提供了一个很方便地将站点部署到 gh-pages 的方法。首先安装 hexo-deployer-git 这个包,在 docs/ 目录下运行命令:

$ npm install --save hexo-deployer-git

然后打开 docs/_config.yml,在文档的最后面,修改部署配置信息,注意将其中的用户名(nodejh)修改为你的用户名:

deploy:
  type: git
  repo: https://github.com/nodejh/hexo-documentation
  branch: gh-pages
  message: "Docs updated: {{ now('YYYY-MM-DD HH:mm:ss') }})"

最后再修改一些其他配置:

# Site
title: Hexo documentation
subtitle: Hexo documentation article
description: Hexo documentation article
author: nodejh
language: zh-cn
timezone: GMT

# URL
url: https://nodejh.github.io/hexo-documentation
root: /hexo-documentation/

OK!现在就只剩下一件事情了,就是将网站部署到 Github 上,在终端上运行:

$ hexo generate
$ hexo deploy

Hexo 将生成静态文件,并将其自动部署到 gh-pages 分支上。部署完成之后,我们就可以通过 https://nodejh.github.io/hexo-documentation 来访问了。

总结

如果你想要的项目被被人使用,文档是非常必要的。在 GitHub 上也有很多创建项目文档的方法。对于中大型项目来说,维护一个文档网站也是很有必要的。Hexo 不仅能生成静态网站,同时也提供了部署网站的方案,非常方便我们使用。


快速入门 - Vue2 Tutorials (一)

+++
title = "Vue2 Tutorials 01 Quick Start"
description = "快速入门 - Vue2 Tutorials (一)"
date = 2017-07-06T14:53:34+08:00
tags = ["vue", "前端"]
categories = ["vue", "前端"]
draft = false
+++

Vue 的官方文档 对 Vue 介绍非常详细,但官方文档使用在 HTML 中引入 vue 的方式进行讲解,而实际项目中一般使用脚手架如 vue-cli 初始化项目。以至于刚看完文档时,却依旧不能立即立即 vue-cli 创建的项目代码。所以本文 vue-cli 构建的项目为基础,详细解释其代码及对应的概念,并进行简单的实践。

本文的代码在 https://github.com/nodejh/vue2-tutorials/tree/master/01.QuickStart

命令行工具

安装 vue-cli 并初始化项目

首先要全局安装 vue-cl:

$ npm install --global vue-cli

然后使用 vue-cli 初始化一个基于 webpack 模板的新项目,除了 Install vue-router?N (No),其余都可以直接回车选 Y (Yes),因为我们暂时不会讲到 vue-router

$ vue init webpack demo
? Project name demo
? Project description A Vue.js project

? Author nodejh <[email protected]>
? Vue build standalone
? Install vue-router? No
? Use ESLint to lint your code? Yes
? Pick an ESLint preset Standard
? Setup unit tests with Karma + Mocha? Yes
? Setup e2e tests with Nightwatch? Yes

安装依赖

$ cd demo
$ npm install
$ npm run dev

然后打开浏览器,输入 http://localhost:8080 就能看到界面。

接下来分析一下代码。

代码分析

项目目录结构如下:

├── README.md
├── build    # 编译项目的配置文件目录
├── config    # 配置文件目录
├── src    # 项目主要代码目录
├── static    # 静态资源
├── test    # 测试文件目录

开发阶段的主要代码都在 src 目录中编写,vue-cli 默认生成了一些代码:

src
├── App.vue
├── assets
│   └── logo.png
├── components
│   └── Hello.vue
└── main.js

可以发现,代码的后缀名有两种:

  • .js JS 文件
  • .vue Vue 组件,里面定义了 Vue 实例、模板、样式等。需要由 webpack 等工具来转换为 js 代码

接下来会逐一解释这些文件及代码。

main.js

main.js 是项目的入口文件,也是 webpack 打包的入口文件。里面最代码很少,主要就是通过 new Vue() 创建 Vue 实例:

new Vue({
  el: '#app',
  template: '<App/>',
  components: { App },
});

每个 Vue.js 应用都是通过构造函数 Vue 创建一个 Vue 的根实例启动的。

在实例化 Vue 时,传入一个了一个对象,对象包含以下几个选项。

el

el 的值是 Vue 实例的挂载目标,这里是 #app,也就是 demo/index.htmlid="app" 这个元素:

<div id="app"></div>

el 必须是一个已存在的元素。

api/#el

components

在说 template 之前,先来看看 components 属性。

components: { App } 等价于 components: { App: App },是一个包含了对 Vue 实例可见的组件的哈希表。只有在 components 里面列出来的组件,才可以在 template 里面使用。

如果我们把 components: { App } 改为 components: { App } 改为 components: { MyApp: App },那么在 template 里面就需要这样使用:template: '<my-app />'

由于 HTML 标签不区分大小写,所以 components 里面的驼峰命名会自动转换为短横线。详见 camelCase vs. kebab-case

template

template 就是挂载到页面的模板。

这里的值是 <App/> 组件就是 components 属性中的 App,也就是通过 import 引入的 App 这个模板。

new Vue({
  el: '#app',
  // 这里的 <App/> 就是 components 属性的值 App
  template: '<App/>',
  components: { App },
});

所以这段代码的含义就是,将 <App/> 这个模板挂载到元素 #app 上。

src/App.vue

src/App.vue 是一个典型的单文件组件。实际在项目中,我们写的基本都是组件,再根据需要用组件组成页面,这其实就是组件化。组件与组件之间相互独立,项目结构更加清晰,也更有利于维护。

一个组件里面封装了 HTML、CSS 和 JS,有自己独立的样式和逻辑。

<template> 就是组件中的模板,模板的代码都在 <template> 标签中,除 <hello> 之外都是普通的 HTML。因为 hello 也是一个组件,然后通过标签的形式注入到模板中。

为什么模板中能使用 hello 这个组件呢?

这是因为 <script></script> 标签里面定义了 Hello(首字母大写)这个组件:

import Hello from './components/Hello'

export default {
  name: 'app',
  components: {
    // Hello 组件,即 ./components/Hello 的一个引用
    Hello  
  }
}

这里 components 属性的含义,在之前已经提到过了,只有在 components 里面列出来的组件,才能被模板使用。这里列出了 Hello 这个组件,所以在 <template> 中我们可以使用 <hello>(前面也提到过, vue 会自动将驼峰法命名转为短横线)。

components 属性里面的 Hello,则是 ./components/Hello 这个组件的一个引用:

import Hello from './components/Hello'

最后就是 <style> 标签,里面就是普通的 CSS 了。

src/components/Hello.vue

最后再来看看 src/components/Hello.vue 这个组件的代码。

基本跟 src/App.vue 是一样的,除了下面这两个地方之外:

<h1>{{ msg }}</h1>
data () {
  return {
    msg: 'Welcome to Your Vue.js App'
  }
}

恭喜你!看到这里,我们就可以真正开始写代码了。

{{}} 是 Vue 的一个模板语法,文本插值。如上面的例子所示,我们在 data 里面定义一个对象,就可以在模板中通过 {{ }} 来访问。

data 虽然是一个函数,但它执行之后就等价于:

data: {
  msg: 'Welcome to Your Vue.js App'
}

当我们改变 msg 的值,在页面上渲染出来的数据也会改变。也就是数据和 DOM 绑定在了一起。

模板语法

插值

文本插值

上面我们已经接触到了文本插值 {{}}{{ msg }} 将会被替代为对应数据对象上 msg 属性的值。无论何时,绑定的数据对象(即 data)上 msg 属性发生了改变,插值处的内容都会更新。

通过使用 v-once 指令,我们也能执行一次性地插值,当数据改变时,插值处的内容不会更新。但请留心这会影响到该节点上所有的数据绑定:

<h1 v-once>This will never change: {{ msg }}</h1>

纯 HTML

双大括号会将数据解释为纯文本,而非 HTML 。为了输出真正的 HTML ,需要使用 v-html 指令:

<div v-html="rawHtml"></div>

这个 div 的内容将会被替换成为属性值 rawHtml,直接作为 HTML —— 数据绑定会被忽略。注意,你不能使用 v-html 来复合局部模板,因为 Vue 不是基于字符串的模板引擎。组件更适合担任 UI 重用与复合的基本单元。

你的站点上动态渲染的任意 HTML 可能会非常危险,因为它很容易导致 XSS 攻击。请只对可信内容使用 HTML 插值,绝不要对用户提供的内容插值。

JS 表达式

{{}} 中也可以写 JS 表达式:

{{ number + 1 }}
{{ ok ? 'YES' : 'NO' }}
{{ message.split('').reverse().join('') }}

<div v-bind:id="'list-' + id"></div>

指令

指令(Directives)是带有 v- 前缀的特殊属性。

v-bind

{{}} 不能在 HTML 属性中使用。针对 HTML 属性需要使用 v-bind

<div v-bind:id="dynamicId"></div>

这对布尔值的属性也有效 —— 如果条件被求值为 false 的话该属性会被移除:

<button v-bind:disabled="isButtonDisabled">Button</button>

v-bind 也可以缩写:

<div :id="dynamicId"></div>
<button :disabled="isButtonDisabled">Button</button>

v-on

v-on 用来监听 DOM 事件:

<button v-on:click="doSomething"></button>

也可以缩写成下面这样:

<button @click="doSomething"></button>

v-if

<template>
  <p v-if="seen">Now you see me</p>
</template>

<script>
export default {
  name: 'hello',
  data: {
    seen: true
  }
}
</script>

这里 v-if 指令将根据表达式 seen 的值的真假来移除/插入 <p> 元素。

v-for

v-for 指令可以绑定数组的数据来渲染一个项目列表:

<template>
  <ol>
    <li v-for="todo in todos">
      {{ todo.text }}
    </li>
  </ol>
</template>

<script>
  export default {
    data: {
      todos: [
        { text: '学习 JavaScript' },
        { text: '学习 Vue' },
        { text: '整个牛项目' }
      ]
    }
  }
</script>

实践

让我们把目光回到 Hello.vue。在这个组件里面有一些链接列表, Essential Links 和 Ecosystem,这些列表直接使用 HTML 编写:

<ul>
  <li><a href="https://vuejs.org" target="_blank">Core Docs</a></li>
  <li><a href="https://forum.vuejs.org" target="_blank">Forum</a></li>
  <li><a href="https://gitter.im/vuejs/vue" target="_blank">Gitter Chat</a></li>
  <li><a href="https://twitter.com/vuejs" target="_blank">Twitter</a></li>
  <br>
  <li><a href="http://vuejs-templates.github.io/webpack/" target="_blank">Docs for This Template</a></li>
</ul>
<h2>Ecosystem</h2>
<ul>
  <li><a href="http://router.vuejs.org/" target="_blank">vue-router</a></li>
  <li><a href="http://vuex.vuejs.org/" target="_blank">vuex</a></li>
  <li><a href="http://vue-loader.vuejs.org/" target="_blank">vue-loader</a></li>
  <li><a href="https://github.com/vuejs/awesome-vue" target="_blank">awesome-vue</a></li>
</ul>

按照传统的写法,如果我们需要往里面添加链接的时候,每次我们都得添加 <li><a> 标签。思考两个问题:

  • 添加几个链接还好,如果要添加非常非常多呢?难到要复制几十次 <li><a> 标签?
  • 如果要动态改变链接列表呢?难道要使用 innerHTML 等方法修改 DOM?

聪明的你可能已经想到了,很明显不需要这么做,我们可以使用模板语法。将链接信息写到 Vue 的数据对象 data 里面,然后通过动态绑定的方式,将数据绑定到 DOM。

所以修改如下:

<template>
  <div class="hello">
    <h1>{{ msg }}</h1>
    <h2>Essential Links</h2>
    <ul>
      <li v-for="essentialLink in essentialLinks">
        <a :href="essentialLink.link" target="_blank">{{ essentialLink.text }}</a>
      </li>
      <br>
      <li><a href="http://vuejs-templates.github.io/webpack/" target="_blank">Docs for This Template</a></li>
    </ul>
    <h2>Ecosystem</h2>
    <ul>
      <li v-for="ecosystem in ecosystems">
        <a :href="ecosystem.link" target="_blank">{{ ecosystem.text }}</a>
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  name: 'hello',
  data () {
    return {
      msg: 'Welcome to Your Vue.js App',
      ecosystems: [
        {
          link: 'http://router.vuejs.org/',
          text: 'vue-router'
        },
        {
          link: 'http://vuex.vuejs.org/',
          text: 'vuex'
        },
        {
          link: 'http://vue-loader.vuejs.org/',
          text: 'vue-loader'
        },
        {
          link: 'https://github.com/vuejs/awesome-vue',
          text: 'awesome-vue'
        }
      ],
      essentialLinks: [
        {
          link: 'https://vuejs.org',
          text: 'Core Docs'
        },
        {
          link: 'https://forum.vuejs.org',
          text: 'Forum'
        },
        {
          link: 'https://gitter.im/vuejs/vue',
          text: 'Gitter Chat'
        },
        {
          link: 'https://github.com/vuejs/awesome-vue',
          text: 'awesome-vue'
        },
        {
          link: 'https://twitter.com/vuejs',
          text: 'Twitter'
        }
      ]
    }
  }
}
</script>

这样我们就把数据和视图分开了,模板里面的代码也简洁了很多,不再需要写很多重复的代码。并且根据不同数据,我们也能展示出不同的 UI。

总结

本文详细讲解了 vue-cli 初始化的项目代码,并且在讲解代码的过程中,介绍了构造 vue 对象的一些参数,以及 vue 的一些基本概念,比如模板语法中的插值和指令。最后通过修改代码对以上知识点进行实践。

相信看到了这里,你对如何使用 vue 写一个项目已经有了初步了解。当然,看完本文,可能还有很多概念理解不清楚,这时推荐去看一下 vue 的官方文档,这个时候再去看官方文档,应该就会轻松很多了。

macOS/Linux 环境变量设置

我们安装一个软件后,之所以能够使用一些与该软件相关的命令,是因为该命令被添加到了系统的环境变量里面。比如安装完 Atom 之后,就可以使用 atom 命令打开文件。有时候我们需要自己设置环境变量,MacOS 设置环境变量有很多种方法,最常用的是编辑当前 SHELL 对应的用户级环境变量配置文件,如 bash 对应的 .bash_profile

MacOS 和 Linux 都是类 Unix 系统,它们添加环境变量的方式也是类似的。本文以 macOS 为例。

SEHLL 类型

在添加环境变量之前,首先要知道使用的是什么 SHELL。MacOS 内置了多种 SHELL,可通过 cat /etc/shells 查看:

$ cat /etc/shells
/bin/bash
/bin/csh
/bin/ksh
/bin/sh
/bin/tcsh
/bin/zsh
$ echo $SHELL
/bin/zsh
  • sh(全称 Bourne Shell)是UNIX最初使用的 shell,而且在每种 UNIX 上都可以使用。Bourne Shell 在 shell 编程方便相当优秀,但在处理与用户的交互方便作得不如其他几种 shell。
  • bash(全称 Bourne Again Shell)LinuxOS 默认的,它是 Bourne Shell 的扩展。与 Bourne Shell 完全兼容,并且在 Bourne Shell 的基础上增加了很多特性。可以提供命令补全,命令编辑和命令历史等功能。它还包含了很多 C Shell 和 Korn Shell 中的优点,有灵活和强大的编辑接口,同时又很友好的用户界面。
  • csh(全称 C Shell)是一种比 Bourne Shell更适合的变种 Shell,它的语法与 C 语言很相似。
  • Tcsh 是 Linux 提供的 C Shell 的一个扩展版本。
  • Tcsh 包括命令行编辑,可编程单词补全,拼写校正,历史命令替换,作业控制和类似 C 语言的语法,他不仅和 Bash Shell 提示符兼容,而且还提供比 Bash Shell 更多的提示符参数。
  • ksh(全称 Korn Shell)集合了 C Shell 和 Bourne Shell 的优点并且和 Bourne Shell 完全兼容。
  • pdksh 是 Linux 系统提供的 ksh 的扩展。pdksh 支持人物控制,可以在命令行上挂起,后台执行,唤醒或终止程序。
  • zsh Zsh 是一款功能强大终端(shell)软件,既可以作为一个交互式终端,也可以作为一个脚本解释器。它在兼容 Bash 的同时 (默认不兼容,除非设置成 emulate sh) 还有提供了很多改进,例如:更高效、更好的自动补全、更好的文件名展开(通配符展开)、更好的数组处理、可定制性高。

环境变量配置文件

macOS 默认的是 Bourne Shell,其环境变量配置文件及加载顺序如下:

/etc/profile
/etc/bashrc
/etc/paths 
~/.bash_profile # macOS
~/.bash_login 
~/.profile 
~/.bashrc # linux

其中 /etc/profile /etc/bashrc/etc/paths 是系统级环境变量,对所有用户都有效。但它们的加载时机有所区别:

  • /etc/profile 任何用户登陆时都会读取该文件
  • /etc/bashrc bash shell执行时,不管是何种方式,读取此文件
  • /etc/paths 任何用户登陆时都会读取该文件

后面几个是当前用户级的环境变量。macOS 默认用户环境变量配置文件为 ~/.bash_profile,Linux 为 ~/.bashrc

如果不存在 ~/.bash_profile,则可以自己创建一个 ~/.bash_profile

  • 如果 ~/.bash_profile 文件存在,则后面的几个文件就会被忽略
  • 如果 ~/.bash_profile 文件不存在,才会以此类推读取后面的文件

如果使用的是 SHELL 类型是 zsh,则还可能存在对应的 /etc/zshrc~/.zshrc。任何用户登录 zsh 的时候,都会读取该文件。某个用户登录的时候,会读取其对应的 ~/.zshrc

添加环境变量

系统环境变量 /etc/paths

一般添加系统环境变量,建议通过修改 /etc/paths 的方式进行添加。一般不建议直接修改 /etc/paths 文件,而是将路径写在 /etc/paths.d/ 目录下的一个文件里,系统会逐一读取 /etc/paths.d/ 下的每个文件。

Git 路径就是这样实现的。我们先看看 Git 的例子:

首先 /etc/paths 的文件内容大致如下:

$ cat /etc/paths
/usr/local/bin
/usr/bin
/bin
/usr/sbin
/sbin
/opt/local/bin
/opt/local/sbin

然后查看 /etc/paths.d/ 目录:

$ ls -l /etc/paths.d
-rw-r--r--    1 root  wheel    19  6 10  2015 git
-rw-r--r--    1 root  wheel    17  4 20  2016 go

查看 /etc/paths.d/git 文件的内容:

$ cat /etc/paths.d/git
/usr/local/git/bin

/usr/local/git/bin 就是 Git 的可执行文件路径。

所以我们如果要添加一个系统环境变量,也可以参考这种方式。

提供一个添加环境变量的的 shell语句,其中 /usr/local/sbin/mypath 就是我们自己的可执行文件的路径:

$ sudo -s 'echo "/usr/local/sbin/mypath" > /etc/paths.d/mypath'

但添加完成之后,命令不会立即生效,有两种方法使配置文件生效:

  • 重新登录终端(如果是图形界面,即重新打开 Terminal)
  • 通过 source 命令加载:source /etc/paths

配置生效之后,就可以使用 mypath 命令了。

系统环境变量 /etc/profile/etc/bashrc

注:一般不建议修改这两个文件

添加环境变量的语法为:

export PATH="$PATH:<PATH 1>:<PATH 2>:<PATH 3>:...:<PATH N>"

所以在 /etc/profile/etc/bashrc 中添加环境变量,只需要在文件中加入如下代码:

export PATH="/Users/jh/anaconda/bin:$PATH"

用户环境变量

添加用户环境变量,只需要修改 ~/.bash_profile(Bourne Shell)或 ~/.zshrc(zsh)或其他用户级配置文件即可。添加环境变量的语法也是:

export PATH="$PATH:<PATH 1>:<PATH 2>:<PATH 3>:...:<PATH N>"

下面是我的 ~/.zshrc 的部分配置:

export ANDROID_HOME=~/Library/Android/sdk
export PATH=$PATH:~/Library/Android/sdk/tools:~/Library/Android/sdk/platform-tools
export PATH=$PATH:/usr/local/mysql/bin:/usr/local/mysql/support-files
alias tree="find . -print | sed -e 's;[^/]*/;|____;g;s;____|; |;g'"
alias t="trans"
export PATH="/usr/local/sbin:$PATH"

可以通过 echo $PATH 命令查看当前环境变量:

echo $PATH
/usr/local/sbin:/Users/jh/.nvm/versions/node/v7.6.0/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/git/bin:/Users/jh/Library/Android/sdk/tools:/Users/jh/Library/Android/sdk/platform-tools:/usr/local/mysql/bin:/usr/local/mysql/support-files:/Applications/Sublime Text.app/Contents/SharedSupport/bin

修改了配置文件之后,依旧需要重新登录 SHELL 或者使用 source ~/.zshrc 来是配置立即生效。

export

还有一种添加环境变量的方法: export 命令。

export 命令用于设置或显示环境变量。通过 export 添加的环境变量仅在此次登陆周期内有效。

比如很多时候我们的开发环境和生产环境,就可以通过设置一个临时环境变量来,然后在程序中根据不同的环境变量来设置不同的参数。

# 设置 NODE_ENV 环境变量。退出 SHELL 时失效
$ export NODE_ENV=development
# 查看当前所有环境变量
$ export -p 
...
typeset -x NODE_ENV=development
typeset -x USER=jh
...

在 Node.js 代码中判断当前环境是开发环境还是生产环境:

if (process.env.NODE_ENV === 'development' {
    console.log('开发环境';
} else {
    console.log('生产环境';
}

微信公众平台开发接入指南

在进行微信公众平台开发之前,需要先接入微信公众平台。具体的步骤在 公众平台开发者文档-接入指南 已有详细介绍,文档中也提供了验证服务器的 PHP 示例代码。

本文主要提供了 Node.js 版本的验证代码,同时把步骤细化,让开发者更方便地了解整个接入过程,对初学者更友好。

TL;DR

在微信公众平台后台的 开发者中心/填写服务器配置 页面,配置好 URL 和 Token 后,复制下面的代码,修改 Token,在服务器运行,然后再在页面上点击提交即可进行接入验证。

// checkSignature.js
/**
 * 整个验证步骤分为三步
 *    1. 将token、timestamp、nonce三个参数进行字典序排序
 *    2. 将三个参数字符串拼接成一个字符串进行sha1加密
 *    3. 开发者获得加密后的字符串可与signature对比,标识该请求来源于微信
 */


const http = require('http');
const url = require('url');
const crypto = require('crypto');


// Web 服务器端口
const port = 3333;
// 微信公众平台服务器配置中的 Token
const token = 'token';


/**
 *  对字符串进行sha1加密
 * @param  {string} str 需要加密的字符串
 * @return {string}     加密后的字符串
 */
function sha1(str) {
  const md5sum = crypto.createHash('sha1');
  md5sum.update(str);
  const ciphertext = md5sum.digest('hex');
  return ciphertext;
}

/**
 * 验证服务器的有效性
 * @param  {object} req http 请求
 * @param  {object} res http 响应
 * @return {object}     验证结果
 */
function checkSignature(req, res) {
  const query = url.parse(req.url, true).query;
  console.log('Request URL: ', req.url);
  const signature = query.signature;
  const timestamp = query.timestamp;
  const nonce = query.nonce;
  const echostr = query.echostr;
  console.log('timestamp: ', timestamp);
  console.log('nonce: ', nonce);
  console.log('signature: ', signature);
  // 将 token/timestamp/nonce 三个参数进行字典序排序
  const tmpArr = [token, timestamp, nonce];
  const tmpStr = sha1(tmpArr.sort().join(''));
  console.log('Sha1 String: ', tmpStr);
  // 验证排序并加密后的字符串与 signature 是否相等
  if (tmpStr === signature) {
    // 原样返回echostr参数内容
    res.end(echostr);
    console.log('Check Success');
  } else {
    res.end('failed');
    console.log('Check Failed');
  }
}


const server = http.createServer(checkSignature)
server.listen(port, () => {
  console.log(`Server is runnig ar port ${port}`);
  console.log('Start Checking...');
});

填写服务器配置

登录进入微信公众平台后台管理页面

登录

然后进入 基本配置 页面

基本配置

再然后选择 修改配置,进入到 填写服务器配置 子页面

填写服务器配置

  • URL 为已经解析到你的服务器的域名,这里以 http://wechat.nodejh.com 这个二级域名为例
  • Token 随意填写即可

验证服务器地址的有效性

验证服务器地址的有效性,需要在域名对应的服务器上运行一段验证程序。该程序会接收上个步骤中的域名所发送的 HTTP 请求。

官方文档提供了 PHP 的示例程序,下面是 Node.js 版本:

// checkSignature.js
/**
 * 整个验证步骤分为三步
 *    1. 将token、timestamp、nonce三个参数进行字典序排序
 *    2. 将三个参数字符串拼接成一个字符串进行sha1加密
 *    3. 开发者获得加密后的字符串可与signature对比,标识该请求来源于微信
 */


const http = require('http');
const url = require('url');
const crypto = require('crypto');


// Web 服务器端口
const port = 3333;
// 微信公众平台服务器配置中的 Token
const token = 'token';


/**
 *  对字符串进行sha1加密
 * @param  {string} str 需要加密的字符串
 * @return {string}     加密后的字符串
 */
function sha1(str) {
  const md5sum = crypto.createHash('sha1');
  md5sum.update(str);
  const ciphertext = md5sum.digest('hex');
  return ciphertext;
}

/**
 * 验证服务器的有效性
 * @param  {object} req http 请求
 * @param  {object} res http 响应
 * @return {object}     验证结果
 */
function checkSignature(req, res) {
  const query = url.parse(req.url, true).query;
  console.log('Request URL: ', req.url);
  const signature = query.signature;
  const timestamp = query.timestamp;
  const nonce = query.nonce;
  const echostr = query.echostr;
  console.log('timestamp: ', timestamp);
  console.log('nonce: ', nonce);
  console.log('signature: ', signature);
  // 将 token/timestamp/nonce 三个参数进行字典序排序
  const tmpArr = [token, timestamp, nonce];
  const tmpStr = sha1(tmpArr.sort().join(''));
  console.log('Sha1 String: ', tmpStr);
  // 验证排序并加密后的字符串与 signature 是否相等
  if (tmpStr === signature) {
    // 原样返回echostr参数内容
    res.end(echostr);
    console.log('Check Success');
  } else {
    res.end('failed');
    console.log('Check Failed');
  }
}


const server = http.createServer(checkSignature)
server.listen(port, () => {
  console.log(`Server is runnig ar port ${port}`);
  console.log('Start Checking...');
});

因为验证要使用 80(HTTP) 端口或 443(HTTPS) 端口,而 Node.js 一般不直接监听 80 端口,所以需要使用 Nginx 或其他程序将来自 http://wechat.nodejh.com 的请求转发到 Node.js 程序端口如上面的 3333。关于 Nginx 的配置,可以看我之前写的《使用 Ngnix 给 Node.js 应用做反向代理》

这里也顺便给出该程序的 Nginx 配置

upstream nodejs {
    server 127.0.0.1:3333;
    keepalive 64;
}

server {
    listen 80;
    server_name wechat.nodejh.com;
    # 日志
    access_log /var/log/nginx/wechat.nodejh.com.log;
    location / {
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host  $http_host;
        proxy_set_header X-Nginx-Proxy true;
        proxy_set_header Connection "";
        proxy_pass http://nodejs;
    }
}

配置就绪之后,启动验证程序

$ node checkSignature.js
Server is runnig ar port 3333
Start Checking...

这样,checkSignature.js 就会创建一个 3333 端口的服务。访问 http://wechat.nodejh.com 这个域名的时候,Nginx 就会将请求转发到 3333 端口。

在微信公众平台后台管理的服务器配置页面,点击提交按钮,就会填写的 URL (这里是 http://wechat.nodejh.com)发送一个 HTTP 请求,并带上 signature,timestamp,nonce,echostr 这四个参数。

启动 checkSignature.js 后,在服务器配置页面,点击提交按钮,就会开启验证。

服务器端出现下面的结果,就说明验证成功。验证成功后,微信公众平台后台会自动跳转到 基本配置 页面。

# 服务端响应...
signature:  8fffb8f011d64819ec61105415114694bb03d392
Sha1 String:  8fffb8f011d64819ec61105415114694bb03d392
Check Success

然后就可以依据接口文档实现业务逻辑了。

后续我也还会更新一些关于微信公众平台开发的文章,欢迎关注。

在 MacOS Sierra 上安装 Apache 和多个版本的 PHP

+++
date = "2016-12-29T00:04:41+08:00"
description = "在 MacOS Sierra 上安装 Apache 和多个版本的 PHP"
title = "macOS Sierra Apache Multiple PHP versions"
tags = ["PHP", "macOS"]
categories = ["PHP"]

+++

前言:每次搭建开发环境真的是一件很麻烦的事情,但随着时间的推移和系统环境的差异,网上的教程自己曾经安装的经验经常不合时宜,总会出现一些这样或那样的问题。

通过几番搜寻,我终于找到一篇完善的教程,将其翻译成了中文。我也会持续更新这篇文章,不断完善并记录遇到的或新或旧的问题。希望这篇文章可以终结在 macOS 上安装 PHP 开发环境的话题。

本文主要内容翻译自《macOS 10.12 Sierra Apache Setup: Multiple PHP Versions》,并加入了自己的实践。

关于如何在 macOS 10.12 上搭建 Web 开发环境,原作者一共写了三篇文章。这是第一篇。

当前的操作系统是 macOS 10.12,这篇教程讲述的环境搭建和一般 PHP 安装最大的不同是,我们不使用系统自带的 Apache(macOS 自带了 apache、python、ruby 等一些列开发工具),而是使用 Homebrew 的 Apache。当然,系统自带的 Apaceh 也是可以工作的。

在 macOs 上进行 Web 开发,确实是一件很令人愉快的事情。目前也有很多集成开发工具可以方便地搭建开发环境,比如 MAMP PRO,它具有非常漂亮的一个 UI 界面,并且集成了 Apache、PHP 和 MySQL 三个软件,非常适合新手使用。但是有时候,由于其配置模板有限,软件没有及时升级,使用它的时候可能会遇到软件版本不能及时更新、配置不够方便灵活等问题。

于是很多人就开始寻找替代方案。幸运的是,这篇文章就是一个替代方案。并且文章给出了简单直接的安装配置步骤。

1. 安装 Homebrew

以下所有软件的安装都依赖于 macOS 上的一个包管理工具 Homebrew。使用 brew 命令,我们可以方便地在 mac 上安装各种功能各异的软件,当然,首先的安装 homebrew。安装步骤也非常简单,打开终端然后输入下面的代码:

$ ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"

然后等待终端提示你输入密码,程序就会开始自动安装。如果你没有安装 XCode Command Line Tools,它会自动帮你装上。等待几分钟,待完成后,就可以使用下面的命令查看 Homebrew 是否正确安装:

$ brew --version
Homebrew 1.1.5
Homebrew/homebrew-core (git revision a50a6; last commit 2016-12-28)

同时可能你也需要执行一下下面的命令,来检测配置是否正确:

$ brew doctor

若有配置不当的地方,brew 会给出提示,安装提示修复即可。

1.1 添加第三方仓库

我们将需要使用一些来自第三方仓库的软件,所以需要添加额外的仓库:

$ brew tap homebrew/php
$ brew tap homebrew/apache

然后更新 brew:

$ brew update

接下来就可以尽情使用 brew 了。

2. Apache 的安装

最新的 macOS 10.12 预装了 Apache 2.4,但苹果移除了一些必要的脚本,所以 Apache 和 Homebrew 配合使用将会变得困难。当然解决该问题的方法就是,使用 Homebrew 重新安装配置 Apache,并使其运行在标准端口上(80/443)。

如果之前已经安装有 Apache 并且在运行中,首先就需要关闭它,然后移除自动加载脚本。下面的命令没有任何副作用,只管运行就好了:

$ sudo apachectl stop
$ sudo launchctl unload -w /System/Library/LaunchDaemons/org.apache.httpd.plist 2>/dev/null
$ brew install httpd24 --with-privileged-ports --with-http2

接下来需要等待一小会儿,因为这个命令是下载源码并编译安装 Apache。上面命令执行结果如下面这样:

🍺  /usr/local/Cellar/httpd24/2.4.23_2: 212 files, 4.4M, built in 1 minute 45 seconds

这里的路径很重要,因为在接下来的步骤中,我们都需要使用这个路径,在这个例子中,路径是 /usr/local/Cellar/httpd24/2.4.23_2/,如果你的路径不是这个,则在运行下面的命令的时候,将其替换为你的路径就可以了:

$ sudo cp -v /usr/local/Cellar/httpd24/2.4.23_2/homebrew.mxcl.httpd24.plist /Library/LaunchDaemons
$ sudo chown -v root:wheel /Library/LaunchDaemons/homebrew.mxcl.httpd24.plist
$ sudo chmod -v 644 /Library/LaunchDaemons/homebrew.mxcl.httpd24.plist
$ sudo launchctl load /Library/LaunchDaemons/homebrew.mxcl.httpd24.plist

现在我们就通过 Homebrew 安装上了 Apache,并使用管理员权限将其配置为自动启动。这个时候 Apache 应该已经在运行了,所以你打开浏览器访问 localhost 将会看到 “It works!”。

macOS-Sierra-Apache-Multiple-PHP-versions-php-information-page-it-works

2.1 一些小问题

如果你的浏览器提示说不能建立连接到服务器,那么首先检查一下 Apache 服务是否已经启动了:

$ ps -aef | grep httpd

如果 Apache 正在运行,你将会看到一些 httpd 进程。

重启 Apache:

$ sudo apachectl -k restart

我们也可以监控 Apache 的错误日志,查看是否有错误信息:

$ tail -f /usr/local/var/log/apache2/error_log

如果上面这些步骤都没有解决问题,那么确定你的 Apache 是否监听了 80 端口,即 /usr/local/etc/apache2/2.4/httpd.conf 配置文件是否有 Listen: 80,或者 Listen: 后面是其他端口。

Apache 是通过 apachectl 来进行控制的,它的基本使用方法如下:

$ sudo apachectl start
$ sudo apachectl stop
$ sudo apachectl -k restart

-k 参数将强制重启 Apache,不管 Apache 是否准备好。

最终我将我的 Apache 的端口修改为了 7070,主要是因为 80 端口是 HTTP 服务的默认端口,8080 端口是 Tomcat 的默认端口,为了避免与其他软件如 nginx 等发生潜在端口冲突问题,所以改为了 7070

2.2 Apache 的配置

现在我们已经有了一个 Web 服务器,接下来我们需要对其进行一些配置,以便于更方便我们本地的开发。

首先修改 Apache 的 document root,这是 Web 服务的根目录,Apache 会从这个目录中寻找资源文件。默认的 document root 是 /Library/WebServer/Documents。作为一个开发环境,我们可能更希望网站的根目录(即 Web 服务的根目录)在我们自己的用户主目录下。所以接下来修改配置。

我们可以在终端通过 open -e 命令使用 Mac 默认的文本编辑器打开一个文件:

$ open -e /usr/local/etc/apache2/2.4/httpd.conf

macOS-Sierra-Apache-Multiple-PHP-versions-php-information-page-it-works-textedit

搜索 DocumentRoot,然后你会看到下面这行:

DocumentRoot "/usr/local/var/www/htdocs"

将这行配置改为自己的用户主目录中的目录,把 your_user 改为你的用户名:

DocumentRoot "/Users/your_user/sites"

紧接着还要将上面这行代码下面的 <Directory> 也改为你的新的 document root:

<Directory "/Users/your_user/sites">

<Directory> 代码块里面,我们还需要将 AllowOverride 改为下面的样子:

# AllowOverride controls what directives may be placed in .htaccess files.
# It can be "All", "None", or any combination of the keywords:
#   AllowOverride FileInfo AuthConfig Limit
#
AllowOverride All

接下来通过取消注释使用 mod_rewrite 模块:

LoadModule rewrite_module libexec/mod_rewrite.so

2.4 用户和用户组

现在 Apache 已经指向了我们的用户主目录下的 sites 目录,不过依旧还存在一个问题。Apche 运行的时候,其用户及用户组都是 daemon,当程序访问我们的用户主目录的时候,就会遇到权限问题。解决这个问题的方法就是,在 httpd.conf 将用户和用户组分别改为你的用户名 your_userstaff

User your_user
Group staff

2.5 sites 目录

接下来就需要在用户主目录下创建一个 sites 目录了,然后添加一个简单的 index.html 文件,并写入 <h1>My User Web Root</h1> 代码:

$ mkdir ~/Sites
$ echo "<h1>My User Web Root</h1>" > ~/sites/index.html

注意,sites 目录的绝对路径需要和 httpd.conf 中的 DocumentRoot 保持一致。

然后重启 Apache 使配置生效:

$ sudo apachectl -k restart

再访问 http://localhost 就会显示 index.html 里面的内容。如果你的配置也生效了,我们就可以继续下一步了。

macOS-Sierra-Apache-Multiple-PHP-versions-php-sites-webroot

3. PHP 的安装

我们接下来安装 PHP 5.5、PHP 5.6、PHP 7.0 和 PHP 7.1,并使用一个简单的脚本来切换不同的版本。

你可以使用 brew options php55 来查看所有可以安装的选项设置,比如在这个例子中,我们使用 --with-httpd24 参数来编译安装 PHP 以及使 Apache 支持 PHP 所需要的一些模块。

$ brew install php55 --with-httpd24
$ brew unlink php55
$ brew install php56 --with-httpd24
$ brew unlink php56
$ brew install php70 --with-httpd24
$ brew unlink php70
$ brew install php71 --with-httpd24

上面的命令会从源码下载 PHP,并进行编译安装。这可能需要一点时间。

如果之前已经安装过 PHP,可能需要使用 reinstall 来代替 install 进行安装。

3.0.1 ISSUE configure: error: Cannot find libz

我在安装的时候遇到了一个错误:

==> ./configure --prefix=/usr/local/Cellar/php56/5.6.11_2 --localstatedir=/usr/local/var --sysconfdir=/usr/local/etc/php/5.6 --with-config-file-path=/usr/local/etc/php/5.6 --with
checking whether to enable the SQLite3 extension... yes
checking bundled sqlite3 library... yes
checking for ZLIB support... yes
checking if the location of ZLIB install directory is defined... no
configure: error: Cannot find libz

最终在 Github 上找到了该 ISSUE Cannot find libz when install php56 #1946

其原因可能是 Xcode 的 Command Line Tool 没有正确安装。解决问题的方法是运行下面的命令来重新安装:

$ xcode-select --install

可能你也需要根据自己的需要修改一些 PHP 的配置,常见的比如修改内存配置或 date.timezone 配置。各个版本的配置文件即 php.ini 在下面的目录:

/usr/local/etc/php/5.5/php.ini
/usr/local/etc/php/5.6/php.ini
/usr/local/etc/php/7.0/php.ini
/usr/local/etc/php/7.1/php.ini

3.1. Apache 和 PHP 的配置 - 第一部分

我们已经成功安装了多个版本的 PHP,但我们还需要告诉 Apache 怎么使用它们。再次打开 /usr/local/etc/apache2/2.4/httpd.conf,并搜索 LoadModule php5_module 所在行。

可以发现,每个版本的 PHP 都有一个 LoadModule 入口,每个 LoadModule 都指向一个特定版本的 PHP。这里默认的路径如 /usr/local/Cellar/php71/7.1.0_11/libexec/apache2/libphp7.sobrew 应用程序的安装路径,我们可以使用一个更通用的路径来替换它们(具体的路径可能有所不同):

LoadModule php5_module        /usr/local/Cellar/php55/5.5.38_11/libexec/apache2/libphp5.so
LoadModule php5_module        /usr/local/Cellar/php56/5.6.29_5/libexec/apache2/libphp5.so
LoadModule php7_module        /usr/local/Cellar/php70/7.0.14_7/libexec/apache2/libphp7.so
LoadModule php7_module        /usr/local/Cellar/php71/7.1.0_11/libexec/apache2/libphp7.so

将上面的路径修改为:

LoadModule php5_module        /usr/local/opt/php55/libexec/apache2/libphp5.so
LoadModule php5_module        /usr/local/opt/php56/libexec/apache2/libphp5.so
LoadModule php7_module        /usr/local/opt/php70/libexec/apache2/libphp7.so
LoadModule php7_module        /usr/local/opt/php71/libexec/apache2/libphp7.so

之所以可以这么修改,是因为 /usr/local/opt/php71 其实是由 brew 创建的 /usr/local/Cellar/php71 的一个软连接。

这么修改的好处是,升级 PHP 的小版本号的时候,比如由 7.1.0_11 时,我们就不需要再修改 LoadModule 对应的值了。

我们每次开发或运行依旧只需要一般也只能使用某一个版本的 PHP。以我们要使用 7.1 这个版本开发应用为例,将其他版本的 LoadModule 都注释掉,只保留对应版本的 LoadModule

#LoadModule php5_module        /usr/local/opt/php55/libexec/apache2/libphp5.so
#LoadModule php5_module        /usr/local/opt/php56/libexec/apache2/libphp5.so
#LoadModule php7_module        /usr/local/opt/php70/libexec/apache2/libphp7.so
LoadModule php7_module        /usr/local/opt/php71/libexec/apache2/libphp7.so

这样的配置就会告诉 Apache 使用 PHP 7.1 来处理 PHP 请求。(稍后我们将添加切换 PHP 版本的脚本。)

接下来还需要配置 PHP 的主目录索引文件(Directory Indexes),找到下面的代码块:

<IfModule dir_module>
    DirectoryIndex index.html
</IfModule>

将其替换为下面的代码:

<IfModule dir_module>
    DirectoryIndex index.php index.html
</IfModule>

<FilesMatch \.php$>
    SetHandler application/x-httpd-php
</FilesMatch>

保存后重启 Apache,PHP 的安装就完成了:

$ sudo apachectl -k restart

3.2. 验证 PHP 安装结果

验证 PHP 是否正确安装的最好方法是使用 phpinfo() 这个函数,这个函数会输出 PHP 的版本等信息。当然,最好不要在你的生产环境中使用它。但在开发环境中,使用它对我们了解系统中 PHP 安装信息非常有帮助。

在网站主目录即你的 sites/ 目录下创建一个 info.php 的文件,然后输入下面的代码:

<?php
phpinfo();
?>

打开浏览器,访问 http://localhost:7070/info.php,你讲看到下面的完美的 PHP 信息页面:

php information page

如果能够看到类似的页面,就说明 Apache 和 PHP 已经成功运行了。你可以通过注释 LoadModule ... php56 ... 来测试其他版本的 PHP。修改配置后,重启 Apache 并刷新页面就能看到类似的页面了。

3.3 PHP 版本切换脚本

在开发中,如果每次都通过修改 /usr/local/etc/apache2/2.4/httpd.conf 文件去切换 PHP 版本,显然太麻烦了。有没有更容易的方法呢?幸运的是,一些勤劳的开发者已经写好了这样的一个脚本 PHP switcher script

接下来我们将添加 sphp 到 brew 的 /usr/local/bin 里面。原文给的方法是使用下面的命令:

$ curl -L https://gist.github.com/w00fz/142b6b19750ea6979137b963df959d11/raw > /usr/local/bin/sphp
$ chmod +x /usr/local/bin/sphp

其中第一行命令的作用,就是将 Gist 上的这个切换 PHP 版本的脚本下载并写入到 /usr/local/bin/sphp 这个文件里面。第二行命令的作用是赋予 /usr/local/bin/sphp 可执行权限。

但由于国内并不能访问 Gist,所以第一行命令并不能执行成功。所以我在这里提供了整个脚本代码:

#!/bin/bash

# Check if command was ran as root.
if [[ $(id -u) -eq 0 ]]; then
    echo "The command \"sphp\" should not be executed as root or via sudo directly."
    echo "When a service requires root access, you will be prompted for a password as needed."
    exit 1
fi

# Usage
if [ $# -ne 1 ]; then
    echo "Usage: sphp [phpversion]"
    echo "Versions installed:"
    brew list | grep '^php[0-9]\{2,\}$' | grep -o -E '[0-9]+' | while read -r line ; do
        echo " - phpversion: $line"
    done
    exit 1
fi

currentversion="`php -r \"error_reporting(0); echo str_replace('.', '', substr(phpversion(), 0, 3));\"`"
newversion="$1"

majorOld=${currentversion:0:1}
majorNew=${newversion:0:1}
minorNew=${newversion:1:1}

brew list php$newversion 2> /dev/null > /dev/null

if [ $? -eq 0 ]; then
    echo "PHP version $newversion found"

    # Check if new version is already the current version.
    # if [ "${newversion}" == "${currentversion}" ]; then
    #     echo -n "PHP version ${newversion} is already being used. Continue by reloading? (y/n) "
    #     while true; do
    #         read -n 1 yn
    #         case $yn in
    #             [Yy]* ) echo && break;;
    #             [Nn]* ) echo && exit 1;;
    #         esac
    #     done
    # fi

    echo "Unlinking old binaries..."
    brew unlink php$currentversion 2> /dev/null > /dev/null

    echo "Linking new binaries..."
    brew link php$newversion

    echo "Linking new modphp addon..."
    sudo ln -sf `brew list php$newversion | grep libphp` /usr/local/lib/libphp${majorNew}.so
    echo /usr/local/lib/libphp${majorNew}.so
    echo "Fixing LoadModule..."
    apacheConf=`httpd -V | grep -i server_config_file | cut -d '"' -f 2`
    sudo sed -i -e "/LoadModule php${majorOld}_module/s/^#*/#/" $apacheConf

    if grep "LoadModule php${majorNew}_module .*php${newversion}" $apacheConf > /dev/null
    then
        sudo sed -i -e "/LoadModule php${majorNew}_module .*php${newversion}/s/^#//" $apacheConf
    else
        sudo sed -i -e "/LoadModule php${majorNew}_module/s/^#//" $apacheConf
    fi

    echo "Updating version file..."

    pgrep -f /usr/sbin/httpd 2> /dev/null > /dev/null
    if [ $? -eq 0 ]; then
        echo "Restarting system Apache..."
        sudo pkill -9 -f /usr/sbin/httpd
        sudo /usr/sbin/apachectl -k restart > /dev/null 2>&1
    fi

    pgrep -f /usr/local/"Cellar|opt"/*/httpd 2> /dev/null > /dev/null
    if [ $? -eq 0 ]; then
        echo "Restarting homebrew Apache..."
        sudo pkill -9 -f /usr/local/"Cellar|opt"/*/httpd
        sudo /usr/local/bin/apachectl -k restart > /dev/null 2>&1
    fi

    # pgrep -x httpd 2> /dev/null > /dev/null
    # if [ $? -eq 0 ]; then
    #     echo "Restarting non-root homebrew Apache..."
    #     httpd -k restart > /dev/null 2>&1
    # fi

    echo "Done."

    # Show PHP CLI version for verification.
    echo && php -v
else
    echo "PHP version $majorNew.$minorNew was not found."
    echo "Try \`brew install php${newversion}\` first."
    exit 1
fi

首先在 /usr/local/bin/ 目录下新建 sphp 文件,然后通过 open 命令使用默认的编辑器打开它:

$ touch /usr/local/bin/sphp
$ open -e /usr/local/bin/sphp

将上面的代码复制进去并保存,然后赋予可执行权限:

$ chmod +x /usr/local/bin/sphp

3.4. 检测 PATH 路径

Homebrew 在安装程序的时候一般会把程序的可执行文件加入到 /usr/local/bin/usr/local/sbin 这两个目录里面。通过下面的命令可以快速测试可执行文件路径是否正确:

$ echo $PATH
/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin

如果没有看到类似的输出,你可能需要手动添加这些路径。添加下面的代码到 shell 的配置文件中(不同的 shell 可能需要将路径添加到不同的配置文件中,如 ~/.profile ~/.bash_profile ~/.zshrc)。如果使用的是 macOS 默认的 shell,则添加到 ~/.profile (若没有这个文件,则创建它);如果使用的是 zsh,则添加到 ~/.zshrc

export PATH=/usr/local/bin:/usr/local/sbin:$PATH

在添加路径到配置文件的时候,最好关闭其他不相关的终端,因为某些开着的终端可能会对路径造成一些奇怪的问题。添加完成后,配置会在下次打开终端的时候生效。或者使用下面的命令,重新载入配置文件使其立即生效:

# 若使用的是默认终端
$ source ~/.profile
# 或如果使用的是 zsh
$ source ~/.zshrc

3.5. Apache 和 PHP 的配置 - 第二部分

尽管之前我们已经配置好了 Apache 和 PHP,现在我们还需要修改配置文件,来使用 PHP switcher script 切换 PHP 的版本。继续打开 /usr/local/etc/apache2/2.4/httpd.conf 然后找到 LoadModule php 所在的行:

然后注释掉正在使用的 LoadModule

#LoadModule php5_module        /usr/local/opt/php55/libexec/apache2/libphp5.so
#LoadModule php5_module        /usr/local/opt/php56/libexec/apache2/libphp5.so
#LoadModule php7_module        /usr/local/opt/php70/libexec/apache2/libphp7.so
#LoadModule php7_module        /usr/local/opt/php71/libexec/apache2/libphp7.so

并在下面添加:

#Brew PHP LoadModule for `sphp` switcher
#LoadModule php5_module /usr/local/lib/libphp5.so
LoadModule php7_module /usr/local/lib/libphp7.so

如果你安装了 PHP 5.5 或 5.6 等,注释掉的 php5_module 同样也非常重要。PHP switcher script 会自动注释或取消注释 PHP module。

3.6 PHP 切换测试

完成上面的步骤之后,就可以使用 sphp 命令来切换 PHP 版本了。sphp 的参数是由两个数字组合成的两位数:

$ sphp 55
PHP version 55 found
Unlinking old binaries...
Linking new binaries...
Linking /usr/local/Cellar/php55/5.5.38_11... 17 symlinks created
Linking new modphp addon...
/usr/local/lib/libphp5.so
Fixing LoadModule...
Updating version file...
Restarting homebrew Apache...
Done.

PHP 5.5.38 (cli) (built: Dec 28 2016 15:48:28)
Copyright (c) 1997-2015 The PHP Group
Zend Engine v2.5.0, Copyright (c) 1998-2015 Zend Technologies

然后访问 http://localhost:7070/info.php,可以看到 PHP 版本已经正确切换:

macOS-Sierra-Apache-Multiple-PHP-versions-php-information-page-sphp

3.6.1 ISSUE Unable to load dynamic library '/usr/local/opt/php56-mongo/mongo.so'

我在切换版本的时候也遇到了问题:

$ sphp 71
...
Warning: PHP Startup: Unable to load dynamic library '/usr/local/opt/php56-mongo/mongo.so' - dlopen(/usr/local/opt/php56-mongo/mongo.so, 9): image not found in Unknown on line 0
PHP 5.6.29 (cli) (built: Dec 28 2016 15:58:30)
Copyright (c) 1997-2016 The PHP Group
Zend Engine v2.6.0, Copyright (c) 1998-2016 Zend Technologies

根据错误提示,大概是因为 php56-mongo 这个扩展没有,于是我使用 brew 安装了 php56-mongo 问题就解决了:

$ brew install php56-mongo

3.7 更新 PHP 和其他通过 Brew 安装的程序

使用 brew 更新 PHP 和其他通过 brew 安装的程序非常简单,第一步是更新 brew 本身:

$ brew update

升级后将会列出一系列可测回归内心的程序,然后使用下面的命令更新:

$ brew upgrade

3.8 使用具体的/最新的 PHP 版本

当我们使用 PHP 的时候,每次我们只使用了其中一个版本,并且只有当前使用的版本会更新到最新版本。可以使用下面的命令查看当前 PHP 版本:

$ php -v

可以使用下面的命令查看具体的可用 PHP 版本:

$ brew info php55
homebrew/php/php55: stable 5.5.38 (bottled), HEAD
PHP Version 5.5
https://php.net
Conflicts with: php53, php54, php56, php70, php71
/usr/local/Cellar/php55/5.5.38_11 (329 files, 47.7M)
  Built from source on 2016-12-28 at 15:49:20 with: --with-httpd24

比如上面的 PHP 5.5 版本的只有一个可用版本 5.5.38。

然后可以使用 brew 来切换到一个具体的版本:

$ brew switch php55 5.5.38

到此,这篇文章就结束了。你已经完成掌握了 Apache 2.4 和各个版本 PHP 的安装,并且能够在 5.5 5.6 7.0 7.1 中快速切换 PHP 版本。原作者还写了两外两篇文章,分别是 macOS 10.12 Sierra Apache Setup: MySQL, APC & MoremacOS 10.12 Sierra Apache Setup: SSL,近期内我也会将它们翻译成中文,并加入自己的实践总结。


Github Issue: #25

Webstorm 中 Node.js 核心模块配置

在 Webstrom 中引入 Node.js 自带模块的时候,Webstorm 有如下错误提示:

Node.js core module source not configured

这是因为没有配置 Webstorm 支持 Node.js 自带模块。

打开 File | Settings | Languages & Frameworks | Node.js and NPM,在右侧面板会发现 Node.js Core library is not enabled.,点击旁边的 Enable 按钮然后再点击下面的 OK 按钮即可。

模拟登录某某大学图书馆系统

本文详细讲述如何模拟登录某某大学图书馆系统

为什么说是某某大学?往下看了就知道了 😉

对于爬虫程序,如果需要抓取的页面,需要登录后才能访问,这时一般就需要进行模拟登录了。由于最近需要抓取登录四川大学图书馆后的一些信息,所以以此为例详细说明整个分析和编码过程。

总的来说,对于一般系统的模拟登录分为三大步骤:

  • 分析页面,得到登录 URL 和所需要传递的数据
  • 通过程序向所得 URL 发送数据
  • 根据服务端的响应判断是否登录成功,若登录成功,则保存返回的 cookie

只要得到了 cookie,当需要抓取登陆后才能访问的页面时,只需要发送 HTTP 请求时,在 HTTP Header 带上 cookie 即可。

对于写爬虫程序,还有一些小技巧(其实目前就总结出来一个):

  • 能抓取手机站就抓取手机站,因为手机网站一般比较容易

1. 分析四川大学移动图书馆

1.1. 分析

图书馆系统有一个手机网站,所以优先选择手机站作为目标。其链接是 http://m.5read.com/395

首先看到这个链接的时候,我也是比较奇怪,毕竟这个域名就比较奇怪,川大图书馆系统手机版的域名为什么不是 scu.edu.cn 的子域名,而且域名的 PATH 部分为什么是 395

域名打开后是 四川大学移动图书馆

crawler-for-scu-lib-1-com-scu-lib.png

然后我把 395 去掉,直接输入了 http://m.5read.com,打开也是川大图书馆首页。但如果我在另一个没有打开过该链接的浏览器中打开 http://m.5read.com/ ,则是 默认单位移动图书馆

crawler-for-scu-lib-1-com-default-lib.png

这个时候就得出两个猜测:

  • 一是 URL 中的 395 是学校的编号
  • 二是打开 http://m.5read.com/395 后,客户端肯定会生成对应的 cookies ,表示当前客户端访问的是 395 这所大学的图书馆系统

为了验证第一个猜测,我们把 395 改为任意一个其他数字。这样大概有两种情况,如 http://m.5read.com/1则提示 对不起,还没有开通手机业务!http://m.5read.com/20 则是厦门大学的图书馆系统,见下图:

crawler-for-scu-lib-1-com-1-not-dredge.png

crawler-for-scu-lib-1-com-20-xmu.png

接下来再看看是不是生成了对应的 cookie 信息。在 Chrome 的 开发者工具 -> Application 的左侧菜单栏选中 Cookies,然后在右侧选中某个 cookie,并点击右键,选择 Clear All 清除所有 cookies。也可以直接在左侧菜单栏 Cookies 展开后的域名上,点击右键,选择 clear

crawler-for-scu-lib-1-clear-cache.png

因为我的目的是模拟模拟登录四川大学图书馆系统,所以我还是先访问 http://m.5read.com/395,再来看看 cookies:

crawler-for-scu-lib-1-cookies-scu.png

可以看到,的确生成了 cookies,结构和之前厦门大学类似。为了弄清楚 cookies 是怎么生成的,接下来要查看的就是 HTTP 请求的详细内容了:

crawler-for-scu-lib-1-http-request.png

可以发现,Response Headers 里面有很多 Set-Cookie 字段。请求头中没有特殊的字段。所以访问 http://m.5read.com/395 的大致流程是:

  • 浏览器(客户端)发起 HTTP 请求,请求的 URL 地址是 http://m.5read.com/395
  • 服务端接收到请求,并根据 URL 中的 395 参数,分析得出访问的是 四川大学移动图书馆
  • 服务端根据 395 在响应头信息中加入对应的 set-cookie 字段
  • 浏览器接收到服务端的响应,并根据响应头中的 Set-Cookie 字段,生成对应的 cookies

如果要访问 四川大学移动图书馆 的其他页面,必然也要带上这些 cookies,不然系统无法区分访问的是那个大学的移动图书馆。

1.2 结论

根据以上分析,得出结论如下:

  • http://m.5read.com/395 表示某个学校移动图书馆的首页,URL 中的 395 参数表示学校代码
  • 访问不同学校的移动图书馆首页,会生成对应的 cookies
  • 当需求访问某学校移动图书馆系统的其他页面时,必须带上访问首页时生成的 cookies

1.3 代码

我用的 Node.js 的 request 这个包来发送 HTTP 请求。在使用前,需要先安装: npm install request --save

具体的代码如下:

const request = require('request');
const options = {
  url: 'http://m.5read.com/395'
};
request(options, (error, response, body) => {
  if (error) {
    console.error('访问首页失败: \n', error);
    return { error };
  }
  const cookie = response.headers['set-cookie'];
  console.log('cookie:\n ', cookie);
});

程序运行后,如果没出错,则会以标准输出的形式输出 cookies:

$ node index.js
cookie:
  [ 'JSESSIONID=E2741DEB3D5296EF15A1F8914E92EE77.irdmblhome72b; Path=/; HttpOnly',
  'DSSTASH_LOG=C%5f4%2dUN%5f395%2dUS%5f%2d1%2dT%5f1475793477551; Domain=.5read.com; Path=/',

  'mgid=274; Domain=.5read.com; Expires=Sat, 05-Nov-2016 22:37:57 GMT; Path=/',
  'maid=395; Domain=.5read.com; Expires=Sat, 05-Nov-2016 22:37:57 GMT; Path=/',
  'msign_dsr=1475793477609; Domain=.5read.com; Expires=Wed, 01-Oct-2036 22:37:57 GMT; Path=/',
  'mduxiu=musername%2c%3dblmobile%2c%21muserid%2c%3d1000086%2c%21mcompcode%2c%3d1009%2c%21menc%2c%3d26546915E1F9381939EA005CB06A28F6; Domain=.5read.com; Expires=
Sat, 05-Nov-2016 22:37:57 GMT; Path=/',
  'xc=6; Domain=.5read.com; Expires=Sat, 05-Nov-2016 22:37:57 GMT; Path=/' ]

2. 分析登录页面

2.1 分析

接下来需要寻找的就是对应的登录页面。登录页面的 URL是 http://mc.m.5read.com/user/login/showLogin.jspx

打开该页面,再看看 HTTP 请求:

crawler-for-scu-lib-1-cookies-send-scu.png

可以发现,发送请求头中的 Cookie 为

DSSTASH_LOG=C%5f4%2dUN%5f395%2dUS%5f%2d1%2dT%5f1475781232585; mgid=274; maid=395; msign_dsr=1475781232606; mduxiu=musername%2c%3dblmobile%2c%21muserid%2c%3d1000086%2c%21mcompcode%2c%3d1009%2c%21menc%2c%3d13A4F68ACE9126AA111D239F62C09038; xc=5; Hm_lvt_d2fe4972d5c5737ef70e82fe0c8deaee=1475781234; Hm_lpvt_d2fe4972d5c5737ef70e82fe0c8deaee=1475781234
Host:mc.m.5read.com

其中不包含 JSESSIONID,而响应头中返回了一个新的 Set-Cookie:JSESSIONID=9C04830620D2783E63E852BC67AE031D.irdmbl72a; Path=/; HttpOnly 字段。

JSESSIONID 是 Tomcat 中的 SESSIONID,主要作用是用来标识当前请求对应的用户。SESSIONID 是唯一的。当客户端访问服务器时,服务器(这里是 Tomcat)会生成一个唯一的 SESSIONID(这里是 JSESSIONID),并返回给客户端,客户端将 SESSIONID 保存在 cookie 中。之后客户端再发送 HTTP 请求时,就会在 HTTP Headers 中以 cookie 的形式发送 SESSIONID 到服务器。服务器接收到 SESSIONID 后,就可以根据 SESSIONID 来判断是哪一个客户端发送的请求。

对于该图书馆系统,访问首页 http://m.5read.com/395 和访问登录页 http://mc.m.5read.com/user/login/showLogin.jspx 是生成的不同的 JSESSIONID,说明该系统认为访问这两个页面是不同的用户,即使事实上是同一个用户访问的。

JSESSIONID 的作用来看,JSESSIONID 和用户登录没有直接关系。所以模拟登录的时候,依旧只需要使用访问首页时生成的 cookie 即可。怎么验证呢?可以在 Chrome 开发者工具的 Application 面板中,找到 cookie 里面的 JSESSIONID 字段,并删除,然后刷新页面,会发现又生成了另一个新的 JSESSIONID。所以不论 JSESSIONID 是什么值,我们都可以登录。所以 JSESSIONID 不会影响模拟登录。

2.2 结论

  • 进行模拟登录,和用户登录有关的 cookie 信息是访问首页时生成的 cookie
  • 访问首页和登录页面时,JSESSIONID 虽然会发生变化,但 JSESSIONID 并不会影响用户通过账号和密码进行认证

2.3 代码

这部分没有直接的代码。但因为接下来要进行模拟登录,所以肯定又会再写一个 request 发送 HTTP 请求,所以现在可以把之前的代码结构优化一下:

// login.js

const request = require('request');


const errorText = {
  account: '用户名或密码错误',
  emptyPassword: '借阅证密码不能为空',
  emptyNumber: '借阅证号不能为空',
};
const url = {
  // 图书馆手机首页
  home: 'http://m.5read.com/395',
  // 登陆 URL
  login: 'http://mc.m.5read.com/irdUser/login/opac/opacLogin.jspx',
};
const regexp = {
  number: number: /^\d+$/,
};


/**
 * 获取 cookie
 * @method getCookie
 * @param  {object}   options  HTTP请求设置信息
 * @param  {Function} callback
 * @return {string}   {error, HTTP响应中的cookie}
 */
const getCookie = (options, callback) => {
  request(options, (error, response) => {
    if (error) {
      return callback({ error, code: 1018 });
    }
    const cookie = response.headers['set-cookie'].join();
    return callback(null, cookie);
  });
};


getCookie({url: url.home}, (error, resHome) => {
  if (error) {
    console.error('获取首页 cookie 失败: \n', error);
    return false;
  }
  const cookieHome = resHome.cookie;
  console.log('首页cookie:\n ', cookieHome);
});

3. 模拟登录

3.1 分析

前面做了那么多分析,主要就是为了登录的时候,发送正确的 cookie。在最终模拟登录之前,还需要做一点分析。

现在需要做的就是,通过学号和密码登录,并继续查看 HTTP 请求,找到登录认证的接口,并分析请求头和响应头。

下面是我输入正确的学号和密码之后,HTTP 请求:

请求头:

crawler-for-scu-lib-2-scu-request-header.png

响应头:

crawler-for-scu-lib-2-scu-response-header.png

数据:

crawler-for-scu-lib-2-scu-request-data.png

从请求头中可以发现,用户登录的 URL 是 http://mc.m.5read.com/irdUser/login/opac/opacLogin.jspx,HTTP Method 是 POST,需要传递的数据是 schoolid=学校编号&backurl=&userType=0&username=xxxxxx&password=xxx,并且是通过表单的方式传递的数据:Content-Type: application/x-www-form-urlencoded。当然,发送 HTTP 请求时,请求头中还有 cookie。除了 JSESSIONID 是新生成之外,其余 cookie 都是访问首页时生成的。

登录成功后,再去查看 cookie ,就会发现 cookie 已经更新为响应头中 set-cookie 中的字段和值了。

3.2 结论

URL: http://mc.m.5read.com/irdUser/login/opac/opacLogin.jspx
Method: POST
Content-Type:application/x-www-form-urlencoded
Cookie: ...               // 访问首页时生成的 cookie
Form Data: {
  schoolid:395,           // 学校代码
  backurl: ''             // 登录后跳转的 URL
  userType: 0,            // 登录时的账号类型,0 表示学号密码登录
  username: 000000000000, // 学号
  password: 000000,        // 密码
}

3.3 代码

登录模块的完整代码如下:

// login.js

const request = require('request');


const errorText = {
  account: '用户名或密码错误',
  emptyPassword: '借阅证密码不能为空',
  emptyNumber: '借阅证号不能为空',
};
const schoolid = 395;
const url = {
  // 图书馆手机首页
  home: 'http://m.5read.com/395',
  // 登陆 URL
  login: 'http://mc.m.5read.com/irdUser/login/opac/opacLogin.jspx',
};
const regexp = {
  number: /^\d+$/,
};


/**
 * 获取 cookie
 * @method getCookie
 * @param  {object}   options  HTTP请求设置信息
 * @param  {Function} callback
 * @return {string}   {error, HTTP响应中的cookie}
 */
const getCookie = (options, callback) => {
  request(options, (error, response) => {
    if (error) {
      return callback({ error, code: 1018 });
    }
    const cookie = response.headers['set-cookie'].join();
    return callback(null, cookie);
  });
};


/**
 * 模拟登录操作
 * @method doLogin
 * @param  {object}   options  HTTP 请求信息
 * @param  {string}   cookie   cookie
 * @param  {Function} callback 回调函数
 * @return {object}   {error, 登录成功后的cookie}
 */
const doLogin = (options, callback) => {
  request(options, (error, response, body) => {
    if (error) {
      return callback({ error });
    }
    if (body.indexOf(errorText.account) !== -1) {
      return callback({
        error: errorText.account,
        code: 1019,
      });
    }
    if (body.indexOf(errorText.emptyPassword) !== -1) {
      return callback({
        error: errorText.emptyPassword,
        code: 1020,
      });
    }
    if (body.indexOf(errorText.emptyNumber) !== -1) {
      return callback({
        error: website.errorText.emptyNumber,
        code: 1021,
      });
    }
    const cookieLogined = response.headers['set-cookie'].join();
    return callback(null, cookieLogined);
  });
};


/**
 * 模拟登录
 * @method login
 * @param  {string}   number   学号(借阅证号)
 * @param  {string}   password 密码
 * @param  {Function} callback 回调函数
 * @return {object}   登录成功后的cookie
 */
const login = (number, password, callback) => {
  // 验证 number
  if (!regexp.number.test(number)) {
    return callback({ code: 1016, error: '登录移动图书馆学号格式错误' });
  }
  // 验证 password
  if (!regexp.number.test(password)) {
    return callback({ code: 1017, error: '登录移动图书馆密码格式错误' });
  }
  // 获取图书馆首页 cookie
  getCookie({ url: url.home }, (errHome, cookieHome) => {
    if (errHome) {
      console.log('获取图书馆首页 cookie 失败: \n', errHome);
      return callback({
        code: errHome.code,
        error: errHome.error,
      });
    }
    console.log('首页cookie:\n ', cookieHome);
    // 模拟登录
    const options = {
      url: url.login,
      form: {
        schoolid: schoolid,
        backurl: '',
        userType: 0,
        username: number,
        password,
      },
      headers: {
        Cookie: cookieHome,
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      method: 'POST',
    };
    doLogin(options, (errLogin, cookieLogined) => {
      if (errLogin) {
        console.log('登录失败: \n', errLogin);
        return callback({
          code: errLogin.code,
          error: errLogin.error,
        });
      }
      console.log('登录成功后的 cookie:\n ', cookieLogined);
      return callback(null, cookieLogined);
    });
  });
};


module.exports = login;

4. 抓取借阅信息

为了验证登录后的 cookie 是不是最终正确,访问一下需要登录后才能访问的页面即可。

所以下面就来抓取借阅信息。为了解析 HTML 文本,我们还需要用到 cheerio 这个包。cheerio 就相当于是服务端的 jQuery,可以像使用 jQuery 选择器一样从一个 HTML 文本中取出想要的内容。

新建一个 get_books.js 文件,添加如下代码:

// get_books.js
const request = require('request');
const cheerio = require('cheerio');
const login = require('./login');

const url = {
  books: 'http://mc.m.5read.com/cmpt/opac/opacLink.jspx?stype=1',
}
const errorText = {
  cookieTips: '请确认您的浏览器Cookie开启和正常访问移动图书馆首页'
};

const number = '0000000000000'; // 学号(借阅证号)
const password = '000000';  // 密码

const fetchBooks = (cookie, callback) => {
  const options = {
    url: url.books,
    headers: {
      Cookie: cookie,
    },
  };

  request(options, (error, response, body) => {
    if (error) {
      console.log('获取图书借阅列表失败: ', error);
      return callback({
        code: 1025,
        error: '获取图书借阅列表失败',
        detail: error,
      });
    }
    console.log('response.statusCode: ', response.statusCode);
    if (response.statusCode !== 200) {
      return callback({
        code: 1026,
        error: '获取图书借阅列表失败',
        detail: response,
      });
    }
    return callback(null, body);
  });
};


const parseBooks = (html, callback) => {
  if (html.indexOf(errorText.cookieTips) !== -1) {
    console.log(errorText.cookieTips);
    return {
      code: 1027,
      error: '移动图书馆系统 cookie 信息过期,请重新登录',
      detail: html,
    };
  }
  const $ = cheerio.load(html, {
    ignoreWhitespace: true,
    xmlMode: false,
    lowerCaseTags: false,
  });
  const domBooks = $('.boxBd').find('.sheet');
  const booksNumber = domBooks.length; // 借阅数量
  // console.log(domBooks.length);
  const books = [];
  domBooks.each(function () {
    const barCodeValue = $(this).find('td').eq(5).find('form input')
        .eq(0)
        .attr('value');
    const borIdValue = $(this).find('td').eq(5).find('form input')
        .eq(1)
        .attr('value');
    books.push({
      // 作者
      author: $(this).find('td').eq(0).text(),
      // 书名
      name: $(this).find('td').eq(1).text(),
      // 应还日期
      expiredate: $(this).find('td').eq(2).text(),
      // 分馆
      libraryBranch: $(this).find('td').eq(3).text(),
      // 索书号
      number: $(this).find('td').eq(4).text(),
      borId: borIdValue,
      barCode: barCodeValue,
    });
  });
  return callback(null, {
    booksNumber,
    books,
  });
};

login(number, password, (error, cookie) => {
  if (error) {
    return console.log(error);
  }
  // 获取借阅列表页面html
  fetchBooks(cookie, (errFetch, resFetch) => {
    if (errFetch) {
      return console.log(errFetch);
    }
    // 解析借阅列表html
    parseBooks(resFetch, (errParse, resParse) => {
      if (errParse) {
        console.log('errParse: ', errParse);
        return console.log(errParse);
      }
      return console.log(null, { books: resParse });
    });
  });
});

安装 npm install cheerio --save,将 numberpassword 改为正确的借阅证号和密码就可以登录成功,并获取到该用户的借阅列表了。

运行结果如下:

$ node get_books.js
首页cookie:
  JSESSIONID=6EA8121AB1E4B3045A331198321F8ADC.irdmblhome72a; Path=/; HttpOnly,DSSTASH_LOG=C%5f4%2dUN%5f395%2dUS%5f%2d1%2dT%5f1475865139485; Domain=.5read.com; Path=/,mgid=274; Domain=.5read.com; Expires=Sun, 06-Nov-2016 18:32:19 GMT; Path=/,maid=395; Domain=.5read.com; Expires=Sun, 06-Nov-2016 18:32:19 GMT; Path=/,msign_dsr=1475865139507; Domain=.5read.com; Expires=Thu, 02-Oct-2036 18:32:19 GMT; Path=/,mduxiu=musername%2c%3dblmobile%2c%21muserid%2c%3d1000086%2c%21mcompcode%2c%3d1009%2c%21menc%2c%3d26A35FCD85F5A5677706DC7CE503113A; Domain=.5read.com; Expires=Sun, 06-Nov-2016 18:32:19 GMT; Path=/,xc=6; Domain=.5read.com; Expires=Sun, 06-Nov-2016 18:32:19 GMT; Path=/
登录成功后的 cookie:
  xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
response.statusCode:  200

{ booksNumber: 2,
  books:
   [ { author: '李刚',
       name: '疯狂Swift讲义',
       expiredate: '20161010',
       libraryBranch: 'JZLKS',
       number: 'TP312SW/4072',
       borId: 'U13014748',
       barCode: '90577318' },
     { author: '杨宏焱',
       name: 'iOS 8 Swift编程指南',
       expiredate: '20161010',
       libraryBranch: 'JZLKS',
       number: 'TP312SW/4739',
       borId: 'U13014748',
       barCode: '90597040' },
    ]
}

到目前为止,模拟登录的程序就完成了,并且成功获取到了用户的借阅列表。

由于很多很多大学的移动图书馆都使用同一个系统,所以这个程序具有通用性,所以本文的标题是《模拟登录某某大学图书馆系统》。

不信你试试,说不定就有你的学校。

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.