Git内部存储原理

栏目: 编程工具 · 发布时间: 5年前

内容简介:Git是程序员工作中使用频率非常高的工具,要提高日常的工作效率,就需要熟练掌握Git的使用方法。相对于传统的版本控制系统而言,Git更为强大和灵活,其各种命令和命令参数也非常多,如果不了解Git的内部原理,要把Git使用得顺手的话非常困难。本文将用一个具体的例子来帮助理解Git的内部存储原理,加深对Git的理解,从掌握各种Git命令,以在使用Git进行工作时得心应手。Git的本质是一个文件系统,其工作目录中的所有文件的历史版本以及提交记录(Commit)都是以文件对象的方式保存在.git目录中的。首先创建一

Git是 程序员 工作中使用频率非常高的工具,要提高日常的工作效率,就需要熟练掌握Git的使用方法。相对于传统的版本控制系统而言,Git更为强大和灵活,其各种命令和命令参数也非常多,如果不了解Git的内部原理,要把Git使用得顺手的话非常困难。本文将用一个具体的例子来帮助理解Git的内部存储原理,加深对Git的理解,从掌握各种Git命令,以在使用Git进行工作时得心应手。

Git 目录结构

Git的本质是一个文件系统,其工作目录中的所有文件的历史版本以及提交记录(Commit)都是以文件对象的方式保存在.git目录中的。

首先创建一个work目录,并采用git init命令初始化git仓库。该命令会在工作目录下生成一个.git目录,该目录将用于保存工作区中所有的文件历史的历史版本,提交记录,branch,tag等信息。

$ mkdir work
$ cd work
$ git init

其目录结构如下:

├── branches             不这么重要,暂不用管                    
├── config               git配置信息,包括用户名,email,remote repository的地址,本地branch和remote
|                        branch的follow关系
├── description          该git库的描述信息,如果使用了GitWeb的话,该描述信息将会被显示在该repo的页面上
├── HEAD                 工作目录当前状态对应的commit,一般来说是当前branch的head,HEAD也可以通过git checkout 命令被直接设置到一个特定的commit上,这种情况被称之为 detached HEAD      
├── hooks                钩子程序,可以被用于在执行git命令时自动执行一些特定操作,例如加入changeid
│   ├── applypatch-msg.sample
│   ├── commit-msg.sample
│   ├── post-update.sample
│   ├── pre-applypatch.sample
│   ├── pre-commit.sample
│   ├── prepare-commit-msg.sample
│   ├── pre-push.sample
│   ├── pre-rebase.sample
│   └── update.sample
├── info                不这么重要,暂不用管
│   └── exclude
├── objects             保存git对象的目录,包括三类对象commit,tag, tree和blob
│   ├── info
│   └── pack
└── refs                保存branch和tag对应的commit
    ├── heads           branch对应的commit
    └── tags            tag对应的commit

Git Object存储方式

目前objects目录中还没有任何内容,我们创建一个文件并提交。

$ echo "my project" > README
$ echo "hello world" > src/file1.txt
$ git add .
$ git commit -sm "init commit"
[master (root-commit) b767d71] init commit
 2 files changed, 2 insertions(+)
 create mode 100644 README
 create mode 100644 src/file1.txt

从打印输出可以看到,上面的命令创建了一个commit对象,该commit包含两个文件。 查看.git/objects目录,可以看到该目录下增加了5个子目录 06,3b, 82, b7, ca,每个子目录下有一个以一长串字母数字命令的文件。

.git/objects
├── 06
│   └── 5bcad11008c5e958ff743f2445551e05561f59
├── 3b
│   └── 18e512dba79e4c8300dd08aeb37f8e728b8dad
├── 82
│   └── 424451ac502bd69712561a524e2d97fd932c69
├── b7
│   └── 67d7115ef57666c9d279c7acc955f86f298a8d
├── ca
│   └── 964f37599d41e285d1a71d11495ddc486b6c3b
├── info
└── pack

