npm其实是Node.js的包管理工具(package manager),和PHP里的 composer 一样的功能。
因为我们在Node.js上开发时,会用到很多别人写的JavaScript代码。如果我们要使用别人写的某个包,每次都根据名称搜索一下官方网站,下载代码,解压,再使用,非常繁琐。于是一个集中管理的工具应运而生:大家都把自己开发的模块打包后放到npm官网上,如果要使用,直接通过npm安装就可以直接用,不用管代码存在哪,应该从哪下载。
更重要的是,如果我们要使用模块A,而模块A又依赖于模块B,模块B又依赖于模块X和模块Y,npm可以根据依赖关系,把所有依赖的包都下载下来并管理起来。否则,靠我们自己手动管理,肯定既麻烦又容易出错。
npm已经在Node.js安装的时候顺带装好了。我们在命令提示符或者终端输入npm -v
可以看到当前 npm 的版本信息。
npm install <Module Name>
安装好之后,模块的包就放在了工程目录下的 node_modules 下。
npm 的包安装分为本地安装(local)、全局安装(global)两种,从敲的命令行来看,差别只是有没有-g而已,比如
npm install express # 本地安装
npm install express -g # 全局安装
本地安装
全局安装
如果希望具备两者功能,则需要在两个地方安装它或使用 npm link。
npm list -g # 查看所有全局安装的模块
npm list grunt # 查看某个模块的版本信息
package.json 位于模块的目录下,用于定义包的属性。内容可能如下:
{
"name": "test",
"version": "1.0.0",
"description": "test",
"main": "index.js",
"scripts": {
"test": "test"
},
"repository": {
"type": "git",
"url": "git+https://github.com/laravel/elixir.git"
},
"keywords": [
"test"
],
"author": "sunyu",
"license": "MIT",
"bugs": {
"url": "https://github.com/laravel/elixir/issues"
},
"homepage": "https://github.com/laravel/elixir#readme",
"devDependencies": {
"gulp": "^3.9.1"
}
}
Package.json 属性说明
name - 包名。
version - 包的版本号。
description - 包的描述。
homepage - 包的官网 url 。
author - 包的作者姓名。
contributors - 包的其他贡献者姓名。
dependencies - 依赖包列表。如果依赖包没有安装,npm 会自动将依赖包安装在 node_module 目录下。
repository - 包代码存放的地方的类型,可以是 git 或 svn,git 可在 Github 上。
main - main 字段指定了程序的主入口文件,require(‘moduleName’) 就会加载这个文件。这个字段的默认值是模块根目录下面的 index.js。
keywords - 关键字
npm uninstall express
npm update express
npm search express
创建模块,package.json 文件是必不可少的。我们可以使用 NPM 生成 package.json 文件,生成的文件包含了基本的结果。
初始化信息
sunyu:test sunyu$ npm init
This utility will walk you through creating a package.json file.
It only covers the most common items, and tries to guess sensible defaults.
See `npm help json` for definitive documentation on these fields
and exactly what they do.
Use `npm install <pkg>` afterwards to install a package and
save it as a dependency in the package.json file.
Press ^C at any time to quit.
package name: (test)
version: (1.0.0)
description: test
entry point: (index.js)
test command: test
git repository: https://github.com/laravel/elixir
keywords: test
author: sunyu
license: (ISC) test
Sorry, license should be a valid SPDX license expression (without "LicenseRef"), "UNLICENSED", or "SEE LICENSE IN <filename>".
license: (ISC) MIT
About to write to /Users/sunyu/Documents/workspace/sunyu/test/package.json:
{
"name": "test",
"version": "1.0.0",
"description": "test",
"main": "index.js",
"scripts": {
"test": "test"
},
"repository": {
"type": "git",
"url": "git+https://github.com/laravel/elixir.git"
},
"keywords": [
"test"
],
"author": "sunyu",
"license": "MIT",
"bugs": {
"url": "https://github.com/laravel/elixir/issues"
},
"homepage": "https://github.com/laravel/elixir#readme"
}
Is this ok? (yes) yes
以上的信息,你需要根据你自己的情况输入。在最后输入 “yes” 后会生成 package.json 文件。要注意的是 git repository 这里的地址。
添加依赖
npm install --save-dev gulp
它会把 gulp 作为依赖添加到配置文件中,且是添加到 devDependencies中,如果要添加到 dependencies,需要用 --save-prod
:
npm install --save-prod gulp --registry=https://registry.npm.taobao.org
注册用户
$ npm adduser
Username: sun86yu
Password:
Email: (this IS public) sun86yu@gmail.com
发布模块
npm publish
使用NPM下载和发布代码时都会接触到版本号。NPM使用语义版本号来管理代码,语义版本号分为X.Y.Z三位,分别代表主版本号、次版本号和补丁版本号。当代码变更时,版本号按以下原则更新:
版本号有了这个保证后,在申明第三方包依赖时,除了可依赖于一个固定版本号外,还可依赖于某个范围的版本号。例如”argv”: “0.0.x”表示依赖于0.0.x系列的最新版argv。
国内直接使用 npm 的官方镜像是非常慢的,这里推荐使用淘宝 NPM 镜像。
淘宝 NPM 镜像是一个完整 npmjs.org 镜像,你可以用此代替官方版本(只读),同步频率目前为 10分钟 一次以保证尽量与官方服务同步。
你可以使用淘宝定制的 cnpm (gzip 压缩支持) 命令行工具代替默认的 npm:
npm install -g cnpm --registry=https://registry.npm.taobao.org
这样就可以使用 cnpm 命令来安装模块了:
cnpm install [name]
利用 Node.js 流的威力,你可以快速构建项目并减少频繁的 IO 操作。
gulp 基于 node.js,所以可以用 npm 来进行安装。可以把它安装到 dev 环境也可以到 prod 环境。
可以安装到当前项目,也可以安装在全局 npm install -g
npm install --save-dev gulp
建立 gulpfile.js 文件
gulp也需要一个文件作为它的主文件,在gulp中这个文件叫做gulpfile.js。新建一个文件名为gulpfile.js的文件,然后放到你的项目目录中。之后要做的事情就是在gulpfile.js文件中定义我们的任务。内容可以如下:
var gulp = require('gulp');
gulp.task('default',function(){
console.log('hello world');
});
运行gulp任务
直接运行 gulp
命令即可:
sunyu:test sunyu$ gulp
[20:32:56] Using gulpfile ~/Documents/workspace/sunyu/test/gulpfile.js
[20:32:56] Starting 'default'...
hello world
[20:32:56] Finished 'default' after 166 μs
gulp后面可以加上要执行的任务名,例如gulp task1,如果没有指定任务名,则会执行任务名为default的默认任务。我们在前面的配置文件中定义了 default,所以这里就自动运行了。
使用gulp,仅需知道4个API即可:gulp.task(), gulp.src(), gulp.dest(), gulp.watch() 可以考虑官方介绍:点我前往
在Gulp中,使用的是Nodejs中的stream(流),首先获取到需要的stream,然后可以通过stream的pipe()方法把流导入到你想要的地方,比如Gulp的插件中,经过插件处理后的流又可以继续导入到其他插件中,当然也可以把流写入到文件中。
所以Gulp是以stream为媒介的,它不需要频繁的生成临时文件,这也是Gulp的速度比Grunt快的一个原因。
gulp.src()方法正是用来获取流的,但要注意这个流里的内容不是原始的文件流,而是一个虚拟文件对象流(Vinyl files),其语法为:
gulp.src(globs[, options])
globs参数是文件匹配模式(类似正则表达式),用来匹配文件路径(包括文件名),当然这里也可以直接指定某个具体的文件路径。当有多个匹配模式时,该参数可以为一个数组。
options为可选参数。通常情况下我们不需要用到。
Gulp内部使用了node-glob模块来实现其文件匹配功能。我们可以使用下面这些特殊的字符来匹配我们想要的文件:
*
匹配文件路径中的0个或多个字符,但不会匹配路径分隔符,除非路径分隔符出现在末尾
**
匹配路径中的0个或多个目录及其子目录,需要单独出现,即它左右不能有其他东西了。如果出现在末尾,也能匹配文件。
?
匹配文件路径中的一个字符(不会匹配路径分隔符)
[...]
匹配方括号中出现的字符中的任意一个,当方括号中第一个字符为^或!时,则表示不匹配方括号中出现的其他字符中的任意一个,类似js正则表达式中的用法
!(pattern|pattern|pattern)
匹配任何与括号中给定的任一模式都不匹配的
?(pattern|pattern|pattern)
匹配括号中给定的任一模式0次或1次,类似于js正则中的(pattern|pattern|pattern)?
+(pattern|pattern|pattern)
匹配括号中给定的任一模式至少1次,类似于js正则中的(pattern|pattern|pattern)+
*(pattern|pattern|pattern)
匹配括号中给定的任一模式0次或多次,类似于js正则中的(pattern|pattern|pattern)*
@(pattern|pattern|pattern)
匹配括号中给定的任一模式1次,类似于js正则中的(pattern|pattern|pattern)
举例如:
*
能匹配 a.js
, x.y
, abc
, abc/
, 但不能匹配 a/b.js
*.*
能匹配 a.js
, style.css
, a.b
, x.y
*/*/*.js
能匹配 a/b/c.js
, x/y/z.js
, 不能匹配 a/b.js
, a/b/c/d.js
**
能匹配 abc
, a/b.js
, a/b/c.js
, x/y/z
, x/y/z/a.b
,能用来匹配所有的目录和文件
**/*.js
能匹配 foo.js
, a/foo.js
, a/b/foo.js
, a/b/c/foo.js
a/**/z
能匹配 a/z
, a/b/z
, a/b/c/z
, a/d/g/h/j/k/z
a/**b/z
能匹配 a/b/z
, a/sb/z
, 但不能匹配 a/x/sb/z
,因为只有单**
单独出现才能匹配多级目录
?.js
能匹配 a.js
, b.js
, c.js
a??
能匹配 a.b
, abc
, 但不能匹配 ab/
, 因为它不会匹配路径分隔符
[xyz].js
只能匹配 x.js
, y.js
, z.js
, 不会匹配 xy.js
, xyz.js
等,整个中括号只代表一个字符
[^xyz].js
能匹配 a.js
, b.js
, c.js
等,不能匹配x.js
, y.js
, z.js
当有多种匹配模式时可以使用数组
//使用数组的方式来匹配多种文件
gulp.src(['js/*.js','css/*.css','*.html'])
使用数组的方式还有一个好处就是可以很方便的使用排除模式,在数组中的单个匹配模式前加上!即是排除模式,它会在匹配的结果中排除这个匹配,要注意一点的是不能在数组中的第一个元素中使用排除模式
gulp.src([*.js,'!b*.js']) //匹配所有js文件,但排除掉以b开头的js文件
gulp.src(['!b*.js',*.js]) //不会排除任何文件,因为排除模式不能出现在数组的第一个元素中
此外,还可以使用展开模式。展开模式以花括号作为定界符,根据它里面的内容,会展开为多个模式,最后匹配的结果为所有展开的模式相加起来得到的结果。展开的例子如下:
a{b,c}d
会展开为 abd
, acd
a{b,}c
会展开为 abc
, ac
a{0..3}d
会展开为 a0d
, a1d
, a2d
, a3d
a{b,c{d,e}f}g
会展开为 abg
, acdfg
, acefg
a{b,c}d{e,f}g
会展开为 abdeg
, acdeg
, abdeg
, abdfg
gulp.dest()方法是用来写文件的,其语法为:
gulp.dest(path[,options])
path为写入文件的路径
options为一个可选的参数对象,通常我们不需要用到
要想使用好gulp.dest()
这个方法,就要理解给它传入的路径参数与最终生成的文件的关系。
gulp的使用流程一般是这样子的:首先通过gulp.src()
方法获取到我们想要处理的文件流,然后把文件流通过pipe方法导入到gulp的插件中,最后把经过插件处理后的流再通过pipe方法导入到gulp.dest()
中,gulp.dest()
方法则把流中的内容写入到文件中,这里首先需要弄清楚的一点是,我们给gulp.dest()
传入的路径参数,只能用来指定要生成的文件的目录,而不能指定生成文件的文件名,它生成文件的文件名使用的是导入到它的文件流自身的文件名,所以生成的文件名是由导入到它的文件流决定的,即使我们给它传入一个带有文件名的路径参数,然后它也会把这个文件名当做是目录名,例如:
var gulp = require('gulp');
gulp.src('script/jquery.js')
.pipe(gulp.dest('dist/foo.js'));
//最终生成的文件路径为 dist/foo.js/jquery.js,而不是dist/foo.js
要想改变文件名,可以使用插件 gulp-rename
gulp.dest(path)生成的文件路径是我们传入的path参数后面再加上gulp.src()
中有通配符开始出现的那部分路径。例如:
var gulp = reruire('gulp');
//有通配符开始出现的那部分路径为 **/*.js
gulp.src('script/**/*.js')
.pipe(gulp.dest('dist')); //最后生成的文件路径为 dist/**/*.js
//如果 **/*.js 匹配到的文件为 jquery/jquery.js ,则生成的文件路径为 dist/jquery/jquery.js
gulp.src('script/avalon/avalon.js') //没有通配符出现的情况
.pipe(gulp.dest('dist')); //最后生成的文件路径为 dist/avalon.js
//有通配符开始出现的那部分路径为 **/underscore.js
gulp.src('script/**/underscore.js')
//假设匹配到的文件为script/util/underscore.js
.pipe(gulp.dest('dist')); //则最后生成的文件路径为 dist/util/underscore.js
gulp.src('script/*') //有通配符出现的那部分路径为 *
//假设匹配到的文件为script/zepto.js
.pipe(gulp.dest('dist')); //则最后生成的文件路径为 dist/zepto.js
通过指定gulp.src()
方法配置参数中的base属性,我们可以更灵活的来改变gulp.dest()
生成的文件路径。
当我们没有在gulp.src()
方法中配置base属性时,base的默认值为通配符开始出现之前那部分路径,例如:
gulp.src('app/src/**/*.css') //此时base的值为 app/src
上面我们说的gulp.dest()
所生成的文件路径的规则,其实也可以理解成,用我们给gulp.dest()
传入的路径替换掉gulp.src()
中的base路径,最终得到生成文件的路径。
gulp.src('app/src/**/*.css') //此时base的值为app/src,也就是说它的base路径为app/src
//设该模式匹配到了文件 app/src/css/normal.css
.pipe(gulp.dest('dist')) //用dist替换掉base路径,最终得到 dist/css/normal.css
所以改变base路径后,gulp.dest()
生成的文件路径也会改变
gulp.src(script/lib/*.js) //没有配置base参数,此时默认的base路径为script/lib
//假设匹配到的文件为script/lib/jquery.js
.pipe(gulp.dest('build')) //生成的文件路径为 build/jquery.js
gulp.src(script/lib/*.js, {base:'script'}) //配置了base参数,此时base路径为script
//假设匹配到的文件为script/lib/jquery.js
.pipe(gulp.dest('build')) //此时生成的文件路径为 build/lib/jquery.js
用gulp.dest()
把文件流写入文件后,文件流仍然可以继续使用。
gulp.task方法用来定义任务,内部使用的是Orchestrator,其语法为:
gulp.task(name[, deps], fn)
name 为任务名
deps 是当前定义的任务需要依赖的其他任务,为一个数组。当前定义的任务会在所有依赖的任务执行完毕后才开始执行。如果没有依赖,则可省略这个参数
fn 为任务函数,我们把任务要执行的代码都写在里面。该参数也是可选的。
如:
gulp.task('mytask', ['array', 'of', 'task', 'names'], function() { //定义一个有依赖的任务
// Do something
});
gulp中执行多个任务,可以通过任务依赖来实现。例如我想要执行 one, two, three这三个任务,那我们就可以定义一个空的任务,然后把那三个任务当做这个空的任务的依赖就行了:
//只要执行default任务,就相当于把one,two,three这三个任务执行了
gulp.task('default',['one','two','three']);
如果任务相互之间没有依赖,任务会按你书写的顺序来执行,如果有依赖的话则会先执行依赖的任务。 但是如果某个任务所依赖的任务是异步的,就要注意了,gulp并不会等待那个所依赖的异步任务完成,而是会接着执行后续的任务。例如:
gulp.task('one',function(){
//one是一个异步执行的任务
setTimeout(function(){
console.log('one is done')
},5000);
});
//two任务虽然依赖于one任务,但并不会等到one任务中的异步操作完成后再执行
gulp.task('two',['one'],function(){
console.log('two is done');
});
上面的例子中我们执行two任务时,会先执行one任务,但不会去等待one任务中的异步操作完成后再执行two任务,而是紧接着执行two任务。所以two任务会在one任务中的异步操作完成之前就执行了。
那如果我们想等待异步任务中的异步操作完成后再执行后续的任务,可以这样:
1.在异步操作完成后执行一个回调函数来通知gulp这个异步任务已经完成,这个回调函数就是任务函数的第一个参数。
gulp.task('one',function(cb){ //cb为任务函数提供的回调,用来通知任务已经完成
//one是一个异步执行的任务
setTimeout(function(){
console.log('one is done');
cb(); //执行回调,表示这个异步任务已经完成
}, 2000);
});
//这时two任务会在one任务中的异步操作完成后再执行
gulp.task('two',['one'],function(){
console.log('two is done');
});
2.定义任务时返回一个流对象。适用于任务就是操作gulp.src获取到的流的情况。
gulp.task('one',function(cb){
var stream = gulp.src('client/**/*.js')
.pipe(dosomething()) //dosomething()中有某些异步操作
.pipe(gulp.dest('build'));
return stream;
});
gulp.task('two',['one'],function(){
console.log('two is done');
});
3.返回一个promise对象,例如
var Q = require('q'); //一个著名的异步处理的库 https://github.com/kriskowal/q
gulp.task('one',function(cb){
var deferred = Q.defer();
// 做一些异步操作
setTimeout(function() {
deferred.resolve();
}, 5000);
return deferred.promise;
});
gulp.task('two',['one'],function(){
console.log('two is done');
});
gulp.watch()用来监视文件的变化,当文件发生变化后,我们可以利用它来执行相应的任务,例如文件压缩等。其语法为:
gulp.watch(glob[, opts], tasks)
glob 为要监视的文件匹配模式,规则和用法与gulp.src()方法中的glob相同
opts 为一个可选的配置对象,通常不需要用到
tasks 为文件变化后要执行的任务,为一个数组
如:
gulp.task('uglify',function(){
//do something
});
gulp.task('reload',function(){
//do something
});
gulp.watch('js/**/*.js', ['uglify','reload']);
gulp.watch()还有另外一种使用方式:
gulp.watch(glob[, opts, cb])
glob 和 opts 参数与第一种用法相同
cb参数为一个函数。每当监视的文件发生变化时,就会调用这个函数,并且会给它传入一个对象,该对象包含了文件变化的一些信息,type
属性为变化的类型,可以是added
, changed
, deleted
;
path属性为发生变化的文件的路径
gulp.watch('js/**/*.js', function(event){
console.log(event.type); //变化类型 added为新增,deleted为删除,changed为改变
console.log(event.path); //变化的文件的路径
});
npm install --save-dev gulp-load-plugins
要使用gulp的插件,首先得用require
来把插件加载进来,如果我们要使用的插件非常多,那我们的gulpfile.js
文件开头可能就会是这个样子的:
var gulp = require('gulp'),
//一些gulp插件,abcd这些命名只是用来举个例子
a = require('gulp-a'),
b = require('gulp-b'),
c = require('gulp-c'),
虽然这没什么问题,但会使我们的 gulpfile.js
文件变得很冗长,看上去不那么舒服。gulp-load-plugins
插件正是用来解决这个问题。
gulp-load-plugins
这个插件能自动帮你加载 package.json
文件里的gulp插件。例如假设你的 package.json
文件里的依赖是这样的:
{
"devDependencies": {
"gulp": "~3.6.0",
"gulp-rename": "~1.2.0",
"gulp-ruby-sass": "~0.4.3",
"gulp-load-plugins": "~0.5.1"
}
}
然后我们可以在 gulpfile.js
中使用 gulp-load-plugins
来帮我们加载插件:
var gulp = require('gulp');
//加载gulp-load-plugins插件,并马上运行它
var plugins = require('gulp-load-plugins')();
然后我们要使用gulp-rename
和gulp-ruby-sass
这两个插件的时候,就可以使用plugins.rename
和plugins.rubySass
来代替了,也就是原始插件名去掉gulp-
前缀,之后再转换为驼峰命名。
实质上gulp-load-plugins是为我们做了如下的转换
plugins.rename = require('gulp-rename');
plugins.rubySass = require('gulp-ruby-sass');
gulp-load-plugins
并不会一开始就加载所有package.json
里的gulp插件,而是在我们需要用到某个插件的时候,才去加载那个插件。
最后要提醒的一点是,因为 gulp-load-plugins
是通过你的package.json
文件来加载插件的,所以必须要保证你需要自动加载的插件已经写入到了package.json
文件里,并且这些插件都是已经安装好了的。
npm install --save-dev gulp-rename
用来重命名文件流中的文件。用gulp.dest()
方法写入文件时,文件名使用的是文件流中的文件名,如果要想改变文件名,那可以在之前用gulp-rename
插件来改变文件流中的文件名。
var gulp = require('gulp'),
rename = require('gulp-rename'),
uglify = require("gulp-uglify");
gulp.task('rename', function () {
gulp.src('js/jquery.js')
.pipe(uglify()) //压缩
.pipe(rename('jquery.min.js')) //会将jquery.js重命名为jquery.min.js
.pipe(gulp.dest('js'));
});
npm install --save-dev gulp-uglify
用来压缩js文件,使用的是uglify引擎
var gulp = require('gulp'),
uglify = require("gulp-uglify");
gulp.task('minify-js', function () {
gulp.src('js/*.js') // 要压缩的js文件
.pipe(uglify()) //使用uglify进行压缩,更多配置请参考:
.pipe(gulp.dest('dist/js')); //压缩后的路径
});
使用 gulp-minify-css
npm install --save-dev gulp-minify-css
var gulp = require('gulp'),
minifyCss = require("gulp-minify-css");
gulp.task('minify-css', function () {
gulp.src('css/*.css') // 要压缩的css文件
.pipe(minifyCss()) //压缩css
.pipe(gulp.dest('dist/css'));
});
使用gulp-minify-html
npm install --save-dev gulp-minify-html
var gulp = require('gulp'),
minifyHtml = require("gulp-minify-html");
gulp.task('minify-html', function () {
gulp.src('html/*.html') // 要压缩的html文件
.pipe(minifyHtml()) //压缩
.pipe(gulp.dest('dist/html'));
});
npm install --save-dev gulp-jshint
var gulp = require('gulp'),
jshint = require("gulp-jshint");
gulp.task('jsLint', function () {
gulp.src('js/*.js')
.pipe(jshint())
.pipe(jshint.reporter()); // 输出检查结果
});
npm install --save-dev gulp-concat
用来把多个文件合并为一个文件,我们可以用它来合并js或css文件等,这样就能减少页面的http请求数了
var gulp = require('gulp'),
concat = require("gulp-concat");
gulp.task('concat', function () {
gulp.src('js/*.js') //要合并的文件
.pipe(concat('all.js')) // 合并匹配到的js文件并命名为 "all.js"
.pipe(gulp.dest('dist/js'));
});
可以使用gulp-imagemin插件来压缩jpg、png、gif等图片
npm install --save-dev gulp-imagemin
var gulp = require('gulp');
var imagemin = require('gulp-imagemin');
var pngquant = require('imagemin-pngquant'); //png图片压缩插件
gulp.task('default', function () {
return gulp.src('src/images/*')
.pipe(imagemin({
progressive: true,
use: [pngquant()] //使用pngquant来压缩png图片
}))
.pipe(gulp.dest('dist'));
});
Bower是Web开发中的一个前端文件包管理器。
类似于Node模块的npm包管理器,php的composer。Bower为Web组件提供了类似的功能。它使用的是 github 上面的资源。所以,bower 是依赖 git 命令的。
npm install bower -g
bower 通过 bower.json 和 .bowerrc 来配置包。
.bowerrc
在项目根目录下,内容大致如下:
{
"directory" : "components",
"json" : "bower.json",
"endpoint" : "https://Bower.herokuapp.com",
"searchpath" : "",
"shorthand_resolver" : ""
}
其中的属性含义如下。
directory:存放库文件的子目录名。
json:描述各个库的json文件名。
endpoint:在线索引的网址,用来搜索各种库。
searchpath:一个数组,储存备选的在线索引网址。如果某个库在endpoint中找不到,则继续搜索该属性指定的网址,通常用于放置某些不公开的库。
shorthand_resolver:定义各个库名称简写形式。
bower.json
保存项目的库信息,供项目二次安装时使用(重复使用)。
使用 bower init
命令可以来创建bower.json文件,它会自动提示你输入一系列的内容,以生成最终的文件,包括项目名称、作者信息、项目描述信息、关键词、开源证书等等。
sunyu:test sunyu$ bower init
? name test
? description some test
? main file test.js
? keywords do it
? authors sunyu <sunvipyu@gmail.com>
? license MIT
? homepage https://github.com/easy-swoole/demo
? set currently installed components as dependencies? Yes
? add commonly ignored files to ignore list? Yes
? would you like to mark this package as private which prevents it from being accidentally published to the registry? Yes
{
name: 'test',
homepage: 'https://github.com/easy-swoole/demo',
authors: [
'sunyu <sunvipyu@gmail.com>'
],
description: 'some test',
main: 'test.js',
keywords: [
'do',
'it'
],
license: 'MIT',
private: true,
ignore: [
'**/.*',
'node_modules',
'bower_components',
'test',
'tests'
]
}
? Looks good? Yes
这时候创建出来的 bower.json 文件内容是:
{
"name": "test",
"homepage": "https://github.com/easy-swoole/demo",
"authors": [
"sunyu <sunvipyu@gmail.com>"
],
"description": "some test",
"main": "test.js",
"keywords": [
"do",
"it"
],
"license": "MIT",
"private": true,
"ignore": [
"**/.*",
"node_modules",
"bower_components",
"test",
"tests"
]
}
这里没有声明任何依赖包。我们可以追加声明:
bower install bootstrap --save
上面的 -save
就是把下载的包信息写入到配置文件的依赖项里,它会自动安装最新版本的bootstrap并更新bower.json文件:
{
"name": "test",
"homepage": "https://github.com/easy-swoole/demo",
"authors": [
"sunyu <sunvipyu@gmail.com>"
],
"description": "some test",
"main": "test.js",
"keywords": [
"do",
"it"
],
"license": "MIT",
"private": true,
"ignore": [
"**/.*",
"node_modules",
"bower_components",
"test",
"tests"
],
"dependencies": {
"bootstrap": "^4.0.0"
}
}
这里有两个版本的依赖,一个是dependencies,另一个是devDependencies,分别代表生产环境和开发环境中的依赖包,它们可以分别通过下面两个指令自动添加:
bower install jquery --save //添加到dependencies
bower install angular --save-dev //添加到devDependencies
bower 默认情况都会去bower.com上面找最新的包,除非指定版本号。安装时,我们也可以指定对应的版本进行安装,如:
bower install jQuery#2.1
bower install github 网址
按照上面的做法,例如,我创建了一个前端开发的较为齐全的包,就可以挂在git或者bower的官网上了。
别人通过下载这个bower.json文件,在已经安装bower的前提下,直接运行bower install就可以使用这个前端开发包了。
你可以注册自己的包,这样其他人也可以使用了,这个操作只是在服务器上保存了一个映射,服务器本身不托管代码。
提交你的 bower 包给 bower.com:
bower register sunyu_test git://github.com/jquery/jquery
注意,如果你的库与现有的库重名,就会提交失败。
缓存是前端比较头疼的东西。Bower cache
可以清理缓存: bower cache clean
。
列出缓存列表: bower cache list
清理后,再安装时它就会从远程下载。
bower update # 更新包
bower unstall # 卸载包
现今的很多网页其实可以看做是功能丰富的应用,它们拥有着复杂的JavaScript代码和一大堆依赖包。为了简化开发的复杂度,前端社区涌现出了很多好的实践方法。
WebPack可以看做是模块打包机:它做的事情是,分析你的项目结构,找到JavaScript模块以及其它的一些浏览器不能直接运行的拓展语言(Scss,TypeScript等),并将其转换和打包为合适的格式供浏览器使用。
Gulp/Grunt是一种能够优化前端的开发流程的工具,而WebPack是一种模块化的解决方案,不过Webpack的优点使得Webpack在很多场景下可以替代Gulp/Grunt类的工具。
Grunt和Gulp的工作方式是:在一个配置文件中,指明对某些文件进行类似编译,组合,压缩等任务的具体步骤,工具之后可以自动替你完成这些任务。
Webpack的工作方式是:把你的项目当做一个整体,通过一个给定的主文件(如:index.js),Webpack将从这个文件开始找到你的项目的所有依赖文件,使用loaders处理它们,最后打包为一个(或多个)浏览器可识别的JavaScript文件。
Webpack可以使用npm安装,在终端中进入项目文件夹后执行下述指令就可以完成安装。不过先要
在安装之前最好是先初始化一下 npm 配置:
npm init
输入这个命令后,终端会问你一系列诸如项目名称,项目描述,作者等信息,不过不用担心,如果你不准备在npm中发布你的模块,这些问题的答案都不重要,回车默认即可。
这里会帮我们建立一个 package.json,用来记录项目的软件包依赖。如果不初始化 npm 直接使用 install 安装,依赖信息会保存在 package-lock.json 文件中。
//全局安装
npm install -g webpack
npm install -g webpack-cli
//安装到你的项目目录,并更新 npm 配置文件 package.json,
# 且从淘宝镜像安装,速度要更快.推荐该方法
npm install --save-dev webpack --registry=https://registry.npm.taobao.org
npm install --save-dev webpack-cli --registry=https://registry.npm.taobao.org
在项目文件夹里创建两个目录 ,app 和 public。app文件夹用来存放原始数据和我们将写的JavaScript 模块,public 文件夹用来存放之后供浏览器读取的文件。
然后创建三个文件:
index.html:
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Webpack Sample Project</title>
</head>
<body>
<div id='root'>
</div>
<script src="bundle.js"></script>
</body>
</html>
slave.js:
module.exports = function() {
var greet = document.createElement('div');
greet.textContent = "Hi there and greetings!";
return greet;
};
main.js
const greeter = require('./slave.js');
document.querySelector("#root").appendChild(greeter());
使用webpack
在终端中使用,在基本的使用方法如下:
# {extry file}出填写入口文件的路径,本文中就是上述main.js的路径,
# {destination for bundled file}处填写打包文件的存放路径
# 填写路径的时候不用添加{}
webpack {entry file} {destination for bundled file}
指定入口文件后,webpack将自动识别项目所依赖的其它文件,不过需要注意的是如果你的webpack不是全局安装的,那么当你在终端中使用此命令时,需要额外指定其在node_modules中的地址。这里运行:
# webpack非全局安装的情况
node_modules/.bin/webpack app/main.js --output public/bundle.js
Hash: 00f096bf738fce903aa2
Version: webpack 4.3.0
Time: 302ms
Built at: 2018-3-28 16:31:17
Asset Size Chunks Chunk Names
bundle.js 730 bytes 0 [emitted] main
Entrypoint main = bundle.js
[0] ./app/slave.js 143 bytes {0} [built]
[1] ./app/main.js 95 bytes {0} [built]
WARNING in configuration
The 'mode' option has not been set. Set 'mode' option to 'development' or 'production' to enable defaults for this environment.
这时候,main.js 和 slave.js 被打包到了 bundle.js里,而且进行了压缩。它的内容是:
1 !function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports} n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{configurable:!1,enumerable:!0,get:r})},n.r=function(e){Object.defineProperty(e,"__esMod ule",{value:!0})},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Ob ject.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=1)}([function(e,t){e.exports=function(){var e=document.createElement("div");return e.textContent="Hi t here and greetings!",e}},function(e,t,n){const r=n(0);document.querySelector("#root").appendChild(r())}]);
具体 webpack 的命令可以用 webpack -h
查看。
在当前件夹的根目录下新建一个名为 webpack.config.js 的文件,我们在其中写入如下所示的简单配置代码,目前的配置主要涉及到的内容是入口文件路径和打包后文件的存放路径:
module.exports = {
entry: __dirname + "/app/main.js",
output: {
path: __dirname + "/public",
filename: "bundle.js"
}
}
注:__dirname 是node.js中的一个全局变量,它指向当前执行脚本所在的目录
有了这个配置之后,再打包文件,只需在终端里运行webpack(非全局安装需使用node_modules/.bin/webpack)命令就可以了,这条命令会自动引用webpack.config.js 文件中的配置选项。这样就省去了打包时命令行里复杂的配置。
在命令行中输入命令需要代码类似于node_modules/.bin/webpack
这样的路径其实是比较烦人的,不过值得庆幸的是npm可以引导任务执行,对npm进行配置后可以在命令行中使用简单的npm start
命令来替代上面略微繁琐的命令。在package.json
中对scripts
对象进行相关设置即可,设置方法如下:
{
"name": "test",
"version": "1.0.0",
"description": "test",
"main": "index.js",
"scripts": {
"test": "test",
"start": "webpack"
},
"repository": {
"type": "git",
"url": "git+https://github.com/laravel/elixir.git"
},
"keywords": [
"test"
],
"author": "sunyu",
"license": "MIT",
"bugs": {
"url": "https://github.com/laravel/elixir/issues"
},
"homepage": "https://github.com/laravel/elixir#readme",
"devDependencies": {
"webpack": "^4.3.0"
},
"dependencies": {
"gulp": "^3.9.1",
"webpack-cli": "^2.0.13"
}
}
注:package.json中的script会按照一定顺序寻找命令对应位置,本地的node_modules/.bin路径就在这个寻找清单中,所以无论是全局还是局部安装的Webpack,你都不需要写前面那指明详细的路径了。
npm的start命令是一个特殊的脚本名称,其特殊性表现在,在命令行中使用npm start
就可以执行其对应的命令,如果对应的此脚本名称不是start,想要在命令行中运行时,需要这样用npm run {script name}
如 npm run test
,这里我们先执行:
sunyu:test sunyu$ npm run start
> test@1.0.0 start /Users/sunyu/Documents/workspace/sunyu/test
> webpack
Hash: 00f096bf738fce903aa2
Version: webpack 4.3.0
Time: 116ms
Built at: 2018-3-28 16:41:57
Asset Size Chunks Chunk Names
bundle.js 730 bytes 0 [emitted] main
Entrypoint main = bundle.js
[0] ./app/slave.js 143 bytes {0} [built]
[1] ./app/main.js 95 bytes {0} [built]
这里,只用 npm 就可以管理 webpack 的功能了。下面还有许多扩展功能:
开发总是离不开调试,方便的调试能极大的提高开发效率,不过有时候通过打包后的文件,不容易找到出错了的地方,Source Maps就是来帮我们解决这个问题的,用来找到原始文件对应的位置。
通过简单的配置,webpack就可以在打包时为我们生成的source maps,这为我们提供了一种对应编译文件和源文件的方法,使得编译后的代码可读性更高,也更容易调试。
在 webpack 的配置文件中配置source maps,需要配置devtool,它有以下四种不同的配置选项,各具优缺点,描述如下:
选项 | 描述 |
source-map | 在一个单独的文件中产生一个完整且功能完全的文件。这个文件具有最好的source map,但是它会减慢打包速度; |
cheap-module-source-map | 在一个单独的文件中生成一个不带列映射的map,不带列映射提高了打包速度,但是也使得浏览器开发者工具只能对应到具体的行,不能对应到具体的列(符号),会对调试造成不便; |
eval-source-map | 使用eval打包源文件模块,在同一个文件中生成干净的完整的source map。这个选项可以在不影响构建速度的前提下生成完整的sourcemap,但是对打包后输出的JS文件的执行具有性能和安全的隐患。在开发阶段这是一个非常好的选项,在生产阶段则一定不要启用这个选项; |
cheap-module-eval-source-map | 这是在打包文件时最快的生成source map的方法,生成的Source Map 会和打包后的JavaScript文件同行显示,没有列映射,和eval-source-map选项具有相似的缺点; |
对小到中型的项目中,eval-source-map
是一个很好的选项,再次强调你只应该开发阶段使用它,我们继续对配置文件webpack.config.js,进行如下配置:
module.exports = {
devtool: 'eval-source-map',
entry: __dirname + "/app/main.js",
output: {
path: __dirname + "/public",
filename: "bundle.js"
}
}
这时候再打包一次 npm run start
, 生成的 bundle.js 内容变成:
!function(e){var n={};function t(r){if(n[r])return n[r].exports;var u=n[r]={i:r,l:!1,exports:{}};return e[r].call(u.exports,u,u.exports,t),u.l=!0,u.exports} t.m=e,t.c=n,t.d=function(e,n,r){t.o(e,n)||Object.defineProperty(e,n,{configurable:!1,enumerable:!0,get:r})},t.r=function(e){Object.defineProperty(e,"__esMod ule",{value:!0})},t.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(n,"a",n),n},t.o=function(e,n){return Ob ject.prototype.hasOwnProperty.call(e,n)},t.p="",t(t.s=1)}([function(module,exports){eval("module.exports = function() {\n var greet = document.createElemen t('div');\n greet.textContent = \"Hi there and greetings!\";\n return greet;\n};\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;chars et=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIndlYnBhY2s6Ly8vLi9hcHAvc2xhdmUuanM/Y2FlNyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQTtBQUNBO0FBQ0E7QUFDQTtBQUNB IiwiZmlsZSI6IjAuanMiLCJzb3VyY2VzQ29udGVudCI6WyJtb2R1bGUuZXhwb3J0cyA9IGZ1bmN0aW9uKCkge1xuICB2YXIgZ3JlZXQgPSBkb2N1bWVudC5jcmVhdGVFbGVtZW50KCdkaXYnKTtcbiAgZ3Jl ZXQudGV4dENvbnRlbnQgPSBcIkhpIHRoZXJlIGFuZCBncmVldGluZ3MhXCI7XG4gIHJldHVybiBncmVldDtcbn07XG4iXSwic291cmNlUm9vdCI6IiJ9\n//# sourceURL=webpack-internal:///0\n" )},function(module,exports,__webpack_require__){eval('const greeter = __webpack_require__(0);\ndocument.querySelector("#root").appendChild(greeter());\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIndlYnBhY2s6Ly8vLi9hcHAvbWFpbi5qcz9mMTYx Il0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBO0FBQ0EiLCJmaWxlIjoiMS5qcyIsInNvdXJjZXNDb250ZW50IjpbImNvbnN0IGdyZWV0ZXIgPSByZXF1aXJlKCcuL3NsYXZlLmpzJyk7XG5kb2N1bWVu dC5xdWVyeVNlbGVjdG9yKFwiI3Jvb3RcIikuYXBwZW5kQ2hpbGQoZ3JlZXRlcigpKTtcbiJdLCJzb3VyY2VSb290IjoiIn0=\n//# sourceURL=webpack-internal:///1\n')}]);
Loaders是webpack提供的最激动人心的功能之一了。通过使用不同的loader,webpack有能力调用外部的脚本或工具,实现对不同格式的文件的处理,比如说分析转换scss为css,或者把下一代的JS文件(ES6,ES7)转换为现代浏览器兼容的JS文件。
Loaders需要单独安装并且需要在webpack.config.js中的modules关键字下进行配置。
Loaders的配置包括以下几方面:
在我们的测试项目中,我们把 slave.js 里的消息内容放到外部的一个 json 文件里,并通过配置,让它读取文件里的值:
app/config.json
{
"helloText": "Hi there and greetings from JSON!"
}
app/slave.js
var config = require('./config.json');
module.exports = function() {
var greet = document.createElement('div');
greet.textContent = config.helloText;
return greet;
};
再打包一次试试 npm run start
,再打开 index.html 可以看到输出:
Hi there and greetings from JSON!
说明成功了。
Babel其实是一个编译JavaScript的平台,它可以编译代码帮你达到以下目的:
Babel其实是几个模块化的包,其核心功能位于称为babel-core的npm包中,webpack可以把其不同的包整合在一起使用,对于每一个你需要的功能或拓展,你都需要安装单独的包(用得最多的是解析Es6的babel-env-preset包和解析JSX的babel-preset-react包)。
先安装依赖包:
npm install --save-dev babel-core babel-loader babel-preset-env babel-preset-react babel-preset-es2015 babel-cli babel-preset-stage-0 --registry=http://registry.npm.taobao.org
由于 Babel6 默认不再使用ES2015和React转换,那么使用require钩子的gulpfile.babel.js和Mocha将无法使用。你需要在项目目录增加 .babelrc
文件来解决此问题。
{
"presets": ["es2015", "stage-0"]
}
webpack会自动调用.babelrc里的babel配置选项
在webpack中配置Babel的方法如下:
module.exports = {
entry: __dirname + "/app/main.js",
output: {
path: __dirname + "/public",
filename: "bundle.js"
},
devtool: 'eval-source-map',
module: {
rules: [
{
test: /(\.jsx|\.js)$/,
use: {
loader: "babel-loader",
options: {
presets: [
"env", "react"
]
}
},
exclude: /node_modules/
}
]
}
};
现在你的webpack的配置已经允许你使用ES6以及JSX的语法了。继续用上面的例子进行测试,不过这次我们会使用React,记得先安装 React 和 React-DOM:
npm install --save-dev react react-dom --registry=http://registry.npm.taobao.org
接下来我们使用ES6的语法,更新 slave.js 并返回一个React组件
// app/slave,js
import React, {Component} from 'react'
import config from './config.json';
class Slave extends Component{
render() {
return (
<div>
{config.helloText}
</div>
);
}
}
export default Slave
修改main.js如下,使用ES6的模块定义和渲染Greeter模块
// app/main.js
import React from 'react';
import {render} from 'react-dom';
import Slave from './slave';
render(<Slave />, document.getElementById('root'));
重新打包测试:npm run start
,并看结果。
webpack提供两个工具处理样式表,css-loader 和 style-loader,二者处理的任务不同,css-loader使你能够使用类似@import 和 url(…)的方法实现 require()的功能,style-loader将所有的计算后的样式加入页面中,二者组合在一起使你能够把样式表嵌入webpack打包后的JS文件中。
使用之前也要先安装一些模块:
npm install --save-dev style-loader css-loader --registry=http://registry.npm.taobao.org
然后修改配置文件 webpack.config.js
:
module.exports = {
entry: __dirname + "/app/main.js",
output: {
path: __dirname + "/public",
filename: "bundle.js"
},
devtool: 'eval-source-map',
module: {
rules: [
{
test: /(\.jsx|\.js)$/,
use: {
loader: "babel-loader",
options: {
presets: [
"env", "react"
]
}
},
exclude: /node_modules/
},
{
test: /\.css$/,
use: [
{
loader: "style-loader"
}, {
loader: "css-loader"
}
]
}
]
}
};
接下来,在app文件夹里创建一个名字为”main.css”的文件,对一些元素设置样式:
/* app/main.css */
html {
box-sizing: border-box;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
*, *:before, *:after {
box-sizing: inherit;
}
body {
margin: 0;
border: 3px solid red;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
h1, h2, h3, h4, h5, h6, p, ul {
margin: 0;
padding: 0;
}
这里到的webpack只有单一的入口,其它的模块需要通过 import, require, url等与入口文件建立其关联,为了让webpack能找到”main.css“文件,我们把它导入”main.js “中,如下:
//app/main.js
import Slave from './slave';
import './main.css';
render(<Greeter />, document.getElementById('root'));
再重新打包,并打开 index.html 测试,可以看到内容上有了 3px 的红色边框。说明上面的样式成本配置上了。
通常情况下,css会和js打包到同一个文件中,并不会打包为一个单独的css文件,不过通过合适的配置webpack也可以把css打包为单独的文件的。
CSS modules的意在把模块化思想带入CSS中来,通过CSS模块,所有的类名,动画名默认都只作用于当前模块。
Webpack对CSS模块化提供了非常好的支持,只需要在CSS loader中进行简单配置即可,然后就可以直接把CSS的类名传递到组件的代码中,这样做有效避免了全局污染。具体的代码如下:
module.exports = {
entry: __dirname + "/app/main.js",
output: {
path: __dirname + "/public",
filename: "bundle.js"
},
devtool: 'eval-source-map',
module: {
rules: [
{
test: /(\.jsx|\.js)$/,
use: {
loader: "babel-loader",
options: {
presets: [
"env", "react"
]
}
},
exclude: /node_modules/
},
{
test: /\.css$/,
use: [
{
loader: "style-loader"
}, {
loader: "css-loader",
options: {
modules: true,
localIdentName: '[name]__[local]--[hash:base64:5]'
}
}
]
}
]
}
};
我们在app文件夹下创建一个 slave.css 文件来进行一下测试:
.root {
background-color: #eee;
padding: 10px;
border: 3px solid #ccc;
}
导入.root到 slave.js 中:
import React, {Component} from 'react'
import config from './config.json';
import styles from './slave.css';
class Slave extends Component{
render() {
return (
<div className={styles.root}>
{config. helloText}
</div>
);
}
}
export default Slave
重新打包,打开 index.html 后它的 html 内容是这样的:
<div id="root">
<div class="slave__root--tzYbv">
Hi there and greetings from JSON! hello sunyu
</div>
</div>
而且可以看到上面配置的样式,背景,padding 等效果。这里有更多的介绍:https://github.com/css-modules/css-modules
插件(Plugins)是用来拓展Webpack功能的,它们会在整个构建过程中生效,执行相关的任务。
Loaders和Plugins常常被弄混,但是他们其实是完全不同的东西,loaders是在打包构建过程中用来处理源文件的(JSX,Scss,Less..),一次处理一个,插件并不直接操作单个文件,它直接对整个构建过程其作用。
Webpack有很多内置插件,同时也有很多第三方插件。
HtmlWebpackPlugin
这个插件的作用是依据一个简单的index.html模板,生成一个自动引用你打包后的JS文件的新index.html。这在每次生成的js文件名称不同时非常有用(比如添加了hash值)。
npm install --save-dev html-webpack-plugin
这个插件自动完成了我们之前手动做的一些事情,在正式使用之前需要对一直以来的项目结构做一些更改:
1.在app目录下,创建一个 index.tmpl.html
文件模板,这个模板包含title等必须元素,在编译过程中,插件会依据此模板生成最终的html页面,会自动添加所依赖的 css, js,favicon等文件,index.tmpl.html中的模板源代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Webpack Sample Project</title>
</head>
<body>
<div id='root'>
</div>
</body>
</html>
2.更新webpack的配置文件,添加插件配置并把输出目录改为 build
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: __dirname + "/app/main.js",
output: {
path: __dirname + "/build",
filename: "bundle.js"
},
devtool: 'eval-source-map',
module: {
rules: [
{
test: /(\.jsx|\.js)$/,
use: {
loader: "babel-loader",
options: {
presets: [
"env", "react"
]
}
},
exclude: /node_modules/
},
{
test: /\.css$/,
use: [
{
loader: "style-loader"
},
{
loader: "css-loader",
options: {
modules: true,
localIdentName: '[name]__[local]--[hash:base64:5]'
}
}
]
}
]
},
plugins: [
new HtmlWebpackPlugin({
template: __dirname + "/app/index.tmpl.html"
})
],
};
3.新建一个build文件夹用来存放最终的输出文件
运行测试后,build 里会生成一个 bundle.js 一个 index.html.
在产品阶段,可能还需要对打包的文件进行额外的处理,比如说优化,压缩,缓存以及分离CSS和JS。
对于复杂的项目来说,需要复杂的配置,这时候分解配置文件为多个小的文件可以使得事情井井有条,以上面的例子来说,我们创建一个 webpack.production.config.js
的文件,在里面加上基本的配置,它和原始的webpack.config.js很像,如下:
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: __dirname + "/app/main.js",
output: {
path: __dirname + "/build",
filename: "bundle.js"
},
devtool: 'null',
module: {
rules: [
{
test: /(\.jsx|\.js)$/,
use: {
loader: "babel-loader",
options: {
presets: [
"env", "react"
]
}
},
exclude: /node_modules/
},
{
test: /\.css$/,
use: [
{
loader: "style-loader"
},
{
loader: "css-loader",
options: {
modules: true,
localIdentName: '[name]__[local]--[hash:base64:5]'
}
}
]
}
]
},
plugins: [
new HtmlWebpackPlugin({
template: __dirname + "/app/index.tmpl.html"
})
],
};
这里把 devtool 修改成了 null,这样可以大大压缩代码。
然后,再修改 npm 配置文件 package.json:
{
"name": "test",
"version": "1.0.0",
"description": "test",
"main": "index.js",
"scripts": {
"test": "echo \"Hello world!\" && exit 1",
"start": "webpack",
"build": "NODE_ENV=production webpack --config ./webpack.production.config.js --progress"
},
"repository": {
"type": "git",
"url": "git+https://github.com/laravel/elixir.git"
},
"keywords": [
"test"
],
"author": "sunyu",
"license": "MIT",
"bugs": {
"url": "https://github.com/laravel/elixir/issues"
},
"homepage": "https://github.com/laravel/elixir#readme",
"devDependencies": {
"babel": "^6.23.0",
"babel-cli": "^6.26.0",
"babel-core": "^6.26.0",
"babel-loader": "^7.1.4",
"babel-preset-env": "^1.6.1",
"babel-preset-es2015": "^6.24.1",
"babel-preset-react": "^6.24.1",
"babel-preset-stage-0": "^6.24.1",
"css-loader": "^0.28.11",
"es2015": "0.0.0",
"html-webpack-plugin": "^3.1.0",
"react": "^16.2.0",
"react-dom": "^16.2.0",
"style-loader": "^0.20.3",
"webpack": "^4.3.0",
"webpack-cli": "^2.0.13"
},
"dependencies": {
"gulp": "^3.9.1"
}
}
这里,添加了一个 build 命令。我们可以通过它改变npm模式并使用对应的webpack配置文件进行打包:
npm run build
webpack提供了一些在发布阶段非常有用的优化插件,它们大多来自于webpack社区,可以通过npm安装,通过以下插件可以完成产品发布阶段所需的功能
OccurenceOrder 和 UglifyJS plugins 都是内置插件,你需要做的只是安装其它非内置插件:
npm install --save-dev mini-css-extract-plugin
在配置文件的plugins后引用它们:
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
module.exports = {
entry: __dirname + "/app/main.js",
output: {
path: __dirname + "/build",
filename: "bundle.js"
},
devtool: 'null',
module: {
rules: [
{
test: /(\.jsx|\.js)$/,
use: {
loader: "babel-loader",
options: {
presets: [
"env", "react"
]
}
},
exclude: /node_modules/
},
{
test: /\.css$/,
use: [
MiniCssExtractPlugin.loader,
"css-loader"
]
}
]
},
plugins: [
new HtmlWebpackPlugin({
template: __dirname + "/app/index.tmpl.html"
}),
new MiniCssExtractPlugin({
filename: "[name].css",
})
],
};
在顶部声明了插件,在底部引入插件。
这时候再执行编译: npm run build
Artisan 是 Laravel 的命令行接口的名称,它提供了许多实用的命令来帮助你开发 Laravel 应用,它由强大的 Symfony Console 组件所驱动。
可以使用 list
命令来列出所有可用的 Artisan 命令:
php artisan list
每个命令也包含了「帮助」界面,它会显示并概述命令可使的参数及选项。只要在命令前面加上 help 即可显示帮助界面:
php artisan help migrate
除了使用 Artisan 本身所提供的命令之外,Laravel 也允许你自定义 Artisan 命令。
自定义命令默认存储在 app/Console/Commands
目录中,当然,只要在 composer.json
文件中的配置了自动加载,你可以自由选择想要放置的地方。
若要创建新的命令,你可以使用 make:console
Artisan 命令生成命令文件:
php artisan make:console SendEmails
上面的这个命令会生成 app/Console/Commands/SendEmails.php
类,--command
参数可以用来指定调用名称:
php artisan make:console SendEmails --command=emails:send
一旦生成这个命令,应先填写类的 signature
和 description
这两个属性,它们会被显示在 list
界面中。
命令运行时 handle
方法会被调用,请将程序逻辑放置在此方法中。
接下来讲解一个发送邮件的例子。
为了更好的代码重用性,还有可读性,建议把处理业务逻辑的代码抽到一个功能类里。
Command 类构造器允许注入需要的依赖,Laravel 的 服务容器 将会自动把功能类 DripEmailer 解析到构造器中:
<?php
namespace App\Console\Commands;
use App\User;
use App\DripEmailer;
use Illuminate\Console\Command;
class SendEmails extends Command
{
/**
* 命令行的名称及用法。
*
* @var string
*/
protected $signature = 'email:send {user}';
/**
* 命令行的概述。
*
* @var string
*/
protected $description = 'Send drip e-mails to a user';
/**
* 滴灌电子邮件服务。
*
* @var DripEmailer
*/
protected $drip;
/**
* 创建新的命令实例。
*
* @param DripEmailer $drip
* @return void
*/
public function __construct(DripEmailer $drip)
{
parent::__construct();
$this->drip = $drip;
}
/**
* 运行命令。
*
* @return mixed
*/
public function handle()
{
$this->drip->send(User::find($this->argument('user')));
}
}
定义预期的输入
signature 属性定义了希望从用户获得的输入格式,signature 属性可用来定义命令的名字、参数及选项,具有与路由相似的语法特性。
参数及选项都包在大括号中。如以下例子,此命令会定义一个 必须的 参数 user:
/**
* 命令行的名称及用法。
*
* @var string
*/
protected $signature = 'email:send {user}';
以下是可选参数和默认值的例子(注意括号内的符号):
// 选择性的参数...
email:send {user?}
// 选择性的参数及默认的值...
email:send {user=foo}
选项就跟参数一样,同样也是用户输入的一种格式,不过当使用选项时,需要在命令行加入两个连字符号(–),选项的定义如下:
/**
* 命令行的名称及用法。
*
* @var string
*/
protected $signature = 'email:send {user} {--queue}';
在这个例子中,当调用 Artisan 命令时,–queue 这个选项可以被明确的指定。如果 –queue 被当成输入时,这个选项的值会是 true,如果没有指定时,这个选项的值将会是 false:
php artisan email:send 1 --queue
你也可以借助在这个选项后面加个 = 来为选项明确指定值:
/**
* 命令行的名称及用法。
*
* @var string
*/
protected $signature = 'email:send {user} {--queue=}';
在这个例子中,用户可以为这个参数传入一个值:
php artisan email:send 1 --queue=default
指定默认值给选项:
email:send {user} {--queue=default}
为选项定义简写方式:
email:send {user} {--Q|queue}
如果你想要参数和选项接受数组输入,你可以使用 * 字符:
email:send {user*}
email:send {user} {--id=*}
增加概述 使用冒号 : 可以为参数和选项增加概述:
/**
* 命令行的名称及用法。
*
* @var string
*/
protected $signature = 'email:send
{user : 用户的 ID }
{--queue= : 这个工作是否该进入队列}';
获取输入
代码里通过调用 argument 及 option 方法来获取对应的参数和选项输入。
使用 argument 方法来获取参数的值:
/**
* 命令行的处理逻辑
*
* @return mixed
*/
public function handle()
{
$userId = $this->argument('user');
//
}
不加参数调用,可以获取到所有的参数 数组:
$arguments = $this->argument();
option 方法的使用同 argument 一样:
// 获取特定的选择
$queueName = $this->option('queue');
// 获取所有选择
$options = $this->option();
如果参数或选项不存在,将会返回 null。
让用户输入
ask 方法提供的问题来提示用户,并且接受他们的输入,返回的是用户输入:
/**
* 命令行的处理逻辑
*
* @return mixed
*/
public function handle()
{
$name = $this->ask('你是名字是?');
}
secret 如同 ask 方法一般,但是用户的输入将不会显示在命令行。这个方法适用于要求提供如密码的敏感信息时:
$password = $this->secret('密码是?');
让用户确认
confirm 方法提供询问用户确认,默认的情况下,这个方法会返回 false。如果用户对这个提示输入 y,那这个方法将会返回 true:
if ($this->confirm('你希望继续吗? [y|N]')) {
//
}
让用户做选择
anticipate 方法可被用于为可能的选择提供自动完成。用户仍可以选择任何答案而不理会这些选择。
$name = $this->anticipate('你的名字是?', ['Taylor', 'Dayle']);
choice 方法让用户从给定选项里选择,用户会选择答案的索引,但是返回的是答案的值。可以设置返回默认值来防止没有任何东西被选择的情况:
$name = $this->choice('你的名字是?', ['Taylor', 'Dayle'], false);
编写输出
使用 line、info、comment、question 和 error 方法来发送输出到终端。每个方法都有适当的 ANSI 颜色来表达它们的目的。
使用 info 方法来发送信息消息给用户,并在终端以绿色呈现:
/**
* 命令行的处理逻辑
*
* @return mixed
*/
public function handle()
{
$this->info('把我显示在界面上');
}
使用 error 方法来发送错误消息给用户,并在终端以红色呈现:
$this->error('有东西出问题了!');
line 方法不会输出任何特殊的颜色:
$this->line('把我显示在界面上');
数据表布局
使用 table 方法格式化输出多行与多列数据,宽跟高将会基于数据做动态计算:
$headers = ['Name', 'Email'];
$users = App\User::all(['name', 'email'])->toArray();
$this->table($headers, $users);
进度条
对于需要长时间运行的任务,可以使用进度条来提示用户:
$users = App\User::all();
// 多少个任务
$bar = $this->output->createProgressBar(count($users));
foreach ($users as $user) {
$this->performTask($user);
// 一个任务处理完了,可以前进一点点了
$bar->advance();
}
$bar->finish();
命令编写完成后,需要注册 Artisan 后才能使用。注册文件为 app/Console/Kernel.php。
在这个文件中,commands 属性是命令的清单,要注册命令,请在此清单加入类的名称即可。
当 Artisan 启动时,所有罗列在这个 属性的命令,都会被 服务容器 解析并向 Artisan 注册:
protected $commands = [
Commands\SendEmails::class,
];
利用 Artisan facade 的 call 方法,可以在程序内部调用 Artisan 命令。
call 方法的第一个参数为命令的名称,第二个参数为数组型态的命令输入,退出码将会被返回:
Route::get('/foo', function () {
$exitCode = Artisan::call('email:send', [
'user' => 1,
'--queue' => 'default'
]);
//
});
在 Artisan facade 使用 queue 方法,可以将 Artisan 命令丢给后台的 队列服务器 运行:
Route::get('/foo', function () {
Artisan::queue('email:send', [
'user' => 1,
'--queue' => 'default'
]);
//
});
如果需要指定非接收字符串选项的值,如 migrate:refresh 命令的 –force 标记,你可以传递一个 true 或 false 的布尔值:
$exitCode = Artisan::call('migrate:refresh', [
'--force' => true,
]);
Command 类的 call 方法可以让你在命令中调用命令,call 方法接受命令名称和命令参数的数组:
/**
* 命令行的处理逻辑
*
* @return mixed
*/
public function handle()
{
$this->call('email:send', [
'user' => 1,
'--queue' => 'default'
]);
//
}
调用其它命令并忽略它所有的输出,可以使用 callSilent 命令。callSilent 方法有和 call 方法一样的用法:
$this->callSilent('email:send', [
'user' => 1,
'--queue' => 'default'
]);
有一个需求, 需要把已有的老数据做整理, 为每篇 Topic 生成一个摘要信息, 并放到数据库里面, 方便以后的读取.
命令行生成文件
以下命令生成文件 app/Console/Commands/TopicMakeExcerptCommand.php
$ php artisan make:console TopicMakeExcerptCommand --command=topics:excerpt
Command created successfully.
激活 Artisan 命令行
在 app/Console/Kernel.php 文件里面, 添加以下:
protected $commands = [
\App\Console\Commands\TopicMakeExcerptCommand::class,
];
加入业务逻辑代码
第一步生成的 TopicMakeExcerptCommand.php 文件, 修改以下区域:
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class TopicMakeExcerptCommand extends Command
{
/**
* 1. 这里是命令行调用的名字, 如这里的: `topics:excerpt`,
* 命令行调用的时候就是 `php artisan topics:excerpt`
*
* @var string
*/
protected $signature = 'topics:excerpt';
/**
* 2. 这里填写命令行的描述, 当执行 `php artisan` 时
* 可以看得见.
*
* @var string
*/
protected $description = '这里修改为命令行的描述';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* 3. 这里是放要执行的代码, 如在我这个例子里面,
* 生成摘要, 并保持.
*
* @return mixed
*/
public function handle()
{
$topics = Topic::all();
$transfer_count = 0;
foreach ($topics as $topic) {
if (empty($topic->excerpt))
{
$topic->excerpt = Topic::makeExcerpt($topic->body);
$topic->save();
$transfer_count++;
}
}
$this->info("Transfer old data count: " . $transfer_count);
$this->info("It's Done, have a good day.");
}
}
命令行调用
php artisan topics:excerpt
cd /data/soft/
wget http://archive.apache.org/dist/hbase/hbase-0.94.9/hbase-0.94.9.tar.gz
tar -zxf hbase-0.94.9-bin.tar.gz
mv hbase-0.94.9 /usr/local/hbase
注意:下载的时候一定要注意版本。如果通过JAVA进行客户端连接,如果客户端 jar 和服务端的版本不一致,会连接失败。 当安装过一个版本,想安装另一个版本时,一定要把 /tmp/hbase* 目录及文件全部删掉
修改 /etc/profile 添加:
export HBASE_HOME=/usr/local/hbase
启动 hbase:
/usr/local/hbase/bin/start-hbase.sh
starting master, logging to /usr/local/hbase/bin/../logs/hbase-root-master-localhost.out
启动后,16010 端口会开始监听。 同时,会自动启动 zookeeper,它是监听 2181 端口
可以访问管理端:http://192.168.192.110:60010/master-status
hbase 的配置信息主要在两个文件里: hbase-env.sh, hbase-site.xml 里。这两个文件在安装文件的解压目录。即:/usr/local/conf/。
在单机模式的默认设置里,HBase把数据写到了 /tmp 下,但此处不宜久留。我们可以更改 hbase-site.xml 文件,添加下面的配置来修改数据目录:
<property>
<name>hbase.rootdir</name>
<value>/data/hbase/</value>
</property>
改完后,重新启动 hbase(执行 /usr/local/hbase/bin/stop-hbase.sh 和 /usr/local/hbase/bin/start-hbase.sh)。启动后,/data/hbase/目录下会有数据文件和目录。
php 操作 hbase 是基于 thrift 的。
启动 hbase 和 hbase 的 thrift 服务
/usr/local/hbase/bin/start-hbase.sh
/usr/local/hbase/bin/hbase thrift -p 9090 start
生成 php 的 hbase-thrift 客户端
/usr/local/bin/thrift --gen php /usr/local/hbase/src/main/resources/org/apache/hadoop/hbase/thrift/Hbase.thrift
或:
/usr/local/bin/thrift --gen php /usr/local/hbase/src/main/resources/org/apache/hadoop/hbase/thrift2/hbase.thrift
后面的是生成的 thrift2 的代码.它和 thrift的接口有许多不一样
执行完后当前目录下会有 gen-php 目录,目录下有文件: Hbase.php, Types.php
把这两个文件复制到测试代码的项目目录,如:
cp gen-php/* /home/httpd/sites/hbase
测试代码:
<?php
use Thrift\Protocol\TBinaryProtocol;
use Thrift\Transport\TSocket;
use Thrift\Transport\THttpClient;
use Thrift\Transport\TBufferedTransport;
use Thrift\Exception\TException;
ini_set('display_errors', '1');
error_reporting(E_ERROR);
ini_set('memory_limit', '2048m');
set_time_limit(600);
$GLOBALS['THRIFT_ROOT'] = '/usr/local/edengweb/php/Thrift';
require_once( $GLOBALS['THRIFT_ROOT'].'/Thrift.php' );
require_once( $GLOBALS['THRIFT_ROOT'].'/Transport/TSocket.php' );
require_once( $GLOBALS['THRIFT_ROOT'].'/Transport/TBufferedTransport.php' );
require_once( $GLOBALS['THRIFT_ROOT'].'/Protocol/TBinaryProtocol.php' );
require_once( $GLOBALS['THRIFT_ROOT'].'/Type/TMessageType.php' );
require_once( $GLOBALS['THRIFT_ROOT'].'/Type/TType.php' );
require_once( $GLOBALS['THRIFT_ROOT'].'/Factory/TStringFuncFactory.php' );
require_once( $GLOBALS['THRIFT_ROOT'].'/StringFunc/TStringFunc.php' );
require_once( $GLOBALS['THRIFT_ROOT'].'/StringFunc/Core.php' );
require_once( $GLOBALS['THRIFT_ROOT'].'/Exception/TException.php' );
require_once( $GLOBALS['THRIFT_ROOT'].'/packages/Types.php');
require_once( $GLOBALS['THRIFT_ROOT'].'/packages/Hbase.php');
$socket = new TSocket( 'localhost', 9090 );
$socket->setSendTimeout( 10000 ); // 发送10秒超时 ( 因为是测试所以可以这么长 ;)
$socket->setRecvTimeout( 20000 ); // 接收超时 20 秒
$transport = new TBufferedTransport( $socket );
$protocol = new TBinaryProtocol( $transport );
$client = new \HbaseClient( $protocol );
$transport->open();
$t = 'demo_table';
echo( "scanning tables...\n" );
$tables = $client->getTableNames();
sort( $tables );
foreach ( $tables as $name ) {
echo( " found: {$name}\n" );
if ( $name == $t ) {
if ($client->isTableEnabled( $name )) {
echo( " disabling table: {$name}\n");
$client->disableTable( $name );
}
echo( " deleting table: {$name}\n" );
$client->deleteTable( $name );
}
}
$columns = array(
new ColumnDescriptor( array(
'name' => 'entry:',
'maxVersions' => 10
) ),
new ColumnDescriptor( array(
'name' => 'unused:'
) )
);
echo( "creating table: {$t}\n" );
try {
$client->createTable( $t, $columns );
} catch ( AlreadyExists $ae ) {
echo( "WARN: {$ae->message}\n" );
}
echo( "column families in {$t}:\n" );
$descriptors = $client->getColumnDescriptors( $t );
asort( $descriptors );
foreach ( $descriptors as $col ) {
echo( " column: {$col->name}, maxVer: {$col->maxVersions}\n" );
}
$invalid = "foo-\xfc\xa1\xa1\xa1\xa1\xa1";
$valid = "foo-\xE7\x94\x9F\xE3\x83\x93\xE3\x83\xBC\xE3\x83\xAB";
# non-utf8 is fine for data
$mutations = array(
new Mutation( array(
'column' => 'entry:foo',
'value' => $invalid
) ),
);
$client->mutateRow( $t, "foo", $mutations , array());
# try empty strings
$mutations = array(
new Mutation( array(
'column' => 'entry:',
'value' => ""
) ),
);
$client->mutateRow( $t, "", $mutations , array());
# this row name is valid utf8
$mutations = array(
new Mutation( array(
'column' => 'entry:foo',
'value' => $valid
) ),
);
$client->mutateRow( $t, $valid, $mutations , array());
# Run a scanner on the rows we just created
echo( "Starting scanner...\n" );
$scanner = $client->scannerOpen( $t, "1", array ('column' => 'entry:foo') , array());
try {
$nbRows = 100;
$arr = $client->scannerGetList($scanner, $nbRows);
print_r($arr);
} catch ( NotFound $nf ) {
$client->scannerClose( $scanner );
echo( "Scanner finished\n" );
}
$transport->close();
?>
HBase 基于 hadoop 和 zookeeper。先安装JAVA环境
HBase作为一个典型的NoSQL数据库,可以通过行键(Rowkey)检索数据,仅支持单行事务,主要用于存储非结构化和半结构化的松散数据。与Hadoop相同,HBase设计目标主要依靠横向扩展,通过不断增加廉价的商用服务器来增加计算和存储能力。
HBase有一些显著的特点:
爬虫从互联网上抓取各网页的内容,然后放到 Hbase 中,再通过 MapReduce 对数据进行索引的构建。 用户在搜索引擎上搜索时,先将搜索词进行分词,然后获取各个分词索引的内容,再按各内容的匹配度得分进行排序,返回最符合的答案。
诸如百度统计,CNZZ等或者公司内容用来监控服务器集群信息的平台。将各机器各指标增量的存入HBase。查询速度快,可扩展性高。
诸如百度广告联盟,谷歌广告联盟。各广告的展现、点击数据统计。及用户行为的事件跟踪。
诸如 Facebook、Twitter,用户量巨大的社交平台,要存储大量的用户及用户帖子及评论相关内容。传统的数据库已经无法满足扩展性和性能的要求。
诸如平台的站内信系统,及聊天工具的信息发送系统。
cd /data/soft/
wget http://archive.apache.org/dist/hbase/hbase-0.94.9/hbase-0.94.9.tar.gz
tar -zxf hbase-0.94.9-bin.tar.gz
mv hbase-0.94.9 /usr/local/hbase
注意:下载的时候一定要注意版本。后面通过JAVA进行客户端连接时,如果客户端 jar 和服务端的版本不一致,会连接失败。 当安装过一个版本,想安装另一个版本时,一定要把 /tmp/hbase* 目录及文件全部删掉
修改 /etc/profile 添加:
export HBASE_HOME=/usr/local/hbase
启动 hbase:
/usr/local/hbase/bin/start-hbase.sh
starting master, logging to /usr/local/hbase/bin/../logs/hbase-root-master-localhost.out
启动后,16010 端口会开始监听。 同时,会自动启动 zookeeper,它是监听 2181 端口
可以访问管理端:http://192.168.192.110:60010/master-status
hbase 的配置信息主要在两个文件里: hbase-env.sh, hbase-site.xml 里。这两个文件在安装文件的解压目录。即:/usr/local/conf/。
在单机模式的默认设置里,HBase把数据写到了 /tmp 下,但此处不宜久留。我们可以更改 hbase-site.xml 文件,添加下面的配置来修改数据目录:
<property>
<name>hbase.rootdir</name>
<value>/data/hbase/</value>
</property>
改完后,重新启动 hbase(执行 /usr/local/hbase/bin/stop-hbase.sh 和 /usr/local/hbase/bin/start-hbase.sh)。启动后,/data/hbase/目录下会有数据文件和目录。
可以通过命令行和hbase进行交互。
/usr/local/hbase/bin/hbase shell
HBase Shell; enter 'help<RETURN>' for list of supported commands.
Type "exit<RETURN>" to leave the HBase Shell
Version 1.1.3, r72bc50f5fafeb105b2139e42bbee3d61ca724989, Sat Jan 16 18:29:00 PST 2016
hbase(main):001:0>list
TABLE
0 row(s) in 0.2290 seconds
=> []
hbase(main):001:0> quit
HBase 使用表作为存储数据的结构。和关系数据库(MySQL, Oracle 等)的行存储不同的是,HBase 使用的是列存储。
hbase(main):001:0> create 'mytable', 'cf'
0 row(s) in 1.5430 seconds
=> Hbase::Table - mytable
hbase(main):002:0> list
TABLE
mytable
1 row(s) in 0.0120 seconds
=> ["mytable"]
现在往表中写入字符串: hello HBase。即:往表 mytable 的 first 行中的 cf:message 列对应的数据单元写入字符串: hello HBase。命令如下:
hbase(main):005:0> put 'mytable', 'first', 'cf:message', 'hello HBase'
0 row(s) in 0.0060 seconds
多插入几条:
hbase(main):005:0> put 'mytable', 'second', 'cf:foo', '0x0'
0 row(s) in 0.0080 seconds
hbase(main):005:0> put 'mytable', 'third', 'cf:bar', '3.14159'
0 row(s) in 0.0130 seconds
现在表里有三条数据了。要注意的是:插入的时候的列名是我们事先没有定义的。而且也没有定义列的字段类型。
HBase有两种读取数据的方式: get 和 scan。scan 是取多条,类似遍历,会返回所有的数据。 和插入数据的 put 对应,取单条数据就用 get。
hbase(main):010:0> get 'mytable', 'first'
COLUMN CELL
cf:message timestamp=1465973297264, value=hello HBase
1 row(s) in 0.0350 seconds
HBase 出来的值还带有一个时间戳。因为它可以为每个数据单元存储多个时间版本。版本默认是 3 个,但是可以重新设置。读取时,除非特别指定,否则默认返回的是最新时间的版本。如果不希望存储多个时间版本,可以设置 HBase 只存储一个版本,但千万不要禁用该功能。
hbase(main):011:0> scan 'mytable'
ROW COLUMN+CELL
first column=cf:message, timestamp=1465973297264, value=hello HBase
second column=cf:foo, timestamp=1465973405764, value=0x0
third column=cf:bar, timestamp=1465973412731, value=3.14159
3 row(s) in 0.0210 seconds
scan 返回了所有的数据行。而且行的顺序是按行的名字排的。
HBase 使用坐标来定位表中的数据。行键是第一个坐标,下一个是列族。列族用做坐标时,表示一组列。再下一个坐标是列限定符。
上面示例中,first, second, third 是行键;cf 是列族,它可以包括许多限定符(message, foo, bar)。 行键最好是用独一无二的值来充当。
hbase(main):006:0> create 'users', 'info'
0 row(s) in 1.3100 seconds
=> Hbase::Table - users
users 是表的名称,info 是列族,它里面可以存很多列限定符。 创建表时至少要指定一个列族。 表创建后,列族是可以更改的,但比较麻烦。
hbase(main):008:0> list
TABLE
mytable
users
2 row(s) in 0.0110 seconds
=> ["mytable", "users"]
hbase(main):009:0> describe 'users'
Table users is ENABLED
users
COLUMN FAMILIES DESCRIPTION
{NAME => 'info', DATA_BLOCK_ENCODING => 'NONE', BLOOMFILTER => 'ROW', REPLICATION_SCOPE => '0', VERSIONS => '1', COMPRESSION => 'NONE', MIN_VERSIONS => '0', TTL
=> 'FOREVER', KEEP_DELETED_CELLS => 'FALSE', BLOCKSIZE => '65536', IN_MEMORY => 'false', BLOCKCACHE => 'true'}
1 row(s) in 0.0140 seconds
put 'users', 'finetkx', 'info:email', 'finetkx@alopecia.com'
put 'users', 'finetkx', 'info:name', 'Feny Ok'
put 'users', 'finetkx', 'info:password', 'abc123'
put 'users', 'finetkx', 'info:user', 'finetkx'
put 'users', 'testuser2', 'info:email', 'testuser2@alopecia.com'
put 'users', 'testuser2', 'info:name', 'Tony anthony'
put 'users', 'testuser2', 'info:password', 'abc123'
put 'users', 'testuser2', 'info:user', 'testuser2'
put 'users', 'areyousure', 'info:email', 'areyousure@alopecia.com'
put 'users', 'areyousure', 'info:name', 'Any Hais'
put 'users', 'areyousure', 'info:password', 'abc123'
put 'users', 'areyousure', 'info:user', 'areyousure'
put 'users', 'howareyou', 'info:email', 'howareyou@abc.com'
put 'users', 'howareyou', 'info:name', 'Sun Yu'
put 'users', 'howareyou', 'info:password', 'abc123'
put 'users', 'howareyou', 'info:user', 'howareyou'
开发IDE是 eclipse, 操作系统是MAC OS, JAVA 版本是 1.7 HBase 服务是安装在虚拟机上的 CentOS 6.4, JAVA 版本是 1.7, 软件环境是 HBase 0.94, IP 是 192.168.192.110
编辑文件:
vi /etc/sysconfig/network
NETWORKING=yes
HOSTNAME=vitual_box
保存文件后,执行命令:
hostname vitual_box
192.168.192.110 localhost vitual_box
vitual_box 是虚拟机的 hostname
理更改文件 hbase-site.xml 如:
vi /usr/local/hbase-0.94.9/conf/hbase-site.xml
<configuration>
<property>
<name>hbase.zookeeper.quorum</name>
<value>192.168.192.110</value>
</property>
<property>
<name>hbase.rootdir</name>
<value>/data/hbase-0.94/</value>
</property>
</configuration>
这一步应该不设置也行。
192.168.192.110 vitual_box localhost
vitral_box 是虚拟机的 hostname 将本地 localhost 也配置成虚拟机的IP
下载相关依赖包 包有: apache-commons-lang.jar apache-logging-log4j.jar com.google.protobuf-2.4.0.jar commons-configuration-1.7.jar google-collect-1.0.jar hadoop-core-1.0.3.jar hbase-0.94.9.jar joda-time-2.0.jar org-apache-commons-logging.jar zookeeper.jar
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.hbase.HBaseConfiguration;
import org.apache.hadoop.hbase.HColumnDescriptor;
import org.apache.hadoop.hbase.HTableDescriptor;
import org.apache.hadoop.hbase.client.Delete;
import org.apache.hadoop.hbase.client.HBaseAdmin;
import org.apache.hadoop.hbase.client.HTableInterface;
import org.apache.hadoop.hbase.client.HTablePool;
import org.apache.hadoop.hbase.client.Put;
import org.apache.hadoop.hbase.client.Result;
import org.apache.hadoop.hbase.client.ResultScanner;
import org.apache.hadoop.hbase.client.Scan;
import org.apache.hadoop.hbase.util.Bytes;
public class testHbase {
public static void main(String[] args) throws Exception {
Configuration conf = HBaseConfiguration.create();
conf.set("hbase.zookeeper.quorum", "192.168.192.110");
HBaseAdmin admin = new HBaseAdmin(conf);
// 如果表 user 存在,遍历表,取出各行的值
// 如果表 user 不存在,新建表
if (admin.tableExists("users")) {
System.out.println("User table already exists.lists table users");
byte[] INFO_FAM = Bytes.toBytes("info");
byte[] USER_COL = Bytes.toBytes("user");
byte[] NAME_COL = Bytes.toBytes("name");
byte[] EMAIL_COL = Bytes.toBytes("email");
byte[] PASS_COL = Bytes.toBytes("password");
// 连接池
HTablePool pool = new HTablePool();
HTableInterface users = pool.getTable("users");
// 添加数据
Put p = new Put(Bytes.toBytes("javauser"));
p.add(INFO_FAM, USER_COL, Bytes.toBytes("javauser"));
p.add(INFO_FAM, NAME_COL, Bytes.toBytes("javauser"));
p.add(INFO_FAM, EMAIL_COL, Bytes.toBytes("javauser@abc.com"));
p.add(INFO_FAM, PASS_COL, Bytes.toBytes("abc123"));
users.put(p);
Scan s = new Scan();
s.addFamily(INFO_FAM);
// 遍历表 users
ResultScanner results = users.getScanner(s);
for (Result r : results) {
// 获取列族 info 中的各个限定符的值
String name = Bytes.toString(r.getValue(INFO_FAM, NAME_COL));
String email = Bytes.toString(r.getValue(INFO_FAM, EMAIL_COL));
String user = Bytes.toString(r.getValue(INFO_FAM, USER_COL));
String password = Bytes
.toString(r.getValue(INFO_FAM, PASS_COL));
System.out.println(name + "==" + email + "==" + user + "=="
+ password + "\r\n");
}
// 删除数据
Delete d = new Delete(Bytes.toBytes("javauser"));
users.delete(d);
users.close();
} else {
// 创建表 users
System.out.println("Creating User table...");
HTableDescriptor desc = new HTableDescriptor("users");
// 创建列族 info
HColumnDescriptor c = new HColumnDescriptor("info");
desc.addFamily(c);
admin.createTable(desc);
System.out.println("User table created.");
}
}
}
运行后输出:
Any Hais==areyousure@alopecia.com==abc123
Feny Ok==finetkx@alopecia.com==abc123
Sun Yu==howareyou@abc.com==abc123
javauser==javauser@abc.com==javauser==abc123
Tony anthony==testuser2@alopecia.com==abc123
执行完后,再 scan 一次,发现 javauser 用户已经没有了,因为代码中已经做了删除处理。
或者通过 maven 创建项目。pom.xml 里的值如下:
<?xml version="1.0" encoding="UTF-8"?>
<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd"
xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<modelVersion>4.0.0</modelVersion>
<groupId>sunyu.bigdata</groupId>
<artifactId>hbase</artifactId>
<version>1.0.0</version>
<name>TwitBase</name>
<url>http://www.manning.com/dimidukkhurana/</url>
<description>TwitBase is a running example used throughout HBase In Action</description>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>2.4</version>
<configuration>
<source>1.3</source>
<target>1.3</target>
</configuration>
</plugin>
<plugin>
<artifactId>maven-resources-plugin</artifactId>
<version>2.4.3</version>
<configuration>
<encoding>utf-8</encoding>
</configuration>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.12.4</version>
<configuration>
<encoding>utf-8</encoding>
</configuration>
</plugin>
<plugin>
<artifactId>maven-jar-plugin</artifactId>
<version>2.4</version>
<configuration>
<encoding>utf-8</encoding>
</configuration>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>apache release</id>
<url>https://repository.apache.org/content/repositories/releases/</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>org.apache.hadoop</groupId>
<artifactId>hadoop-core</artifactId>
<version>1.0.3</version>
</dependency>
<dependency>
<groupId>org.apache.hbase</groupId>
<artifactId>hbase</artifactId>
<version>0.94.9</version>
<exclusions>
<exclusion>
<artifactId>maven-release-plugin</artifactId>
<groupId>org.apache.maven.plugins</groupId>
</exclusion>
</exclusions>
</dependency>
<!-- Hadoop requires commons-io but doesn't list it as an explicit
or transient dependency. include it manually. -->
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.1</version>
</dependency>
</dependencies>
</project>
HBase 是无模式的,我们在使用前不用把每个列限定符都定义出来。只用定义列族就行了。也不用指定数据类型。
修改数据和存储新数据一样,用 put 方式即可。
HBase接到写数据命令(新增或编辑)时,将变化信息存储。写入失败时则抛出异常。
默认会写到两个地方:预写式日志(HLog)和 MemStore。以保证数据的持久化。只有当两个地方都写入了,写入动作才算完成。
MemStore 是内存里的写入缓冲区,Hbase中数据在永久写入磁盘之前是在这里的。当 MemStore 写满后,数据会写到磁盘,并形成一个新的 HFile。HFile 和列族有对应关系,一个列族可以有多个 HFile 文件,但一个 HFile 文件不能跨列族存储。
在集群的每个节点上,每个列族都有一个 MemStore。
在分布式结构中,如果还没写入 HFile服务器就崩溃了,那数据就丢失了。为了应对这种情况,HBase 会在写动作完成前先写入一个日志文件。集群中每台服务器都会有一个日志来记录所有的数据变化。如果写入日志都失败了,那么这次写操作会被认为失败。
服务器可以通过回放日志里的操作来进行数据恢复。
一个服务器只有一个日志,所有的列族共用该日志文件。
可以禁用写入日志,但这会面临数据丢失的风险。禁用方式是:
Put p = new Put(Bytes.toBytes("javauser"));
p.setWriteToWAL(false);
从读取速度上来看,理想状态是所有数据都在内存中。
HBase 的读取必须连接HFile 和 MemStore 。它会通过 LRU(最近最少使用算法),把最常读取的数据保存到内存中(Block Cache)。每个列族都有自己的Block Cache。
BlockCache 中的 Block 是HBase从硬盘完成一次读取的数据单位。HFile 里存放的是 Block 的序列及索引。所以,从 HBase 里读取数据,首先要从 HFile 里读取索引并查到 Block ,再从硬盘中读取该 Block的内容。
Block 默认的大小是 64K,可以自己定义。Block 太小,会使索引增大,于是会增大内存消耗。如果Block太大,每次在Block里查找需要的数据时就会消耗更多的时间。
从HBase里读取一行,过程是:
注意:HFile里存放着某个时刻MemStore的快照。一个完整的行可能包含大多个 HFile 里。
delete 命令不是马上删除数据,它只是给数据打上一个标记。被标记的数据不会在 get 和 scan 命令中返回。
这是因为 HFile 文件是不能改变的,MemStore 每次会生成新的 HFile,但旧的是不会被改的。只有当执行文件合并的时候这些被标记的数据才会被处理掉。
文件合并分为两种:大合并 和 小合并。两者都会重整存储在 HFile 里的数据。
小合并把多个小 HFile 合并生成一个大文件。
大合并会同时处理一个列族的专有 HFile 文件。
可以在 shell 中手动触发整个表或者特定范围的大合并。这个动作会非常耗时,不要经常使用。小合并是轻量级的,可以经常使用。
大合并是清理被删记录的唯一机会。因为一个列族可以被记在多个HFile里,但我们不能保证小合并处理的HFile 里就包括了一个信息的所有列。
HBase 会为每个值保存多个时间版本(默认是 3 个,可以设置),也就是说,我们在更新一个值时,旧的值还是会被保留。
设置方式是:
HColumnDescriptor c = new HColumnDescriptor("列族名字");
c.setMaxVersions(1);
我们在GET值时,可以提供时间版本来获得对应版本的值。如果不提供,则默认使用当时时间的值。
如果一个单元的版本超过最大数量,多出的记录会在下一次大合并的时候被丢掉。在实际应用中,可以将该值设置得足够大,以满足业务场景的需要。比如设置成几千万,几十亿。这样,就可以实现RDBS里的一对多的结构。比如用户的操作记录等。
我们也可以通过 deleteColumns() (带 s )删除小于指定时间版本的所有值。若不指定时间,则默认使用当前的时间。deleteColumn() 只删除指定的一个版本的值。
综上所述,HBase 里的数据相当于是一个四维的数组:表名[row_key][列族][列限定符][时间版本]
HBase 没有类似 query 的查询命令。要进行范围查找,只能通过 scan 命令进行扫描,然后再使用过滤器(filter)缩小结果集。
扫描缓存
在HBase的设置里,可以设置每次扫描返回多少条数据。设置方式是在 hbase-site.xml 里设置:
<property>
<name>HBase.client.scanner.caching</name>
<value>10</value>
</property>
也可以在JAVA代码中的扫描对象上进行 setCaching(int) 进行设置。
如果缓存设置为N,每次RPC调用扫描就会返回N条数据。默认该值是 1。可以调大一点。如果设置的值过大,会导致扫描超时。
扫描过滤器
扫描时可以使用一些常用的过滤器,也可以自己实现一个。
HBase 不是一个ACID兼容的数据库。但它也有自己的一些特性:
Get
Put
Delete
Scan
Increment
建表之前我们通常要考虑如下内容:
前面建立了用户表 user 及基本信息的列族 info, 并添加了几个列限定符:user, email, name, password。用来存放用户的基本信息。 现在要添加新的功能:存放用户之间的关联信息。即类似微博里的关注功能。
常见的需求有:
分析: 一个列族,在物理上会放在一起存储,它可能是多个HFile文件。理想状态下是可以合并成一个大文件的。在这种性质下,我们应该将不同访问模式的数据放在不同的列族中。
新建一个表 follow,每个用户存为一行,行键是用户的ID。每个他关注的人存为一列,所有的列在一个列族里:
行键 userid | 列族:follows | ||||
9527 | 1: 张三 | 2: 李四 | 3: 王五 | 4:赵六 | 5:孙七 |
问题一:用户A关注了谁? 只要知道A的ID,以它作为行键,去获得该行的数据,然后遍历所有的列就得到了他关注的所有人。
问题二:用户A关注了B吗? 将上一步得到的用户列表遍历一次,就可以知道是否关注了B。
问题三:谁关注了用户A? 该功能需要遍历所有的行,然后从各行的列族中去查找是否有A。显然不可行。效率极低。
问题四:用户B关注了用户A吗? 该功能和问题二一样,只用遍历B的各列即可。
所以,当前这种方案虽然能解决读取数据的基本需求,但性能上有严重缺陷。
下面再看当数据发生变更时的情况,就是写数据的需求:
当新关注一个人时,需要往列族 follows 里添加一列。目前,列限定符是用的自增数,但如果不遍历该用户所有的列,不知道当前ID自增到多少了。所以为了方便,我们需要在行上额外添加一个用来保存当前自增数的列限定符。如:
行键 userid | 列族:follows | |||||
9527 | 1: 张三 | 2: 李四 | 3: 王五 | 4:赵六 | 5:孙七 | count:5 |
1024 | 1: 张三 | 2: 李四 | 3: 王五 | count:3 | -- | -- |
当用户新关注一个人时,处理流程是:先从表中读取计数,添加用户,更新计数器的值。看上去象关系数据库中的事务。但由于 HBase 是不支持事务的,所以这个过程就会有问题。
如果用户打开多个窗口,同时关注不同的人。很有可能两次操作读取的计数器是同一个值,导致一个列限定符的数据会覆盖另一个的。
为了应对这种情况,显然计数器是一定不能存在的。而且,列限定符也不能用自增的值来充当。我们直接用关注用户的ID来作列限定符,这时的数据如下:
行键 userid | 列族:follows | ||||
9527 | 张三:1 | 李四:1 | 王五:1 | 赵六:1 | 孙七:1 |
1024 | 张三:1 | 李四:1 | 王五:1 | -- | -- |
可以看到,直接用关注的用户作为列限定符,而列的值则可以存其它东西,如果没有什么要存的,就直接存为 0 或 1。因为数据不能为空。而用用户名作为列限定符的另一个好处是,用户名是唯一的,不会担心数据被覆盖。
我们可以这么干,就是因为HBase 的列族是可以无限扩展,且不用事先定义。
该方案有个问题:
用户关注的列表是不平均的,有的人关注很多人,有的关注的人少。所以,各行的列长度可能会有非常大差距。
在程序上,查询A是否关注了B,只需要遍历A的列,看是否有B即可。但在性能上,查一行数据的开销如下:
在访问HBase的数据时,决定性因素是扫描HFile数据块时找到相关KeyValue对象所花费的时间。如果使用上面那种宽行,扫描过程会增加处理整行的开销。
结论就是:访问宽行比窄行开销大。同样的信息,我们可以使用高表形式。
利用高表的形式,数据示例如下:
行键 | 列族:f |
张三+李四 | 昵称一:1 |
王五+赵六 | 昵称二:1 |
把用户名昵称放在列限定符中的好处是可以节省在功能上获取用户昵称的时间。但如果用户修改了昵称,就需要改动所有关注了该用户的数据。如果用户修改信息的频率不高,是可以这么做的。
查找用户关注的列表时,需要找到以他自己用户名为前缀的数据块,然后再进行扫描。 当用户新关注一个人时,只用新加一行。取消时则删除该行。
注意: 高表带来性能的代价是放弃了原子性。在宽表中,更新用户关注列表用 put 就可以,是原子性的。但在 v3.0 里,需要添加,删除行。这些操作不是原子性的。
上面的方案里,行键是由用户的用户名组合而成。而用户名的长度是不固定的,我们可以把各个用户名MD5,这样就可以实现定长。方便计算扫描时的起始键和停止键。
而且加密后,也有助于数据更均匀的分布在 region 上。但是象关注这种数据,天生就会出现一些用户关注的多,一些少。所以就会造成分布不均匀,数据会集中到某些 region 上。而这些 region 将成为整个系统的瓶颈。数据示例如下:
行键 | 列族:f |
md5(张三)md5(李四) | 李四:昵称一 |
md5(张三)md5(王五) | 王五:昵称二 |
md5(张三)md5(赵六) | 赵六:昵称三 |
HBase里只有键可以建立索引(行键,列限定符,时间戳)。访问一个特定行的唯一办法就是通过行键。在列限定符和时间戳上建立索引可以在一行上扫描时直接跳到对应的列。
有序特性
region 基于行键为一个区间的行提供服务,并且负责区间的每一行。 HFile 在硬盘上存储有序的行。当内存中的数据往硬盘上写的时候,它会被排序。
IO性能
HBase表的有序特性是一个重要的性质。比如要查找最新的一些信息,如果我们插入的时候就是拿时间作为键,查询时会非常快。因为数据在硬盘上保存就是排好序的。 负面影响就是这就会造成这些数据的访问频率会远高于一些老数据。因为大家会更关心最新数据,而不是旧数据。而最新的数据都集中在某些 region 中。这些 region 的性能就是系统的瓶颈。
写性能优化
当在 HBase 表里写入大量数据时,我们希望数据分布更平均一些。但这样就会影响读的效率。
数据块大小
数据块大小可以在列族上进行设置。
每个HFile 默认是 64K(65536 字节)。数据块索引文件里记录了每个HFile数据块的起始键。数据块越小,索引文件越大,因为它要记录更多的数据。但是,随机查找的性能会越高,比如查单条数据,它能很快定义到某个更小的块中,然后再定位到需要的记录上。数据块越大,顺序查找的性能会越高,所以要根据对应的场景进行相应的配置。
可以在初始化表的时候设置数据块的大小:
hbase(main):001:0> create 'mytable',
{NAME => 'colframe1', BLOCKSIZE => '65536'}
有时候需要关闭缓存。如:
表里的数据只被顺序扫描访问或很少访问,而且业务上不介意 get 或 scan 的时间是否有点长,这时候可以考虑关闭列族的缓存,可以在新建或修改表时设置:
hbase(main):001:0> create 'mytable',
{NAME => 'colframe1', BLOCKCACHE => 'false'}
激进缓存
如果一些列族在业务上预计会比其它列访问会更多,可以将该列族设置更高的优先级:
hbase(main):001:0> create 'mytable',
{NAME => 'colframe1', IN_MEMORY => 'true'}
IN_MEMORY 参数默认是 false。
bloom 过滤器
在访问一个特定行时,数据块索引提供有效的帮助。索引文件里存放了各数据块的起始键。
如果要查找一个短行,情况可能如下:
假如一行占用 100字节,一个 64K的数据块可以存 64 * 1024 / 100 ~= 700 行。但索引中只包括了起始行的位置。所以,要找到的行可能在一个特定的数据块区间内,但不确定在哪一个,需要依次查找。
bloom 过滤器提供类似: is_exsist 的方法,可以对每个块上的数据做是否存在的测试。当某行被请求时,先请求过滤器,检查是否存在。该过滤器可以以行为单位进行比较,也可以以列限定符作为比较的根据。
bloom 过滤器的功能需要提供额外的内存空间来存储数据。而且,行级过滤器要比列限定符级的占用空间要少。如果内存足够,可以用该方法来提升性能。
如:
hbase(main):001:0> create 'mytable',
{NAME => 'colframe1', BLOOMFILTE => 'ROWCOL'}
BLOOMFILTE 参数默认值是 NONE。启动行级过滤器用 ROW。列限定符级用 ROWCOL。
行级过滤器检测特定行键是否存在。列限定符级过滤器检测行和列限定符的组合是否存在。
生存时间
应用中可能会有一些老数据是不会被访问的,比如新闻网站两年前的文章等。但可能后面一些特殊功能会需要查询,如:专题数据,专栏。所以,可以将某一时间点之前的数据归档存在硬盘的文件系统中。
HBase 可以在列族上设置一个TTL。早于指定TTL的数据会在下一次大合并的时候删除。如果同一单元上有多个时间版本,早于这个TTL的版本会被删除。
可以在建表的时候指定TTL,如果不设置,默认值是 2147483647(永不过期)。如:
hbase(main):001:0> create 'mytable',
{NAME => 'colframe1', TTL => '18000'}
压缩
HFile 可以被压缩并存放在HDFS上。这有助于节省硬盘IO,但读取数据时的压缩和解压会提升CPU利用率。通常建议打开表的压缩设置。压缩设置有多种:LZO, Snappy, GZIP。
LZO和Snappy是最流行的两种。两者性能上差不多。但 Snappy 拥有BSD许可。所以建议使用:
hbase(main):001:0> create 'mytable',
{NAME => 'colframe1', COMPRESSION => 'SNAPPY'}
注意: 数据只是在硬盘上压缩了,在 MemStore 里或网络传输时是没有的。
snappy安装:
下载 snappy:
http://google.github.io/snappy/
编译安装:
tar -zxf google-snappy-1.1.3-14-g32d6d7d.tar.gz
cd google-snappy-32d6d7d/
./autogen.sh
./configure
make
make install
默认的安装地址是在:/usr/local/lib
这时候查看该目录,会发现有一些库:
ll /usr/local/lib | grep snappy
-rw-r--r-- 1 root root 472574 7月 28 15:52 libsnappy.a
-rwxr-xr-x 1 root root 934 7月 28 15:52 libsnappy.la
lrwxrwxrwx 1 root root 18 7月 28 15:52 libsnappy.so -> libsnappy.so.1.3.0
lrwxrwxrwx 1 root root 18 7月 28 15:52 libsnappy.so.1 -> libsnappy.so.1.3.0
-rwxr-xr-x 1 root root 227696 7月 28 15:52 libsnappy.so.1.3.0
安装 hadoop-snappy:
https://code.google.com/archive/p/hadoop-snappy/
目前官网没有软件包提供,只能借助 svn 下载源码:
svn checkout http://hadoop-snappy.googlecode.com/svn/trunk/ hadoop-snappy
编译:
mvn package [-Dsnappy.prefix=SNAPPY_INSTALLATION_DIR]
单元时间版本
默认情况下 HBase 会为每个数据单元维护 3 个时间版本。也可以自己设定成 1。如:
hbase(main):001:0> create 'mytable',
{NAME => 'colframe1', VERSIONS => 1}
也可以在创建时指定多个属性:
hbase(main):001:0> create 'mytable',
{NAME => 'colframe1', VERSIONS => 1, TTL => '18000'}
也可以指定最少时间版本数,如:
hbase(main):001:0> create 'mytable',
{NAME => 'colframe1', VERSIONS => 5, MIN_VERSION => '1'}
前面提到了,当时间版本早于TTL,这些版本会丢弃掉。但是如果指定了 MIN_VERSION,在合并时,最少会留下该参数值数的版本。这样,就算时间版本早于TTL,数据还是会有保留。
我们通过设计合适的行键,规划列族,让访问的数据在硬盘上也在一起,这样来减少读写操作时硬盘的寻道时间。但查询的时候通常会有一些条件,不会是单纯的顺序访问,所以我们可以进一步优化数据访问,过滤器就可以用来达到这一目的。
过滤器可以把过滤规则推送到服务器。在读取数据时,就对数据进行过滤,这样就可以减少服务器和客户端之间无用数据传输造成的IO浪费。当然,完整的数据还是需要从硬盘读进RegionServer,毕竟硬盘不可能配合过滤器去做过滤。
HBase 提供一个API,可以实现自己的过滤器,也可以使用一个或多个预置的过滤器。过滤器类必须实现 Filter 接口。或继承一个实现了该接口的抽象类。目前有一个 FilterBase 抽象类,已经实现了 Filter 接口。我们可以继承它,这样就不用去写接口的各个方法了。
当读取一行数据时,过滤器内有一些方法会被执行,但执行的顺序是固定的:
boolean filterRowKey(byte[] buffer, int offset, int length)
方法返回一个布尔值,意思就是,如果该行要被过滤掉,返回 true;否则返回 false。由于每读取一行都会执行该方法,所以,可以通过行键对每一行先进行过滤。
ReturnCode filterKeyValue(KeyValue v)
方法返回一个ReturnCode,是一个在 Filter 接口中定义的枚举类型。返回值用来判断该 KeyValue 对象将要发生什么。具体要发生的事情在枚举定义中可以看到。
void filterRow(List<KeyValue> kvs)
该方法传入成功通过过滤的 KeyValue 列表。在该方法里可以对列表中的元素进行任何操作,包括修改它的值。
boolean filterRow()
同第一步一样,返回 true 表示要过滤掉该行。
boolean filterAllRemaining()
当扫描很多行,在行键、列限定符或单元值里查找东西时,一旦找到目标,我们就不用关心剩下的行(比如按行键查找某个用户的信息,我们只需要一行)。这时候,这一步的方法就很方便。
另外还有一个 reset() 方法。它会重置过滤器,是由服务器调用的。
自定义过滤器
假设有如下需求:系统中一些老用户的密码强度不够,现在要求所有的密码都至少为 8 位。但有很多是不符合的。现在要找出不符合的用户。
定义过滤器:
我们可以自定义一个过滤器,从前面的五个过滤方法来看。判断密码长度显然不能通过行键来增行,只能通过 KeyValue 值来。所以我们要实现的自定义逻辑就是第 2 步的。代码如下:
package hbase;
import org.apache.hadoop.hbase.KeyValue;
import org.apache.hadoop.hbase.filter.FilterBase;
import org.apache.hadoop.hbase.util.Bytes;
import java.io.DataInput;
import java.io.DataOutput;
import java.io.IOException;
// 继承 FilterBase
public class PasswordStrengthFilter extends FilterBase {
private int len;
private boolean filterRow = false;
public PasswordStrengthFilter() {
super();
}
// 构造函数采用要判断的密码长度作为输入参数
public PasswordStrengthFilter(int len) {
this.len = len;
}
// 检查密码长度.如果长度大于要求的值,该行会被过滤掉不返回
public ReturnCode filterKeyValue(KeyValue v) {
if (Bytes.toString(v.getQualifier()).equals("password")) {
// 长度大于值,设置过滤状态
if (v.getValueLength() >= len) {
this.filterRow = true;
}
// 在返回的结果中,不返回密码,将密码排除
return ReturnCode.SKIP;
}
return ReturnCode.INCLUDE;
}
// 告知该行是否被过滤
public boolean filterRow() {
return this.filterRow;
}
// 过滤器应用到给定行后,重置过滤器状态
public void reset() {
this.filterRow = false;
}
public void write(DataOutput out) throws IOException {
out.writeInt(len);
}
public void readFields(DataInput in) throws IOException {
this.len = in.readInt();
}
}
由于过滤器是在服务端执行的。所以,我们要把自定义的过滤器打包成 jar,并放到 HBase 的加载目录,供 HBase 启动时加载。要不然在客户端使用该过滤器时会报错。
打包 jar :
进入项目目录并执行:
cd /Users/sunyu/Documents/workspace_java/hbase
mvn install
执行完上面命令后,会发现在项目目录下的 target 文件夹下会有个文件:
hbase-1.0.0.jar
将该文件放到HBase服务器上。并修改服务端配置文件:$HBASE_HOME/conf/hbase-env.sh
,将上一步得到的 jar 文件配置进去:export HBASE_CLASSPATH=/vagrant/hbase-1.0.0.jar
然后重启HBase
编辑测试脚本,测试代码如下:
package hbase;
import java.io.IOException;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.hbase.HBaseConfiguration;
import org.apache.hadoop.hbase.client.HBaseAdmin;
import org.apache.hadoop.hbase.client.HTableInterface;
import org.apache.hadoop.hbase.client.HTablePool;
import org.apache.hadoop.hbase.client.Result;
import org.apache.hadoop.hbase.client.ResultScanner;
import org.apache.hadoop.hbase.client.Scan;
import org.apache.hadoop.hbase.filter.Filter;
import org.apache.hadoop.hbase.util.Bytes;
public class PasswordStrengthFilterExample {
public static final byte[] TABLE_NAME = Bytes.toBytes("users");
public static final byte[] INFO_FAM = Bytes.toBytes("info");
public static final byte[] USER_COL = Bytes.toBytes("user");
public static final byte[] NAME_COL = Bytes.toBytes("name");
public static final byte[] EMAIL_COL = Bytes.toBytes("email");
public static final byte[] PASS_COL = Bytes.toBytes("password");
public static void main(String[] args) {
try {
Configuration conf = HBaseConfiguration.create();
conf.set("hbase.zookeeper.quorum", "192.168.192.110");
HBaseAdmin admin = new HBaseAdmin(conf);
if (admin.tableExists("users")) {
System.out
.println("User table already exists.lists table users");
// 连接池
HTablePool pool = new HTablePool();
HTableInterface users = pool.getTable("users");
Scan scan = new Scan();
scan.addColumn(PasswordStrengthFilterExample.INFO_FAM,
PasswordStrengthFilterExample.PASS_COL);
scan.addColumn(PasswordStrengthFilterExample.INFO_FAM,
PasswordStrengthFilterExample.NAME_COL);
scan.addColumn(PasswordStrengthFilterExample.INFO_FAM,
PasswordStrengthFilterExample.EMAIL_COL);
Filter f = new PasswordStrengthFilter(8);
scan.setFilter(f);
ResultScanner results = users.getScanner(scan);
for (Result r : results) {
// 获取列族 info 中的各个限定符的值
String name = Bytes.toString(r.getValue(
PasswordStrengthFilterExample.INFO_FAM,
PasswordStrengthFilterExample.NAME_COL));
String email = Bytes.toString(r.getValue(
PasswordStrengthFilterExample.INFO_FAM,
PasswordStrengthFilterExample.EMAIL_COL));
String password = Bytes.toString(r.getValue(
PasswordStrengthFilterExample.INFO_FAM,
PasswordStrengthFilterExample.PASS_COL));
System.out.println(name + "==" + email + "==" + password);
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
执行结果是:
Naoto Srinivas== Srinivas32@alopecia.com==null
Any Hais==areyousure@alopecia.com==null
Feny Ok==finetkx@alopecia.com==null
Sun Yu==howareyou@abc.com==null
Rob Ole==le23@tarboard.com==null
Ben Norbert==orbert 5@conflagrative.com==null
Tony anthony==testuser2@alopecia.com==null
Srinivas Micheal==vas Micheal38@wolfsbergite.com==null
Rodney Tollefsen==y Tollefsen97@areality.com==null
因为在过滤器中将密码这一列排队了,所以上面的结果中,密码都是 null
HBase 自带了许多过滤器,可以直接使用。常见的过滤器有:
行过滤器 RowFilter
基于行键对数据进行过滤,可以进行精确匹配,字符串匹配、正则匹配等。 为了实例化 RowFilter,需要提供比较操作符和要比较的值。它的构造函数是:
public RowFilter(CompareOp rowCompareOp, WritableByteArrayComparable rowComparator)
比较操作符是在CompareOp里指定的。它是一个枚举类型。值有:
比较器要继承 WritableByteArrayComparable 抽象类。可以直接使用的有:
示例:
package hbase;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.hbase.HBaseConfiguration;
import org.apache.hadoop.hbase.HColumnDescriptor;
import org.apache.hadoop.hbase.HTableDescriptor;
import org.apache.hadoop.hbase.client.Delete;
import org.apache.hadoop.hbase.client.HBaseAdmin;
import org.apache.hadoop.hbase.client.HTableInterface;
import org.apache.hadoop.hbase.client.HTablePool;
import org.apache.hadoop.hbase.client.Put;
import org.apache.hadoop.hbase.client.Result;
import org.apache.hadoop.hbase.client.ResultScanner;
import org.apache.hadoop.hbase.client.Scan;
import org.apache.hadoop.hbase.filter.CompareFilter.CompareOp;
import org.apache.hadoop.hbase.filter.Filter;
import org.apache.hadoop.hbase.filter.RegexStringComparator;
import org.apache.hadoop.hbase.filter.RowFilter;
import org.apache.hadoop.hbase.util.Bytes;
public class testFilter {
public static void main(String[] args) throws Exception {
Configuration conf = HBaseConfiguration.create();
conf.set("hbase.zookeeper.quorum", "192.168.192.110");
HBaseAdmin admin = new HBaseAdmin(conf);
byte[] INFO_FAM = Bytes.toBytes("info");
byte[] USER_COL = Bytes.toBytes("user");
byte[] NAME_COL = Bytes.toBytes("name");
byte[] EMAIL_COL = Bytes.toBytes("email");
byte[] PASS_COL = Bytes.toBytes("password");
// 连接池
HTablePool pool = new HTablePool();
HTableInterface users = pool.getTable("users");
Scan s = new Scan();
s.addFamily(INFO_FAM);
Filter rowFilter = new RowFilter(CompareOp.EQUAL,
new RegexStringComparator(".*you"));
s.setFilter(rowFilter);
// 遍历表 users
ResultScanner results = users.getScanner(s);
for (Result r : results) {
// 获取列族 info 中的各个限定符的值
String name = Bytes.toString(r.getValue(INFO_FAM, NAME_COL));
String email = Bytes.toString(r.getValue(INFO_FAM, EMAIL_COL));
String user = Bytes.toString(r.getValue(INFO_FAM, USER_COL));
String password = Bytes.toString(r.getValue(INFO_FAM, PASS_COL));
System.out.println(name + "==" + email + "==" + user + "=="
+ password);
}
users.close();
}
}
执行结果:
Any Hais==areyousure@alopecia.com==areyousure==abc123
Sun Yu==howareyou@abc.com==howareyou==abc123
前缀过滤器
这是 RowFilter 的一种特例。它基于行键的前缀值进行过滤。它相当于给 scan 提供了一个结束区间。使用如下:
String prefix = "a";
Scan scan_prefix = new Scan(prefix.getBytes());
scan_prefix.setFilter(new PrefixFilter(prefix.getBytes()));
限定符过滤器
它是用来匹配列限定符而不是行键。但用法和前面的行键过滤差不多:
Filter col_filter = new QualifierFilter(CompareOp.LESS_OR_EQUAL,
new BinaryComparator(Bytes.toBytes("name")));
值过滤器
// 过滤掉单元值不是以 areyou 开始的列
Filter value_filter = new ValueFilter(CompareOp.EQUAL,
new BinaryPrefixComparator(Bytes.toBytes("areyou")));
运行结果:
null==areyousure@alopecia.com==areyousure==null
时间戳过滤器
该过滤器允许对各时间版本进行精细的控制,可以提供一个应用返回的时间戳列表,只有与列表匹配的单元才会返回。
List<Long> time_lists = new ArrayList<Long>();
time_lists.add(1466669143737L);
Filter time_filter = new TimestampsFilter(time_lists);
过滤器列表
如果需要进行多种过滤,可以将多个过滤器组合起来。多个过滤器可以以两种模式进行:MUST_PASS_ALL 或 MUST_PASS_ONE。前者表示必须满足所有的过滤器,后者表示只用满足一个即可。用法:
List<Filter> filter_list = new ArrayList<Filter>();
filter_list.add(time_filter);
filter_list.add(value_filter);
FilterList list_filter = new FilterList(FilterList.Operator.MUST_PASS_ALL, filter_list);
list_filter.addFilter(col_filter);
过滤器列表中的过滤器会按照添加入列表的顺序执行。
一个HBase 请求的生命周期大致如下:
observer 位于客户端和HBase之间。一次操作可以配置多个触发器,它们会按优先级次序执行。触发器的触发过程大致如下:
可以看出,在 prePut() 时就有可能直接返回一个结果或者中断请求。在 postPut() 也可以对处理的结果进行自定义的处理。
协处理器和 RegionServer 在相同的进程空间里。所以,协处理器的代码拥有服务器上 HBase用户进程的所有权限,意味着如果协处理器出错可能使整个 HBase 崩溃。
Observer 目前有三种:
自定义触发器
package hbase;
import java.io.IOException;
import org.apache.hadoop.hbase.CoprocessorEnvironment;
import org.apache.hadoop.hbase.KeyValue;
import org.apache.hadoop.hbase.client.HTableInterface;
import org.apache.hadoop.hbase.client.HTablePool;
import org.apache.hadoop.hbase.client.Put;
import org.apache.hadoop.hbase.coprocessor.BaseRegionObserver;
import org.apache.hadoop.hbase.coprocessor.ObserverContext;
import org.apache.hadoop.hbase.coprocessor.RegionCoprocessorEnvironment;
import org.apache.hadoop.hbase.regionserver.wal.WALEdit;
import org.apache.hadoop.hbase.util.Bytes;
public class UserObserver extends BaseRegionObserver {
private HTablePool pool = null;
@Override
public void start(CoprocessorEnvironment env) throws IOException {
pool = new HTablePool(env.getConfiguration(), Integer.MAX_VALUE);
}
@Override
public void stop(CoprocessorEnvironment env) throws IOException {
pool.close();
}
@Override
public void postPut(final ObserverContext<RegionCoprocessorEnvironment> e,
final Put put, final WALEdit edit, final boolean writeToWAL)
throws IOException {
byte[] table = e.getEnvironment().getRegion().getRegionInfo()
.getTableName();
if (!Bytes.equals(table, Bytes.toBytes("users")))
return;
KeyValue kv = put.get(Bytes.toBytes("info"), Bytes.toBytes("password"))
.get(0);
String pwd = Bytes.toString(kv.getValue());
String nowRowKey = Bytes.toString(kv.getKey());
HTableInterface t = pool.getTable(Bytes.toBytes("users"));
Put p = new Put(Bytes.toBytes(nowRowKey));
p.add(Bytes.toBytes("info"), Bytes.toBytes("password"),
Bytes.toBytes("obverserpwd" + pwd));
t.put(p);
t.close();
}
}
安装触发器
进入项目目录,并执行打包命令:
cd ~/Documents/workspace_java/hbase/
mvn package
运行完后,在项目目录下的 target 目录下会有个 jar 文件:hbase-1.0.0.jar 将文件文件传到 HBase 服务器上, 路径为:/vagrant/hbase-1.0.0.jar
登录 hbase shell,并将 users 表下线,然后再安装触发器:
[root@vitual_box vagrant]# /usr/local/hbase-0.94.9/bin/hbase shell
HBase Shell; enter 'help<RETURN>' for list of supported commands.
Type "exit<RETURN>" to leave the HBase Shell
Version 0.94.9, r1496217, Mon Jun 24 20:57:30 UTC 2013
hbase(main):001:0> disable 'users'
0 row(s) in 2.9400 seconds
hbase(main):002:0> alter 'users', METHOD => 'table_att', 'coprocessor' => 'file:///vagrant/hbase-1.0.0.jar|hbase.UserObserver|1001|'
Updating all regions with the new schema...
1/1 regions updated.
Done.
0 row(s) in 1.1090 seconds
hbase(main):003:0> enable 'users'
0 row(s) in 1.1050 seconds
hbase(main):004:0> describe 'users'
DESCRIPTION ENABLED
'users', {METHOD => 'table_att', coprocessor$1 => 'file:///vagrant/hbase-1.0.0.jar|hbase.UserObserver| true
1001|'}, {NAME => 'info', DATA_BLOCK_ENCODING => 'NONE', BLOOMFILTER => 'NONE', REPLICATION_SCOPE => '
0', VERSIONS => '3', COMPRESSION => 'NONE', MIN_VERSIONS => '0', TTL => '2147483647', KEEP_DELETED_CEL
LS => 'false', BLOCKSIZE => '65536', IN_MEMORY => 'false', ENCODE_ON_DISK => 'true', BLOCKCACHE => 'tr
ue'}
1 row(s) in 0.0370 seconds
上面操作中,关闭该表会让所有 region 下线。alter 命令更新表模式,让它知道新的协处理器。这种在线安装的办法只适用于 observer 协处理器。处理器属性参数用 | 字符分隔。 第一个参数是 jar 的路径,第二个参数是协处理器的类,第三个参数是处理器的优先级。当有多个协处理器时,会按该优先级顺序执行。
最后查看表的描述,可以看到协处理器已经应用上去了。
当往 users 表里插入新数据时,UserObserver 会起作用,会把用户的密码进行重新设置。测试:
put 'users', 'testobserver', 'info:password', '123456'
// TODO
HBase 是构建在 hadoop 上的。目的是两个方面:
1.hadoop 的 map reduce 提供分布式计算框架,支持高吞吐量的数据访问 2.HDFS 提供可用性,可靠性
一个简单的示例可以参照: http://blog.sina.com.cn/s/blog_5f54f0be0101f6sj.html
为了提供一个普遍适用、可靠、容错的分布式计算框架,MapReduce对于如何实现应用程序有一定限制:
时间计算案例
假设有一个服务器日志,用来记录用户在各个应用中花的时间,内容如下:
Data | Time | UserId | Activity | TimeSpent |
2016-07-21 | 17:00 | user1 | load_page1 | 3s |
2016-07-21 | 17:01 | user1 | load_page2 | 5s |
2016-07-21 | 17:01 | user2 | load_page1 | 1s |
2016-07-21 | 17:01 | user3 | load_page1 | 2s |
2016-07-21 | 17:04 | user4 | load_page3 | 10s |
2016-07-21 | 17:05 | user1 | load_page3 | 3s |
2016-07-21 | 17:05 | user3 | load_page5 | 3s |
2016-07-21 | 17:06 | user4 | load_page4 | 3s |
2016-07-21 | 17:06 | user1 | purchase | 3s |
2016-07-21 | 17:10 | user4 | purchase | 3s |
2016-07-21 | 17:10 | user1 | confirm | 3s |
2016-07-21 | 17:11 | user4 | confirm | 3s |
2016-07-21 | 17:11 | user1 | load_page3 | 3s |
如果我们要计算每个用户使用该应用所花的总的时间。一种最简单的办法就是遍历整个文件,为每个用户加总的 TimeSpent 值。将 UserID 作为键。伪代码可能如:
agg = {}
for line in file
record = split(line)
agg[record['UserId']] += record['TimeSpent']
end
print agg
上面的方法虽然可行,但受限于单个机器的性能。如果日志文件太大(TB甚至更大的PB),上面的处理的耗时将是以“天”来计。 这里,我们能想到的办法是:将日志切分成N份,分配给 N 台不同的机器处理,处理完后再把所有结果整合。
JAVA代码如下:
package hbase;
import java.io.IOException;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.Mapper;
import org.apache.hadoop.mapreduce.Reducer;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.input.TextInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
import org.apache.hadoop.mapreduce.lib.output.TextOutputFormat;
public class TimeSpent {
public static class Map extends
Mapper<LongWritable, Text, Text, LongWritable> {
private static final String splitRE = "\\W+";
private Text user = new Text();
private LongWritable time = new LongWritable();
public void map(LongWritable key, Text value, Context context)
throws IOException, InterruptedException {
String line = value.toString();
String[] splits = line.split(splitRE);
if (null == splits || splits.length < 8)
return;
user.set(splits[5]);
time.set(new Long(splits[7].substring(0, splits[7].length() - 1)));
context.write(user, time);
}
}
public static class Reduce extends
Reducer<Text, LongWritable, Text, LongWritable> {
public void reduce(Text key, Iterable<LongWritable> values,
Context context) throws IOException, InterruptedException {
long sum = 0;
for (LongWritable time : values) {
sum += time.get();
}
context.write(key, new LongWritable(sum));
}
}
public static void main(String[] args) throws Exception {
Path inputPath = new Path(
"/Users/sunyu/Downloads/listing_user_time.txt");
Path outputPath = new Path("./out/");
Configuration conf = new Configuration();
Job job = new Job(conf, "TimeSpent");
job.setOutputKeyClass(Text.class);
job.setOutputValueClass(LongWritable.class);
job.setMapperClass(Map.class);
job.setCombinerClass(Reduce.class);
job.setReducerClass(Reduce.class);
job.setInputFormatClass(TextInputFormat.class);
job.setOutputFormatClass(TextOutputFormat.class);
FileInputFormat.addInputPath(job, inputPath);
FileOutputFormat.setOutputPath(job, outputPath);
FileSystem fs = outputPath.getFileSystem(conf);
if (fs.exists(outputPath)) {
System.out.println("Deleting output path before proceeding.");
fs.delete(outputPath, true);
}
System.exit(job.waitForCompletion(true) ? 0 : 1);
}
}
运行结果如下:
16/07/27 22:54:37 WARN util.NativeCodeLoader: Unable to load native-hadoop library for your platform... using builtin-java classes where applicable
16/07/27 22:54:37 WARN mapred.JobClient: Use GenericOptionsParser for parsing the arguments. Applications should implement Tool for the same.
16/07/27 22:54:37 WARN mapred.JobClient: No job jar file set. User classes may not be found. See JobConf(Class) or JobConf#setJar(String).
16/07/27 22:54:37 INFO input.FileInputFormat: Total input paths to process : 1
16/07/27 22:54:37 WARN snappy.LoadSnappy: Snappy native library not loaded
16/07/27 22:54:37 INFO mapred.JobClient: Running job: job_local_0001
16/07/27 22:54:37 INFO mapred.Task: Using ResourceCalculatorPlugin : null
16/07/27 22:54:37 INFO mapred.MapTask: io.sort.mb = 100
16/07/27 22:54:37 INFO mapred.MapTask: data buffer = 79691776/99614720
16/07/27 22:54:37 INFO mapred.MapTask: record buffer = 262144/327680
16/07/27 22:54:38 INFO mapred.MapTask: Starting flush of map output
16/07/27 22:54:38 INFO mapred.MapTask: Finished spill 0
16/07/27 22:54:38 INFO mapred.Task: Task:attempt_local_0001_m_000000_0 is done. And is in the process of commiting
16/07/27 22:54:38 INFO mapred.JobClient: map 0% reduce 0%
16/07/27 22:54:40 INFO mapred.LocalJobRunner:
16/07/27 22:54:40 INFO mapred.Task: Task 'attempt_local_0001_m_000000_0' done.
16/07/27 22:54:40 INFO mapred.Task: Using ResourceCalculatorPlugin : null
16/07/27 22:54:40 INFO mapred.LocalJobRunner:
16/07/27 22:54:40 INFO mapred.Merger: Merging 1 sorted segments
16/07/27 22:54:40 INFO mapred.Merger: Down to the last merge-pass, with 1 segments left of total size: 66 bytes
16/07/27 22:54:40 INFO mapred.LocalJobRunner:
16/07/27 22:54:40 INFO mapred.Task: Task:attempt_local_0001_r_000000_0 is done. And is in the process of commiting
16/07/27 22:54:40 INFO mapred.LocalJobRunner:
16/07/27 22:54:40 INFO mapred.Task: Task attempt_local_0001_r_000000_0 is allowed to commit now
16/07/27 22:54:40 INFO output.FileOutputCommitter: Saved output of task 'attempt_local_0001_r_000000_0' to out
16/07/27 22:54:41 INFO mapred.JobClient: map 100% reduce 0%
16/07/27 22:54:43 INFO mapred.LocalJobRunner: reduce > reduce
16/07/27 22:54:43 INFO mapred.Task: Task 'attempt_local_0001_r_000000_0' done.
16/07/27 22:54:44 INFO mapred.JobClient: map 100% reduce 100%
16/07/27 22:54:44 INFO mapred.JobClient: Job complete: job_local_0001
16/07/27 22:54:44 INFO mapred.JobClient: Counters: 17
16/07/27 22:54:44 INFO mapred.JobClient: File Output Format Counters
16/07/27 22:54:44 INFO mapred.JobClient: Bytes Written=46
16/07/27 22:54:44 INFO mapred.JobClient: FileSystemCounters
16/07/27 22:54:44 INFO mapred.JobClient: FILE_BYTES_READ=1480
16/07/27 22:54:44 INFO mapred.JobClient: FILE_BYTES_WRITTEN=64884
16/07/27 22:54:44 INFO mapred.JobClient: File Input Format Counters
16/07/27 22:54:44 INFO mapred.JobClient: Bytes Read=535
16/07/27 22:54:44 INFO mapred.JobClient: Map-Reduce Framework
16/07/27 22:54:44 INFO mapred.JobClient: Reduce input groups=4
16/07/27 22:54:44 INFO mapred.JobClient: Map output materialized bytes=70
16/07/27 22:54:44 INFO mapred.JobClient: Combine output records=4
16/07/27 22:54:44 INFO mapred.JobClient: Map input records=13
16/07/27 22:54:44 INFO mapred.JobClient: Reduce shuffle bytes=0
16/07/27 22:54:44 INFO mapred.JobClient: Reduce output records=4
16/07/27 22:54:44 INFO mapred.JobClient: Spilled Records=8
16/07/27 22:54:44 INFO mapred.JobClient: Map output bytes=182
16/07/27 22:54:44 INFO mapred.JobClient: Total committed heap usage (bytes)=514850816
16/07/27 22:54:44 INFO mapred.JobClient: Combine input records=13
16/07/27 22:54:44 INFO mapred.JobClient: Map output records=13
16/07/27 22:54:44 INFO mapred.JobClient: SPLIT_RAW_BYTES=114
16/07/27 22:54:44 INFO mapred.JobClient: Reduce input records=4
运行完后,会在项目根目录下创建一个 out 文件夹,里面会有两个文件:
_SUCCESS
part-r-00000
part-r-00000 里的内容就是结果:
cat part-r-00000
user1 30
user2 2
user3 6
user4 35
Hbase是一种搭建在 Hadoop 上面的数据库。所以它才叫 HBase。理论上,HBase 可以运行在任务文件系统上。
HBase 中的表由行和列族 ,列组成。表中的数据可能达到数十亿行,数百万列。每个表的大小可能达到TB,甚至PB。显然不能在一台机器上存放整个表。表会切分成小一点的数据单位,然后分配到多台服务器上。这些小一点的数据单位就叫 region。管理 region 的服务器就叫 RegionServer。
每个 RegionServer 可以管理多个 region。 在 HBase 配置文件 hbase-site.xml 文件里,可以设置单个 region 的大小(HBase.hregion.max.filesize)。当一个 region 的大小超过该值的时候,它会被切成两个。
表被切成多个 region 时,它会被分配给 RegionServer。分配是没有什么预先设定好的规则的。 当一个 RegionServer 故障或者有新的 RegionServer 加入集群的时候,region 会被重新分配。
对客户端程序来说,如何知道数据在哪个 RegionServer 呢?HBase 有两个特殊的表:-ROOT 和 .META。
-ROOT 不管多大,都不会被切割,它会指向知道答案的 .META region 在哪里。 .META 可能被切割,它记录了 region 在哪个 RegionServer 里。
数据访问顺序是:先找 -ROOT 表,查出 .META 的 region 位置(.META可能会有多个 region),然后再从那个 .META 的 region 里查出要找的 region 在哪个 RegionServer。
通过 -ROOT 和 .META可以知道要访问的数据在哪个 RegionServer。那么,-ROOT 在哪个 RegionServer 呢?
HBase 是通过 zookeeper 来维护配置信息的。
当客户端和 HBase 系统交互时,步骤大致如下:
前面的示例中,是将本地文件作为数据源。我们也可以将HDFS中的文件作为输入,也可以直接使用HBase表中的数据。
当以 HBase 作为数据输入源时,每个 HBase 表的 region 都会启动一个 map 任务。一个任务读取一个 region。
package hbase;
import java.util.Random;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.hbase.HBaseConfiguration;
import org.apache.hadoop.hbase.client.Result;
import org.apache.hadoop.hbase.client.Scan;
import org.apache.hadoop.hbase.io.ImmutableBytesWritable;
import org.apache.hadoop.hbase.mapreduce.TableMapReduceUtil;
import org.apache.hadoop.hbase.mapreduce.TableMapper;
import org.apache.hadoop.hbase.util.Bytes;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.lib.output.NullOutputFormat;
public class CountShakespeare {
public static class Map extends TableMapper<Text, LongWritable> {
public static enum Counters {
ROWS, SHAKESPEAREAN
};
private Random rand;
/**
* Determines if the message pertains to Shakespeare.
*/
private boolean containsShakespear(String msg) {
return rand.nextBoolean();
}
@Override
protected void setup(Context context) {
rand = new Random(System.currentTimeMillis());
}
@Override
protected void map(ImmutableBytesWritable rowkey, Result result,
Context context) {
byte[] b = result.getColumnLatest(TwitsDAO.TWITS_FAM,
TwitsDAO.TWIT_COL).getValue();
if (b == null)
return;
String msg = Bytes.toString(b);
if (msg.isEmpty())
return;
context.getCounter(Counters.ROWS).increment(1);
if (containsShakespear(msg))
context.getCounter(Counters.SHAKESPEAREAN).increment(1);
}
}
public static void main(String[] args) throws Exception {
Configuration conf = HBaseConfiguration.create();
conf.set("hbase.zookeeper.quorum", "192.168.192.110");
Job job = new Job(conf, "TwitBase Shakespeare counter");
job.setJarByClass(CountShakespeare.class);
Scan scan = new Scan();
scan.addColumn(TwitsDAO.TWITS_FAM, TwitsDAO.TWIT_COL);
TableMapReduceUtil.initTableMapperJob(
Bytes.toString(TwitsDAO.TABLE_NAME), scan, Map.class,
ImmutableBytesWritable.class, Result.class, job);
job.setOutputFormatClass(NullOutputFormat.class);
job.setNumReduceTasks(0);
System.exit(job.waitForCompletion(true) ? 0 : 1);
}
}
运行结果:
00:26:08 WARN util.NativeCodeLoader: Unable to load native-hadoop library for your platform... using builtin-java classes where applicable
00:26:08 WARN mapred.JobClient: Use GenericOptionsParser for parsing the arguments. Applications should implement Tool for the same.
00:26:08 WARN mapred.JobClient: No job jar file set. User classes may not be found. See JobConf(Class) or JobConf#setJar(String).
00:26:11 INFO mapred.JobClient: Running job: job_local_0001
00:26:11 INFO mapred.Task: Using ResourceCalculatorPlugin : null
00:26:12 INFO mapred.JobClient: map 0% reduce 0%
00:26:14 INFO mapred.Task: Task:attempt_local_0001_m_000000_0 is done. And is in the process of commiting
00:26:17 INFO mapred.LocalJobRunner:
00:26:17 INFO mapred.LocalJobRunner:
00:26:17 INFO mapred.Task: Task 'attempt_local_0001_m_000000_0' done.
00:26:17 WARN mapred.FileOutputCommitter: Output path is null in cleanup
00:26:18 INFO mapred.JobClient: map 100% reduce 0%
00:26:18 INFO mapred.JobClient: Job complete: job_local_0001
00:26:18 INFO mapred.JobClient: Counters: 21
00:26:18 INFO mapred.JobClient: HBase Counters
00:26:18 INFO mapred.JobClient: REMOTE_RPC_CALLS=10544
00:26:18 INFO mapred.JobClient: RPC_CALLS=10544
00:26:18 INFO mapred.JobClient: RPC_RETRIES=0
00:26:18 INFO mapred.JobClient: NOT_SERVING_REGION_EXCEPTION=0
00:26:18 INFO mapred.JobClient: MILLIS_BETWEEN_NEXTS=3866
00:26:18 INFO mapred.JobClient: NUM_SCANNER_RESTARTS=0
00:26:18 INFO mapred.JobClient: BYTES_IN_RESULTS=1938143
00:26:18 INFO mapred.JobClient: BYTES_IN_REMOTE_RESULTS=1938143
00:26:18 INFO mapred.JobClient: REMOTE_RPC_RETRIES=0
00:26:18 INFO mapred.JobClient: REGIONS_SCANNED=1
00:26:18 INFO mapred.JobClient: File Output Format Counters
00:26:18 INFO mapred.JobClient: Bytes Written=0
00:26:18 INFO mapred.JobClient: hbase.CountShakespeare$Map$Counters
00:26:18 INFO mapred.JobClient: ROWS=10541
00:26:18 INFO mapred.JobClient: SHAKESPEAREAN=5223
package hbase;
import java.util.Iterator;
import java.util.Random;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.hbase.HBaseConfiguration;
import org.apache.hadoop.hbase.client.Put;
import org.apache.hadoop.hbase.client.Result;
import org.apache.hadoop.hbase.client.Scan;
import org.apache.hadoop.hbase.io.ImmutableBytesWritable;
import org.apache.hadoop.hbase.mapreduce.IdentityTableReducer;
import org.apache.hadoop.hbase.mapreduce.TableMapReduceUtil;
import org.apache.hadoop.hbase.mapreduce.TableMapper;
import org.apache.hadoop.hbase.mapreduce.TableReducer;
import org.apache.hadoop.hbase.util.Bytes;
import org.apache.hadoop.mapreduce.Job;
import hbase.TwitsDAO;
import hbase.UsersDAO;
public class HamletTagger {
public static class Map extends TableMapper<ImmutableBytesWritable, Put> {
public static enum Counters {
HAMLET_TAGS
};
private Random rand;
private boolean mentionsHamlet(String msg) {
return rand.nextBoolean();
}
protected void setup(Context context) {
rand = new Random(System.currentTimeMillis());
}
protected void map(ImmutableBytesWritable rowkey, Result result,
Context context) {
byte[] b = result.getColumnLatest(TwitsDAO.TWITS_FAM,
TwitsDAO.TWIT_COL).getValue();
String msg = Bytes.toString(b);
b = result.getColumnLatest(TwitsDAO.TWITS_FAM, TwitsDAO.USER_COL)
.getValue();
String user = Bytes.toString(b);
if (mentionsHamlet(msg)) {
Put p = UsersDAO.mkPut(user, UsersDAO.INFO_FAM,
UsersDAO.HAMLET_COL, Bytes.toBytes(true));
ImmutableBytesWritable outkey = new ImmutableBytesWritable(
p.getRow());
try {
context.write(outkey, p);
context.getCounter(Counters.HAMLET_TAGS).increment(1);
} catch (Exception e) {
// gulp!
}
}
}
}
public static class Reduce extends
TableReducer<ImmutableBytesWritable, Put, ImmutableBytesWritable> {
@Override
protected void reduce(ImmutableBytesWritable rowkey,
Iterable<Put> values, Context context) {
Iterator<Put> i = values.iterator();
if (i.hasNext()) {
try {
context.write(rowkey, i.next());
} catch (Exception e) {
// gulp!
}
}
}
}
public static void main(String[] args) throws Exception {
Configuration conf = HBaseConfiguration.create();
conf.set("hbase.zookeeper.quorum", "192.168.192.110");
Job job = new Job(conf, "TwitBase Hamlet tagger");
job.setJarByClass(HamletTagger.class);
Scan scan = new Scan();
scan.addColumn(TwitsDAO.TWITS_FAM, TwitsDAO.USER_COL);
scan.addColumn(TwitsDAO.TWITS_FAM, TwitsDAO.TWIT_COL);
TableMapReduceUtil.initTableMapperJob(
Bytes.toString(TwitsDAO.TABLE_NAME), scan, Map.class,
ImmutableBytesWritable.class, Put.class, job);
TableMapReduceUtil.initTableReducerJob(
Bytes.toString(UsersDAO.TABLE_NAME),
IdentityTableReducer.class, job);
job.setNumReduceTasks(0);
System.exit(job.waitForCompletion(true) ? 0 : 1);
}
}
运行结果:
00:32:19 WARN util.NativeCodeLoader: Unable to load native-hadoop library for your platform... using builtin-java classes where applicable
00:32:19 WARN mapred.JobClient: Use GenericOptionsParser for parsing the arguments. Applications should implement Tool for the same.
00:32:20 WARN mapred.JobClient: No job jar file set. User classes may not be found. See JobConf(Class) or JobConf#setJar(String).
00:32:22 INFO mapred.JobClient: Running job: job_local_0001
00:32:22 INFO mapred.Task: Using ResourceCalculatorPlugin : null
00:32:23 INFO mapred.JobClient: map 0% reduce 0%
00:32:26 INFO mapred.Task: Task:attempt_local_0001_m_000000_0 is done. And is in the process of commiting
00:32:28 INFO mapred.LocalJobRunner:
00:32:28 INFO mapred.LocalJobRunner:
00:32:28 INFO mapred.Task: Task 'attempt_local_0001_m_000000_0' done.
00:32:28 WARN mapred.FileOutputCommitter: Output path is null in cleanup
00:32:29 INFO mapred.JobClient: map 100% reduce 0%
00:32:29 INFO mapred.JobClient: Job complete: job_local_0001
00:32:29 INFO mapred.JobClient: Counters: 20
00:32:29 INFO mapred.JobClient: HBase Counters
00:32:29 INFO mapred.JobClient: REMOTE_RPC_CALLS=10544
00:32:29 INFO mapred.JobClient: RPC_CALLS=10544
00:32:29 INFO mapred.JobClient: RPC_RETRIES=0
00:32:29 INFO mapred.JobClient: NOT_SERVING_REGION_EXCEPTION=0
00:32:29 INFO mapred.JobClient: MILLIS_BETWEEN_NEXTS=3861
00:32:29 INFO mapred.JobClient: NUM_SCANNER_RESTARTS=0
00:32:29 INFO mapred.JobClient: BYTES_IN_RESULTS=2634733
00:32:29 INFO mapred.JobClient: BYTES_IN_REMOTE_RESULTS=2634733
00:32:29 INFO mapred.JobClient: REMOTE_RPC_RETRIES=0
00:32:29 INFO mapred.JobClient: REGIONS_SCANNED=1
00:32:29 INFO mapred.JobClient: File Output Format Counters
00:32:29 INFO mapred.JobClient: Bytes Written=0
00:32:29 INFO mapred.JobClient: File Input Format Counters
00:32:29 INFO mapred.JobClient: Bytes Read=0
00:32:29 INFO mapred.JobClient: hbase.HamletTagger$Map$Counters
00:32:29 INFO mapred.JobClient: HAMLET_TAGS=5279
00:32:29 INFO mapred.JobClient: FileSystemCounters
00:32:29 INFO mapred.JobClient: FILE_BYTES_READ=11963037
00:32:29 INFO mapred.JobClient: FILE_BYTES_WRITTEN=12111577
00:32:29 INFO mapred.JobClient: Map-Reduce Framework
00:32:29 INFO mapred.JobClient: Map input records=10541
00:32:29 INFO mapred.JobClient: Spilled Records=0
00:32:29 INFO mapred.JobClient: Total committed heap usage (bytes)=325058560
00:32:29 INFO mapred.JobClient: SPLIT_RAW_BYTES=65
00:32:29 INFO mapred.JobClient: Map output records=5279
本系统用来存储,索引,从大规模计算机系统采集来的监控指标数据,并用图表展示出来。类似百度统计,监控宝此类系统。
该系统存储的是按时序顺序存储的数据。虽然这种系统也可以使用 MySQL 此类关系数据库,但数据量大了后, HBase 的优势就体现出来了。我们不用关心数据分区的问题。而且我们可以使用 MapReduce 高效的处理离线数据。并且将在线实时查询的功能通过过滤器等把逻辑在服务端解决。
如果用 MySQL,假如我们 15 秒检测一次服务器状态;那一天可能有几百万数据;一个月呢?半年呢?
需要用到两个表。
表 tsdb 提供时间序列数据的存储和查询。就是所有的监控数据。
表 tsdb-uid 表维护全局唯一的UID索引,UID是对应监控指标的标签。如:CPU使用率,硬盘使用率,IO等,tsdb里记录各个标签的数据时,就用这个 uid 来区分。
通过 shell 脚本创建表:
#!/bin/sh
test -n "$HBASE_HOME" || {
echo >&2 'HBASE_HOME must be set!'
exit 1
}
test -d "$HBASE_HOME" || {
echo >&2 'No such directory: HBASE_HOME=$HBASE_HOME'
exit 1
}
TSDB_TABLE=${TSDB_TABLE-'tsdb'}
UID_TABLE=${UID_TABLE-'tsdb-uid'}
COMPRESSION=${COMPRESSION-'SNAPPY'}
exec "$HBASE_HOME/bin/hbase" shell <<EOF
create '$UID_TABLE',
{NAME => 'id', COMPRESSION => '$COMPRESSION'},
{NAME => 'name', COMPRESSION => '$COMPRESSION}
create '$TSDB_TABLE',
{NAME => 't', COMPRESSION => '$COMPRESSION}
EOF
上面脚本 创建了拥有列族 id,和 name 的表 tsdb-uid 以及拥有列族 t 的表 tsdb。
通常,每隔 15 秒或 30 秒会有监控程序检测一次状态,并将结果存入 tsdb 中。
tsdb-uid 表
tsdb-uid 用来管理UID。UID固定为 3 个字节宽,作为表 tsdb 外键关联用。 注册一个新的UID,会在表里插入两行数据:一行从标签名映射到UID,另一行从UID映射到标签名。
比如要监控CPU的负载 cpu.upload。
首先要生成一个新的UID(\x00\x00\x01),把UID作为行键,该行的 name 列族存储所有要监控标签的名称(cpu.upload),列限定符用来表示该行存储的是什么值,metrics 表示存储的是监控指标(标签)。
同时要用 cpu.upload 作为行键产生一行,id 列族存储上一步的UID(\x00\x00\x01)。并且还是使用 metrics 作为列限定符,表示它是监控指标(标签)。
数据可能是:
ROW | COLUMN+CELL |
\x00\x00\x01 | column=name:metrics, value=cpu.upload |
cpu.upload | column=id:metrics, value=\x00\x00\x01 |
\x00\x00\x02 | column=name:metrics, value=disk.io |
disk.io | column=id:metrics, value=\x00\x00\x02 |
\x00\x00\x03 | column=name:metrics, value=mem.use |
mem.use | column=id:metrics, value=\x00\x00\x03 |
UID映射标签的记录可以用来表示UID是哪种监控,因为记录监控信息的时候都是用UID作为监控数据的行键。
标签映射UID的数据可以用来类似 autocomplate 的自动匹配功能。我们在监控结果页面查询某个监控指标时,比如 cpu.upload,当我们在输入框中输入 cpu 时,就可以从该表中查询出所有以 cpu 开头的记录,方便我们选择。它是通过限定行键的范围扫描实现的。
除了存储监控指标外,还可以存储一些其它的信息,如:主机名;把被监控的所有主机都录入到该表中。数据如下:
ROW | COLUMN+CELL |
\x00\x01\x01 | column=name:host, value=web-1 |
web-1 | column=id:host, value=\x00\x01\x01 |
\x00\x01\x02 | column=name:host, value=web-2 |
web-2 | column=id:host, value=\x00\x01\x02 |
\x01\x01\x01 | column=name:type, value=host |
host | column=id:type, value=\x01\x01\x01 |
注意,这里的UID是 \x00\x01 打头的,列族是 host;前面的UID是 \x00\x00 打头,列族是 metrics。
同理,我们可以把其它系统常量及扩展信息都存在该表中。
tsdb 表
tsdb 表是核心数据。存储时间序列上的监控结果。通常的查询方式是按日期范围和标签进行查询。这时候我们就可以精心设计一下行键,因为hbase里数据会按行键排序。行键设计可能如下:
监控指标UID | 部分时间戳 | 标签1名称UID | 标签1值UID |
3字节 | 4字节 | 3字节 | 3字节 |
可以看到,tsdb-uid 表里的UID也被拿来当作 tsdb行键的一部分。
因为通常不会一下查询多个监控项(标签)的数据,所以把监控指标UID作为行键的最开头部分。
由于 HBase 是按行键顺序存储数据的,所以我们可以把行键里的时间戳四舍五入到 60 分钟。那么,这 60 分钟里,这个标签所有的监控数据的时间戳都是一样的,于是这些数据行键都一样,就都在一行显示。而各个具体的监控时间点的值,我们再用列限定符去区分。
该表结果存储的时候,只有一个列族 t。列限定符,由时间和掩码组合而成。由于前面的行键中的时间戳只计算到了小时的时间,这里则存的是除小时外的精确时间。
假设现在有一条数据,是检测CPU负载(\x00\x00\x01),值是 25,检测的时间是 2016-07-28 07:00:00(unix值是 1469660400,它会被切割成 1469660000 和 400 ),检测的机器的 host (\x01\x01\x01)是 web-1(\x00\x01\x01)。
则它的数据应该如下: 行键:\x00\x00\x01 + 1469660000 + \x01\x01\x01 + \x00\x01\x01
列限定符:400 + 0x07 值:25
这里是从 bit 级别上去构思的,主要是为了优化性能。 每行存储多个观测值,是为了让带过滤器的扫描一次过滤就能滤掉更多的数据。这也会大大减少基于 bloomfilter 需要跟踪的行数。
tsdb-uid
在业务开展前,我们要把系统常量都整理好,存入 tsdb-uid 中。如:要监控的指标,标签(如: host),标签值(如: web-1)。而且,每次插入的时候要判断当前是否已经有这个UID。
每次存储时,重要的是生成行键。
行键在HBase存储时是一个二进制码流,Rowkey的长度被很多开发者建议说设计在10~100个字节,不过建议是越短越好,不要超过16个字节。
目前操作系统是都是64位系统,内存8字节对齐。控制在16个字节,8字节的整数倍利用操作系统的最佳特性。
系统查询功能主要为了满足如下几个需求:
标签自动补全 使用行键扫描功能来完成此功能,比如用户输入 web,我们就可以以此为扫描的起始键,查找以这个为前缀的条目,一直到 wez结束。
查询某监控指标的时间序列数据 从监控数据表里查数据,也使用行键过滤功能,不过规则要复杂一些,因为行键是由多个值组合而成的:监控指标UID 部分时间戳 标签1名称UID 标签1值UID 象这种过滤器要在服务器端使用,而不是在客户端。因为这样可以减少每次请求时传递给客户端的数据量。
互联网广告,讲究的是实时。而它的数据量则是海量的,特别是展现、点击数据非常多。所以传统的关系型数据库是无法满足业务需要的。我们要处理的数据主要有:
记录广告的展现、点击数据,并根据历史数据制定广告投放策略。
提供某广告在两个月内每天展现量的统计,并且可以分省份、地市、用户三个维度统计。
该系统分为如下几层:
广告展现数据表
由于广告要分省、市、用户分别进行查询。所以这里行键需要三个。整体设计如下:
结构 | 值 |
表名 | adpv_stat |
行键 |
[AD_ID]_[省ID]_[日期] [AD_ID]_[市ID]_[日期] [AD_ID]_[用户ID]_[日期] |
列族 | pv |
列名 | cnt |
测试数据可能如下:
2211_ d930807e48a46653a72ccba6f5290bb1-2016-09-01
广告点击数据表
结构 | 值 |
表名 | adclick_stat |
行键 | [AD_ID]_[日期] |
列族 | clk |
列名 | cnt |
// TODO
// TODO
卸载旧版本
yum remove docker \
docker-client \
docker-client-latest \
docker-common \
docker-latest \
docker-latest-logrotate \
docker-logrotate \
docker-selinux \
docker-engine-selinux \
docker-engine
安装依赖
yum install -y yum-utils device-mapper-persistent-data lvm2
安装新版本
// 国内源安装
yum-config-manager --add-repo https://mirrors.ustc.edu.cn/docker-ce/linux/centos/docker-ce.repo
// 官方源安装
yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
// 安装
yum-config-manager --enable docker-ce-edge
yum makecache fast
yum install docker-ce
systemctl enable docker
systemctl start docker
添加内核配置参数
tee -a /etc/sysctl.conf <<-EOF
net.bridge.bridge-nf-call-ip6tables = 1
net.bridge.bridge-nf-call-iptables = 1
EOF
重新加载配置
sysctl -p
创建一个基于 centos7 的 ngxin 服务的镜像:
搜索基础镜像
docker search centos
结果:
结果:
NAME DESCRIPTION STARS OFFICIAL AUTOMATED
centos The official build of CentOS. 3522 [OK]
jdeathe/centos-ssh CentOS-6 6.9 x86_64 / CentOS-7 7.3.1611 x8... 78 [OK]
tutum/centos Simple CentOS docker image with SSH access 33
kinogmt/centos-ssh CentOS with SSH 15 [OK]
centos/postgresql-94-centos7 PostgreSQL 9.4 SQL database server 11
我们可以用最基础的: centos ,然后自己在该镜像基础上再安装额外的环境。
下载镜像
docker pull centos:latest // 或者 docker pull centos:centos7
冒号后面是要安装的版本,这个可以在 Docker Hub 上看到: https://hub.docker.com/_/centos/
在国内服务器上安装的时候非常缓慢,直接使用阿里云的镜像。如果 Docker客户端版本大于1.10:
mkdir -p /etc/docker
tee /etc/docker/daemon.json <<-'EOF'
{
"registry-mirrors": ["https://apdv9k7p.mirror.aliyuncs.com"]
}
EOF
systemctl daemon-reload
systemctl restart docker
然后再下载镜像。
查看当前镜像
docker images
结果:
docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
centos centos7 e934aafc2206 3 weeks ago 195MB
创建自定义镜像
我们可以先创建一个空的容器,然后连接进去再一步下不安装。也可以通过配置文件来定义规则让它自动安装。
创建一个新的镜像,标签是 nginx,然后用它创建一个容器:
docker tag e934aafc2206 centos7:nginx
docker run -t -i centos7:nginx /bin/bash
可以通过 docker ps -a
查看容器。也可以通过 docker rm [容器名称或ID]
删除容器。创建完成后,通过 docker start [容器名称或ID]
来启动,docker stop [容器名称或ID]
关闭容器。
启动后,可以通过
docker exec -it [容器名称或ID] bash
进入容器的控制台。在控制台安装完环境后,可能通过
docker export 7691a814370e > centos-nginx.tar
建立容器的快照。后面,可以通过
cat centos-nginx.tar | docker import - centos/nginx:v1.0
导入为镜像除了容器快照的方式,还可以直接把变更添加到镜像上:
docker commit -a "SunYu" -m "安装 nginx" [容器名称] nginx:v1
创建好后可以通过
docker run --name web -d -p 80:80 nginx:v1
运行容器。并进行端口映射以及容器的命名。
要注意的是,commit 方式产生的镜像文件特别大。
自定义安装是通过 Dockerfile 定义的。先创建一个工作目录,如:/data/soft/docker, 在该目录下先下载安装文件: wget http://nginx.org/download/nginx-1.12.2.tar.gz
再创建 nginx 的服务控制文件 nginx:
#!/bin/bash
# nginx Startup script for the Nginx HTTP Server
# it is v.0.0.2 version.
# chkconfig: - 85 15
# description: Nginx is a high-performance web and proxy server.
# It has a lot of features, but it's not for everyone.
# processname: nginx
# pidfile: /usr/local/webserver/nginx/logs/nginx.pid
# config: /usr/local/webserver/nginx/conf/nginx.conf
nginxd=/usr/local/webserver/nginx/sbin/nginx
nginx_config=/usr/local/webserver/nginx/conf/nginx.conf
nginx_pid=/usr/local/webserver/nginx/logs/nginx.pid
RETVAL=0
prog="nginx"
# Source function library.
. /etc/rc.d/init.d/functions
# Source networking configuration.
. /etc/sysconfig/network
# Check that networking is up.
[ ${NETWORKING} = "no" ] && exit 0
[ -x $nginxd ] || exit 0
# Start nginx daemons functions.
start() {
if [ -e $nginx_pid ];then
echo "nginx already running...."
exit 1
fi
echo -n $"Starting $prog: "
daemon $nginxd -c ${nginx_config}
RETVAL=$?
echo
[ $RETVAL = 0 ] && touch /var/lock/subsys/nginx
return $RETVAL
}
# Stop nginx daemons functions.
stop() {
echo -n $"Stopping $prog: "
killproc $nginxd
RETVAL=$?
echo
[ $RETVAL = 0 ] && rm -f /var/lock/subsys/nginx /var/run/nginx.pid
}
# reload nginx service functions.
reload() {
echo -n $"Reloading $prog: "
#kill -HUP `cat ${nginx_pid}`
killproc $nginxd -HUP
RETVAL=$?
echo
}
# See how we were called.
case "$1" in
start)
start
;;
stop)
stop
;;
reload)
reload
;;
restart)
stop
start
;;
status)
status $prog
RETVAL=$?
;;
*)
echo $"Usage: $prog {start|stop|restart|reload|status|help}"
exit 1
esac
exit $RETVAL
然后再创建配置文件 Dockerfile:
FROM centos7:nginx
RUN mkdir -p /data/soft/
ADD nginx /etc/init.d/
ADD nginx-1.12.2.tar.gz /data/soft/
RUN yum -y install gcc gcc-c++ autoconf libjpeg libjpeg-devel libpng libpng-devel freetype freetype-devel libxml2 libxml2-devel zlib zlib-devel glibc glibc-devel glib2 glib2-devel bzip2 bzip2-devel curl curl-devel e2fsprogs e2fsprogs-devel openssl openssl-devel openldap openldap-devel nss_ldap openldap-clients openldap-servers python-devel libicu-devel chrpath nspr-devel readline-devel ncurses-devel boost boost-devel pcre-devel js-devel git tcsh js js-devel libevent libevent-devel subversion git-core tcsh tcl net-tools net-snmp net-snmp-devel net-snmp-utils cmake postgresql-devel libxslt-devel && \
/usr/sbin/groupadd -f www && \
/usr/sbin/useradd -s /sbin/nologin -g www www && \
mkdir -p /usr/local/webserver/nginx
RUN cd /data/soft/nginx-1.12.2 && \
./configure --user=www --group=www --prefix=/usr/local/webserver/nginx --with-http_stub_status_module --with-http_ssl_module --with-http_gzip_static_module --with-pcre && \
make && make install && \
chmod a+x /etc/init.d/nginx && \
chkconfig --add nginx && \
chkconfig nginx --level 2345 on
CMD ["/usr/local/webserver/nginx/sbin/nginx", "-g", "daemon off;"]
EXPOSE 80
创建容器:
docker build -t centos7:nginx .
通过 ADD 方式将本地的文件添加容器中。而且它会自动把压缩文件解压。所以上面的配置文件中,直接用了 cd /data/soft/nginx-1.12.2。而没有进行解压处理。
通过 CMD 命令,我们让 nginx 启动。并在前台运行。对于容器而言,其启动程序就是容器应用进程,容器就是为了主进程而存在的,主进程退出,容器就失去了存在的意义,从而退出。所以要把 nginx 在前台运行。要不然我们开启容器后它会马上自动退出。
然后,通过 EXPOSE 80 将容器的 80 端口暴露出来。后面我们可以将宿主机器的 80 端口和它进行映射。这样就可以通过访问主机的 80 和容器连通了。
启动容器
创建完后,创建新容器:
docker run --name web -d -p 80:80 centos7:nginx
创建完后,可能在 docker ps -a
的结果里查看。同时,docker stop [容器名称或ID]
可以关闭,docker start [名称或ID]
可以启动已经存在且已经关闭的容器。
通过 docker exec -it web bash
可以连接到容器的控制台,在这里直接 exit 可以退出控制台但不会关闭容器。
这时候查看主机的端口,发现 80 端口已经开启了。但直接访问 http://[IP] 是没内容的。因为我们的容器里的 WEB 服务里还是空的。我们可以把项目放在主机上,通过目录映射到容器中。如: 在当前目录下创建 testproject 目录,里面创建一个 index.html 内容是 123。
我们关闭容器,重新创建容器。
docker stop web
docker rm web
docker run --name web -d -p 80:80 -v /data/soft/docker/testproject/:/usr/local/webserver/nginx/html centos7:nginx
这样就进行了目录的绑定。而且,在主机上改了文件后,容器中会自动同步。这时候访问 http://[借主IP] 就可以看到内容了。
但是,目前为止我们只能访问静态文件。容器里并没有安装PHP,MYSQL等。所以无法完整的支撑起一个项目。我们当然可以把这些全部安装到一个容器里,但为了灵活。我们也可以分别安装在不同的容器。
容器间的互联,可以建立一个自有的网络,然后进行通信。但更好的方案是用 Docker compose。
使用该工具,可以快速的部署分布式应用。它的定位是:定义和运行多个 Docker 容器的应用。
通过 compose,我们可以把独立的一组容器进行关联。它通过配置文件:docker-compose.yml 进行配置。它有两个重要概念:
一个项目可以由多个服务(容器)关联而成,Compose 面向项目进行管理。
curl -L https://raw.githubusercontent.com/docker/compose/1.8.0/contrib/completion/bash/docker-compose > /etc/bash_completion.d/docker-compose
卸载
rm /usr/local/bin/docker-compose
新建一个工作目录。如:/data/soft/docker/compose
,进入目录
创建 app.py
from flask import Flask
from redis import Redis
app = Flask(__name__)
redis = Redis(host='redis', port=6379)
@app.route('/')
def hello():
count = redis.incr('hits')
return 'Hello World! 该页面已被访问 {} 次。\n'.format(count)
if __name__ == "__main__":
app.run(host="0.0.0.0", debug=True)
创建 Dockerfile
FROM python:3.6-alpine
ADD . /code
WORKDIR /code
RUN pip install redis flask
CMD ["python", "app.py"]
创建 docker-compose.yml
version: '3'
services:
web:
build: .
ports:
- "80:5000"
redis:
image: "redis:alpine"
运行 compose 项目
$ docker-compose up
这时候,访问 http://[宿主IP]/ 可以看到,每次刷新后,计数都会 +1。
这里就是用 Compose 来控制容器了,包括了端口映射等,而且它会创建两个容器。
docker-compose build
构建(重新构建)项目中的服务容器。
docker-compose config
验证 Compose 文件格式是否正确,若正确则显示配置,若格式错误显示错误原因。
docker-compose down
停止 up 命令所启动的容器,并移除网络
docker-compose exec
进入指定的容器。
docker-compose images
列出 Compose 文件中包含的镜像。
docker-compose ps
列出项目中目前的所有容器。
docker-compose restart
重启项目中的服务。
docker-compose rm
删除所有(停止状态的)服务容器。先执行 docker-compose stop 命令来停止容器。
-f, –force 强制直接删除,包括非停止状态的容器。-v 删除容器所挂载的数据卷。
docker-compose start
启动已经存在的服务容器。
docker-compose stop
停止已经处于运行状态的容器,但不删除它。通过 docker-compose start 可以再次启动这些容器。
docker-compose up
该命令十分强大,它将尝试自动完成包括构建镜像,(重新)创建服务,启动服务,并关联服务相关容器的一系列操作。
链接的服务都将会被自动启动,除非已经处于运行状态。
可以说,大部分时候都可以直接通过该命令来启动一个项目。
默认情况,docker-compose up
启动的容器都在前台,控制台将会同时打印所有容器的输出信息,可以很方便进行调试。
当通过 Ctrl-C
停止命令时,所有容器将会停止。
如果使用 docker-compose up -d
,将会在后台启动并运行所有的容器。一般推荐生产环境下使用该选项。
默认情况,如果服务容器已经存在,docker-compose up
将会尝试停止容器,然后重新创建(保持使用 volumes-from 挂载的卷),以保证新启动的服务匹配 docker-compose.yml
文件的最新内容。如果用户不希望容器被停止并重新创建,可以使用 docker-compose up --no-recreate
。这样将只会启动处于停止状态的容器,而忽略已经运行的服务。如果用户只想重新部署某个服务,可以使用 docker-compose up --no-deps -d <SERVICE_NAME>
来重新创建服务并后台停止旧服务,启动新服务,并不会影响到其所依赖的服务。
模板文件是使用 Compose 的核心,涉及到的指令关键字也比较多。这里面大部分指令跟 docker run 相关参数的含义都是类似的。
默认的模板文件名称为 docker-compose.yml,格式为 YAML 格式。如:
version: "3"
services:
webapp:
image: examples/web
ports:
- "80:80"
volumes:
- "/data"
每个服务都必须通过 image 指令指定镜像或 build 指令(需要 Dockerfile)等来自动构建生成镜像。
如果使用 build 指令,在 Dockerfile 中设置的选项(例如:CMD, EXPOSE, VOLUME, ENV 等) 将会自动被获取,无需在 docker-compose.yml 中再次设置。
build
指定 Dockerfile 所在文件夹的路径(可以是绝对路径,或者相对 docker-compose.yml 文件的路径)。 Compose 将会利用它自动构建这个镜像,然后使用这个镜像。如: build: ./dir
也可以使用 context 指令指定 Dockerfile 所在文件夹的路径。使用 dockerfile 指令指定 Dockerfile 文件名。使用 arg 指令指定构建镜像时的变量。如:
webapp:
build:
context: ./dir
dockerfile: Dockerfile-alternate
args:
buildno: 1
image
指定为镜像名称或镜像 ID。如果镜像在本地不存在,Compose 将会尝试拉取这个镜像。如:
image: ubuntu
image: orchardup/postgresql
image: a4bc65fd
command
覆盖容器启动后默认执行的命令。如: command: echo "hello world"
container_name
指定容器名称。默认将会使用 项目名称_服务名称_序号 这样的格式。自定义方式如:
container_name: docker-web-container
注意: 指定容器名称后,该服务将无法进行扩展(scale),因为 Docker 不允许多个容器具有相同的名称。
depends_on
解决容器的依赖、启动先后的问题。以下例子中会先启动 redis db 再启动 web
version: '3'
services:
web:
build: .
depends_on:
- db
- redis
redis:
image: redis
db:
image: postgres
注意:web 服务不会等待 redis, db 「完全启动」之后才启动。
environment 设置环境变量。可以使用数组或字典两种格式。
只给定名称的变量会自动获取运行 Compose 主机上对应变量的值,可以用来防止泄露不必要的数据。如:
environment:
RACK_ENV: development
SESSION_SECRET:
environment:
- RACK_ENV=development
- SESSION_SECRET
如果变量名称或者值中用到 true|false,yes|no 等表达 布尔 含义的词汇,最好放到引号里,避免 YAML 自动解析某些内容为对应的布尔语义。这些特定词汇,包括
y|Y|yes|Yes|YES|n|N|no|No|NO|true|True|TRUE|false|False|FALSE|on|On|ON|off|Off|OFF
expose
暴露端口,但不映射到宿主机,只被连接的服务访问。
ports
暴露端口信息。
使用宿主端口:容器端口 (HOST:CONTAINER) 格式,或者仅仅指定容器的端口(宿主将会随机选择端口)都可以。
ports:
- "3000"
- "8000:8000"
- "49100:22"
- "127.0.0.1:8001:8001"
注意:当使用 HOST:CONTAINER 格式来映射端口时,如果你使用的容器端口小于 60 并且没放到引号里,可能会得到错误结果,因为 YAML 会自动解析 xx:yy 这种数字格式为 60 进制。为避免出现这种问题,建议数字串都采用引号包括起来的字符串格式。
secrets
存储敏感数据,例如 mysql 服务密码。
version: "3"
services:
mysql:
image: mysql
environment:
MYSQL_ROOT_PASSWORD_FILE: /run/secrets/db_root_password
secrets:
- db_root_password
- my_other_secret
secrets:
my_secret:
file: ./my_secret.txt
my_other_secret:
external: true
sysctls
配置容器内核参数。
sysctls:
net.core.somaxconn: 1024
net.ipv4.tcp_syncookies: 0
ulimits
指定容器的 ulimits 限制值。例如,指定最大进程数为 65535,指定文件句柄数为 20000(软限制,应用可以随时修改,不能超过硬限制) 和 40000(系统硬限制,只能 root 用户提高)。
ulimits:
nproc: 65535
nofile:
soft: 20000
hard: 40000
restart 指定容器退出后的重启策略为始终重启。该命令对保持服务始终运行十分有效,在生产环境中推荐配置为 always 或者 unless-stopped。
restart: always
volumes
数据卷所挂载路径设置。可以设置宿主机路径 (HOST:CONTAINER) 或加上访问模式 (HOST:CONTAINER:ro)。
该指令中路径支持相对路径。
volumes:
- /var/lib/mysql
- cache/:/tmp/cache
- ~/configs:/etc/configs/:ro
现在创建一个 nginx + mysql + php7 的项目环境。需要的容器有:MySQL, Nginx, Php (项目文件也放在这里)。由于 MySQL, Nginx 都有现成的镜像,但我们项目需要 php7,且需要 compose, npm 等工具,所以我们自己安装。
PHP安装定义
创建 Dockfile:
FROM centos:centos7
MAINTAINER SunYu sunvipyu@gmail.com
RUN yum -y install vim wget sendmail
RUN yum -y install libtool make automake autoconf nasm libpng-static
RUN yum -y install git
RUN git --version
# Install PHP 7.1 on CentOS
RUN rpm -Uvh https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm \
&& rpm -Uvh https://mirror.webtatic.com/yum/el7/webtatic-release.rpm
RUN yum install yum-utils
RUN yum-config-manager --enable remi-php71
RUN yum -y install php71w \
php71w-bcmath \
php71w-cli \
php71w-common \
php71w-curl \
php71w-fpm \
php71w-gd \
php71w-ldap \
php71w-imap \
php71w-intl \
php71w-mbstring \
php71w-mcrypt \
php71w-mysqlnd \
php71w-opcache \
php71w-pdo \
php71w-pear \
php71w-pecl-apcu \
php71w-pecl-imagick \
php71w-pgsql \
php71w-process \
php71w-pspell \
php71w-recode \
php71w-soap \
php71w-tidy \
php71w-xml
RUN php -v
# Prepare PHP environment
COPY config/php/php-fpm.conf /etc/php-fpm.conf
COPY config/php/www.conf /etc/php-fpm.d/www.conf
COPY config/php/php.ini /usr/local/etc/php/php.ini
# Install Composer
RUN curl -sS https://getcomposer.org/installer | php
RUN mv composer.phar /usr/bin/composer
RUN composer --version
# Install Node.js
RUN curl -sL https://rpm.nodesource.com/setup_7.x | bash -
RUN yum -y install nodejs
RUN yum list installed nodejs
RUN node -v
# Final update and clean up
RUN yum -y update
RUN yum clean all
# Define work directory
WORKDIR /var/www/my-project
# Expose ports
EXPOSE 9000
CMD ["php-fpm", "-F", "-O"]
这里,我们安装 php7.1 且安装 compose, node.js 等环境。并开放 9000 端口供 nginx 后面使用。
docker-compose.yml
version: '2'
services:
mysql:
image: mysql:latest
volumes:
- "./data/db:/var/lib/mysql"
ports:
- "3306:3306"
restart: always
environment:
- MYSQL_ROOT_PASSWORD=secret
- MYSQL_DATABASE=laravel_boilerplate
- MYSQL_USER=root
- MYSQL_PASSWORD=secret
project-env:
build: ./dockerfiles
depends_on:
- mysql
volumes:
- "/data/soft/my-project:/var/www/my-project"
- "./dockerfiles/config/php/php-fpm.conf:/etc/php-fpm.conf"
- "./dockerfiles/config/php/www.conf:/etc/php-fpm.d/www.conf"
- "./dockerfiles/config/php/php.ini:/usr/local/etc/php/php.ini"
nginx:
image: nginx:latest
depends_on:
- project-env
volumes:
- "/data/soft/my-project:/var/www/my-project"
- "./dockerfiles/config/nginx/default.conf:/etc/nginx/conf.d/default.conf"
ports:
- "80:80"
restart: always
模板定义中,创建了 mysql, nginx 两个新容器。并通过 Dockfile 创建项目容器 project-env,并把本地的一些配置文件上传,作为容器里的配置文件,配置文件内容有:
php-fpm.conf
[global]
error_log = /proc/self/fd/2
process.max = 128
systemd_interval = 0
include=/etc/php-fpm.d/*.conf
www.conf
[www]
user = apache
group = apache
listen = 9000
pm = dynamic
pm.max_children = 50
pm.start_servers = 5
pm.min_spare_servers = 5
pm.max_spare_servers = 35
pm.status_path = /status
ping.path = /ping
slowlog = /proc/self/fd/2
request_slowlog_timeout = 60s
chdir = /var/www
catch_workers_output = yes
env[HOSTNAME] = $HOSTNAME
env[PATH] = /usr/local/bin:/usr/bin:/bin
env[HOME] = /var/www
env[TMP] = /tmp
env[TMPDIR] = /tmp
env[TEMP] = /tmp
php_admin_value[date.timezone] = UTC
php_value[session.save_handler] = files
php_value[session.save_path] = /var/lib/php/session
php_value[soap.wsdl_cache_dir] = /var/lib/php/wsdlcache
php.ini
# Custom PHP settings
upload_max_filesize = 100M
post_max_size = 100M
memory_limit = 512M
max_execution_time = 180
default.conf
# Enable Gzip
gzip on;
gzip_http_version 1.0;
gzip_comp_level 2;
gzip_min_length 1100;
gzip_buffers 4 8k;
gzip_proxied any;
gzip_types
# text/html is always compressed by HttpGzipModule
text/css
text/javascript
text/xml
text/plain
text/x-component
application/javascript
application/json
application/xml
application/rss+xml
font/truetype
font/opentype
application/vnd.ms-fontobject
image/svg+xml;
gzip_static on;
gzip_proxied expired no-cache no-store private auth;
gzip_disable "MSIE [1-6]\.";
gzip_vary on;
# Expires map
map $sent_http_content_type $expires {
default off;
application/json off;
text/html 1h;
text/css max;
application/javascript max;
application/octet-stream max;
~image/ 1d;
~img/ 1d;
}
server {
listen 80 default_server;
listen [::]:80 default_server ipv6only=on;
server_name localhost;
root /var/www/my-project;
index index.php index.html index.htm;
charset utf-8;
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
access_log /var/log/nginx/access.log combined;
error_log /var/log/nginx/error.log error;
expires $expires;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# This block will catch static file requests, such as images, css, js
# The ?: prefix is a 'non-capturing' mark, meaning we do not require
# the pattern to be captured into $1 which should help improve performance
location ~* \.(?:ico|css|js|gif|jpe?g|png|woff|woff2|ttf|html)$ {
# Some basic cache-control for static files to be sent to the browser
add_header Pragma public;
add_header Cache-Control "public, must-revalidate, proxy-revalidate";
}
location ~ \.php$ {
try_files $uri /index.php =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass project-env:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.ht {
deny all;
}
client_max_body_size 100m;
}
nginx 的配置中,将 php 的请求转到了 project-env 去处理。
启动各容器
docker-compose -f docker-compose.yml up -d
执行一些自定义任务
项目可能会需要进行一些初始化。比如 php composer 的安装或者 npm 包的安装等。我们可以定义一个 sh 文件:
project-build.sh
#!/bin/bash
# Install dependencies
composer install --prefer-dist --no-interaction
# Install dependencies
npm install
npm run dev
然后执行:
docker exec project-env_1 ./dockerfiles/bin/prj-build.sh
这里的 project-env_1
是想要执行任务容器名称。可以通过 ps -a
查看。
各种虚拟机技术开启了云计算时代;而Docker,作为新一代虚拟化技术,正在改变我们开发、测试、部署应用的方式。开发者可以打包他们的应用以及依赖包到一个可移植的容器中,然后发布到任何流行的Linux机器上,也可以实现虚拟化。
Docker容器不是虚拟机
我们将多个应用使用虚拟机分别部署时,逻辑结构如下:
从下到上理解上图:
使用Docker容器运行多个相互隔离的应用时,如下图:
从下到上理解图上的组件分别是:
Docker守护进程可以直接与主操作系统进行通信,为各个Docker容器分配资源;它还可以将容器与主操作系统隔离,并将各个容器互相隔离。
由于没有臃肿的从操作系统,Docker可以节省大量的磁盘空间以及其他系统资源。
Docker通常用于隔离不同的应用,例如前端,后端以及数据库。或者用来发布SOA架构中的各个微服务。
Docker 运行在 CentOS 7.X. 及以上的版本(只针对Centos 来说) Docker 需要 64 位系统. 并且 kernel 版本至少是 3.10
检查 kernel 版本可以用如下命令:
$ uname -r
3.10.0-229.el7.x86_64
有两种方法安装Docker 引擎。一种是通过 yum 方式;另一种是通过下载源码安装。
yum 安装
1.先以 root 身份登录或者登录后 sudo 到 root,并保证系统当前是最新的
yum update
2.添加 yum 库
tee /etc/yum.repos.d/docker.repo <<-'EOF'
[dockerrepo]
name=Docker Repository
baseurl=https://yum.dockerproject.org/repo/main/centos/$releasever/
enabled=1
gpgcheck=1
gpgkey=https://yum.dockerproject.org/gpg
EOF
3.安装 Docker 包
yum install docker-engine
4.启动 Docker daemon.
service docker start
5.验证是否安装正确 启动一个测试镜像
docker run hello-world
Unable to find image 'hello-world:latest' locally
latest: Pulling from hello-world
a8219747be10: Pull complete
91c95931e552: Already exists
hello-world:latest: The image you are pulling has been verified. Important: image verification is a tech preview feature and should not be relied on to provide security.
Digest: sha256:aa03e5d0d5553b4c3473e89c8619cf79df368babd1.7.1cf5daeb82aab55838d
Status: Downloaded newer image for hello-world:latest
Hello from Docker.
This message shows that your installation appears to be working correctly.
To generate this message, Docker took the following steps:
1. The Docker client contacted the Docker daemon.
2. The Docker daemon pulled the "hello-world" image from the Docker Hub.
(Assuming it was not already locally available.)
3. The Docker daemon created a new container from that image which runs the
executable that produces the output you are currently reading.
4. The Docker daemon streamed that output to the Docker client, which sent it
to your terminal.
To try something more ambitious, you can run an Ubuntu container with:
$ docker run -it ubuntu bash
For more examples and ideas, visit:
http://docs.docker.com/userguide/
源码脚本安装
1.确保拥有 root 权限并保证所有的包都已更新
yum update
2.下载并安装
curl -fsSL https://get.docker.com/ | sh
该脚本会自动安装 Docker
3.启动 Docker
service docker start
4.验证是否安装成功 运行测试镜像
docker run hello-world
Docker 不是通过开辟TCP端口来进行的,而是通过一个 Unix socket 文件,而这个文件通常是属于 root,所以 Docker 通常是以 root 用户来执行。
为了避免每次运行的时候都要 sudo,可以建一个 docker 的用户组,把其它用户添加进去。
注意: docker 组里的用户和 root 等效
创建组并添加用户:
1.登录系统,并且必须要有 sudo 权限
2.创建 docker 组并添加用户
sudo usermod -aG docker your_username
3.退出登录并再重新登录
保证用户权限的更新
4.验证运行权限
docker run hello-world
开机自启动 — 为保证 docker 服务自动启动可以如下:
sudo chkconfig docker on
列出已经安装的包
yum list installed | grep docker
docker-engine.x86_64 1.7.1-1.el7 @/docker-engine-1.7.1-1.el7.x86_64.rpm
移除安装
sudo yum -y remove docker-engine.x86_64
这个命令不会删除 docker 的镜像,配置文件等,如果要这样,可以如下:
rm -rf /var/lib/docker
hello world
docker run ubuntu /bin/echo 'Hello world'
Hello world
上面命令的执行过程如下:
/bin/echo 'Hello world'
命令行中会显示:
Hello world
当 hello world 显示后,容器就停止了。
有了上面的基础 ,我们可以尝试在容器中执行更多的脚本:
$ docker run -t -i ubuntu /bin/bash
root@af8bae53bdd3:/#
在这里,我们在容器启动后执行命令时,加入了 -t 和 -i 参数。
-t 在容器中开启伪终端
-i 允许我们和容器的标准输出交互
于是,当容器启动后我们就可以在宿主机器上和容器提供的输入通道里进行交互:
root@af8bae53bdd3:/#
往容器里输入一些 sh 命令:
root@af8bae53bdd3:/# pwd
/
root@af8bae53bdd3:/# ls
bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var
当交互完成时,可以输入 exit
命令或按 Ctrl-D 结束交互。
root@af8bae53bdd3:/# exit
当退出后,相当于 /bin/bash 脚本结束了。于是,容器关闭了。
前者的容器都是在命令执行完后自动退出。我们也可以创建一直运行的容器。当然正常线上使用时肯定是要一直运行。
$ docker run -d ubuntu /bin/sh -c "while true; do echo hello world; sleep 1; done"
1e5535038e285177d5214659a068137486f96ee5c2e85a4ac52dc83f2ebe4147
-d 参数可以让容器一直在后台运行
同时,执行后面一段 sh 命令,每秒钟输出一次 hello world
但容器启动后并没有输出 hello world 而是返回一长串字符,这串字符就是容器的唯一ID。
我们可以通过容器的ID来观察容器里的情况。
首先要确认容器正在运行,可以通过 ps
命令查看当前正在运行的容器。
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
1e5535038e28 ubuntu /bin/sh -c 'while tr 2 minutes ago Up 1 minute insane_babbage
在这里可以看到所有在后台运行的容器。第一列的 CONTAINER ID 是前面说的容器ID 的简写:1e5535038e28. 同时可以看到我们的容器是正在运行的状态,以及一个自动加上的 PORTS NAMES: insane_babbage.
对于已经启动的容器,可以使用 docker logs
命令查看容器内的情况:
docker logs insane_babbage
hello world
hello world
hello world
. . .
docker logs
命令返回容器的标准输出。所以上面就返回了 hello world
docker stop insane_babbage
insane_babbage
docker stop
命令告诉 Docker 停止正在运行的容器。如果成功了,就会返回被停止容器的名称。
停止后,再查看当前正在运行的窗口:
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
没有正在运行的了,说明停止成功
运行WEB服务
如果我们要运行一个 phthon 的WEB服务,可以如下:
docker run -d -P training/webapp python app.py
-d 是让容器在后台运行
-P 告诉 Docker 和宿主机器做一个端口映射,这样我们就可以在宿主机上观察容器里的 WEB 应用了
窗容器启动后,会运行 python app.py
通过 docker ps
命令观察当前运行的容器:
docker ps -l
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
bc533791f3f5 training/webapp:latest python app.py 5 seconds ago Up 2 seconds 0.0.0.0:49155->5000/tcp nostalgic_morse
-l 参数可以列出容器详细的信息
默认情况下 ps 命令只会列出正在运行的容器,如果想列出所有的,则要加 -a 参数 同时还可以看到加了 -P参数后的端口映射情况:
PORTS
0.0.0.0:49155->5000/tcp
现在,容器的 5000 端口和宿主机的 49155 端口连接。上面我们只是单纯的加上了 -P 参数,它实际上是 -p 5000 的简写。
意思是和容器里的 5000 端口作 映射。(宿主机上的端口通常是在 32768 至 61000 之间) .我们也可以指定自己需要的端口,如:
docker run -d -p 80:5000 training/webapp python app.py
所以,现在我们可以通过访问宿主机的 49155 端口来访问容器中的应用,如: http://your_domain:49155/
如果我们想要更详细的了解WEB应用的情况,可以通过 docker logs
命令:
docker logs -f nostalgic_morse
* Running on http://0.0.0.0:5000/
10.0.2.2 - - [23/May/2014 20:16:31] "GET / HTTP/1.1" 200 -
10.0.2.2 - - [23/May/2014 20:16:31] "GET /favicon.ico HTTP/1.1" 404 -
这里加入了 -f 参数。它会让 docker logs
命令执行类似 tail -f
的功能,去访问标准输出。
可以使用docker top
命令。
docker top nostalgic_morse
PID USER COMMAND
854 root python app.py
检查应用容器
—
我们可以通过 docker inspect
命令来获取一些容器信息:
docker inspect nostalgic_morse
得到的返回大致如下:
[{
"ID": "bc533791f3f500b280a9626688bc79e342e3ea0d528efe3a86a51ecb28ea20",
"Created": "2014-05-26T05:52:40.808952951Z",
"Path": "python",
"Args": [
"app.py"
],
"Config": {
"Hostname": "bc533791f3f5",
"Domainname": "",
"User": "",
. . .
我们也可以指定返回哪些信息,如返回容器IP:
docker inspect -f '' nostalgic_morse
172.17.0.5
docker stop nostalgic_morse
关闭容器后,如果要重新启动,有两种选择:重新创建一个容器或者重新启动旧的。使用旧的方式如下:
docker start nostalgic_morse
docker rm nostalgic_morse
Error: Impossible to remove a running container, please stop it first or use -f
2014/05/24 08:12:56 Error: failed to remove one or more containers
删除的时候报错了,因为不能删除一个正在运行的容器。要先停止后再删除。
docker stop nostalgic_morse
nostalgic_morse
docker rm nostalgic_morse
nostalgic_morse
docker 镜像是容器的基础。每次运行容器时都必须要拥有镜像。
查看当前主机上的镜像
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
ubuntu 14.04 1d073211c498 3 days ago 187.9 MB
busybox latest 2c5ac3f849df 5 days ago 1.113 MB
training/webapp latest 54bb4e8718e8 5 months ago 348.7 MB
一个镜像可以有多个版本,如 ubuntu 可以有 10.04, 12.04, 12.10, 13.04, 13.10 等。在启动容器里,如果要指定某个名称对应的某个版本,可以如下:
ubuntu:14.04
$ docker run -t -i ubuntu:14.04 /bin/bash
如果未指定具体的版本,而又存在多个版本,默认会使用:ubuntu:latest
注意: 最好是每次都指定版本,这样就可以避免不必要的麻烦
下载镜像
当我们的主机上不存在我们要启动的镜像时,它会自动从公开的库中下载。但这会需要很长时间,因为要下载。如果想在启动容器前就预先下载好镜像,可以用 docker pull
命令。如:
$ docker pull centos
Pulling repository centos
b7de3133ff98: Pulling dependent layers
5cc9e91966f7: Pulling fs layer
511136ea3c5a: Download complete
ef52fb1fe610: Download complete
. . .
Status: Downloaded newer image for centos
可以看到,镜像已经被下载了,但未启动容器。 下面我们可以利用这个镜像去启动容器:
$ docker run -t -i centos /bin/bash
bash-4.1#
搜索外网镜像
有许多人会把自己的镜像公开出来给大家用。我们可以到 Docker Hub 上去找, 或者通过 docker search
命令查找,如:
$ docker search sinatra
NAME DESCRIPTION STARS OFFICIAL AUTOMATED
training/sinatra Sinatra training image 0 [OK]
marceldegraaf/sinatra Sinatra test app 0
mattwarren/docker-sinatra-demo 0 [OK]
luisbebop/docker-sinatra-hello-world 0 [OK]
bmorearty/handson-sinatra handson-ruby + Sinatra for Hands on with D... 0
subwiz/sinatra 0
bmorearty/sinatra 0
. . .
该命令返回了许多可以使用的镜像。可以用 pull 命令下载。
$ docker pull training/sinatra
创建自己的镜像
直接下载的镜像可能和自己的业务需求不相符,我们需要做许多调整。调整完后,我们可以将镜像重新上传,更新镜像。或者使用 Dockerfile
创建新的镜像。
更新镜像
在更新镜像之前,必须新建一个容器:
$ docker run -t -i training/sinatra /bin/bash
root@0b2616b0e5a8:/#
注意: 新创建的容器ID会返回回来,后面会用到: 0b2616b0e5a8
上面已经打开了和容器的交互通道。接下来要在容器中运行:
root@0b2616b0e5a8:/# gem install json
命令执行完后,再执行 exit
退出交互。
现在,我们已经在容器中做了简单的变更。我们可以通过 docker commit
命令将该容器作为一个镜像提交:
$ docker commit -m "Added json gem" -a "Kate Smith" \
0b2616b0e5a8 ouruser/sinatra:v2
4f177bd27a9ff0f6dc2a830403925b5360bfe0b93d476f7fc3231110e7f71b1c
-m 参数是指相应的描述,注释
-a 参数是指更新镜像的作者
同时,命令还描述了新的镜像是从哪个容器生成的,以及需要更新哪个镜像:
ouruser/sinatra:v2
这时候再次查看当前机器上的镜像:
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
training/sinatra latest 5bc342fa0b91 10 hours ago 446.7 MB
ouruser/sinatra v2 3c59e02ddd1a 10 hours ago 446.7 MB
ouruser/sinatra latest 5db5f8471261 10 hours ago 446.7 MB
通过该镜像,可以再新建容器:
$ docker run -t -i ouruser/sinatra:v2 /bin/bash
root@78e82f680994:/#
通过 commit command
可以快速的扩展镜像。但在开发团队中共享却不是很方便,这里我们可以使用新命令:docker build
, 创建新的镜像。
在此之前,必须新建一个 Dockerfile, 来告诉 Docker 怎么去创建镜像。
新建目录和 Dockerfile.
$ mkdir sinatra
$ cd sinatra
$ touch Dockerfile
文件内容如下:
# This is a comment
FROM ubuntu:14.04
MAINTAINER Kate Smith <ksmith@example.com>
RUN apt-get update && apt-get install -y ruby ruby-dev
RUN gem install sinatra
如示例所写,每个项的名称必须大写。并且使用 # 来添加注释
第一个描述: FROM 告诉 Docker 从哪里新建镜像:Ubuntu 14.04
MAINTAINER 描述了当前创建镜像人的信息
最后,通过RUN,执行一些内部命令,安装必要的环境
接下来新建镜像:
$ docker build -t ouruser/sinatra:v2 .
Sending build context to Docker daemon 2.048 kB
Sending build context to Docker daemon
Step 1 : FROM ubuntu:14.04
---> e54ca5efa2e9
Step 2 : MAINTAINER Kate Smith <ksmith@example.com>
---> Using cache
---> 851baf55332b
Step 3 : RUN apt-get update && apt-get install -y ruby ruby-dev
---> Running in 3a2558904e9b
...
Step 4 : RUN gem install sinatra
---> Running in 6b81cb6313e5
...
Successfully built 97feabe5d2ed
创建完后,可以进一步创建容器:
$ docker run -t -i ouruser/sinatra:v2 /bin/bash
root@8196968dac35:/#
给镜像设置标签
$ docker tag 5db5f8471261 ouruser/sinatra:devel
设置完后查看当前所有的镜像:
$ docker images ouruser/sinatra
REPOSITORY TAG IMAGE ID CREATED SIZE
ouruser/sinatra latest 5db5f8471261 11 hours ago 446.7 MB
ouruser/sinatra devel 5db5f8471261 11 hours ago 446.7 MB
ouruser/sinatra v2 5db5f8471261 11 hours ago 446.7 MB
创建完我们自己的镜像后,我们可以通过 docker push
命令,把它上传到 Docker Hub。这样,其它人都可以使用该镜像。
$ docker push ouruser/sinatra
The push refers to a repository [ouruser/sinatra] (len: 1)
Sending image list
Pushing repository ouruser/sinatra (3 tags)
. . .
$ docker rmi training/sinatra
Untagged: training/sinatra:latest
Deleted: 5bc342fa0b91cabf65246837015197eecfa24b2213ed6a51a8974ae250fedd8d
Deleted: ed0fffdcdae5eb2c3a55549857a8be7fc8bc4241fb19ad714364cbfd7a56b22f
Deleted: 5c58979d73ae448df5af1d8142436d81116187a7633082650549c52c3a2418f0
注意: 删除镜像时必须保证当前没有容器是基于该镜像创建的。
命名容器
前面已经通过 ps
命令查看容器了,返回信息的最后一列是容器的名称,默认情况下是自动生成的,如:nostalgic_morse
, 我们可以通过 --name
命令自己命名容器:
$ docker run -d -P --name web training/webapp python app.py
这时候再查看容器列表:
$ docker ps -l
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
aed84ee21bde training/webapp:latest python app.py 12 hours ago Up 2 seconds 0.0.0.0:49154->5000/tcp web
或者也可以通过 docker inspect
来命名:
$ docker inspect web
[
{
"Id": "3ce51710b34f5d6da95e0a340d32aa2e6cf64857fb8cdb2a6c38f7c56f448143",
"Created": "2015-10-25T22:44:17.854367116Z",
"Path": "python",
"Args": [
"app.py"
],
"State": {
"Status": "running",
"Running": true,
"Paused": false,
"Restarting": false,
"OOMKilled": false,
...
容器名称必须是唯一的。如果要重复使用名称,必须先删除原容器(使用
docker rm
命令)。当然,删除前要先停止容器。
$ docker stop web
web
$ docker rm web
web
默认情况下,Docker 提供两种网络连接方式:bridge 和 overlay。在高级应用中也可以自己写网络驱动。。 每个容器安装完后,都会自动包括三个网络:
$ docker network ls
NETWORK ID NAME DRIVER
18a2866682b8 none null
c288470c46f6 host host
7b369448dccb bridge bridge
bridge 是常用的网络方式。测试如下:
$ docker run -itd --name=networktest ubuntu
74695c9cea6d9810718fddadc01a727a5dd3ce6a69d09752239736c030599741
通过 docker inspect 获得IP:
$ docker network inspect bridge
[
{
"Name": "bridge",
"Id": "f7ab26d71dbd6f557852c7156ae0574bbf62c42f539b50c8ebde0f728a253b6f",
"Scope": "local",
"Driver": "bridge",
"IPAM": {
"Driver": "default",
"Config": [
{
"Subnet": "172.17.0.1/16",
"Gateway": "172.17.0.1"
}
]
},
"Containers": {
"3386a527aa08b37ea9232cbcace2d2458d49f44bb05a6b775fba7ddd40d8f92c": {
"EndpointID": "647c12443e91faf0fd508b6edfe59c30b642abb60dfab890b4bdccee38750bc1",
"MacAddress": "02:42:ac:11:00:02",
"IPv4Address": "172.17.0.2/16",
"IPv6Address": ""
},
"94447ca479852d29aeddca75c28f7104df3c3196d7b6d83061879e339946805c": {
"EndpointID": "b047d090f446ac49747d3c37d63e4307be745876db7f0ceef7b311cbba615f48",
"MacAddress": "02:42:ac:11:00:03",
"IPv4Address": "172.17.0.3/16",
"IPv6Address": ""
}
},
"Options": {
"com.docker.network.bridge.default_bridge": "true",
"com.docker.network.bridge.enable_icc": "true",
"com.docker.network.bridge.enable_ip_masquerade": "true",
"com.docker.network.bridge.host_binding_ipv4": "0.0.0.0",
"com.docker.network.bridge.name": "docker0",
"com.docker.network.driver.mtu": "9001"
}
}
]
我们也可以通过提供网络名和容器名来断开容器网络:
$ docker network disconnect bridge networktest
就算断开了容器网络连接,也不会删除容器里名为 bridge 的网络名。
Docker 引擎提供两种网络连接方式: bridge 和 overlay。bridge 只允许在一个主机上运行容器。overlay 可以有多个主机。 创建 bridge 网络:
$ docker network create -d bridge my-bridge-network
-d 参数 Docker 使用 bridge 驱动方式。因为默认是使用 bridge,所以也可以只写 -d, 后面不写参数值。
创建完后再查看网络连接:
$ docker network ls
NETWORK ID NAME DRIVER
7b369448dccb bridge bridge
615d565d498c my-bridge-network bridge
18a2866682b8 none null
c288470c46f6 host host
这时候再 inspect 刚才这个网络,会发现里面什么都没有:
$ docker network inspect my-bridge-network
[
{
"Name": "my-bridge-network",
"Id": "5a8afc6364bccb199540e133e63adb76a557906dd9ff82b94183fc48c40857ac",
"Scope": "local",
"Driver": "bridge",
"IPAM": {
"Driver": "default",
"Config": [
{
"Subnet": "172.18.0.0/16",
"Gateway": "172.18.0.1/16"
}
]
},
"Containers": {},
"Options": {}
}
]
往网络中添加容器 上面我们创建了一个新的网络,但里面没有容器,这里可以往网络里添加容器。
$ docker run -d --net=my-bridge-network --name db training/postgres
这时候再 inspect 网络 my-bridge-network, 会发现里面有容器了:
$ docker inspect --format='' db
{"my-bridge-network":{"NetworkID":"7d86d31b1478e7cca9ebed7e73aa0fdeec46c5ca29497431d3007d2d9e15ed99",
"EndpointID":"508b170d56b2ac9e4ef86694b0a76a22dd3df1983404f7321da5649645bf7043","Gateway":"172.18.0.1","IPAddress":"172.18.0.2","IPPrefixLen":16,"IPv6Gateway":"","GlobalIPv6Address":"","GlobalIPv6PrefixLen":0,"MacAddress":"02:42:ac:11:00:02"}}
现在再次启动容器里的 web 应用,现在不做端口映射:
$ docker run -d --name web training/webapp python app.py
这时候 inspect 会发现应用运行在 bridge 网络下:
$ docker inspect --format='' web
{"bridge":{"NetworkID":"7ea29fc1412292a2d7bba362f9253545fecdfa8ce9a6e37dd10ba8bee7129812",
"EndpointID":"508b170d56b2ac9e4ef86694b0a76a22dd3df1983404f7321da5649645bf7043","Gateway":"172.17.0.1","IPAddress":"172.17.0.2","IPPrefixLen":16,"IPv6Gateway":"","GlobalIPv6Address":"","GlobalIPv6PrefixLen":0,"MacAddress":"02:42:ac:11:00:02"}}
再获得容器的IP:
$ docker inspect --format='' web
172.17.0.2
打开一个和容器交互的 shell 窗口:
$ docker exec -it db bash
root@a205f0dd33b2:/# ping 172.17.0.2
ping 172.17.0.2
PING 172.17.0.2 (172.17.0.2) 56(84) bytes of data.
^C
--- 172.17.0.2 ping statistics ---
44 packets transmitted, 0 received, 100% packet loss, time 43185ms
CTRL-C 可以结束命令。另外,上面输入的 ping 命令失败了,因为两个容器不在一个网络中。
Docker 可以把容器添加到多个网络中。所以这里可以把容器 web 添加到网络 my-bridge-network 中:
$ docker network connect my-bridge-network web
再打开一个 shell 窗口执行 ping 命令:
$ docker exec -it db bash
root@a205f0dd33b2:/# ping web
PING web (172.18.0.3) 56(84) bytes of data.
64 bytes from web (172.18.0.3): icmp_seq=1 ttl=64 time=0.095 ms
64 bytes from web (172.18.0.3): icmp_seq=2 ttl=64 time=0.060 ms
64 bytes from web (172.18.0.3): icmp_seq=3 ttl=64 time=0.066 ms
^C
--- web ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2000ms
rtt min/avg/max/mdev = 0.060/0.073/0.095/0.018 ms
ping 的结果显示 web 在 my-bridge-network 中的IP和在 bridge 中的不一样。
数据卷
数据卷是一种经特殊设计的目录,它可以存在于多个容器中,它可以用来存储或共享数据,它的主要特性有:
添加数据卷
可以使用 -v
参数在启动窗口时向容器中添加一个数据卷。可以添加多个-v
参数。如:
$ docker run -d -P --name web -v /webapp training/webapp python app.py
注意: 也可以使用 Dockerfile 方式,在文件里添加 VOLUME 项来添加数据卷 查看数据卷
$ docker inspect web
输出是数据卷的详细信息:
...
Mounts": [
{
"Name": "fac362...80535",
"Source": "/var/lib/docker/volumes/fac362...80535/_data",
"Destination": "/webapp",
"Driver": "local",
"Mode": "",
"RW": true,
"Propagation": ""
}
]
...
可以看到,数据卷是存在于宿主机器上的某个目录上的。RW的值表示该数据卷是可读写的。
挂载宿主目录
-v参数也可以直接将宿主机器上的某一个目录绑定到容器中作为数据卷使用:
$ docker run -d -P --name web -v /src/webapp:/opt/webapp training/webapp python app.py
该命令将宿主机的目录 /src/webapp, 作为数据卷,绑定到容器的 /opt/webapp.
如果路径 /opt/webapp 已经存在于容器对应的镜像中,/src/webapp 再次绑定,但是目录里已经存在的文件是不会被删除的。一旦解绑定,原内容就又可以访问了。
容器的目录通常都是绝对路径,如: /src/docs。但宿主机的目录可以是绝对路径或者一个名称。如果提供的是名称,Docker 会自己用该名称创建一个卷。
这个名字必须以字母开头,后面可以是a-z0-9, _ (下划线), . (点) or - (横线);绝对路径必须是 / 开头。
绑定宿主机目录是非常有用的。比如,可以把代码放在宿主机上,然后直接修改。这样马上就可以看到效果。
默认情况下,绑定的卷都是可读写的,也可以设置成只读:
$ docker run -d -P --name web -v /src/webapp:/opt/webapp:ro training/webapp python app.py
挂载宿主文件
-v 参数也可以用来挂载单个文件:
$ docker run --rm -it -v ~/.bash_history:/root/.bash_history ubuntu /bin/bash
这样会打开新容器的 shell 窗口,当退出容器时,容器将会拥有宿主机器的 bash history
创建容器并挂载数据卷
如果有些数据是要在多个容器间共享的,最好是创建一个数据卷容器,然后把该容器绑定到其它容器上:
$ docker create -v /dbdata --name dbstore training/postgres /bin/true
这样就可以在其它容器上用 --volumes-from
绑定 /dbdata 了:
$ docker run -d --volumes-from dbstore --name db1 training/postgres
以及:
$ docker run -d --volumes-from dbstore --name db2 training/postgres
这样,如果镜像 postgres 里已经包含目录 /dbdata,原目录下的文件会被隐藏,只有 dbstore 里的文件能访问。等去除绑定后,原文件才可以再次访问。
可以使用多个 --volumes-from
从不同容器中绑定。也可以如下:
$ docker run -d --name db3 --volumes-from db1 training/postgres
备份,恢复,迁移数据卷
$ docker run --rm --volumes-from dbstore -v $(pwd):/backup ubuntu tar cvf /backup/backup.tar /dbdata
现在秒杀业务在电商的业务中是经常用到。成为配合销售的一种常规手段了。但它背后涉及到的技术可不算常规,实际上是很复杂的。因为在短时间内,大量的请求堆积。稍有不慎就会千万服务崩溃。而最容易承压的就是数据库。
在设计一个秒杀系统时,可以对功能进行模块的划分,然后分层进行设计及优化。
在开始秒杀的时候,流量突增。其中有不少是垃圾流量。比如最觉的是反复刷新页面,疯狂点击抢购按钮,以及稍有点网络知识的人直接发起网络请求以跳过前端JS的限制;更甚者操作大量的肉机同时发起请求防止IP等限制。
为了加快页面响应速度,页面的图片,JS,CSS等文件放入CDN进行加速。减少一部分服务器的流量。同时,在用户点击抢购后,可以在JS中将按钮设为不可用。同时,在JS里往 cookie 写入状态,表示已经抢购。这样用户刷新页面后还是不可点击。当然,用户清空 cookie 后还是可以继续刷。但这样已经可以挡住一部分用户了。
同时,在抢购开始前,可以通过时间判断将按钮设置为不可点击。当然,前台的时间判断是根据用户机器上的时间。所以,在服务端还是要进行判断和筛选。但这样还是可以将一部分小白用户拦住。
对于有一些网络知识的用户,他们能够获得到我们请求后的服务端地址。可以直接发起网络请求,跳过JS的限制,这就需要我们在服务端进行判断。第一层就是根据用户ID进行请求次数的限制,这里甚至可以直接在 nginx 里使用 lua 和 redis 进行判断。
经过限流后,剩下的基本上就是较真实的请求了。这里还可以加入缓存。比如抢购页面上商品的信息,可以直接存入 memcache或 redis 等缓存服务里。
请求多的时候,可以使用负载均衡进行分流。由多台机器承担压力。而且扩容方便。同时,还可以引入异步策略,使用消息服务。将请求先放入队列中,再由其它工作线程进行读取并操作。
为了进行限流,当接入负载均衡时,最好是将同一个用户,根据其UID分配到同一台机器。如果是用 nginx 作负载均衡,可以设置 limit_req_zone, limit_zone 进行限流。
经过限流削峰后,还会有许多请求到达服务层。比如有1万件商品。可能请求还会有 100 万。我们首先采用的策略是采用消息服务及队列,将请求先放入队列中。但不是所有的都要入队。这种情况下,我们只把前 10 万个请求入队甚至 5 万。其它的直接返回无货,让用户 30 分钟后再看。
这里假设我们给抢单的用户提供了 30 分钟的付款时间,超时未付款将回笼货物。这样其实就是又将一部分的请求延迟到了 30 分钟后。12306 的分时段放票其实也是这个思路,将流量分散。
使用队列后,处理下单的线程从队列读取,并处理下单,再改库存。这就是按自己的处理能力来的。可谓闲庭信步。当库存减到 0 后,队列里剩下的就都直接返回无货了,新请求也直接返回无货。
有一点要注意的是,就算使用队列。如果我们是多台机器支撑,就会存在数据库并发时的锁问题。比如:库存还有 2 个,这时候有两台机器同时下单,我们要保证不会多卖。
方案一就是明显的数据库事务。但事务有可能出现死锁的情况:
如: 事务一:
start transaction;
update table set column1 = 1 where id = 4;
update table set column1 = 2 where id = 3;
commit;
事务二:
start transaction;
update table set column1 = 3 where id = 3;
update table set column1 = 4 where id = 4;
commit;
两个事务执行第一条语句时都没问题,而且分别取得了两行的写锁。但是执行第二条语句时都不会成功,因为锁都被对方占用了。事务就会不停的等待对方释放资源。进而进入死循环。
为了解决这种问题,数据库系统实现了各种死锁检测及死锁超时机制。InnoDB 引擎则可以预知这种循环的相关性,并立刻返回错误,并回滚拥有最少排他锁的一个事务(因为这个事务最容易回滚)。
但也要等到死锁后才会进行后续处理。效率就非常低下了。这时候要考虑的是不用事务,而在逻辑中进行自我控制。比如在非事务环境下可能是这样处理:
update goods set cnt = cnt - 2;
这时候如果程序出错了,商品库存数已经被改了就没办法回滚了。这样就造成了脏数据。所以这里我们的做法是:先查也当前库存数是多少,然后用 set 的方式替代减法处理:
update goods set cnt = 3 where cnt = 5;
这时,如果有两个请求并发。他们分别先请求总库存数,得到结果是 5。然后开始各自的处理,订单1 set 后,库中的库存是 3;这时候订单2处理时,上面的语句就不会成功,因为 cnt 这时 != 5 了。而且这样来处理还可以在程序中添加出错时的重试。
分布式环境下,多台机器上多个进程对一个数据进行操作,如果不做互斥,就有可能出现“余额扣成负数”,或者“商品超卖”的情况。
多个访问方对同一个资源进行操作,需要进行互斥,通常是利用一个这些访问方同时能够访问到的lock来实施互斥的。
有几种场景:
同一进程内多线程的互斥
设定一个所有线程能够访问到的lock实施互斥
不同进程间的互斥
如,在网站,APP,等不同应用进行下单,设定一个所有进程能够访问到的lock实施互斥(例如文件inode,OS帮我们做了)。
分布式环境下的互斥
分布式环境下,多台机器上多个进程对一个数据进行操作的互斥,例如同一个uid=123要避免同时进行扣款。
根据上面的原理,先找一个多台机器多个进程可以同时访问到的一个lock,例如redis。
在做到了限流削峰,负载均衡,放号后,如果服务还是顶不住。这时候为了保证服务不崩溃,可以采取降级的策略。比如直接放弃 50% 甚至更多的请求,让他们直接返回无货或稍后再试。或者随机为用户返回失败。比如早年间QQ空间为了降负载,就会让一部分用户无法发表内容只能查看。