BlueXIII's Blog

热爱技术,持续学习

0%

引言

起因是某个同事接到了领导安排下来的一个需求,要在一个Web应用(Java+Tomcat)中,记录用户登录时的IP地址和MAC地址,用于安全审计,于是咨询我如何实现。

第一反应是,这个需求本身是不成立的,根据以往的了解,MAC地址应该是过不了路由器的才对。
以往做开发,都是用engineer的思维:先动手做,遇到问题再解决问题。但这个需求,应当用scientist的思维去思考:首先确定能不能做,然后才是怎么做。

翻查了一些资料,想来证实”为什么WEB服务器,可以获取到客户端的IP地址,但获取不到MAC地址“,看着看着才发现,这是个挺大的命题,够写一篇BLOG了。

PS:由于个人对这块内容了解的不够彻底,本文很可能会有谬误,请读者先不要太当真,另外希望平台组的同事给予指证。

先说结论

我所认为的结论应该是这样的:

  1. 获取远程主机的IP地址是可行的(先不讨论使用代理的情况)
  2. 本地网络下,当我们已知一个IPv4地址后,可以通过ARP请求,获取对应的MAC地址。换句话说,MAC地址,只在本地网络下才有意义。
  3. 但我们无法透过路由器,获取其它网络下的IPv4节点的MAC地址。

下面一步步解释一下。

HTTP

先从HTTP说起。
HTTP是一个应用层的协议,它建立在TCP协议之上。
HTTP请求就是用来发送一段文本。关于这段文本如何组织,第一行写什么,第二行写什么,哪里加一个空行,就是HTTP协议所要规范的内容。
举个直接的例子,下面是一个简单的HTTP GET请求,有兴趣可以用telnet模拟一下。

1
2
3
4
5
6
7
8
GET http://www.fiddler2.com/UpdateCheck.aspx?isBeta=False HTTP/1.1
User-Agent: Fiddler/4.6.3.50306 (.NET 4.0; WinNT 5.1.2600 SP3; zh-CN; 2xx86; Auto Update; Full Instance; Extensions: Geoedge, AutoSaveExt, HostsFile, SAZClipboardFactory, EventLog, SimpleFilter, Timeline, APITesting)
Pragma: no-cache
Host: www.fiddler2.com
Accept-Language: zh-CN
Referer: http://fiddler2.com/client/4.6.3.50306
Accept-Encoding: gzip, deflate
Connection: close

我们可以看到,HTTP的这段请求中,完全找不到客户端的MAC地址,甚至连IP地址都没有描述。
那IP地址是从哪里取到的呢?接下来我们再深入一点,看下一个内容:Socket

Socket

HTTP的客户端和服务端,是通过Socket进行连接的。

Socket是什么呢?Socket是对OSI模型第4层-传输层中的TCP/IP协议的封装。Socket本身并不是协议,而是一个调用接口(API)。Socket和TCP/IP协议没有必然的联系。但通过Socket,我们才能使用TCP/IP协议。应用层不必了解TCP/IP协议细节,直接通过对Socket接口函数的调用完成数据在IP网络的传输。

Socket包含了网络通信必须的五种信息:连接使用的协议,本地主机的IP地址,本地进程的协议端口,远地主机的IP地址,远地进程的协议端口

所以,因为有了Socket,客户端和服务端完全不需要了解底层细节,直接通过调用Socket来实现就可以了。

这也就是为什么服务器端可以获取到客户端的IP地址的原因,因为Socket中包含了远地主机的IP地址。(当然,通过代理服务器进行访问的除外,这种要依靠HTTP协议的X-Forwarded-For头来确认IP,不在本次的讨论范围中)

那为什么无法获取到客户端的MAC地址呢?很简单,同理,因为Socket中无法取到MAC地址。。。

如果继续发问,为什么Socket中都既然都包含IP地址了,为什么偏偏不包含MAC地址信息呢?看来我们还要更深入一点,看一下OSI模型吧。

OSI七层模型

首先祭出这张经典的OSI七层模型图,计算机网络的基石,请先盯着看一会儿,认真复习一下

这里还有一张OSI七层模型与TCP/IP四层模型的对照图

为了方便理解,再放上一张更直观的,每一层对应的数据型式和主要协议的示意图

通过上图大体可以知道:

  • MAC地址是记录在第2层-数据链路层的
  • IP地址是记录在第3层-网络层的
  • 端口号(TCP/UDP)是位于于第4层-传输层的
  • HTTP请求报文是记录在第7层-应用层的

当打开一个URL时,究竟发生了什么

下面举个栗子,当我们在浏览器中打开一个链接后,看看OSI各层倒底发生了什么:
这里撇开DNS解析之类东西,只说一下HTTP报文的发送

1、发送端

首先来看一下发送端(浏览器所在的主机)。参照第一张OSI模型图,按照从上向下的顺序来看。应用层数据其实只有那么几行文本,然后往下,每过一层,都要被加上首部/尾部。这个过程就像是一层一层的穿衣服

HTTP请求文本:

最后到了数据链路层之后,数据就变成了这个肥肥的样子,最后转换成0和1的电信号发出:


下面看看,现实中,每一层都做了些什么(现实中的分层和OSI模型还是有区别的):

  • 第7,6,5层,也就是OSI中的应用层,表示层,会话层(也是TCP/IP分层中的应用层),它创建了一个HTTP请求(例如 GET / HTTP/1.1),并交给下一层。一个普通的HTTP GET请求就是几行纯文本。
    处理这三层,是浏览器WEB服务器所要做的工作,浏览器发出请求,WEB服务器做出响应。

  • 第4,3层,也就是传输层/网络层。TCP/IP栈将上层的数据分成包(packets),并将它送往下一层-数据链路层。

IP地址、端口号记录在这两层中
处理这两层,是操作系统要做的工作,操作系统将这两层封装为了Socket,方便调用。

  • 第2层,数据链路层,将包(package)封装成帧(frame),并将它送到下一层物理层。

MAC地址记录在这一层中
这一层的工作,交由网卡来处理。

  • 第1层,物理层,使用电信号来传送0和1。
    最后这一层就是传输介质的工作了,例如双绞线、光纤、同轴电缆。

2、数据流转

数据发出去后,再看一下数据在网络上的流转。
数据一般要经过交换机、路由器等网络设备,层层转发,这些设备所做的事情就像是: 脱掉一件或几件衣服,做一些修修补补,然后再重新穿回去

  • 这里先以L2交换机为例看一下,因为L2交换机会认别到帧这一层,记录/学习MAC地址,并将帧发送到目的地。

通过上面这张图,我们就可以理解,MAC地址在本地网络下的重要作用了。也理解了,本地网络下,是可以查出每个节点的MAC地址的。

  • 下面,再来看一下路由器。当一个LAN希望连接到另一个LAN时,就必须使用路由器设备了。当然,可以通过构建超大型的LAN,来避免使用路由器,但这时,交换机就需要管理大量的MAC地址,同时进行大量的广播通信,设备的负担就会相当大。路由器会进行路由选择,让数据达到下一跳。

经过路由器后,为了能到达下一跳,数据链路层中的MAC地址就被篡改了,下面这张图很能说明问题:

3、接收端

最后看一下接收端(WEB服务器所在的主机)。参照第一张OSI模型图,按照从下至上的顺序来看,它要做的事情是: 将衣服一件一件全部脱掉 ,最后WEB服务器就取到了最初的应用层数据。

4、结论

所以,当一个以太网帧到达目的主机后,其中的MAC地址早已经不是原来客户端的MAC了,操作系统的Socket自然也无法获取原始的MAC地址了。

可不可以通过其它方式获取MAC地址

上面已经证明了,WEB服务端,是无法获取客户端的MAC地址的。
那么,能不能通过一些trick来绕道实现呢?
想了想,大概可以有如下的思路:

  1. 通过JavaScrip获取客户端的某块网卡的MAC地址,最好还要同时获取路由表,挑选出走默认路由的那块网卡。
  2. 将MAC地址,偷偷写入应用层的HTTP协议的Header中(类似Content-Type和User-Agent)
  3. 服务端直接读Header,就可以取到Mac地址了

那么这个思路可不可行呢?

  • 如果客户端是浏览器的话,首先第一步就会被打脸,我想也没有哪个浏览器会允许这种不安全的操作的。但如果硬要通过IE下的ActiveX来取的话,貌似也不是不可以。。。
  • 但如果客户端是APP的话,通过WebView+JS Bridge方式调用私有的API,应该也是可行的。。。

记录MAC地址的意义

最后的最后,不禁思考,获取MAC的意义在哪里呢?
如果单纯是为了取证和审计,我想意义是不大的,甚至不如直接记录IP地址。
因为:

  • 对于普通的正常用户而言,通过IP地址,是可以直接定位操作人的,即使IP是动态的。
  • 对于初级以上Hacker来说,伪造MAC地址和伪造IP地址一样简单,甚至更简单。

所以,一般的安全管控要求下,还是只记录IP吧。

参考资料

推荐学习书目

推荐网站

推荐IDE

其它资料

好用的第三方库

  • requests - HTTP库
  • Flask - RestfulAPI
  • peewee - 读写数据库,替代SQLAlchemy
  • aiohttp - 替代requests
  • BeautifulSoup - HTML解析
  • scipy - 科学计算
  • pandas - 处理csv及excel
  • fdfs_client-py - FastDFS客户端

代码风格规范

参考文档:
Google开源项目风格指南
Python代码规范小结

分号

不要在行尾加分号, 也不要用分号将两条命令放在同一行.

行长度

每行不超过80个字符
例外:

  1. 长的导入模块语句
  2. 注释里的URL

括号

宁缺毋滥的使用括号

1
2
3
4
5
6
7
8
9
10
Yes: if foo:
bar()
while x:
x = bar()
if x and y:
bar()
if not x:
bar()
return foo
for (x, y) in dict.items(): ...
1
2
3
4
5
No:  if (x):
bar()
if not(x):
bar()
return (foo)

缩进

用4个空格来缩进代码,绝对不要用tab, 也不要tab和空格混用

空行

顶级定义之间空两行, 方法定义之间空一行

空格

按照标准的排版规范来使用标点两边的空格
括号内不要有空格

1
2
Yes: spam(ham[1], {eggs: 2}, [])

1
2
No:  spam( ham[ 1 ], { eggs: 2 }, [ ] )

不要在逗号, 分号, 冒号前面加空格, 但应该在它们后面加(除了在行尾).

1
2
3
Yes: if x == 4:
print x, y
x, y = y, x
1
2
3
No:  if x == 4 :
print x , y
x , y = y , x

参数列表, 索引或切片的左括号前不应加空格.

1
Yes: spam(1)
1
2
no: spam (1)

1
Yes: dict['key'] = list[index]
1
No:  dict ['key'] = list [index]

在二元操作符两边都加上一个空格, 比如赋值(=), 比较(==, <, >, !=, <>, <=, >=, in, not in, is, is not), 布尔(and, or, not). 至于算术操作符两边的空格该如何使用, 需要你自己好好判断. 不过两侧务必要保持一致.

1
Yes: x == 1
1
No:  x<1

当’=’用于指示关键字参数或默认参数值时, 不要在其两侧使用空格.

1
2
Yes: def complex(real, imag=0.0): return magic(r=real, i=imag)

1
No:  def complex(real, imag = 0.0): return magic(r = real, i = imag)