说明:Git Object目录中存储了三种对象:Commit, tree和blob。Git为对象生成一个文件,并根据文件信息生成一个 SHA-1 哈希值作为文件内容的校验和,创建以该校验和前两个字符为名称的子目录,并以 (校验和) 剩下 38 个字符为文件命名 ,将该文件保存至子目录下。

查看Git Object存储内容

通过 git cat-file 命令可以查看Git Object中存储的内容及对象类型,命令参数为Git Object的SHA-1哈希值,即目录名+文件名。在没有歧义的情况下,不用输入整个Hash,输入前几位即可。

当前分支的对象引用保存在HEAD文件中,可以查看该文件得到当前HEAD对应的branch,并通过branch查到对应的commit对象。

$ cat .git/HEAD
ref: refs/heads/master
cat .git/refs/heads/master
b767d7115ef57666c9d279c7acc955f86f298a8d

使用 -t 参数查看文件类型:

$ git cat-file -t b767d7
commit

使用 -p 参数可以查看文件内容:

$ git cat-file -p b767d7
tree ca964f37599d41e285d1a71d11495ddc486b6c3b
author Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="e2988a838d8a9783808b8c85a2858f838b8ecc818d8f">[email protected]</a>> 1548055516 +0800
committer Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="83f9ebe2ecebf6e2e1eaede4c3e4eee2eaefade0ecee">[email protected]</a>> 1548055516 +0800

init commit

Signed-off-by: Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="ef95878e80879a8e8d868188af88828e8683c18c8082">[email protected]</a>>

可以看出这是一个commit对象,commit对象中保存了commit的作者,commit的描述信息,签名信息以及该commit中包含哪些tree对象和blob对象。

b767d7这个commit中保存了一个tree对象,可以把该tree对象看成这次提交相关的所有文件的根目录。让我们来看看该tree对象中的内容。

$ git cat-file -p ca964f
100644 blob 065bcad11008c5e958ff743f2445551e05561f59    README
040000 tree 82424451ac502bd69712561a524e2d97fd932c69    src

可以看到该tree对象中包含了一个blob对象,即README文件;和一个tree对象,即src目录。 分别查看该blob对象和tree对象,其内容如下:

$ git cat-file -p 065bca
my project
$ git cat-file -p 824244
100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad    file1.txt

查看file1.txt的内容。

$ git cat-file -p 3b18e51
hello world

从上面的实验我们可以得知,git中存储了三种类型的对象,commit,tree和blob。分别对应git commit,此commit中的目录和文件。这些对象之间的关系如下图所示。

HEAD---> refs/heads/master--> b767d7(commit)
                                    +
                                    |
                                    v
                                ca964f(tree)
                                    +
                                    |
                          +---------+----------+
                          |                    |
                          v                    v
                     065bca(blob)         824244(tree)
                          README              src
                                               +
                                               |
                                               v
                                          3b18e5(blob)
                                             file1.txt

Git branch和tag

从refs/heads/master的内容可以看到,branch是一个指向commit的指针,master branch实际是指向了b767d7这个commit。

$ git checkout -b work
Switched to a new branch 'work'
$ tree .git/refs/
.git/refs/
├── heads
│   ├── master
│   └── work
└── tags
$ cat .git/refs/heads/work .git/refs/heads/master
b767d7115ef57666c9d279c7acc955f86f298a8d
b767d7115ef57666c9d279c7acc955f86f298a8d

上面的命令创建了一个work branch。从其内容可以看到,该branch并没有创建任何新的版本文件,和master一样指向了b767d7这个commit。

从上面的实验可以看出,一个branch其实只是一个commit对象的应用,Git并不会为每个branch存储一份拷贝,因此在git中创建branch几乎没有任何代价。

在work branch上进行一些修改,然后提交。

$ echo "new line" >> src/file1.txt
$ echo "do nothing" >> Makefile
$ git commit -sm "some change"
[work 4f73993] some change
 2 files changed, 2 insertions(+)
 create mode 100644 Makefile

查看当前的HEAD和branch内容。

$ cat .git/HEAD
ref: refs/heads/work
<a href="/cdn-cgi/l/email-protection" data-cfemail="97ffe2f6f5fef9f0d7ffe2f6f5fef9f0baefe2f5e2f9e3e2">[email protected]</a>:~/work$ cat .git/refs/heads/work .git/refs/heads/master
4f73993cf81931bc15375f0a23d82c40b3ae6789
b767d7115ef57666c9d279c7acc955f86f298a8d

可以看到HEAD指向了work branch,而work branch则指向了4f73993这个commit,master branch指向的commit未变化,还是b767d7。

查看4f73993这个commit对象的内容。

$ git cat-file -p 4f73993
tree 082b6d87eeddb15526b7c920e21f09f950f78b54
parent b767d7115ef57666c9d279c7acc955f86f298a8d
author Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="562c3e37393e2337343f383116313b373f3a7835393b">[email protected]</a>> 1548069325 +0800
committer Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="a0dac8c1cfc8d5c1c2c9cec7e0c7cdc1c9cc8ec3cfcd">[email protected]</a>> 1548069325 +0800

some change

Signed-off-by: Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="8cf6e4ede3e4f9edeee5e2ebccebe1ede5e0a2efe3e1">[email protected]</a>>

可以看到commit有一个parent字段,指向了前一个commi b767d7。该commit也包含了一个tree对象,让我们看看其中的内容。

$  git cat-file -p  082b6d
100644 blob 8cc95f278445722c59d08bbd798fbaf60da8ca14    Makefile
100644 blob 065bcad11008c5e958ff743f2445551e05561f59    README
040000 tree 9aeacd1fa832ca167b0f72fb1d0c744a9ee1902f    src

$ git cat-file -p 9aeacd
100644 blob 79ee69e841a5fd382faef2be2f2eb6e836cc980a    file1.txt

可以看到该tree对象中包含了该版本的所有文件和目录,由于README没有变化,还是指向的065bca这个blob对象。Makefile是一个新建的blob对象,src和file1.txt则指向了新版本的对象。

增加了这次commit后,git中各个对象的关系如下图所示:

(parent)
HEAD--> refs/heads/work--> 4f7399(commit) +-------> b767d7(commit)<---refs/heads/master
                              +                             +
                              |                             |
                              v                             v
                         082b6d(tree)                   ca964f(tree)
                              +                             +
                              |                             |
               +-----------------------------+     +--------+-----------+
               |              |              |     |                    |
               v              v              v     v                    v
           9aeacd(tree)    8cc95f(blob)    065bca(blob)            824244(tree)
         src (version 2)    Makefile         README               src (version 1)
               +                                                        +
               |                                                        |
               v                                                        v
          79ee69(blob)                                             3b18e5(blob)
        file1.txt (version 2)                                    file1.txt (version 1)

从上图可以看到,Git会为每次commit时修改的目录/文件生成一个新的版本的tree/blob对象,如果文件没有修改,则会指向老版本的tree/blob对象。而branch则只是指向某一个commit的一个指针。即Git中整个工作目录的version是以commit对象的形式存在的,可以认为一个commit就是一个version,而不同version可以指向相同或者不同的tree和blob对象,对应到不同版本的子目录和文件。如果某一个子目录/文件在版本间没有变化,则不会为该子目录/文件生成新的tree/blob对象,不同version的commit对象会指向同一个tree/object对象。

Tag和branch类似,也是指向某个commit的指针。不同的是tag创建后其指向的commit不能变化,而branch创建后,其指针会在提交新的commit后向前移动。

$ git tag v1.0
$ cat .git/refs/tags/v1.0 .git/refs/heads/work
4f73993cf81931bc15375f0a23d82c40b3ae6789
4f73993cf81931bc15375f0a23d82c40b3ae6789

可以看到新创建的v1.0 tag和work branch都是指向了4f7399这个commit。

Git Stash实现原理

Git stash的功能说明:经常有这样的事情发生,当你正在进行项目中某一部分的工作,里面的东西处于一个比较杂乱的状态,而你想转到其他分支上进行一些工作。问题是,你不想提交进行了一半的工作,否则以后你无法回到这个工作点。解决这个问题的办法就是git stash命令。