不要用空格来垂直对齐多行间的标记, 因为这会成为维护的负担(适用于:, #, =等):

1
2
3
4
5
6
7
8
Yes:
foo = 1000 # comment
long_name = 2 # comment that should not be aligned

dictionary = {
"foo": 1,
"long_name": 2,
}
1
2
3
4
5
6
7
8
No:
foo = 1000 # comment
long_name = 2 # comment that should not be aligned

dictionary = {
"foo" : 1,
"long_name": 2,
}

Shebang

大部分.py文件不必以#!作为文件的开始. 根据 PEP-394 , 程序的main文件应该以 #!/usr/bin/python2或者 #!/usr/bin/python3开始.

注释

文档字符串

Python有一种独一无二的的注释方式: 使用文档字符串. 文档字符串是包, 模块, 类或函数里的第一个语句. 这些字符串可以通过对象的__doc__成员被自动提取, 并且被pydoc所用. 一个文档字符串应该这样组织: 首先是一行以句号, 问号或惊叹号结尾的概述(或者该文档字符串单纯只有一行). 接着是一个空行. 接着是文档字符串剩下的部分, 它应该与文档字符串的第一行的第一个引号对齐.

模块

每个文件应该包含一个许可样板. 根据项目使用的许可(例如, Apache 2.0, BSD, LGPL, GPL), 选择合适的样板.

函数和方法

关于函数的几个方面应该在特定的小节中进行描述记录, 这几个方面如下文所述. 每节应该以一个标题行开始. 标题行以冒号结尾. 除标题行外, 节的其他内容应被缩进2个空格.
Args:
列出每个参数的名字, 并在名字后使用一个冒号和一个空格, 分隔对该参数的描述.如果描述太长超过了单行80字符,使用2或者4个空格的悬挂缩进(与文件其他部分保持一致). 描述应该包括所需的类型和含义. 如果一个函数接受foo(可变长度参数列表)或者**bar (任意关键字参数), 应该详细列出foo和**bar.
Returns: (或者 Yields: 用于生成器)
描述返回值的类型和语义. 如果函数返回None, 这一部分可以省略.
Raises:
列出与接口有关的所有异常.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
def fetch_bigtable_rows(big_table, keys, other_silly_variable=None):
"""Fetches rows from a Bigtable.

Retrieves rows pertaining to the given keys from the Table instance
represented by big_table. Silly things may happen if
other_silly_variable is not None.

Args:
big_table: An open Bigtable Table instance.
keys: A sequence of strings representing the key of each table row
to fetch.
other_silly_variable: Another optional variable, that has a much
longer name than the other args, and which does nothing.

Returns:
A dict mapping keys to the corresponding table row data
fetched. Each row is represented as a tuple of strings. For
example:

{'Serak': ('Rigel VII', 'Preparer'),
'Zim': ('Irk', 'Invader'),
'Lrrr': ('Omicron Persei 8', 'Emperor')}

If a key from the keys argument is missing from the dictionary,
then that row was not found in the table.

Raises:
IOError: An error occurred accessing the bigtable.Table object.
"""
pass

类应该在其定义下有一个用于描述该类的文档字符串. 如果你的类有公共属性(Attributes), 那么文档中应该有一个属性(Attributes)段. 并且应该遵守和函数参数相同的格式.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class SampleClass(object):
"""Summary of class here.

Longer class information....
Longer class information....

Attributes:
likes_spam: A boolean indicating if we like SPAM or not.
eggs: An integer count of the eggs we have laid.
"""

def __init__(self, likes_spam=False):
"""Inits SampleClass with blah."""
self.likes_spam = likes_spam
self.eggs = 0

def public_method(self):
"""Performs operation blah."""

块注释和行注释

1
2
3
4
5
6
# We use a weighted dictionary search to find out where i is in
# the array. We extrapolate position based on the largest num
# in the array and the array size and then do binary search to
# get the exact number.

if i & (i-1) == 0: # true iff i is a power of 2

如果一个类不继承自其它类, 就显式的从object继承. 嵌套类也一样.

1
2
3
4
5
6
7
8
9
10
11
12
Yes: class SampleClass(object):
pass


class OuterClass(object):

class InnerClass(object):
pass


class ChildClass(ParentClass):
"""Explicitly inherits from another class already."""
1
2
3
4
5
6
7
8
No: class SampleClass:
pass


class OuterClass:

class InnerClass:
pass

字符串

即使参数都是字符串, 使用%操作符或者格式化方法格式化字符串. 不过也不能一概而论, 你需要在+和%之间好好判定.

1
2
3
4
5
Yes: x = a + b
x = '%s, %s!' % (imperative, expletive)
x = '{}, {}!'.format(imperative, expletive)
x = 'name: %s; score: %d' % (name, n)
x = 'name: {}; score: {}'.format(name, n)
1
2
3
4
No: x = '%s%s' % (a, b)  # use + in this case
x = '{}{}'.format(a, b) # use + in this case
x = imperative + ', ' + expletive + '!'
x = 'name: ' + name + '; score: ' + str(n)

避免在循环中用+和+=操作符来累加字符串. 由于字符串是不可变的, 这样做会创建不必要的临时对象, 并且导致二次方而不是线性的运行时间. 作为替代方案, 你可以将每个子串加入列表, 然后在循环结束后用 .join 连接列表. (也可以将每个子串写入一个 cStringIO.StringIO 缓存中.)

1
2
3
4
5
Yes: items = ['<table>']
for last_name, first_name in employee_list:
items.append('<tr><td>%s, %s</td></tr>' % (last_name, first_name))
items.append('</table>')
employee_table = ''.join(items)
1
2
3
4
No: employee_table = '<table>'
for last_name, first_name in employee_list:
employee_table += '<tr><td>%s, %s</td></tr>' % (last_name, first_name)
employee_table += '</table>'

在同一个文件中, 保持使用字符串引号的一致性. 使用单引号’或者双引号”之一用以引用字符串, 并在同一文件中沿用. 在字符串内可以使用另外一种引号, 以避免在字符串中使用.

1
2
3
4
Yes:
Python('Why are you hiding your eyes?')
Gollum("I'm scared of lint errors.")
Narrator('"Good!" thought a happy Python reviewer.')
1
2
3
4
No:
Python("Why are you hiding your eyes?")
Gollum('The lint. It burns. It burns us.')
Gollum("Always the great lint. Watching. Watching.")

为多行字符串使用三重双引号”“”而非三重单引号’‘’. 当且仅当项目中使用单引号’来引用字符串时, 才可能会使用三重’‘’为非文档字符串的多行字符串来标识引用. 文档字符串必须使用三重双引号”“”. 不过要注意, 通常用隐式行连接更清晰, 因为多行字符串与程序其他部分的缩进方式不一致.

1
2
3
Yes:
print ("This is much nicer.\n"
"Do it this way.\n")
1
2
3
4
No:
print """This is pretty ugly.
Don't do this.
"""

文件和sockets

在文件和sockets结束时, 显式的关闭它.
推荐使用 “with”语句 以管理文件:

1
2
3
4
with open("hello.txt") as hello_file:
for line in hello_file:
print line

对于不支持使用”with”语句的类似文件的对象,使用 contextlib.closing():

1
2
3
4
5
import contextlib

with contextlib.closing(urllib.urlopen("http://www.python.org/")) as front_page:
for line in front_page:
print line

TODO注释

1
2
# TODO(kl@gmail.com): Use a "*" here for string repetition.
# TODO(Zeke) Change this to use relations.

导入格式

每个导入应该独占一行

1
2
Yes: import os
import sys
1
No:  import os, sys

每种分组中, 应该根据每个模块的完整包路径按字典序排序, 忽略大小写.

1
2
3
4
5
import foo
from foo import bar
from foo.bar import baz
from foo.bar import Quux
from Foob import ar

语句

通常每个语句应该独占一行
不过, 如果测试结果与测试语句在一行放得下, 你也可以将它们放在同一行. 如果是if语句, 只有在没有else时才能这样做. 特别地, 绝不要对 try/except 这样做, 因为try和except不能放在同一行.

1
2
3
Yes:

if foo: bar(foo)
1
2
3
4
5
6
7
8
9
10
11
No:

if foo: bar(foo)
else: baz(foo)

try: bar(foo)
except ValueError: baz(foo)

try:
bar(foo)
except ValueError: baz(foo)

访问控制

在Python中, 对于琐碎又不太重要的访问函数, 你应该直接使用公有变量来取代它们, 这样可以避免额外的函数调用开销. 当添加更多功能时, 你可以用属性(property)来保持语法的一致性.
另一方面, 如果访问更复杂, 或者变量的访问开销很显著, 那么你应该使用像 get_foo() 和 set_foo() 这样的函数调用. 如果之前的代码行为允许通过属性(property)访问 , 那么就不要将新的访问函数与属性绑定. 这样, 任何试图通过老方法访问变量的代码就没法运行, 使用者也就会意识到复杂性发生了变化.

命名

1
2
3
4
5
6
7
8
9
10
module_name
package_name
ClassName
method_name
ExceptionName
function_name
GLOBAL_VAR_NAME
instance_var_name
function_parameter_name
local_var_name

应该避免的名称:

  1. 单字符名称, 除了计数器和迭代器.
  2. 包/模块名中的连字符(-)
  3. 双下划线开头并结尾的名称(Python保留, 例如init)

命名约定:

  1. 所谓”内部(Internal)”表示仅模块内可用, 或者, 在类内是保护或私有的.
  2. 用单下划线( _ )开头表示模块变量或函数是protected的(使用import * from时不会包含).
  3. 用双下划线( __ )开头的实例变量或方法表示类内私有.
  4. 将相关的类和顶级函数放在同一个模块里. 不像Java, 没必要限制一个类一个模块.
  5. 对类名使用大写字母开头的单词(如CapWords, 即Pascal风格), 但是模块名应该用小写加下划线的方式(如lower_with_under.py). 尽管已经有很多现存的模块使用类似于CapWords.py这样的命名, 但现在已经不鼓励这样做, 因为如果模块名碰巧和类名一致, 这会让人困扰.

Main

即使是一个打算被用作脚本的文件, 也应该是可导入的. 并且简单的导入不应该导致这个脚本的主功能(main functionality)被执行, 这是一种副作用. 主功能应该放在一个main()函数中.

在Python中, pydoc以及单元测试要求模块必须是可导入的. 你的代码应该在执行主程序前总是检查 if name == ‘main‘ , 这样当模块被导入时主程序就不会被执行.

1
2
3
4
5
6
def main():
...

if __name__ == '__main__':
main()

所有的顶级代码在模块导入时都会被执行. 要小心不要去调用函数, 创建对象, 或者执行那些不应该在使用pydoc时执行的操作.

语法备忘

Python解释器

  • CPython - 官方版本的解释,C开发
  • IPython - 基于CPython之上的一个交互式解释器
  • PyPy - 采用JIT技术,对Python代码进行动态编译,提高执行速度
  • Jython - 运行在Java平台
  • IronPython - 运行在微软.Net平台

基本输入输出

1
2
print('hello, world')
name = input()

数据类型

整数

1,100,-8080,0
十六进制 0xff00,0xa5b4c3d2

浮点数

1.23,3.14,-9.01
浮点数运算有误差

字符串

字符串是以单引号’或双引号”括起来的任意文本,比如’abc’,”xyz”
如果字符串内部既包含’又包含”,可以用转义字符\来标识

\n表示换行,\t表示制表符,字符 \ 本身也要转义

Python允许用’’’…’’’的格式表示多行内容

1
2
3
print('''line1
line2
line3''')

布尔值

True False

空值

None

变量

1
2
3
4
a = 123 # a是整数
print(a)
a = 'ABC' # a变为字符串
print(a)

常量

通常用全部大写的变量名表示常量:
PI = 3.14159265359
但事实上PI仍然是一个变量,Python根本没有任何机制保证PI不会被改变

除法

有两种除法,一种除法是/,即使是两个整数恰好整除,结果也是浮点数:

1
2
3
4
5
>>> 10 / 3
3.3333333333333335

>>> 9 / 3
3.0

还有一种除法是//,称为地板除,两个整数的除法仍然是整数:

1
2
>>> 10 // 3
3

余数运算:

1
2
>>> 10 % 3
1

字符串

1
2
3
4
5
6
7
8
>>> ord('A')
65
>>> ord('中')
20013
>>> chr(66)
'B'
>>> chr(25991)
'文'
1
2
3
4
>>> len('ABC')
3
>>> len('中文')
2
1
2
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
1
2
3
4
>>> 'Hello, %s' % 'world'
'Hello, world'
>>> 'Hi, %s, you have $%d.' % ('Michael', 1000000)
'Hi, Michael, you have $1000000.'

%d 整数
%f 浮点数
%s 字符串
%x 十六进制整数

list

list是一种有序的集合,可以随时添加和删除其中的元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>>> classmates = ['Michael', 'Bob', 'Tracy']
>>> classmates
['Michael', 'Bob', 'Tracy']

>>> len(classmates)
3

>>> classmates[0]
'Michael'
>>> classmates[1]
'Bob'
>>> classmates[2]
'Tracy'
>>> classmates[-1]
'Tracy'
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
>>> classmates.append('Adam')
>>> classmates
['Michael', 'Bob', 'Tracy', 'Adam']

>>> classmates.insert(1, 'Jack')
>>> classmates
['Michael', 'Jack', 'Bob', 'Tracy', 'Adam']

>>> classmates.pop()
'Adam'
>>> classmates
['Michael', 'Jack', 'Bob', 'Tracy']

>>> classmates.pop(1)
'Jack'
>>> classmates
['Michael', 'Bob', 'Tracy']

>>> classmates[1] = 'Sarah'
>>> classmates
['Michael', 'Sarah', 'Tracy']

list里面的元素的数据类型也可以不同

1
2
>>> L = ['Apple', 123, True]

list元素也可以是另一个list,比如

1
2
3
>>> s = ['python', 'java', ['asp', 'php'], 'scheme']
>>> len(s)
4

tuple

tuple和list非常类似,但是tuple一旦初始化就不能修改

1
2
>>> classmates = ('Michael', 'Bob', 'Tracy')

只有1个元素的tuple定义时必须加一个逗号,,来消除歧义:

1
2
3
>>> t = (1,)
>>> t
(1,)

条件

1
2
3
4
5
6
7
age = 3
if age >= 18:
print('adult')
elif age >= 6:
print('teenager')
else:
print('kid')

循环

1
2
3
names = ['Michael', 'Bob', 'Tracy']
for name in names:
print(name)
1
2
3
4
5
6
sum = 0
n = 99
while n > 0:
sum = sum + n
n = n - 2
print(sum)

dict

1
2
3
>>> d = {'Michael': 95, 'Bob': 75, 'Tracy': 85}
>>> d['Michael']
95
1
2
>>> 'Thomas' in d
False
1
2
3
4
>>> d.pop('Bob')
75
>>> d
{'Michael': 95, 'Tracy': 85}

set

set和dict类似,也是一组key的集合,但不存储value。由于key不能重复,所以,在set中,没有重复的key。

1
2
3
>>> s = set([1, 1, 2, 2, 3, 3])
>>> s
{1, 2, 3}
1
2
3
4
5
6
>>> s.add(4)
>>> s
{1, 2, 3, 4}
>>> s.add(4)
>>> s
{1, 2, 3, 4}
1
2
3
>>> s.remove(4)
>>> s
{1, 2, 3}

内置函数

1
2
3
4
>>> abs(-20)
20
max(2, 3, 1, -5)
3
1
2
3
4
5
6
7
8
9
10
11
12
>>> int(12.34)
12
>>> float('12.34')
12.34
>>> str(1.23)
'1.23'
>>> str(100)
'100'
>>> bool(1)
True
>>> bool('')
False

定义函数

1
2
def power(x):
return x * x

默认参数

1
2
3
4
5
6
def power(x, n=2):
s = 1
while n > 0:
n = n - 1
s = s * x
return s

可变参数

1
2
3
4
5
6
7
8
9
10
11
12
13
def calc(*numbers):
sum = 0
for n in numbers:
sum = sum + n * n
return sum

>>> calc(1, 2)
5
>>> calc()
0
>>> nums = [1, 2, 3]
>>> calc(*nums)
14

关键字参数

1
2
3
4
5
6
7
8
9
10
11
def person(name, age, **kw):
print('name:', name, 'age:', age, 'other:', kw)

>>> person('Bob', 35, city='Beijing')
name: Bob age: 35 other: {'city': 'Beijing'}
>>> person('Adam', 45, gender='M', job='Engineer')
name: Adam age: 45 other: {'gender': 'M', 'job': 'Engineer'}

>>> extra = {'city': 'Beijing', 'job': 'Engineer'}
>>> person('Jack', 24, **extra)
name: Jack age: 24 other: {'city': 'Beijing', 'job': 'Engineer'}

命名关键字参数

1
2
3
4
5
6
7
8
def person(name, age, **kw):
if 'city' in kw:
# 有city参数
pass
if 'job' in kw:
# 有job参数
pass
print('name:', name, 'age:', age, 'other:', kw)

参数组合

1
2
3
4
5
def f1(a, b, c=0, *args, **kw):
print('a =', a, 'b =', b, 'c =', c, 'args =', args, 'kw =', kw)

def f2(a, b, c=0, *, d, **kw):
print('a =', a, 'b =', b, 'c =', c, 'd =', d, 'kw =', kw)

递归函数

1
2
3
4
5
6
7
def fact(n):
return fact_iter(n, 1)

def fact_iter(num, product):
if num == 1:
return product
return fact_iter(num - 1, num * product)

切片

1
2
3
4
5
6
7
8
9
10
11
12
13
14
>>> L = ['Michael', 'Sarah', 'Tracy', 'Bob', 'Jack']

>>> L[0:3]
['Michael', 'Sarah', 'Tracy']

>>> L[:3]
['Michael', 'Sarah', 'Tracy']

>>> L[-2:]
['Bob', 'Jack']

>>> L[-2:-1]
['Bob']

1
2
3
4
5
6
7
8
9
10
11
12
>>> L = list(range(100))
>>> L
[0, 1, 2, 3, ..., 99]

>>> L[:10:2] # 前10个数,每两个取一个:
[0, 2, 4, 6, 8]

>>> L[::5] # 所有数,每5个取一个
[0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90, 95]

>>> L[:] # 原样复制一个list
[0, 1, 2, 3, ..., 99]

迭代

1
2
3
4
5
6
7
>>> d = {'a': 1, 'b': 2, 'c': 3}
>>> for key in d:
... print(key)
...
a
c
b
1
2
3
4
5
6
7
>>> d = {'x': 'A', 'y': 'B', 'z': 'C' }
>>> for k, v in d.items():
... print(k, '=', v)
...
y = B
x = A
z = C
1
2
3
4
5
6
>>> for ch in 'ABC':
... print(ch)
...
A
B
C

判断一个对象是可迭代对象:

1
2
3
4
5
6
7
>>> from collections import Iterable
>>> isinstance('abc', Iterable) # str是否可迭代
True
>>> isinstance([1,2,3], Iterable) # list是否可迭代
True
>>> isinstance(123, Iterable) # 整数是否可迭代
False

列表生成

1
2
3
>>> list(range(1, 11))
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

1
2
>>> [x * x for x in range(1, 11)]
[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
1
2
>>> [x * x for x in range(1, 11) if x % 2 == 0]
[4, 16, 36, 64, 100]
1
2
3
>>> d = {'x': 'A', 'y': 'B', 'z': 'C' }
>>> [k + '=' + v for k, v in d.items()]
['y=B', 'x=A', 'z=C']
1
2
3
>>> L = ['Hello', 'World', 'IBM', 'Apple']
>>> [s.lower() for s in L]
['hello', 'world', 'ibm', 'apple']

生成器

这种一边循环一边计算的机制,称为生成器:generator。
只要把一个列表生成式的[]改成(),就创建了一个generator

1
2
3
4
5
6
>>> L = [x * x for x in range(10)]
>>> L
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
>>> g = (x * x for x in range(10))
>>> g
<generator object <genexpr> at 0x1022ef630>

定义模块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

' a test module '

__author__ = 'Michael Liao'

import sys

def test():
args = sys.argv
if len(args)==1:
print('Hello, world!')
elif len(args)==2:
print('Hello, %s!' % args[1])
else:
print('Too many arguments!')

if __name__=='__main__':
test()

第三方模块

1
pip install Pillow

默认情况下,Python解释器会搜索当前目录、所有已安装的内置模块和第三方模块,搜索路径存放在sys模块的path变量中:

1
2
3
>>> import sys
>>> sys.path
['', '/Library/Frameworks/Python.framework/Versions/3.4/lib/python34.zip', '/Library/Frameworks/Python.framework/Versions/3.4/lib/python3.4', '/Library/Frameworks/Python.framework/Versions/3.4/lib/python3.4/plat-darwin', '/Library/Frameworks/Python.framework/Versions/3.4/lib/python3.4/lib-dynload', '/Library/Frameworks/Python.framework/Versions/3.4/lib/python3.4/site-packages']

添加自己的搜索目录,一是直接修改sys.path,添加要搜索的目录:

1
2
>>> import sys
>>> sys.path.append('/Users/michael/my_py_scripts')

第二种方法是设置环境变量PYTHONPATH,该环境变量的内容会被自动添加到模块搜索路径中。

面向对象

1
2
3
4
5
6
7
8
9
10
11
12
class Student(object):

def __init__(self, name, score):
self.name = name
self.score = score

>>> bart = Student('Bart Simpson', 59)
>>> bart.name
'Bart Simpson'
>>> bart.score
59

1
2
3
4
5
6
7
8
9
10
11
class Student(object):

def __init__(self, name, score):
self.name = name
self.score = score

def print_score(self):
print('%s: %s' % (self.name, self.score))

>>> bart.print_score()
Bart Simpson: 59

访问限制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Student(object):

def __init__(self, name, score):
self.__name = name
self.__score = score

def print_score(self):
print('%s: %s' % (self.__name, self.__score))

def get_name(self):
return self.__name

def get_score(self):
return self.__score

def set_score(self, score):
self.__score = score

继承和多态

1
2
3
class Animal(object):
def run(self):
print('Animal is running...')
1
2
3
4
5
6
7
8
9
class Dog(Animal):

def run(self):
print('Dog is running...')

class Cat(Animal):

def run(self):
print('Cat is running...')
1
2
3
4
5
6
7
>>> isinstance(a, list)
True
>>> isinstance(b, Animal)
True
>>> isinstance(c, Dog)
True

获取对象信息

1
2
3
4
5
6
7
8
9
10
11
>>> type(123)
<class 'int'>
>>> type('str')
<class 'str'>
>>> type(None)
<type(None) 'NoneType'>
>>> type(abs)
<class 'builtin_function_or_method'>
>>> type(a)
<class '__main__.Animal'>

1
2
3
4
5
6
7
8
9
10
11
12
>>> import types
>>> def fn():
... pass
...
>>> type(fn)==types.FunctionType
True
>>> type(abs)==types.BuiltinFunctionType
True
>>> type(lambda x: x)==types.LambdaType
True
>>> type((x for x in range(10)))==types.GeneratorType
True
1
2
3
4
5
6
7
8
>>> isinstance(h, Husky)
True
>>> isinstance('a', str)
True
>>> isinstance(123, int)
True
>>> isinstance(b'a', bytes)
True
1
2
3

>>> dir('ABC')
['__add__', '__class__', '__contains__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getitem__', '__getnewargs__', '__gt__', '__hash__', '__init__', '__iter__', '__le__', '__len__', '__lt__', '__mod__', '__mul__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__rmod__', '__rmul__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'capitalize', 'casefold', 'center', 'count', 'encode', 'endswith', 'expandtabs', 'find', 'format', 'format_map', 'index', 'isalnum', 'isalpha', 'isdecimal', 'isdigit', 'isidentifier', 'islower', 'isnumeric', 'isprintable', 'isspace', 'istitle', 'isupper', 'join', 'ljust', 'lower', 'lstrip', 'maketrans', 'partition', 'replace', 'rfind', 'rindex', 'rjust', 'rpartition', 'rsplit', 'rstrip', 'split', 'splitlines', 'startswith', 'strip', 'swapcase', 'title', 'translate', 'upper', 'zfill']

实例属性和类属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
>>> class Student(object):
... name = 'Student'
...
>>> s = Student() # 创建实例s
>>> print(s.name) # 打印name属性,因为实例并没有name属性,所以会继续查找class的name属性
Student
>>> print(Student.name) # 打印类的name属性
Student
>>> s.name = 'Michael' # 给实例绑定name属性
>>> print(s.name) # 由于实例属性优先级比类属性高,因此,它会屏蔽掉类的name属性
Michael
>>> print(Student.name) # 但是类属性并未消失,用Student.name仍然可以访问
Student
>>> del s.name # 如果删除实例的name属性
>>> print(s.name) # 再次调用s.name,由于实例的name属性没有找到,类的name属性就显示出来了
Student

错误处理

1
2
3
4
5
6
7
8
9
10
try:
print('try...')
r = 10 / 0
print('result:', r)
except ZeroDivisionError as e:
print('except:', e)
finally:
print('finally...')
print('END')

调试

1
2
3
4
5
6
7
def foo(s):
n = int(s)
assert n != 0, 'n is zero!'
return 10 / n

def main():
foo('0')
1
2
3
4
5
6
import logging

s = '0'
n = int(s)
logging.info('n = %d' % n)
print(10 / n)
1
2
$ python3 -m pdb err.py

文件读写

读文件

1
2
3
4
>>> f = open('/Users/michael/test.txt', 'r')
>>> f.read()
'Hello, world!'
>>> f.close()
1
2
with open('/path/to/file', 'r') as f:
print(f.read())
1
2
for line in f.readlines():
print(line.strip())

二进制文件

1
2
3
>>> f = open('/Users/michael/test.jpg', 'rb')
>>> f.read()
b'\xff\xd8\xff\xe1\x00\x18Exif\x00\x00...' # 十六进制表示的字节

指定字符编码

1
2
3
>>> f = open('/Users/michael/gbk.txt', 'r', encoding='gbk')
>>> f.read()
'测试'

写文件

1
2
3
>>> f = open('/Users/michael/test.txt', 'w')
>>> f.write('Hello, world!')
>>> f.close()
1
2
with open('/Users/michael/test.txt', 'w') as f:
f.write('Hello, world!')

操作文件与目录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
>>> import os
>>> os.name # 操作系统类型
'posix'

>>> os.uname()
posix.uname_result(sysname='Darwin', nodename='MichaelMacPro.local', release='14.3.0', version='Darwin Kernel Version 14.3.0: Mon Mar 23 11:59:05 PDT 2015; root:xnu-2782.20.48~5/RELEASE_X86_64', machine='x86_64')

>>> os.environ
environ({'VERSIONER_PYTHON_PREFER_32_BIT': 'no', 'TERM_PROGRAM_VERSION': '326', 'LOGNAME': 'michael', 'USER': 'michael', 'PATH': '/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/X11/bin:/usr/local/mysql/bin', ...})

>>> os.environ.get('PATH')
'/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/X11/bin:/usr/local/mysql/bin'
>>> os.environ.get('x', 'default')
'default'

# 查看当前目录的绝对路径:
>>> os.path.abspath('.')
'/Users/michael'
# 在某个目录下创建一个新目录,首先把新目录的完整路径表示出来:
>>> os.path.join('/Users/michael', 'testdir')
'/Users/michael/testdir'
# 然后创建一个目录:
>>> os.mkdir('/Users/michael/testdir')
# 删掉一个目录:
>>> os.rmdir('/Users/michael/testdir')

>>> os.path.split('/Users/michael/testdir/file.txt')
('/Users/michael/testdir', 'file.txt')

>>> os.path.splitext('/path/to/file.txt')
('/path/to/file', '.txt')


# 对文件重命名:
>>> os.rename('test.txt', 'test.py')
# 删掉文件:
>>> os.remove('test.py')

内建模块datatime

获取当前日期和时间

1
2
3
4
>>> from datetime import datetime
>>> now = datetime.now() # 获取当前datetime
>>> print(now)
2015-05-18 16:28:07.198690

获取指定日期和时间

1
2
3
4
>>> from datetime import datetime
>>> dt = datetime(2015, 4, 19, 12, 20) # 用指定日期时间创建datetime
>>> print(dt)
2015-04-19 12:20:00

datetime转换为timestamp

1
2
3
4
>>> from datetime import datetime
>>> dt = datetime(2015, 4, 19, 12, 20) # 用指定日期时间创建datetime
>>> dt.timestamp() # 把datetime转换为timestamp
1429417200.0

timestamp转换为datetime

1
2
3
4
>>> from datetime import datetime
>>> t = 1429417200.0
>>> print(datetime.fromtimestamp(t))
2015-04-19 12:20:00

str转换为datetime

1
2
3
4
>>> from datetime import datetime
>>> cday = datetime.strptime('2015-6-1 18:19:59', '%Y-%m-%d %H:%M:%S')
>>> print(cday)
2015-06-01 18:19:59

datetime转换为str

1
2
3
4
>>> from datetime import datetime
>>> now = datetime.now()
>>> print(now.strftime('%a, %b %d %H:%M'))
Mon, May 05 16:28

datetime加减

1
2
3
4
5
6
7
8
9
10
>>> from datetime import datetime, timedelta
>>> now = datetime.now()
>>> now
datetime.datetime(2015, 5, 18, 16, 57, 3, 540997)
>>> now + timedelta(hours=10)
datetime.datetime(2015, 5, 19, 2, 57, 3, 540997)
>>> now - timedelta(days=1)
datetime.datetime(2015, 5, 17, 16, 57, 3, 540997)
>>> now + timedelta(days=2, hours=12)
datetime.datetime(2015, 5, 21, 4, 57, 3, 540997)

内建模块base64

Base64是一种用64个字符来表示任意二进制数据的方法。
Base64编码会把3字节的二进制数据编码为4字节的文本数据,长度增加33%。
如果二进制数据不是3的倍数,最后剩下1个或2个字节,Base64用\x00字节在末尾补足后,再在编码的末尾加上1个或2个=号,表示补了多少字节,解码的时候,会自动去掉。

1
2
3
4
5
>>> import base64
>>> base64.b64encode(b'binary\x00string')
b'YmluYXJ5AHN0cmluZw=='
>>> base64.b64decode(b'YmluYXJ5AHN0cmluZw==')
b'binary\x00string'

内建模块hashlib

hashlib提供了常见的摘要算法,如MD5,SHA1等等

1
2
3
4
5
import hashlib
md5 = hashlib.md5()
md5.update('how to use md5 in python hashlib?'.encode('utf-8'))
print(md5.hexdigest())
d26a53750bc40b38b65a520292f69306

内建模块contextlib

使用try...finally关闭资源

1
2
3
4
5
6
try:
f = open('/path/to/file', 'r')
f.read()
finally:
if f:
f.close()

使用with关闭资源,必须实现了上下文管理(通过__enter__和__exit__)

1
2
with open('/path/to/file', 'r') as f:
f.read()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

class Query(object):

def __init__(self, name):
self.name = name

def __enter__(self):
print('Begin')
return self

def __exit__(self, exc_type, exc_value, traceback):
if exc_type:
print('Error')
else:
print('End')

def query(self):
print('Query info about %s...' % self.name)

with Query('Bob') as q:
q.query()

@contextmanager,用于简化enter__和__exit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from contextlib import contextmanager

class Query(object):

def __init__(self, name):
self.name = name

def query(self):
print('Query info about %s...' % self.name)

@contextmanager
def create_query(name):
print('Begin')
q = Query(name)
yield q
print('End')


with create_query('Bob') as q:
q.query()

望在某段代码执行前后自动执行特定代码,也可以用@contextmanager实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@contextmanager
def tag(name):
print("<%s>" % name)
yield
print("</%s>" % name)

with tag("h1"):
print("hello")
print("world")

<h1>
hello
world
</h1>

@closing,把对象变为上下文对象

1
2
3
4
5
6
from contextlib import closing
from urllib.request import urlopen

with closing(urlopen('https://www.python.org')) as page:
for line in page:
print(line)

内建模块urllib

1
2
3
4
5
6
7
from urllib import request
with request.urlopen('https://api.douban.com/v2/book/2129650') as f:
data = f.read()
print('Status:', f.status, f.reason)
for k, v in f.getheaders():
print('%s: %s' % (k, v))
print('Data:', data.decode('utf-8'))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from urllib import request, parse

print('Login to weibo.cn...')
email = input('Email: ')
passwd = input('Password: ')
login_data = parse.urlencode([
('username', email),
('password', passwd),
('entry', 'mweibo'),
('client_id', ''),
('savestate', '1'),
('ec', ''),
('pagerefer', 'https://passport.weibo.cn/signin/welcome?entry=mweibo&r=http%3A%2F%2Fm.weibo.cn%2F')
])

req = request.Request('https://passport.weibo.cn/sso/login')
req.add_header('Origin', 'https://passport.weibo.cn')
req.add_header('User-Agent', 'Mozilla/6.0 (iPhone; CPU iPhone OS 8_0 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/8.0 Mobile/10A5376e Safari/8536.25')
req.add_header('Referer', 'https://passport.weibo.cn/signin/login?entry=mweibo&res=wel&wm=3349&r=http%3A%2F%2Fm.weibo.cn%2F')

with request.urlopen(req, data=login_data.encode('utf-8')) as f:
print('Status:', f.status, f.reason)
for k, v in f.getheaders():
print('%s: %s' % (k, v))
print('Data:', f.read().decode('utf-8'))

urllib可以用requests替代

第三方模块PIL

安装

1
sudo pip3 install pillow

图像缩放

1
2
3
4
5
6
7
8
9
10
11
12
from PIL import Image

# 打开一个jpg图像文件,注意是当前路径:
im = Image.open('test.jpg')
# 获得图像尺寸:
w, h = im.size
print('Original image size: %sx%s' % (w, h))
# 缩放到50%:
im.thumbnail((w//2, h//2))
print('Resize image to: %sx%s' % (w//2, h//2))
# 把缩放后的图像用jpeg格式保存:
im.save('thumbnail.jpg', 'jpeg')

生成字母验证码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
from PIL import Image, ImageDraw, ImageFont, ImageFilter
import random

# 随机字母:
def rndChar():
return chr(random.randint(65, 90))

# 随机颜色1:
def rndColor():
return (random.randint(64, 255), random.randint(64, 255), random.randint(64, 255))

# 随机颜色2:
def rndColor2():
return (random.randint(32, 127), random.randint(32, 127), random.randint(32, 127))

# 240 x 60:
width = 60 * 4
height = 60
image = Image.new('RGB', (width, height), (255, 255, 255))
# 创建Font对象:
font = ImageFont.truetype('Arial.ttf', 36)
# 创建Draw对象:
draw = ImageDraw.Draw(image)
# 填充每个像素:
for x in range(width):
for y in range(height):
draw.point((x, y), fill=rndColor())
# 输出文字:
for t in range(4):
draw.text((60 * t + 10, 10), rndChar(), font=font, fill=rndColor2())
# 模糊:
image = image.filter(ImageFilter.BLUR)
image.save('code.jpg', 'jpeg')

图形界面

Python支持多种图形界面的第三方库,包括:

  • Tk
  • wxWidgets
  • Qt
  • GTK
    Python自带的库是支持Tk的Tkinter,使用Tkinter,无需安装任何包
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from tkinter import *
import tkinter.messagebox as messagebox

class Application(Frame):
def __init__(self, master=None):
Frame.__init__(self, master)
self.pack()
self.createWidgets()

def createWidgets(self):
self.nameInput = Entry(self)
self.nameInput.pack()
self.alertButton = Button(self, text='Hello', command=self.hello)
self.alertButton.pack()

def hello(self):
name = self.nameInput.get() or 'world'
messagebox.showinfo('Message', 'Hello, %s' % name)

app = Application()
# 设置窗口标题:
app.master.title('Hello World')
# 主消息循环:
app.mainloop()

发送邮件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
from email import encoders
from email.header import Header
from email.mime.text import MIMEText
from email.utils import parseaddr, formataddr

import smtplib

def _format_addr(s):
name, addr = parseaddr(s)
return formataddr((Header(name, 'utf-8').encode(), addr))

from_addr = input('From: ')
password = input('Password: ')
to_addr = input('To: ')
smtp_server = input('SMTP server: ')

msg = MIMEText('hello, send by Python...', 'plain', 'utf-8')
msg['From'] = _format_addr('Python爱好者 <%s>' % from_addr)
msg['To'] = _format_addr('管理员 <%s>' % to_addr)
msg['Subject'] = Header('来自SMTP的问候……', 'utf-8').encode()

# 邮件正文是MIMEText:
msg.attach(MIMEText('send with file...', 'plain', 'utf-8'))

# 添加附件就是加上一个MIMEBase,从本地读取一个图片:
with open('/Users/michael/Downloads/test.png', 'rb') as f:
# 设置附件的MIME和文件名,这里是png类型:
mime = MIMEBase('image', 'png', filename='test.png')
# 加上必要的头信息:
mime.add_header('Content-Disposition', 'attachment', filename='test.png')
mime.add_header('Content-ID', '<0>')
mime.add_header('X-Attachment-Id', '0')
# 把附件的内容读进来:
mime.set_payload(f.read())
# 用Base64编码:
encoders.encode_base64(mime)
# 添加到MIMEMultipart:
msg.attach(mime)

server = smtplib.SMTP(smtp_server, 25)
server.set_debuglevel(1)
server.login(from_addr, password)
server.sendmail(from_addr, [to_addr], msg.as_string())
server.quit()

MySQL

1
sudo pip3 install mysql-connector
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 导入MySQL驱动:
>>> import mysql.connector
# 注意把password设为你的root口令:
>>> conn = mysql.connector.connect(user='root', password='password', database='test')
>>> cursor = conn.cursor()
# 创建user表:
>>> cursor.execute('create table user (id varchar(20) primary key, name varchar(20))')
# 插入一行记录,注意MySQL的占位符是%s:
>>> cursor.execute('insert into user (id, name) values (%s, %s)', ['1', 'Michael'])
>>> cursor.rowcount
1
# 提交事务:
>>> conn.commit()
>>> cursor.close()
# 运行查询:
>>> cursor = conn.cursor()
>>> cursor.execute('select * from user where id = %s', ('1',))
>>> values = cursor.fetchall()
>>> values
[('1', 'Michael')]
# 关闭Cursor和Connection:
>>> cursor.close()
True
>>> conn.close()

多进程

1
2
3
4
5
6
7
8
9
import os

print('Process (%s) start...' % os.getpid())
# Only works on Unix/Linux/Mac:
pid = os.fork()
if pid == 0:
print('I am child process (%s) and my parent is %s.' % (os.getpid(), os.getppid()))
else:
print('I (%s) just created a child process (%s).' % (os.getpid(), pid))

multiprocessing模块就是跨平台版本的多进程模块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from multiprocessing import Process
import os

# 子进程要执行的代码
def run_proc(name):
print('Run child process %s (%s)...' % (name, os.getpid()))

if __name__=='__main__':
print('Parent process %s.' % os.getpid())
p = Process(target=run_proc, args=('test',))
print('Child process will start.')
p.start()
p.join()
print('Child process end.')

用进程池的方式批量创建子进程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from multiprocessing import Pool
import os, time, random

def long_time_task(name):
print('Run task %s (%s)...' % (name, os.getpid()))
start = time.time()
time.sleep(random.random() * 3)
end = time.time()
print('Task %s runs %0.2f seconds.' % (name, (end - start)))

if __name__=='__main__':
print('Parent process %s.' % os.getpid())
p = Pool(4)
for i in range(5):
p.apply_async(long_time_task, args=(i,))
print('Waiting for all subprocesses done...')
p.close()
p.join()
print('All subprocesses done.')

需求简介

最近厂里有一个新闻采集类的需求,细节大体如下:

  1. 模拟登录一个内网网站(SSO)
  2. 抓取新闻(支持代理服务器的方式访问)
  3. 加工内容样式,以适配手机屏幕
  4. 将正文中的图片转存到自已的服务器,并替换标签中的url
  5. 图片存储服务器需要复用已有的FastDFS分布式文件系统
  6. 采集结果导入生产库
  7. 支持日志打印

初学Python3,正好用这个需求练练手,最后很惊讶的是只用200多行代码就实现了,如果换成Java的话大概需要1200行吧。果然应了那句老话:人生苦短,我用Python

登录页面抓包

第一步当然是抓包,然后再根据抓到的内容,模拟进行HTTP请求。

常用的抓包工具,有Mac下的Charles和Windows下的Fiddler。
它们的原理都是在本机开一个HTTP或SOCKS代理服务器端口,然后将浏览器的代理服务器设置成这个端口,这样浏览器中所有的HTTP请求都会先经过抓包工具记录下来了。

这里推荐尽量使用Fiddler,原因是Charles对于cookie的展示是有bug的,举个例子,真实情况:请求A返回了LtpaToken这个cookie,请求B中返回了sid这个cookie。但在Charles中的展示是:请求A中已经同时返回了LtpaToken和sid两个cookie,这就很容易误导人了。
另外Fiddler现在已经有了Linux的Beta版本,貌似是用类似wine的方式实现的。

如果网站使用了单点登录,可能会涉及到手工生成cookie。所以不仅需要分析每一条HTTP请求的request和response,以及带回来的cookie,还要对页面中的javascript进行分析,看一下是如何生成cookie的。

模拟登录

将页面分析完毕之后,就可以进行模拟HTTP请求了。
这里有两个非常好用的第三方库, requestBeautifulSoup

requests 库是用来代替urllib的,可以非常人性化的的生成HTTP请求,模拟session以及伪造cookie更是方便。
BeautifulSoup 用来代替re模块,进行HTML内容解析,可以用tag, class, id来定位想要提取的内容,也支持正则表达式等。

具体的使用方式直接看官方文档就可以了,写的非常详细,这里直接给出地址:
requests官方文档
BeautifulSoup官方文档

通过pip3来安装这两个模块:

1
2
3
sudo apt-get install python3-pip
sudo pip3 install requests
sudo pip3 install beautifulsoup4

导入模块:

1
2
import requests
from bs4 import BeautifulSoup

模拟登录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def sso_login():
# 调用单点登录工号认证页面
response = session.post(const.SSO_URL,
data={'login': const.LOGIN_USERNAME, 'password': const.LOGIN_PASSWORD, 'appid': 'np000'})

# 分析页面,取token及ltpa
soup = BeautifulSoup(response.text, 'html.parser')
token = soup.form.input.get('value')
ltpa = soup.form.input.input.input.get('value')
ltpa_value = ltpa.split(';')[0].split('=', 1)[1]

# 手工设置Cookie
session.cookies.set('LtpaToken', ltpa_value, domain='unicom.local', path='/')

# 调用云门户登录页面(2次)
payload = {'token': token}
session.post(const.LOGIN_URL, data=payload, proxies=const.PROXIES)
response = session.post(const.LOGIN_URL, data=payload, proxies=const.PROXIES)
if response.text == "success":
logging.info("登录成功")
return True
else:
logging.info("登录失败")
return False

这里用到了BeautifulSoup进行HTML解析,取出页面中的token、ltpa等字段。
然后使用session.cookies.set伪造了一个cookie,注意其中的domain参数,设置成1级域名。
然后用这个session,去调用网站页面,换回sid这个token。并可以根据页面的返回信息,来简单判断一下成功还是失败。

列表页面抓取

登录成功之后,接下来的列表页面抓取就要简单的多了,不考虑分页的话,直接取一个list出来遍历即可。

1
2
3
4
5
6
7
8
9
10
11
def capture_list(list_url):
response = session.get(list_url, proxies=const.PROXIES)
response.encoding = "UTF-8"
soup = BeautifulSoup(response.text, 'html.parser')
news_list = soup.find('div', 'xinwen_list').find_all('a')
news_list.reverse()
logging.info("开始采集")
for news_archor in news_list:
news_cid = news_archor.attrs['href'].split('=')[1]
capture_content(news_cid)
logging.info("结束采集")

这里使用了response.encoding = "UTF-8"来手工解决乱码问题。

新联页面抓取

新闻页面抓取,涉及到插临时表,这里没有使用每三方库,直接用SQL方式插入。
其中涉及到样式处理与图片转存,另写一个模块pconvert来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def capture_content(news_cid):
# 建立DB连接
conn = mysql.connector.connect(user=const.DB_USERNAME, password=const.DB_PASSWORD, host=const.DB_HOST,
port=const.DB_PORT, database=const.DB_DATABASE)
cursor = conn.cursor()

# 判断是否已存在
cursor.execute('select count(*) from material_prepare where news_cid = %s', (news_cid,))
news_count = cursor.fetchone()[0]
if news_count > 0:
logging.info("采集" + news_cid + ':已存在')
else:
logging.info("采集" + news_cid + ':新增')
news_url = const.NEWS_BASE_URL + news_cid
response = session.post(news_url, proxies=const.PROXIES)
response.encoding = "UTF-8"
soup = BeautifulSoup(response.text, 'html.parser')
# logging.info(soup)
news_title = soup.h3.text.strip()[:64]
news_brief = soup.find('div', 'brief').p.text.strip()[:100]
news_author = soup.h5.span.a.text.strip()[:100]
news_content = soup.find('table', 'unis_detail_content').tr.td.prettify()[66:-7].strip()
# 样式处理
news_content = pconvert.convert_style(news_content)
# 将图片转存至DFS并替换URL
news_content = pconvert.convert_img(news_content)
# 入表
cursor.execute(
'INSERT INTO material_prepare (news_cid, title, author, summary, content, add_time, status) VALUES (%s, %s, %s, %s, %s, now(), "0")'
, [news_cid, news_title, news_author, news_brief, news_content])
# 提交
conn.commit()
cursor.close()

样式处理

文本样式处理,还是要用到BeautifulSoup,因为原始站点上的新闻内容样式是五花八门的,根据实际情况,一边写一个test函数来生成文本,一边在浏览器上慢慢调试。

1
2
3
4
5
6
7
8
9
def convert_style(rawtext):
newtext = '<div style="margin-left: 0px; margin-right:0px; letter-spacing: 1px; word-spacing:2px;line-height: 1.7em; font-size:18px;text-align:justify; text-justify:inter-ideograph">' \
+ rawtext + '</div>'
newtext = newtext.replace(' align="center"', '')
soup = BeautifulSoup(newtext, 'html.parser')
img_tags = soup.find_all("img")
for img_tag in img_tags:
del img_tag.parent['style']
return soup.prettify()

图片转存至DFS

因为原始站点是在内网中的,采集下来的HTML中,标签的地址是内网地址,所以在公网中是展现不出来的,需要将图片转存,并用新的URL替换原有的URL。

1
2
3
4
5
6
7
8
9
def convert_img(rawtext):
soup = BeautifulSoup(rawtext, 'html.parser')
img_tags = soup.find_all("img")
for img_tag in img_tags:
raw_img_url = img_tag['src']
dfs_img_url = convert_url(raw_img_url)
img_tag['src'] = dfs_img_url
del img_tag['style']
return soup.prettify()

图片转存最简单的方式是保存成本地的文件,然后再通过nginx或httpd服务将图片开放出去:

1
2
3
4
pic_name = raw_img_url.split('/')[-1]
pic_path = TMP_PATH + '/' + pic_name
with open(pic_path, 'wb') as pic_file:
pic_file.write(pic_content)

但这里我们需要复用已有的FastDFS分布式文件系统,要用到它的一个客户端的库fdfs_client-py
fdfs_client-py不能直接使用pip3安装,需要直接使用一个python3版的源码,并手工修改其中代码。操作过程如下:

1
2
3
4
5
6
7
git clone https://github.com/jefforeilly/fdfs_client-py.git
cd dfs_client-py
vi ./fdfs_client/storage_client.py
将第12行 from fdfs_client.sendfile import * 注释掉
python3 setup.py install

sudo pip3 install mutagen

客户端的使用上没有什么特别的,直接调用upload_by_buffer,传一个图片的buffer进去就可以了,成功后会返回自动生成的文件名。

1
2
3
4
5
6
7
8
9
from fdfs_client.client import *
dfs_client = Fdfs_client('conf/dfs.conf')
def convert_url(raw_img_url):
response = requests.get(raw_img_url, proxies=const.PROXIES)
pic_buffer = response.content
pic_ext = raw_img_url.split('.')[-1]
response = dfs_client.upload_by_buffer(pic_buffer, pic_ext)
dfs_img_url = const.DFS_BASE_URL + '/' + response['Remote file_id']
return dfs_img_url

其中dfs.conf文件中,主要就是配置一下 tracker_server

日志处理

这里使用配置文件的方式处理日志,类似JAVA中的log4j吧,首先新建一个log.conf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
[loggers]
keys=root

[handlers]
keys=stream_handler,file_handler

[formatters]
keys=formatter

[logger_root]
level=DEBUG
handlers=stream_handler,file_handler

[handler_stream_handler]
class=StreamHandler
level=DEBUG
formatter=formatter
args=(sys.stderr,)

[handler_file_handler]
class=FileHandler
level=DEBUG
formatter=formatter
args=('logs/pspider.log','a','utf8')

[formatter_formatter]
format=%(asctime)s %(name)-12s %(levelname)-8s %(message)s

这里通过配置handlers,可以同时将日志打印到stderr和文件。
注意args=('logs/pspider.log','a','utf8') 这一行,用来解决文本文件中的中文乱码问题。

日志初始化:

1
2
3
4
import logging
from logging.config import fileConfig

fileConfig('conf/log.conf')

日志打印:

1
logging.info("test")

完整源码

到此为止,就是如何用Python3写一个爬虫的全部过程了。
采集不同的站点,肯定是要有不同的处理,但方法都是大同小异。
最后,将源码做了部分裁剪,分享在了GitHub上。
https://github.com/xiiiblue/pspider
最后,将源码做了部分裁剪,分享在了GitLab上。
http://git.si-tech.com.cn/guolei/pspider

什么是Redmine

Redmine是用Ruby On Rails开发的一款基于WEB的项目管理软件。
它集成了项目管理所需的各项功能,可以同时处理多个项目。
请重点关注 问题甘特图日历 三个功能模块。

我的工作台

点击左上角 我的工作台 按钮

  • 指派给我的问题
    提供一个跨项目的指派给当前用户的问题列表,显示问题的ID,项目,跟踪标类型签和主题。
  • 已报告的问题
    提供一个跨项目的由当前用户报告的问题列表,显示问题的ID,项目,跟踪标签类型和主题。 每一个用户可以通过点击”个性化定制本页”的链接个性化我的工作台。 然后用户可以选择哪些可用的模块被显示:
  • 日历
    提供一个跨项目的每周日历概述
  • 文档
    提供一个跨项目的最近文档概述
  • 最近的新闻
    提供一个跨项目的最近新闻概述
  • 耗时
    提供一个跨项目的关于当前用户最近7天工时的概述
  • 跟踪的问题
    提供一个跨项目的由当前用户跟踪的问题列表

项目概述

点击左上角 项目 按钮,再点击 渠道移动营销 项目

即可查看 项目概述 页面

项目活动


该页面列出了该项目所有活动的历史记录, 这些活动包括:

  • 问题
  • 变更
  • 新闻
  • 文档
  • 文件
  • Wiki编辑记录
  • 帖子
  • 耗时

该页面的右边栏允许你选择具体显示哪类活动

问题跟踪

问题是Redmine的核心业务。 一个问题绑定到一个项目, 由某一用于创建, 可以关联到某一版本, 等等。

  • 查看某一问题
    在问题列表页面点击某一问题的链接, 可以查看该问题的具体描述。

  • 过滤器的应用
    默认情况下, 问题列表显示了所有处于打开状态的问题。 你可以添加过滤器, 点击”应用”链接刷新问题列表, 点击”清除”链接删除设置的滤器。
    可以通过点击”+”号按钮, 为过滤器字段选择多个值。 这时会出现一个选择列表, 按住”ctrl”后, 可选择多个值。

  • 自定义查询
    当刷新页面后, 刚设置的过滤器就会消失, 可以通过点击”保存”链接保存你设置的过滤器, 从而建立自定义查询。
    在新建自定义查询的界面输入自定义查询的名称, 以及过滤器和其他属性的设置。
    点击保存之后, 新建的自定义查询将会出现在问题列表界面的右边栏中。

  • 快捷菜单

    在问题列表的某一个问题上, 点击鼠标右键, 将弹出一个快捷菜单, 用于便捷编辑问题。
    通过快捷菜单可以快修改问题的进度。

  • 新建问题
    要创建新的问题, 需要有新建问题的权限。
    创建问题时, 最重要的字段是跟踪标签字段, 它决定了问题的类型。
    默认情况下, Redmine有三种跟踪标签:
    功能 feature
    缺陷 bug
    支持 support

日历

在问题列表页面, 点击右边栏的”日历”链接, 即可进入日历界面。
日历提供了一个按月份显示的项目预览。 在这里你可以看到一个任务状态的起止日期。
像Redmine提供的其他视图一样, 可以通过设置过滤器从而决定日历图上显示的内容。


注意3种图标的含义

甘特图

在问题列表页面, 点击右边栏的”甘特图”链接, 即可进入甘特图界面。
甘特图显示问题的起止日期以及版本的截至日期

新闻

在”新闻”选项卡下, 你可以发布关于项目的新闻条目, 甚至任何你喜欢的新闻条目。

文档

在这里可以书写不同类型的文档, 默认有两种文档类型:

  • 用户文档
  • 技术文档

管理员可以添加文档类型

Wiki

在这里可以查看或编辑WIKI页面

文件

在这里可以共享项目需要用到的其他资源

参考文档

Redmine 用户手册

什么是Redmine

Redmine是用Ruby On Rails开发的一款基于WEB的项目管理软件。
它集成了项目管理所需的各项功能:日历、甘特图、问题跟踪和版本控制,可以同时处理多个项目。

相比禅道等软件,Redmine的安装还是比较繁琐的,一方面是因为它没有一个自动化的安装脚本,另一方面主要是个人对于Ruby初次接触,需要花一些时间在Ruby的学习上。
本文仅讨论Redmine的部署方式,关于Redmine使用今后会单独写一篇博文来讲解。

相关资源

官网WIKI

主机环境

RedHat/CentOS 6.X

代理服务器配置

如果主机处于内网环境中,无法直连Internet时,需要首先配置可用的网络环境,最简单的方式是使用代理服务器。
关于代理服务器的搭建不再赘述了,可以使用srelay+polipo这样的组合,详细步骤请参考之前的一篇文章《内网主机Python3环境搭建》。

代理服务器配置好之后,我们导入两条环境变量即可。

1
2
3
export http_proxy="http://xxx.xxx.xxx.xxx:31081"
export https_proxy="http://xxx.xxx.xxx.xxx:31081"

这时wget、curl、yum等已经可以正常使用了。

安装依赖

首先使用yum安装一些基本依赖,以下只是一些参考,可以在具体编译时缺什么补什么。

1
yum -y install nano zip unzip libyaml-devel zlib-devel curl-devel openssl-devel httpd-devel apr-devel apr-util-devel mysql-devel gcc ruby-devel gcc-c++ make postgresql-devel ImageMagick-devel sqlite-devel perl-LDAP mod_perl perl-Digest-SHA

安装MySQL

这里为了省事,使用最简单的yum的方式,直接在本机安装一套MySQL。yumd源中的MySQL版本比较老旧,是5.1.73。如果时间充裕的话,建议使用源码或二进制方式进行安装。

1
yum -y install mysql mysql-server

将MySQL设置为开机自启,并手工启动服务:

1
2
chkconfig mysqld on
service mysqld start

初次运行,进行密码等安全性

1
/usr/bin/mysql_secure_installation

Disallow root login remotely这一项可以选NO

1
2
3
4
5
6
Enter current password for root (enter for none):
Set root password? [Y/n] y
Remove anonymous users? [Y/n] y
Disallow root login remotely? [Y/n] n
Remove test database and access to it? [Y/n] y
Reload privilege tables now? [Y/n] y

新建MySQL数据库及用户

直接在本机登录mysql,新建一个数据库及用户

1
2
3
4
mysql -uroot -p
> create database redmine_db character set utf8;
> create user 'redmine_admin'@'localhost' identified by 'your_new_password';
> grant all privileges on redmine_db.* to 'redmine_admin'@'localhost';

关闭SELinux

1
2
3
4
vi /etc/selinux/config
SELINUX=disabled

/usr/sbin/setenforce 0 立刻关闭

配置iptables

配置iptables防火墙,将3000端口打开

1
2
3
vi /etc/sysconfig/iptables

-A INPUT -m state --state NEW -m tcp -p tcp --dport 3000 -j ACCEPT

重启iptables:

1
/etc/init.d/iptables restart

安装Ruby

使用RVM进行Ruby的安装。RVM是一个命令行工具,可以提供一个便捷的多版本Ruby环境的管理和切换。

1
2
curl -L https://get.rvm.io | bash
source /etc/profile.d/rvm.sh

列出已知的Ruby版本:

1
rvm list known

这里我们选择安装2.3.0:

1
rvm install 2.3.0

等待编译安装完成之后,可以查看一下版本号,确认安装成功:

1
2
ruby -v
ruby 2.3.0p0 (2015-12-25 revision 53290) [x86_64-linux]

安装Rubygems

RubyGems简称gems,是一个用于对Ruby组件进行打包的Ruby打包系统。

1
yum -y install rubygems

下载Redmine

我们新建一个redmine用户来进行接下来的操作。首先是从官网下载redmine的安装包并解压:

1
2
3
wget http://www.redmine.org/releases/redmine-3.3.2.tar.gz
tar -zxvf redmine-3.3.2.tar.gz
mv redmine-3.3.2 redmine

配置数据库连接

从示例中复制一份database.yml出来:

1
2
cd /path/to/redmine/config
cp database.yml.example database.yml

编辑database.yml,填写刚才新建的数据库的各项参数:

1
2
3
4
5
6
7
8
9
vi database.yml

production:
adapter: mysql2
database: redmine_db
host: localhost
username: redmine_admin
password: "redmine_password"
encoding: utf8

使用Bundle设置Rails

使用Bundle安装依赖的库

1
2
3
gem install bundler
bundle install
rake generate_secret_token

初始化数据库表及数据

1
2
RAILS_ENV=production rake db:migrate
RAILS_ENV=production rake redmine:load_default_data

设置文件系统权限

1
2
3
mkdir -p tmp tmp/pdf public/plugin_assets
sudo chown -R redmine:redmine files log tmp public/plugin_assets
sudo chmod -R 755 files log tmp public/plugin_assets

运行

1
bundle exec rails server -b 0.0.0.0 webrick -e production -d

安装

sudo npm install -g cordova
cordova create AppName
cordova platform add browser
cordova run browser
cordova platform add android –save
cordova platform ls
cordova requirements

编译

cordova build android
cordova build android -release
cordova emulate android

配置XML

vi config.xml


签名

keytool -genkey -v -keystore release-key.keystore -alias cordova-demo -keyalg RSA -keysize 2048 -validity 10000
vi build.json

1
2
3
4
5
6
7
8
9
10
{
"android": {
"release": {
"keystore": "release-key.keystore",
"alias": "cordova-demo",
"storePassword": "password",
"password": "password"
}
}
}

禁用缓存

1
2
3
4
5
6
7
8
9
10
11
12
import android.webkit.WebSettings;
import android.webkit.WebView;
@Override
protected void onResume() {
super.onResume();
// Disable caching ..
WebView wv = (WebView) appView.getEngine().getView();
WebSettings ws = wv.getSettings();
ws.setAppCacheEnabled(false);
ws.setCacheMode(WebSettings.LOAD_NO_CACHE);
loadUrl(launchUrl); // launchUrl is the default url specified in Config.xml
}

通用操作

后台批杀进程

1
ps -ef|grep java|grep webapp|grep -v grep|awk '{print $2}'|xargs kill

简单环境变量

1
2
3
4
export LANG=C
export PS1='[\u@\H $PWD]\$'
stty erase ^H
stty erase ^?

注意:LANG=C在HP主机上可以解决乱码文件导致的server无法stage的启动问题,但Linux下似乎无效。

生产环境分布式部署

  1. 仅在管理Machine上建立Domain,有且只有一个AdminServer
  2. 被管Machine上不需要新建Domain,但需要启动NodeManager并nmEnroll()到AdminServer
  3. Deployment方式,因为有文件乱码,选择第3项 I will make the deployment accessible from the following location。但正式部署时建议选择第二项Copy this application onto every target for me!方便版本发布。

Linux主机需优化启动参数配置

  1. commEnv.sh
    1
    2
    3
    4
    vi /bea/weblogic/wlserver_10.3/common/bin/commEnv.sh  
    约180行,在export JAVA_VM MEM_ARGS JAVA_OPTIONS前新增两行
    MEM_ARGS="-Xms32m -Xmx200m -XX:MaxPermSize=128m"
    JAVA_OPTIONS="${JAVA_OPTIONS} -Djava.security.egd=file:/dev/zero"
  2. startNodeManager.sh
    1
    2
    3
    4
    5
    vi /bea/weblogic/wlserver_10.3/server/bin/startNodeManager.sh  
    约80行,新增以下内容
    MEM_ARGS="-Xms512m -Xmx512m -XX:MaxPermSize=200m"
    JAVA_OPTIONS="${JAVA_OPTIONS} -Djava.security.egd=file:/dev/zero"
    export MEM_ARGS JAVA_OPTIONS
  3. setDomainEnv.sh
    1
    2
    3
    4
    vi /ngbss/webapp/domains/BSSTST/bin/setDomainEnv.sh  
    约375行 进行修改
    JAVA_OPTIONS="${JAVA_OPTIONS} -Djava.security.egd=file:/dev/zero"
    export JAVA_OPTIONS

    管理Machine上操作

    建立domain

    1
    /bea/weblogic/wlserver_10.3/common/bin/config.sh
    注意:将最新的boss.flds上传到domain下!

启动AdminServer

1
2
3
cat>start.sh
nohup ./startWebLogic.sh>start.log 2>&1 &
^C

修改AdminServer启动参数

1
2
3
-server -Xms1024m -Xmx1024m -XX:MaxPermSize=256m -Djava.awt.headless=true -Dcharset=GBK -Dfile.encoding=GBK -Djava.security.egd=file:/dev/zero
注意:AdminServer中,Configuration->SSL->Advanced->Hostname Verification:None,否则报错:
<Nov 8, 2011 11:56:28 PM CST> <Warning> <Security> <BEA-090504> <Certificate chain received from 132.77.138.144 - 132.77.138.144 failed hostname verification check. Certificate contained bssweb2 but check expected 132.77.138.144>

注意:Automatically Acquire Lock and Activate Changes勾掉

建立Machine

注意:端口与nodemanger.properties中的设置一致,一般为5556

建立cluster

IP一般设为224.0.0.1~239.255.255.255
多播端口测试:
cd /bea/weblogic/wlserver_10.3/server/lib

1
java -cp weblogic.jar  utils.MulticastTest -N server100 -A 224.0.0.110 -P 48001

建立server

注意:多网卡的主机上需绑定IP,否则远程启动server时启动参数中adminServer会与实际IP不一致

部署deploy

注意:最后一步一般选择第3项 I will make the deployment accessible from the following location,需分别上传代码至每台主机,但会避免因乱码文件引起的server无法启动问题。
第2项:Copy this application onto every target for me,可以只在Admin主机上放置代码,Sever启动时自动上传部署。但如果有乱码文件则无法正常启动。
注意:根据application中UploadPath,建立相应上传目录

JDBC配置

注意:勾选Test Connection On Reserve,断开后自动连接

配置控制台用户

通常为了weblogic console安全考虑,需要新建多个用户,区分不同的权限:
A.管理员:所有权限,主要是进行配置修改时使用
B.操作员:可以启停所有服务,主要是日常监控和更新程序使用
C.查看者:可以查看所有配合和服务情况,主要是日常监控和巡检使用
D.服务调用用户:只能发送T3消息,timerapp和ejb接口使用
建立3个用户:
administrator:Administrators password:administrator 所有权限
operator:Operators password:operator 可以启停不能修改
monitor:Monitors password:monitormonitor 只能查看,不能修改、启停

在被管Machine上操作

复制nodemanager启动脚本

1
2
3
4
5
cp /bea/weblogic/wlserver_10.3/server/bin/startNodeManager.sh ~
cd ~
mkdir nodemanager
vi startNodeManager.sh
NODEMGR_HOME="/ngbss/webapp/nodemanager"

生成nodemanager.domains

启动AdminServer后

1
2
3
4
/bea/weblogic/wlserver_10.3/common/bin/wlst.sh
connect('weblogic','password','t3://xxx.xxx.xxx.xxx:6010')
nmEnroll('/ngbss/bpsapp/domains/BpsDom','/ngbss/bpsapp/nodemanager')
exit()

其它

应用发布

  1. 版本发布不需要重启server,直接在Deployment界面update一下即可,或通过命令行方式java weblogic.Deployer操作,或通过WLST发布。
  2. 如果StageMode=stage,server启动时会报Failed to initialize the application ‘examples’ due to error weblogic.management.DeploymentException: Exception occured while downloading files,原因不明,此时需要再手工update一下。
  3. 脚本发布工具
    1
    2
    3
    4
    5
    6
    帮助:  
    java -cp /bea/weblogic/wlserver_10.3/server/lib/weblogic.jar weblogic.Deployer -help
    重新发布:
    java -cp /bea/weblogic/wlserver_10.3/server/lib/weblogic.jar weblogic.Deployer -adminurl t3://xxx.xxx.xxx.xxx:57001 -username weblogic -password password -name examples -targets testserver1 -redeploy
    重新发布部分页面:
    java -cp /bea/weblogic/wlserver_10.3/server/lib/weblogic.jar weblogic.Deployer -adminurl t3://xxx.xxx.xxx.xxx:57001 -username weblogic -password password -name examples -targets testserver1 -redeploy /ngbss/webapp/deployTST/examples/xxx.html
  4. WLST发布工具
    1
    2
    3
    /bea/weblogic/wlserver_10.3/common/bin/wlst.sh
    connect('weblogic','password','t3://xxx.xxx.xxx.xxx:57001')
    redeploy('exmples','/ngbss/webapp/deployTST/examples/',targets='testserver1',stageMode='stage');

    3DES加密

    1
    java  -cp /bea/weblogic/wlserver_10.3/server/lib/weblogic.jar weblogic.security.Encrypt test123456

    WLST启停

    1
    2
    3
    connect('weblogic','password','t3://xxx.xxx.xxx.xxx:6001')
    start('PROXY_CLUSTER', 'Cluster', block='false')
    start('proxy_t_13', 'Server', block='false')

场景

同事的一个新需求,需要在AIX上以shell脚本的方式,调用sqlplus客户端连接Oracle数据库,调用mysql客户端连接MySQL数据库。
想尽量简化开发,只用纯脚本方式实现,不想替换为Java或其它方案,且不想远程调用其它Linux主机上的客户端。
故需要在AIX上安装两个客户端(只有普通用户权限,没有root权限)。没什么太特别的地方,简单记录一下过程。

sqlplus客户端的安装

Oracle接供了AIX下的InstantClient二进制zip包,其中包含了sqlplus,安装起来比较简单,主要注意一下环境变量的设置。

下载InstantClient

这里是Oracle Instant Client的下载地址 ,目前最新版本是11.2.0.1.0

  1. 首先根据系统,选择”适用于 AIX5L(64 位)的 Instant Client”
  2. 然后再下载Basic和SQLPlus两个编译好的二进制包,注意两个都要下载

上传并解压

将下载好的两个包 basic-11.2.0.1.0-aix-ppc64.zipsqlplus-11.2.0.1.0-aix-ppc64.zip上传至服务器,使用unzip解压至同一目录,例如: /path/to/instantclient。

设置环境变量

编辑.profile文件,加入以下两行:

1
2
export PATH=$PATH:/path/to/instantclient
export LIBPATH=/path/to/instantclient

注意AIX下要设置LIBPATH环境变量,且只能填写InstantClient一个目录,不能用冒号分割加入其它目录。

mysql客户端的安装

MySQL官网 目前只提供5.5~5.7的下载,且二进制ZIP包和RPM/DEB包主要针对Linux的各个发行版,没有对应的AIX版本。

连源码包也是”Generic Linux”的,在AIX下编译的话编译器和依赖都会有问题。Google上可以搜索到MySQL 5.1版本在AIX下成功编译的案例(需要一些trick),不过老源码目前在官网已经下载不到了。
第三方网站bullfreeware提供了5.5版本的RPM包。此种方式有安全风险,不过已经是唯一的方案了。

下载并处理RPM包

  1. bullfreeware 上下载MySQL5.5版本的客户端 MySQL-client-5.5.10-1.aix5.3.ppc.rpm
  2. 因为我们是没有root权限的,所有无法直接安装rpm包,需要将包里的二进制内容提取出来直接拷贝到安装目录:
    将rpm直接使用unzip解压,解出如下的目录结构:

    直接将bin目录下的二进制文件取出来上传至服务器,路径例如: /path/to/mysqlclient

设置环境变量

编辑.profile文件,加入PATH:

1
export PATH=$PATH:/path/to/mysqlclient

至此安装完毕

场景

与某第三方厂商使用文件接口传递数据。我们是发送方,对方是接收方,对方的接口规范要求对于大于500M文件,必须使用zip格式分卷压缩。
本来在linux下使用zip分卷压缩轻而易举,直接调用系统的zip命令,加上-b参数即可,但恰巧我们的接口机是台IBM小机,AIX系统下的zip不支持分卷功能,需要与split命令结合实现分卷功能。

实现方案

  1. 先使用zip命令将目录打包成单独的zip文件
  2. 使用split命令将zip文件拆分
  3. 对于拆分出来的散乱文件,按格式要求批量重命名

格式要求

生成的文件名格式如下:
yyyymmdd_zzzz_RetentionPhotosSync_iiii_xxxx.zip
其中yyyymmdd为时间,xxxx为分卷序列号

分卷压缩脚本

下面以名为testfolder的目录为例,以5M大小,进行分卷

使用zip命令,将整个目录压缩成.zip(对于目录需要使用-r参数)

1
zip -r tmpfile.zip testfolder

使用split命令,以5M为单位,将.zip进行拆分,生成xaa、xab、xac…(以此类推)等多个文件

1
split -b 5m tmpfile.zip splitfile-

对于生成的xaa、xab、xac…散乱文件,按格式重命名

1
nowdate=`date +%Y%m%d`;n=0; for filename in `ls splitfile* `; do n=`expr ${n} + 1`; suffix=`printf %04d ${n}`; mv ${filename} ${nowdate}_zzzz_RetentionPhotosSync_iiii_${suffix}.zip ; done

清理临时文件

1
rm tmpfile.zip

分卷解压脚本

如果对端恰巧也是AIX系统,可以用如下方式解压缩

使用cat命令合并文件

1
cat yyyymmdd_zzzz_RetentionPhotosSync_iiii_*.zip > yyyymmdd_zzzz_RetentionPhotosSync_iiii.zip

使用unzip命令解压

1
unzip yyyymmdd_zzzz_RetentionPhotosSync_iiii.zip

Git简介

什么是版本控制

版本控制系统(Version Control System,简称VCS)是一种记录一个或若干文件内容变化,以便将来查阅特定版本修订情况的系统。

按类型可以分为:

  • 本地版本控制系统

    例如RCS(至少我是从来没有用过)
    本地版本控制系统解决了版本的管理问题,再也不用时不时的把工程目录,通过手工拷贝的方式来存档了。但本地版本控制系统的缺点是,无法解决多人协作的问题。

  • 集中化的版本控制系统

    例如CVS,SVN等(公司中SVN应该用的比较多)
    有一个集中管理的服务器,所有开发人员通过客户端连到这台服务器,取出最新的 文件 或者提交更新。管理员可以掌控每个开发者的权限。
    集中化的VCS不但解决了版本控制问题,还可以多人协作。但缺点也是有的,就是太依赖于远程服务器,CVS服务器宕机后,会影响所有人的工作。版本记录只保存在一台服务器上,会有数据丢失风险。

  • 分布式版本控制系统

    例如Git
    客户端并不只提取最新版本的文件,而是把 代码仓库 完整地镜像下来。每一次的提取操作,实际上都是一次对 代码仓库 的完整备份。
    所以并没有”中心服务器”的概念,所谓的”Git服务器”,也同每个人的电脑一样,只是为了多人协作时,方便大家交换数据而已。

什么是Git

Git是目前世界上最先进的分布式版本控制系统(没有之一)
好不好用,看看它的开发者是谁就知道了:Linux之父 Linus Torvalds

小历史: Linux内核社区原本使用的是名为BitKeeper的商业化版本控制工具,2005年,因为社区内有人试图破解BitKeeper的协议,BitMover公司收回了免费使用BitKeeper的权力。
Linus原本可以出面道个歉,继续使用BitKeeper,然而并没有。。。Linus大神仅用了两周时间,自已用C写了一个分布式版本控制系统,于是Git诞生了!

为什么要使用Git

为什么要使用Git,或者说Git相比SVN有什么优势呢?

  • 分布式

  • 分支管理

  • GitHub

安装Git

  • 大多数Linux发行版已经预装了Git,系统默认自带,如果不带。。可以源码make安装或使用yum/apt等直接安装,过程不赘述了。
  • macOS下,安装Xcode后,它的CLI工具里应该会包含Git了。或者使用brew手工安装一下。
  • Windows下,可以直接下载安装 msysGit 。 或者如果你的机器上已经有Cygwin,也可以直接用在它下面安装Git。
  • 图形工具推荐使用 SourceTree,查看分支非常直观 。IntelliJ IDEA等IDE也会自带一些图形化的工具,在合并代码时很高效。

学习路径

  • 首先,忘掉SVN/CVS,不要把Git的各种操作与它们做类比,切记。
  • 刚开始不要依赖图形客户端。首先应该将精力用在理解原理上 -> 然后掌握一些基本CLI命令,动手操作实践 -> 最后在实际工作中使用GUI工具以提高效率。
  • 重度Windows用户使用Git时,与平时熟悉GUI的环境会有些违和感,毕竟Git是Linux下的产物,Git遵循Linux的哲学,Simple,简单直接,但Simple并不等于Easy。需要转换一下思维。

了解Git的工作原理

记录文件整体快照

Git和其他版本控制系统的主要差别在于,Git只关心文件数据的 整体 是否发生变化,而大多数其他系统则只关心 文件内容 的具体差异。

SVN在每个版本中,以单一文件为单位,记录各个文件的差异:

Git在每个版本中,以当时的全部文件为单位,记录一个快照:

大多数操作都在本地执行

Git的绝大多数操作都只需要访问本地文件和资源,不用连网。因为你的本机上,就已经是完整的代码库了。这样一来,在无法连接公司内网的环境中,也可以愉快的写代码了。
例如,如果想看当前版本的文件和一个月前的版本之间有何差异,Git会取出一个月前的快照和当前文件作一次差异运算,而不用每次都请求远程服务器。

时刻保持数据完整性

在保存到Git之前,所有数据都要进行内容的校验和(checksum)计算,并将此结果作为数据的唯一标识和索引。
这项特性作为Git的设计哲学,建在整体架构的最底层。所以如果文件在传输时变得不完整,或者磁盘损坏导致文件数据缺失,Git都能立即察觉。
Git使用SHA-1算法计算数据的校验和,通过对文件的内容或目录的结构计算出一个SHA-1哈希值,作为指纹字符串。该字串由40个十六进制字符组成,看起来就像是:
24b9da6552252987aa493b52f8696cd6d3b00373
Git的工作完全依赖于这类指纹字串,所以你会经常看到这样的哈希值。实际上,所有保存在 Git数据库中的东西都是用此哈希值来作索引的,而不是靠文件名。

多数操作仅添加数据

常用的Git操作大多仅仅是把数据添加到数据库,很难让Git执行任何不可逆操作。在Git中一旦提交快照之后就完全不用担心丢失数据,特别是养成定期推送到其他仓库的习惯的话。

文件的三种状态

对于任何一个文件,在 Git 内都只有三种状态:已提交(committed) 已修改(modified) 已暂存(staged)
已提交表示该文件已经被安全地保存在本地数据库中了;
已修改表示修改了某个文件,但还没有提交保存;
已暂存表示把已修改的文件放在下次提交时要保存的清单中。

由此我们看到 Git 管理项目时,文件流转的三个工作区域:Git 的工作目录,暂存区域,以及本地仓库。

每个项目都有一个名为.git的目录,它是 Git用来保存元数据和对象数据库的地方。该目录非常重要,每次克隆镜像仓库的时候,实际拷贝的就是这个目录里面的数据。
从项目中取出某个版本的所有文件和目录,用以开始后续工作的叫做工作目录。这些文件实际上都是从Git目录中的压缩对象数据库中提取出来的,接下来就可以在工作目录中对这些文件进行编辑。
所谓的暂存区域只不过是个简单的文件,一般都放在 Git 目录中。有时候人们会把这个文件叫做索引文件,不过标准说法还是叫暂存区域。

基本的 Git 工作流程如下:

  1. 在工作目录中修改某些文件。
  2. 对修改后的文件进行快照,然后保存到暂存区域。
  3. 提交更新,将保存在暂存区域的文件快照永久转储到 Git 目录中。

所以,我们可以从文件所处的位置来判断状态:如果是Git目录中保存着的特定版本文件,就属于已提交状态;如果作了修改并已放入暂存区域,就属于已暂存状态;如果自上次取出后,作了修改但还没有放到暂存区域,就是已修改状态。

创建版本库

有两种取得Git项目仓库的方法。第一种是在现存的目录下,通过导入所有文件来创建新的Git仓库。 第二种是从已有的Git仓库克隆出一个新的镜像仓库来。

在目录中创建新仓库

如果一个目录还没有使用Git进行管理,只需到此项目所在的目录,执行git init,初始化后,在当前目录下会出现一个名为.git的目录

1
2
3
$ mkdir learngit
$ cd learngit
$ git init

从已有的仓库克隆

如果Git项目已经存在,可以使用git clone从远程服务器上复制一份出来,Git支持多种协议:

1
2
3
$ git clone mobgit@134.32.51.60:learngit.git  #使用SSH传输协议
$ git clone git://134.32.51.60/learngit.git #使用Git传输协议
$ git clone https://134.32.51.60/learngit.git #使用HTTPS传输协议

版本库基本操作

检查当前文件状态

使用git status命令可以查看文件的状态

1
2
3
4
$ git status
On branch master
Initial commit
nothing to commit (create/copy files and use "git add" to track)

出现如上的提示,说明现在的工作目录相当干净,所有已跟踪文件在上次提交后都未被更改过。

现在我们做一些改动,添加一个readme.txt进去,然后再看一下状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ cat>readme.txt
hello git
^C

git status
On branch master

Initial commit

Untracked files:
(use "git add <file>..." to include in what will be committed)
readme.txt

nothing added to commit but untracked files present (use "git add" to track)

Untracked files显示了这个新创建的readme.txt处于未跟跟踪状态

跟踪新文件

使用git add命令开始跟踪一个新文件

1
2
3
4
5
6
7
8
9
$ git status
On branch master

Initial commit

Changes to be committed:
(use "git rm --cached <file>..." to unstage)

new file: readme.txt


readme.txt已 被跟踪 ,并处于 暂存状态

将本次修改暂存

现在我们再对readme.txt进行修改,添加一行,再执行git status查看状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ git status
On branch master

Initial commit

Changes to be committed:
(use "git rm --cached <file>..." to unstage)

new file: readme.txt

Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git checkout -- <file>..." to discard changes in working directory)

modified: readme.txt

可以看到readme.txt 不仅出现在了Changes to be committed,还出现在了Changes not staged for commit
由此可见,Git关心的是 Changes ,而不是文件本身。
再次执行git add,可以将 本次修改 提交到暂存区,Changes not staged for commit提示消失

提交更新

使用git commit命令将暂存区中的内容提交至版本库,工作区又是干净的了

1
2
3
4
5
6
7
8
9
10
$ git commit -m "my first commit"
[master (root-commit) 6c8912a] my first commit
1 file changed, 2 insertions(+)
create mode 100644 readme.txt

$ git status
On branch master
Your branch is based on 'origin/master', but the upstream is gone.
(use "git branch --unset-upstream" to fixup)
nothing to commit, working tree clean

注意:一定要使用-m参数加入注释,认真描述本次的提交具体做了些什么,这对于以后我们查询历史记录非常重要。

如果觉得使用暂存区过于繁琐,可以在commit时直接使用-a参数,Git就会自动把所有已经跟踪过的文件暂存起来一并提交,从而跳过git add步骤。

1
$ git commit -a -m "my first commit"

查看历史

使用git log命令可以查看历史记录

1
2
3
4
5
6
7
8
9
10
11
12
$ git log
commit 43c5d337ffdd76f33ce5f5f90103d57e55474956
Author: BlueXIII <bluexiii@163.com>
Date: Thu Dec 8 14:45:59 2016 +0800

this is my second commit

commit 6c8912ad2a8e90a7ba32cc8578fd0069a205221b
Author: BlueXIII <bluexiii@163.com>
Date: Thu Dec 8 14:38:09 2016 +0800

my first commit

可以看到,每次更新都有一个SHA-1校验和、作者的名字和电子邮件地址、提交时间、提交说明。

撤消操作

撤消操作在这里这里不做重点描述了,只列出几个常用命令。
修改最后一次提交:
git commit –amend
取消已经暂存的文件:
git reset HEAD readme.txt
取消对文件的修改:
git checkout – readme.txt

远程仓库

之前介绍了在本地仓库的一些操作。但当与他人协作开发某个项目时,需要至少使用一个远程仓库,以便推送或拉取数据,分享各自的工作进展。

克隆远程库

之前已经在讲新建仓库时已经提到,如何克隆远程库,这里再重复列一遍:

1
2
3
$ git clone mobgit@134.32.51.60:learngit.git  #使用SSH传输协议
$ git clone git://134.32.51.60/learngit.git #使用Git传输协议
$ git clone https://134.32.51.60/learngit.git #使用HTTPS传输协议

查看绑定的远程库

如果之前我们使用的git clone命令直接克隆了一个远程仓库到本机,Git就已经默认绑定了一个名为origin的远程库。当然我们还可以手工绑定其它远程库,远程仓库可以有多个。
使用git remote -v命令列出我们绑定了哪些远程库:

1
2
3
$ git remote -v
origin mobgit@134.32.51.60:learngit.git (fetch)
origin mobgit@134.32.51.60:learngit.git (push)

接下来还可以使用git remote show origin来查看这个名为origin的远程库的更详细的信息,这里先不细讲

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
$ git remote show origin
* remote origin
Fetch URL: mobgit@134.32.51.60:learngit.git
Push URL: mobgit@134.32.51.60:learngit.git
HEAD branch (remote HEAD is ambiguous, may be one of the following):
dev
master
serverfix
serverfix2
Remote branches:
dev tracked
master tracked
serverfix tracked
serverfix2 tracked
Local branches configured for 'git pull':
dev merges with remote dev
master merges with remote master
serverfix merges with remote serverfix
serverfix2 merges with remote serverfix2
Local refs configured for 'git push':
dev pushes to dev (up to date)
master pushes to master (up to date)
serverfix pushes to serverfix (up to date)
serverfix2 pushes to serverfix2 (up to date)

手工添加一个远程仓库

我们先让管理员新建一个名为learngit2的远程仓库,再使用remote add命令将它添加进来,取名为repo2

1
2
3
4
5
6
7
$ git remote add repo2 mobgit@134.32.51.60:learngit2.git

$ git remote -v
origin mobgit@134.32.51.60:learngit.git (fetch)
origin mobgit@134.32.51.60:learngit.git (push)
repo2 mobgit@134.32.51.60:learngit2.git (fetch)
repo2 mobgit@134.32.51.60:learngit2.git (push)

现在我们有origin和repo2两个远程仓库了

从远程仓库抓取数据

使用git fetch [remote-name]从远程仓库抓取数据,注意fetch命令只是将远端的数据拉到本地仓库,并不自动合并到当前工作分支(关于分支稍后讲解)
例如要抓取名为origin远程仓库:

1
$ git fetch origin

推送数据到远程仓库

使用git push [remote-name] [branch-name]将本机的工作成果推送到远程仓库
例如要将本地的master分支推送到origin远程仓库上:

1
$ git push origin master

分支

也许到之前为止,大家会觉得Git和Svn除了实现原理不同以及实现了分布式之外,在日常使用上并没有什么太大的区别(甚至更繁琐)。但接下来的分支,才是Git的精髓部分。

为什么要使用分支

举个简单的例子:假设你准备开发一个新功能,但是需要两周才能完成,第一周你写了50%的代码,如果立刻提交,由于代码还没写完,不完整的代码库会导致别人不能干活了。如果等代码全部写完再一次提交,又存在丢失每天进度的巨大风险。
于是你创建了一个属于你自己的分支,别人看不到,还继续在原来的分支上正常工作,而你在自己的分支上干活,想提交就提交,直到开发完毕后,再一次性合并到原来的分支上,这样,既安全,又不影响别人工作。
相比于Svn等工具,Git创建、切换分支的开销是非常小的,Git鼓励 频繁使用分支

分支的原理

要理解分支,需要继续深入一下Git的工作原理

Git如何储存数据

在Git中提交时,会保存一个提交对象(commit object),该对象包含一个指向暂存内容快照的指针,并同时包含本次提交的作者等相关附属信息,包含零个或多个指向该提交对象的父对象指针(首次提交是没有直接祖先的,普通提交有一个祖先,由两个或多个分支合并产生的提交则有多个祖先)。

假设在工作目录中有三个文件已经 修改 过,准备将它们暂存后提交。
git add暂存操作时,会对 每一个文件 计算校验和,然后把当前版本的文件快照使用 blog对象 保存到Git仓库中(为提高性能,若文件没有变化,Git不会再次保存)。将它们的SHA-1校验和加入到暂存区域等待提交。
git commit提交操作,时,Git首先会计算 每一个子目录 的校验和,然后将这些校验和保存为 tree对象 。 然后Git会创建一个 commit对象 ,它包含指向这个树对象的指针及注释、提交人、邮箱等信息。
现在,Git仓库中有五个对象:三个blob 对象(保存着文件快照);一个树对象(记录着目录结构和blob对象索引)以及一个提交对象(包含着指向前述树对象的指针和所有提交信息)。

单个提交对象在仓库中的数据结构:

多个提交对象之间的链接关系:

分支是什么

Git 中的分支,其实本质上仅仅是个指向commit对象的可变指针。Git会使用master作为分支的默认名字。在若干次提交后,你其实已经有了一个指向最后一次commit对象的master分支。它在每次提交的时候都会自动向前移动。

创建名为testing的新的分支,本质上就是创建一个指针,可以使用git branch命令:

1
$ git branch testing

当前工作在哪个分支

Git 是如何知道你当前在哪个分支上工作的呢?其实答案也很简单,它还保存着一个名为HEAD的特别指针。它是一个指向你正在工作中的本地分支的指针。

切换分支时发生了什么

切换分支,本质上就是移动HEAD指针。
要切换到其他分支,可以执行git checkout命令。我们现在转换到刚才新建的testing分支:

1
$ git checkout testing

分支切换的实际操作

为了更好的理解分支,我们接下来模拟实际工作中的场景,进行一系列的切换操作。
现在我们已经处于testing分支了,目前testing分支和master分支都是指向同一个commit,所以我们的工作区的内容现在还没有什么变化。
现在,我们要在testing分支上做一些文件修改,然后commit:

1
2
echo "testing branch">>readme.txt
git commit -a -m "modify on testing branch"


提交后,产生了一个新的commit对象,并且HEAD随着当前testing分支一起向前移动。而master分支则是停在原地不动。

我们可以试着使用git checkout命令切回master分支,看看发生了什么:

1
$ git checkout master


这条命令做了两件事:

  1. 它把HEAD指针移回到 master 分支。
  2. 把工作目录中的文件换成了master分支所指向的快照内容。

我们试着在master上再做一些改动并commit:

1
2
echo "testing master">>readme.txt
git commit -a -m "modify on master branch"


现在分支变成了上图所示,我们可以在master与testing间随时切换,并修改工作区的文件内容。必要时再将这两个分支合并。

分支新建与合并的实际操作

接下来,再以一个比较长的真实的工作场景进行举例

我们首先在master分支上进行工作,并提交了几次更新,测试无误后编译发布至生产系统。

之后我们决定要修补问题追踪系统上的53号问题,这时可以使用git checkout -b命令快速创建一个分支并切换过去:

1
$ git checkout -b iss53

这相当于执行了下面这两条命令:

1
2
$ git branch iss53
$ git checkout iss53

我们在iss53分支上写了一些代码,并commit

1
2
$ vi index.html
$ git commit -a -m 'fixed the broken email address'

iss53上的工作还没完成,突然接到通知,生产系统有一个紧急BUG需要立刻修复。所以我们首先切回master分支,然后在master的基础上,又新建出一个hotfix分支来修复BUG。

1
2
3
4
$ git checkout master    #回到master分支
$ git checkout -b hotfix #新建一个hotfix分支,并切过去
$ vim index.html #修改一些东西,修复BUG
$ git commit -a -m 'fixed the broken email address' #提交hotfix

在hotfix分支上搞定BUG之后,我们切回master分支,使用git merge把刚才的hotfix合并进来

1
2
3
4
5
6
$ git checkout master  #切换回master分支
$ git merge hotfix #将hotfix分支的修改,合并到当前master分支来(注意merge的方向,是从其它分支,合到当前分支)。
Updating f42c576..3a0874c
Fast-forward
README | 1 -
1 file changed, 1 deletion(-)


备注:本次合并时出现了“Fast forward”的提示。由于当前 master 分支所在的提交对象是要并入的 hotfix 分支的直接上游,Git 只需把 master 分支指针直接右移。换句话说,如果顺着一个分支走下去可以到达另一个分支的话,那么 Git在合并两者时,只会简单地把指针右移,因为这种单线的历史分支不存在任何需要解决的分歧,所以这种合并过程可以称为快进(Fast forward)。

这时hotfix分支已经没用了,可以删掉了

1
$ git branch -d hotfix    #只是删除了一个指针

现在回到之前未完成的53号问题上,继续写一些代码

1
2
3
$ git checkout iss53
$ vim index.html
$ git commit -a -m 'finished the new footer [issue 53]'

在问题53相关的工作完成之后,可以合并回master分支。实际操作同前面合并hotfix分支差不多,只需回到master分支,运行git merge命令指定要合并进来的分支。

1
2
3
4
5
6
7
$ git checkout master
$ git merge iss53
Auto-merging README
Merge made by the 'recursive' strategy.
README | 1 +
1 file changed, 1 insertion(+)


请注意,这次合并操作的底层实现,并不同于之前 hotfix 的并入方式。因为这次你的开发历史是从更早的地方开始分叉的。由于当前 master 分支所指向的提交对象(C4)并不是 iss53 分支的直接祖先,Git 不得不进行一些额外处理。就此例而言,Git 会用两个分支的末端(C4 和 C5)以及它们的共同祖先(C2)进行一次简单的三方合并计算。


这次,Git 没有简单地把分支指针右移,而是对三方合并后的结果重新做一个新的快照,并自动创建一个指向它的提交对象(C6)。这个提交对象比较特殊,它有两个祖先(C4 和 C5)。

有时候合并操作并不会如此顺利。如果在不同的分支中都修改了同一个文件的同一部分,需要手工来处理冲突。

1
2
3
4
5
$ git merge iss53
Auto-merging index.html
CONFLICT (content): Merge conflict in index.html
Automatic merge failed; fix conflicts and then commit the result.

Git作了合并,但没有提交,它会停下来等你解决冲突。

1
2
3
4
5
6
7
8
9
10
11
$ git status
On branch master
You have unmerged paths.
(fix conflicts and run "git commit")

Unmerged paths:
(use "git add <file>..." to mark resolution)

both modified: index.html

no changes added to commit (use "git add" and/or "git commit -a")

任何包含未解决冲突的文件都会以未合并(unmerged)的状态列出。Git 会在有冲突的文件里加入标准的冲突解决标记。

1
2
3
4
5
6
7
8
$ vi index.html
<<<<<<< HEAD
<div id="footer">contact : email.support@github.com</div>
=======
<div id="footer">
please contact us at support@github.com
</div>
>>>>>>> iss53

可以看到 ======= 隔开的上半部分是 HEAD,即master,下半部分是在iss53分支中的内容。
手工合并代码后,把 <<<<<<<,======= 和 >>>>>>> 这些行也一并删除。这时可以用git commit来提交了。

分支策略

实际开发中,对于分支的管理,已经有很多最佳实践,大多数情况下,我们只需要遵守一些基本原则:

  • 首先,master分支应该是非常稳定的,也就是仅用来发布新版本,平时不能在上面工作。
  • 平时的开发工作都放在dev分支上,也就是说,dev分支是不稳定的。到某个时候,比如测试通过,需要1.2版本发布时,再把dev分支合并到master上,在master分支编译发布1.2版本。
  • 针对新需求、修复等具体的任务,每次都在dev分支上开一个新的任务分支出来,工作完成后,再向dev分支上合并就可以了。名称没有特别的规范,可以是人名,例如:zhangsan,也可以是任务名、需求编号等,例如:iss03、feature04、hotfix。

远程分支

之前讨论过远程仓库,接着又学习了分支,当二者结合到一起时,又会产生一些有趣的东西。

远程分支的概念

远程分支(remote branch),即远程仓库中的分支。同步到本地后,与本地分支不同的是,它们 无法移动 ;且只有在Git进行网络交互时才会更新。远程分支就像是书签,提醒着你上次连接远程仓库时上面各分支的位置。我们用 (远程仓库名)/(分支名) 这样的形式表示远程分支(例如origin/master)。

如果我们在本地master分支做了些改动,与此同时,其他人向远程仓库推送了他们的更新,那么服务器上的master分支就会向前推进,而于此同时,我们在本地的提交历史正朝向不同方向发展。(不过只要你不和服务器通讯,你的 origin/master 指针仍然保持原位不会移动。)

可以运行git fetch origin来同步远程服务器上的数据到本地。该命令首先找到origin是哪个服务器,然后从上面获取你尚未拥有的数据,更新你本地的数据库,然后把origin/master的指针移到它最新的位置上。

可以使用git remote命令查看远程仓库的详情

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ git remote -v    #列出远程服务器清单
origin mobgit@134.32.51.60:learngit.git (fetch)
origin mobgit@134.32.51.60:learngit.git (push)

$ git remote show origin #查询某一个远程服务器的详情
* remote origin
Fetch URL: mobgit@134.32.51.60:learngit.git
Push URL: mobgit@134.32.51.60:learngit.git
HEAD branch (remote HEAD is ambiguous, may be one of the following):
dev
master
Remote branches:
dev tracked
master tracked
Local branch configured for 'git pull':
master merges with remote master
Local ref configured for 'git push':
master pushes to master (up to date)

跟踪远程分支

从远程分支checkout出来的本地分支,称为跟踪分支 (tracking branch)。跟踪分支是一种和某个远程分支有直接联系的本地分支。
在跟踪分支里输入 git push,Git 会自行推断应该向哪个服务器的哪个分支推送数据。同样,在这些分支里运行 git pull 会获取所有远程索引,并把它们的数据都合并到本地分支中来。

在克隆仓库时,Git 通常会自动创建一个名为 master 的分支来跟踪 origin/master。这正是 git push 和 git pull 一开始就能正常工作的原因。

1
2
3
$ git checkout -b serverfix origin/serverfix
或简化为:
$ git checkout --track origin/serverfix

这会新建并切换到serverfix本地分支,其内容同远程分支origin/serverfix一致。

推送本地分支

要想和其他人分享某个本地分支,你需要把它推送到一个你拥有写权限的远程仓库。
例如本地有一个serverfix分支需要和他人一起开发,可以运行 git push (远程仓库名) (分支名):

1
2
3
4
5
6
7
$ git push origin serverfix
Counting objects: 20, done.
Compressing objects: 100% (14/14), done.
Writing objects: 100% (15/15), 1.74 KiB, done.
Total 15 (delta 5), reused 0 (delta 0)
To git@github.com:schacon/simplegit.git
* [new branch] serverfix -> serverfix

或者加入–set-upstream设置跟踪后,以后直接使用git push就可以推送了:

1
2
git push --set-upstream origin serverfix

GitHub

[GitHub](https://github.com)是一个面向开源及私有软件项目的托管平台,因为只支持Git作为唯一的版本库格式进行托管,故名GitHub。

GitHub本身没有什么好学的,随便看就知道怎么用了 知乎:怎样使用GitHub
重点是,GitHub上有非常多优秀的个人项目值得我们学习,我们也可以将自已的代码发布上去。可以看成是程序员的博客吧,只贴代码,不废话。
在GitHub上发布开源项目是免费的,但是私有项目收费。

GitLab

GitLab是一个用Ruby on Rails写的开源的版本管理系统,实现一个自托管的Git项目仓库,可通过Web界面进行访问公开的或者私人项目。它拥有与Github类似的功能,能够浏览源代码,管理缺陷和注释。
可以管理团队对仓库的访问,它非常易于浏览提交过的版本并提供一个文件历史库。团队成员可以利用内置的简单聊天程序(Wall)进行交流。它还提供一个代码片段收集功能可以轻松实现代码复用,便于日后有需要的时候进行查找。
GitLab是目前搭建内部Git服务器的首选,当然如果要求不高的话,我们也可以直接使用SSH协议来快速搭建Git服务端。

常用Git命令清单


更多内容请直接参考 阮一峰的网络日志

推荐文档

不要指忘2小时的培训能带来多大的收益,最简单高效的方式,还是要多看优秀的文档。
本文大量参(chao)考(xi)了以下两部文档:
廖雪峰的在线教程 适合快速上手
Pro Git中文版 中文第一版