“‘储藏”“可以获取你工作目录的中间状态——也就是你修改过的被追踪的文件和暂存的变更——并将它保存到一个未完结变更的堆栈中,随时可以重新应用。

Git是如何实现Stash的呢?理解了Commit, Tree, Blog这三种Git存储对象,我们就可以很容易理解Git Stash的实现原理。因为和bransh及tag类似,Git Stash其实也是通过Commit来实现的。

通过实验来测试一下:

$ echo "another line" >> src/file1.txt
$ git stash

通过上面的命令,我们在file1.txt中增加了一行,然后通过git stash命令将这些改动“暂存”在了一个“堆栈”中,让我们来看看.git目录发生了什么变化。

$ tree .git/
.git/
├── branches
├── COMMIT_EDITMSG
├── config
├── description
├── HEAD
├── hooks
│   ├── applypatch-msg.sample
│   ├── commit-msg.sample
│   ├── post-update.sample
│   ├── pre-applypatch.sample
│   ├── pre-commit.sample
│   ├── prepare-commit-msg.sample
│   ├── pre-push.sample
│   ├── pre-rebase.sample
│   └── update.sample
├── index
├── info
│   └── exclude
├── logs
│   ├── HEAD
│   └── refs
│       ├── heads
│       │   ├── master
│       │   └── work
│       └── stash
├── objects
│   ├── 06
│   │   └── 5bcad11008c5e958ff743f2445551e05561f59
│   ├── 08
│   │   └── 2b6d87eeddb15526b7c920e21f09f950f78b54
│   ├── 11
│   │   └── a6d1031e4fa2d4da0b6303dd74ed8e85c54057
│   ├── 33
│   │   └── f98923002cd224dabf32222c808611badd6d48
│   ├── 3b
│   │   └── 18e512dba79e4c8300dd08aeb37f8e728b8dad
│   ├── 4f
│   │   └── 73993cf81931bc15375f0a23d82c40b3ae6789
│   ├── 6a
│   │   ├── 1474c4da0653af0245970997b6fab0a0a7c1df
│   │   └── d88760c3be94d8cb582bf2d06b99083d034428
│   ├── 75
│   │   └── e170cc1d928ae5a28547b4a3f2f3394a675b9a
│   ├── 79
│   │   └── ee69e841a5fd382faef2be2f2eb6e836cc980a
│   ├── 82
│   │   └── 424451ac502bd69712561a524e2d97fd932c69
│   ├── 8c
│   │   └── c95f278445722c59d08bbd798fbaf60da8ca14
│   ├── 90
│   │   └── c43dbb1e71c271510994d6b147c425cbffa673
│   ├── 9a
│   │   └── eacd1fa832ca167b0f72fb1d0c744a9ee1902f
│   ├── b7
│   │   └── 67d7115ef57666c9d279c7acc955f86f298a8d
│   ├── ca
│   │   └── 964f37599d41e285d1a71d11495ddc486b6c3b
│   ├── e8
│   │   └── 83e779eb08e2d9bca1fc1ee722fc80addac312
│   ├── info
│   └── pack
├── ORIG_HEAD
└── refs
    ├── heads
    │   ├── master
    │   └── work
    ├── stash
    └── tags
        └── v1.0

可以看到objects目录中增加了一些对象文件,refs中增加了一个stash文件。通过命令查看该文件内容:

$ cat .git/refs/stash
11a6d1031e4fa2d4da0b6303dd74ed8e85c54057
$ git cat-file -p 11a6
tree 90c43dbb1e71c271510994d6b147c425cbffa673
parent 4f73993cf81931bc15375f0a23d82c40b3ae6789
parent 6a1474c4da0653af0245970997b6fab0a0a7c1df
author Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="146e7c757b7c6175767d7a73547379757d783a777b79">[email protected]</a>> 1548326421 +0800
committer Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="3b41535a54534e5a5952555c7b5c565a525715585456">[email protected]</a>> 1548326421 +0800

WIP on work: 4f73993 some change
$ git cat-file -p 90c4
100644 blob 8cc95f278445722c59d08bbd798fbaf60da8ca14    Makefile
100644 blob 065bcad11008c5e958ff743f2445551e05561f59    README
040000 tree 33f98923002cd224dabf32222c808611badd6d48    src
$ git cat-file -p 33f9
100644 blob 75e170cc1d928ae5a28547b4a3f2f3394a675b9a    file1.txt
$ git cat-file -p 75e1
hello world
new line
another line

从命令行输出可以看到,git stash实际上创建了一个新的commit对象11a6d1, 该commit对象的父节点为4f7399。commit对象中包含了修改后的file1.txt blob对象75e170。通过git log可以查看:

$ git log --oneline --graph <a href="/cdn-cgi/l/email-protection" data-cfemail="bbc8cfdac8d3fb">[email protected]</a>{0}
*   f566001 WIP on work: 4f73993 some change
|\
| * 0796ced index on work: 4f73993 some change
|/
* 4f73993 some change
* b767d71 init commit

备注:git stash生成的commit对象有两个parent,一个是前面一次git commit命令生成的commit,另一个对应于保存到stage中的commit。

从该试验可以得知,git stash也是以commit,tree和object对象实现的。Git stash保存到“堆栈”中的修改其实一个commit对象。

Git reset 实现原理

在进行一些改动以后并通过git commit 将改动的代码提交到本地的repo后,如果你测试发现刚才的改动不合理,希望回退刚才的改动,应该如何处理?

我们先提交一个错误的改动:

$ echo "I did something wrong" >> src/file1.txt
$ git add .
$ git commit -sm "This commit should not be there"
[work ccbc363] This commit should not be there
 1 file changed, 1 insertion(+)

你可以通过git revert回退刚才的改动,或者修改代码后再次提交,但这样的话你的提交log会显得非常凌乱;如果不想把中间过程的commit push到远程仓库,可以通过git reset 回退刚才的改动。

先查看目前的log

$ git log
commit ccbc3638142191bd68454d47a0f67fd12519806b
Author: Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="bfc5d7ded0d7cadeddd6d1d8ffd8d2ded6d391dcd0d2">[email protected]</a>>
Date:   Fri Jan 25 12:35:31 2019 +0800

    This commit should not be there

    Signed-off-by: Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="e59f8d848a8d9084878c8b82a58288848c89cb868a88">[email protected]</a>>

commit 4f73993cf81931bc15375f0a23d82c40b3ae6789
Author: Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="4f35272e20273a2e2d2621280f28222e2623612c2022">[email protected]</a>>
Date:   Mon Jan 21 19:15:25 2019 +0800

    some change

    Signed-off-by: Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="7c06141d1314091d1e15121b3c1b111d1510521f1311">[email protected]</a>>

commit b767d7115ef57666c9d279c7acc955f86f298a8d
Author: Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="245e4c454b4c5145464d4a43644349454d480a474b49">[email protected]</a>>
Date:   Mon Jan 21 15:25:16 2019 +0800

    init commit

    Signed-off-by: Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="ceb4a6afa1a6bbafaca7a0a98ea9a3afa7a2e0ada1a3">[email protected]</a>>

通过 git reset回退到上一个commit。注意这里HEAD是一个指向当前branch最后一个commit指针,因此HEAD~1表示之前的一个commit。git reset命令也可以直接使用commit号作为命令参数。

$ git reset HEAD~1
Unstaged changes after reset:
M       src/file1.txt

$ git log
commit 4f73993cf81931bc15375f0a23d82c40b3ae6789
Author: Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="176d7f76787f6276757e797057707a767e7b3974787a">[email protected]</a>>
Date:   Mon Jan 21 19:15:25 2019 +0800

    some change

    Signed-off-by: Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="83f9ebe2ecebf6e2e1eaede4c3e4eee2eaefade0ecee">[email protected]</a>>

commit b767d7115ef57666c9d279c7acc955f86f298a8d
Author: Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="e49e8c858b8c9185868d8a83a48389858d88ca878b89">[email protected]</a>>
Date:   Mon Jan 21 15:25:16 2019 +0800

    init commit

    Signed-off-by: Huabing Zhao <<a href="/cdn-cgi/l/email-protection" data-cfemail="92e8faf3fdfae7f3f0fbfcf5d2f5fff3fbfebcf1fdff">[email protected]</a>>

可以看到刚才的commit被回退了,但修改的文件还存在,处于Unstaged状态,你可以对这些文件进行改动后再次提交。

如果你不想保留修改的文件,可以使用–hard参数直接回退到指定的commit,该参数会将HEAD指向该commit,并且工作区中的文件也会和该comit保持一致,该commit后的修改会被直接丢弃。

$ git reset HEAD --hard
HEAD is now at 4f73993 some change
$ git status
On branch work
nothing to commit, working directory clean

Git object存储方式

Git object是通过下面的方式处理并存储在git内部的文件系统中的:

  1. 首先创建一个header,header的值为 “对象类型 内容长度\0”
  2. 将header和文件内容连接起来,计算得到其SHA-1 hash值
  3. 将连接得到的内容采用zlib压缩
  4. 将压缩后的内容写入到以 “hash值前两位命令的目录/hash值后38位命令的文件” 中

可以通过 Ruby 手工创建一个 Git object 来验证上面的步骤。

$ irb
irb(main):001:0> content = "what is up, doc?"                     //文件内容
=> "what is up, doc?"
irb(main):002:0> header = "blob #{content.length}\0"              //创建header
=> "blob 16\u0000"
irb(main):003:0> store = header + content                         //拼接header和文件内容
=> "blob 16\u0000what is up, doc?"
irb(main):004:0> require 'digest/sha1'
=> true
irb(main):005:0> sha1 = Digest::SHA1.hexdigest(store)
=> "bd9dbf5aae1a3862dd1526723246b20206e5fc37"                     //计算得到hash值
irb(main):006:0>  require 'zlib'
=> true
irb(main):007:0> zlib_content = Zlib::Deflate.deflate(store)      //压缩header+文件内容 
=> "x\x9CK\xCA\xC9OR04c(\xCFH,Q\xC8,V(-\xD0QH\xC9O\xB6\a\x00_\x1C\a\x9D"
irb(main):008:0>  path = '.git/objects/' + sha1[0,2] + '/' + sha1[2,38]
=> ".git/objects/bd/9dbf5aae1a3862dd1526723246b20206e5fc37"       //通过hash值计算文件存储路径
irb(main):009:0> require 'fileutils'
=> true
irb(main):010:0>  FileUtils.mkdir_p(File.dirname(path))           //写文件
=> [".git/objects/bd"]
irb(main):011:0> File.open(path, 'w') { |f| f.write zlib_content }
=> 32
irb(main):012:0>

文件以及写入到Git的内部存储中,我们尝试通过git cat-file 验证并读取该文件内容:

$ git cat-file -p bd9dbf5aae1a3862dd1526723246b20206e5fc37
what is up, doc?

可以看到,可以通过git cat-file文件读取该文件内容,因此该文件是一个合法的git object,和通过git 命令写入的文件格式相同。

总结

Git围绕三种Object来实现了版本控制以及Branch,Tag等机制。

  • Commit: Commit可以看作Git中一个Version的所有目录和文件的Snapshot,可以通过git checkout 查看任意一个commit中的内容。
  • Tree: 目录对象,内部包含目录和文件
  • Blob: 文件对象,对应一个文件

理解了Git object的存储机制,就可以理解Git的各个命令的实现原理,更好地使用Git来实现源代码管理。

参考

「真诚赞赏,手留余香」


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Remote

Remote

Jason Fried、David Heinemeier Hansson / Crown Business / 2013-10-29 / CAD 26.95

The “work from home” phenomenon is thoroughly explored in this illuminating new book from bestselling 37signals founders Fried and Hansson, who point to the surging trend of employees working from hom......一起来看看 《Remote》 这本书的介绍吧!

在线进制转换器
在线进制转换器

各进制数互转换器

HSV CMYK 转换工具
HSV CMYK 转换工具

HSV CMYK互换工具