docker 源码分析 六(基于1.8.2版本),Docker run启动过程
上一篇大致了解了docker 容器的创建过程,其实主要还是从文件系统的视角分析了创建一个容器时需要得建立 RootFS,建立volumes等步骤;本章来分析一下建立好一个容器后,将这个容器运行起来的过程,
本章主要分析一下 docker deamon端的实现方法;根据前面几章的介绍可以容易找到,客户端的实现代码在api/client/run.go中,大体步骤是首先通过上一篇文章中的createContainer()方法建立一个container,然后通过调用cli.call("POST", "/containers/"+createResponse.ID+"/start", nil, nil)来实现将这个container启动;在api/server/server.go中,客户端请求对应的mapping为 "/containers/{name:.*}/start": s.postContainersStart,实现方法postContainerStart在api/server/container.go文件中,代码如下:
func (s *Server) postContainersStart(version version.Version, w http.ResponseWriter, r *http.Request, vars map[string]string) error {
if vars == nil {
return fmt.Errorf("Missing parameter")
}
var hostConfig *runconfig.HostConfig
if r.Body != nil && (r.ContentLength > 0 || r.ContentLength == -1) {
if err := checkForJSON(r); err != nil {
return err
}
c, err := runconfig.DecodeHostConfig(r.Body)
if err != nil {
return err
}
hostConfig = c
}
if err := s.daemon.ContainerStart(vars["name"], hostConfig); err != nil {
if err.Error() == "Container already started" {
w.WriteHeader(http.StatusNotModified)
return nil
}
return err
}
w.WriteHeader(http.StatusNoContent)
return nil
}
逻辑非常简单,首先从request中解析参数,然后调用s.daemon.ContainerStart(vars["name"],hostConfig)启动容器,最后将结果写回response;主要的实现部分在s.daemon.ContainerStart(vars["name"],hostConfig)之中。在daemon/start.go中;
func (daemon *Daemon) ContainerStart(name string, hostConfig *runconfig.HostConfig) error {
container, err := daemon.Get(name)
if err != nil {
return err
}
if container.IsPaused() {
return fmt.Errorf("Cannot start a paused container, try unpause instead.")
}
if container.IsRunning() {
return fmt.Errorf("Container already started")
}
// Windows does not have the backwards compatibility issue here.
if runtime.GOOS != "windows" {
// This is kept for backward compatibility - hostconfig should be passed when
// creating a container, not during start.
if hostConfig != nil {
if err := daemon.setHostConfig(container, hostConfig); err != nil {
return err
}
}
} else {
if hostConfig != nil {
return fmt.Errorf("Supplying a hostconfig on start is not supported. It should be supplied on create")
}
}
// check if hostConfig is in line with the current system settings.
// It may happen cgroups are umounted or the like.
if _, err = daemon.verifyContainerSettings(container.hostConfig, nil); err != nil {
return err
}
if err := container.Start(); err != nil {
return fmt.Errorf("Cannot start container %s: %s", name, err)
}
return nil
}
首先根据传进来的名字,通过deamon.Get() (daemon/daemon.go)
func (daemon *Daemon) Get(prefixOrName string) (*Container, error) {
if containerByID := daemon.containers.Get(prefixOrName); containerByID != nil {
// prefix is an exact match to a full container ID
return containerByID, nil
}
// GetByName will match only an exact name provided; we ignore errors
if containerByName, _ := daemon.GetByName(prefixOrName); containerByName != nil {
// prefix is an exact match to a full container Name
return containerByName, nil
}
containerId, indexError := daemon.idIndex.Get(prefixOrName)
if indexError != nil {
return nil, indexError
}
return daemon.containers.Get(containerId), nil
}
首先从daemon.containers中根据name来进行查找,找出container是否已经存在了。daemon.container是contStore类型的结构体,其结构如下:
type contStore struct {
s map[string]*Container
sync.Mutex
}
接着通过GetByName查找:GetByName同样在daemon/daemon.go中,代码如下:
func (daemon *Daemon) GetByName(name string) (*Container, error) {
fullName, err := GetFullContainerName(name)
if err != nil {
return nil, err
}
entity := daemon.containerGraph.Get(fullName)
if entity == nil {
return nil, fmt.Errorf("Could not find entity for %s", name)
}
e := daemon.containers.Get(entity.ID())
if e == nil {
return nil, fmt.Errorf("Could not find container for entity id %s", entity.ID())
}
return e, nil
}
daemon.containerGraph是graphdb.Database类型(pkg/graphdb/graphdb.go文件中),
type Database struct {
conn *sql.DB
mux sync.RWMutex
}
Database是一个存储容器和容器之间关系的数据库;目前Database是一个sqlite3数据库,所在的路径是/var/lib/docker/link/linkgraph.db中,其是在NewDaemon的实例化过程中,传递进来的。
graphdbPath := filepath.Join(config.Root, "linkgraph.db")
graph, err := graphdb.NewSqliteConn(graphdbPath)
if err != nil {
return nil, err
}
d.containerGraph = graph
数据库中最主要有两个表,分别是Entity,Edge,每一个镜像对应一个实体,存在Entity表;每个镜像与其父镜像的关系存在Edge表。每一个表在代码中也对应着一个结构体:
// Entity with a unique id.
type Entity struct {
id string
}
// An Edge connects two entities together.
type Edge struct {
EntityID string
Name string
ParentID string
}
通过建表语句也许更能直观一些:
createEntityTable = `
CREATE TABLE IF NOT EXISTS entity (
id text NOT NULL PRIMARY KEY
);`
createEdgeTable = `
CREATE TABLE IF NOT EXISTS edge (
"entity_id" text NOT NULL,
"parent_id" text NULL,
"name" text NOT NULL,
CONSTRAINT "parent_fk" FOREIGN KEY ("parent_id") REFERENCES "entity" ("id"),
CONSTRAINT "entity_fk" FOREIGN KEY ("entity_id") REFERENCES "entity" ("id")
);
`
最后一步就是通过GetByName查找完之后,接着根据daemon.idIndex.Get()进行查找,idIndex和前一篇中的镜像的idIndex是一样的,是一个trie的结构;
回到ContainerStart() 函数,在获取了container之后,接着判断container是否是停止和正在运行的,如果都不是, 在进行一些参数验证(端口映射的设置、验证exec driver、验证内核是否支持cpu share,IO weight等)后,则启动调用container.Start() (daemon/container.go)启动container;
func (container *Container) Start() (err error) {
container.Lock()
defer container.Unlock()
if container.Running {
return nil
}
if container.removalInProgress || container.Dead {
return fmt.Errorf("Container is marked for removal and cannot be started.")
}
// if we encounter an error during start we need to ensure that any other
// setup has been cleaned up properly
defer func() {
if err != nil {
container.setError(err)
// if no one else has set it, make sure we don't leave it at zero
if container.ExitCode == 0 {
container.ExitCode = 128
}
container.toDisk()
container.cleanup()
container.LogEvent("die")
}
}()
if err := container.Mount(); err != nil {
return err
}
// Make sure NetworkMode has an acceptable value. We do this to ensure
// backwards API compatibility.
container.hostConfig = runconfig.SetDefaultNetModeIfBlank(container.hostConfig)
if err := container.initializeNetworking(); err != nil {
return err
}
linkedEnv, err := container.setupLinkedContainers()
if err != nil {
return err
}
if err := container.setupWorkingDirectory(); err != nil {
return err
}
env := container.createDaemonEnvironment(linkedEnv)
if err := populateCommand(container, env); err != nil {
return err
}
mounts, err := container.setupMounts()
if err != nil {
return err
}
container.command.Mounts = mounts
return container.waitForStart()
}
defer func() 里面的作用就是如果start container出问题的话,进行一些清理工作;
container.Mount() 挂在container的aufs文件系统;
initializeNetworking() 对网络进行初始化,docker网络模式有三种,分别是 bridge模式(每个容器用户单独的网络栈),host模式(与宿主机共用一个网络栈),contaier模式(与其他容器共用一个网络栈,猜测kubernate中的pod所用的模式);根据config和hostConfig中的参数来确定容器的网络模式,然后调动libnetwork包来建立网络,关于docker网络的部分后面会单独拿出一章出来梳理;
container.setupLinkedContainers() 将通过--link相连的容器中的信息获取过来,然后将其中的信息转成环境变量(是[]string数组的形式,每一个元素类似于"NAME=xxxx")的形式
返回;
setupWorkingDirectory() 建立容器执行命令时的工作目录;
createDaemonEnvironment() 将container中的自有的一些环境变量和之前的linkedEnv和合在一起(append),然后返回;
populateCommand(container, env) 主要是为container的execdriver(最终启动容器的) 设置网络模式、设置namespace(pid,ipc,uts)等、资源(resources)限制等,并且设置在容器内执行的Command,Command中含有容器内进程的启动命令;
container.setupMounts() 返回container的所有挂载点;
最后调用container.waitForStart()函数启动容器;
func (container *Container) waitForStart() error {
container.monitor = newContainerMonitor(container, container.hostConfig.RestartPolicy)
// block until we either receive an error from the initial start of the container's
// process or until the process is running in the container
select {
case <-container.monitor.startSignal:
case err := <-promise.Go(container.monitor.Start):
return err
}
return nil
}
首先实例化出来一个containerMonitor,monitor的作用主要是监控容器内第一个进程的执行,如果执行没有成功,那么monitor可以按照一定的重启策略(startPolicy)来进行重启;
看下一下montitor(daemon/monitor.go)中的Start()函数,最主要的部分是
m.container.daemon.Run(m.container, pipes, m.callback)
在daemon/daemon.go文件中, Run方法:
func (daemon *Daemon) Run(c *Container, pipes *execdriver.Pipes, startCallback execdriver.StartCallback) (execdriver.ExitStatus, error) {
return daemon.execDriver.Run(c.command, pipes, startCallback)
}
docker的execDriver有两个:lxc 和 native;lxc是较早的driver,native是默认的,用的是libcontainer;所以最终这个Run的方式是调用daemon/execdriver/native/driver.go中的Run() 方法:
func (d *Driver) Run(c *execdriver.Command, pipes *execdriver.Pipes, startCallback execdriver.StartCallback) (execdriver. ExitStatus, error) {
// take the Command and populate the libcontainer.Config from it
container, err := d.createContainer(c)
if err != nil {
return execdriver.ExitStatus{ExitCode: -1}, err
}
p := &libcontainer.Process{
Args: append([]string{c.ProcessConfig.Entrypoint}, c.ProcessConfig.Arguments...),
Env: c.ProcessConfig.Env,
Cwd: c.WorkingDir,
User: c.ProcessConfig.User,
}
if err := setupPipes(container, &c.ProcessConfig, p, pipes); err != nil {
return execdriver.ExitStatus{ExitCode: -1}, err
}
cont, err := d.factory.Create(c.ID, container)
if err != nil {
return execdriver.ExitStatus{ExitCode: -1}, err
}
d.Lock()
d.activeContainers[c.ID] = cont
d.Unlock()
defer func() {
cont.Destroy()
d.cleanContainer(c.ID)
}()
if err := cont.Start(p); err != nil {
return execdriver.ExitStatus{ExitCode: -1}, err
}
if startCallback != nil {
pid, err := p.Pid()
if err != nil {
p.Signal(os.Kill)
p.Wait()
return execdriver.ExitStatus{ExitCode: -1}, err
}
startCallback(&c.ProcessConfig, pid)
}
oom := notifyOnOOM(cont)
waitF := p.Wait
if nss := cont.Config().Namespaces; !nss.Contains(configs.NEWPID) {
// we need such hack for tracking processes with inherited fds,
// because cmd.Wait() waiting for all streams to be copied
waitF = waitInPIDHost(p, cont)
}
ps, err := waitF()
if err != nil {
execErr, ok := err.(*exec.ExitError)
if !ok {
return execdriver.ExitStatus{ExitCode: -1}, err
}
ps = execErr.ProcessState
}
cont.Destroy()
_, oomKill := <-oom
return execdriver.ExitStatus{ExitCode: utils.ExitStatus(ps.Sys().(syscall.WaitStatus)), OOMKilled: oomKill}, nil
}
d.createContainer(c) 根据command实例化出来一个container需要的配置;Capabilities、Namespace、Group、mountpoints等,首先根据模板生成固定的配置(daemon/execdriver/native/template/default_template.go),然后在根据command建立容器特定的namespace
接着实例化一个libcontainer.Process{},里面的Args参数就是用户输入的entrypoint和cmd参数的组合,这也是将来容器的第一个进程(initProcess)要运行的一部分;
setupPipes(container, &c.ProcessConfig, p, pipes); 将container类(pipes)的标准输入输出与 libcontainer.Process (也是将来容器中的的init processs,就是变量p)进行绑定,这样就可以获取初始进程的输入和输出;
cont, err := d.factory.Create(c.ID, container) 调用driver.factory(~/docker_src/vendor/src/github.com/opencontainers/runc/libcontainer/factory_linux.go )来实例化一个linux container,结构如下:
linuxContainer{
id: id,
root: containerRoot,
config: config,
initPath: l.InitPath,
initArgs: l.InitArgs,
criuPath: l.CriuPath,
cgroupManager: l.NewCgroupsManager(config.Cgroups, nil),
}
这个linuxContainer类和之前的container类是不同的,这个是execdriver专有的类,其中比较主要的,ID就是containerID,initPath:是dockerinit的路径,initArgs是docker init的参数,然后是CriuPath(用于给容器做checkpoint),cgroupMangeer:管理容器的进程所在的资源;
dockerinit要说一下,dockerinit是一个固定的二进制文件,是一个容器运行起来之后去执行的第一个可执行文件,dockerinit的作用是在新的namespace中设置挂在资源,初始化网络栈等等,当然还有一作用是由dockerinit来负责执行用户设定的entrypoint和cmd;执行entrypoint和cmd,执行entrypoint和cmd的时候,与dockerinit是在同一个进程中;
cont.Start(p); 通过linuxcontainer运行之前的libcontainer.Process,这个步骤稍后会详细讲解;
接下来就是常规的步骤了,调用callback函数、监控container是否会有内存溢出的问题(通过cgroupmanager)、然后p.Wait()等待libcontainer.Process执行完毕、无误执行完毕后接着调用destroy销毁linuxcontainer,然后返回执行状态;
接下来对linuxcontainer的start(vendor/src/github.com/opencontainers/runc/libcontainer/container_linux.go)过程详细介绍一下;
func (c *linuxContainer) Start(process *Process) error {
c.m.Lock()
defer c.m.Unlock()
status, err := c.currentStatus()
if err != nil {
return err
}
doInit := status == Destroyed
parent, err := c.newParentProcess(process, doInit)
if err != nil {
return newSystemError(err)
}
if err := parent.start(); err != nil {
// terminate the process to ensure that it properly is reaped.
if err := parent.terminate(); err != nil {
logrus.Warn(err)
}
return newSystemError(err)
}
process.ops = parent
if doInit {
c.updateState(parent)
}
return nil
}
这个Start()函数的作用就是开启容器的第一个进程initProcess,docker daemon开启一个新的容器,其实就是fork出一个新的进程(这个进程有自己的namespace,从而实现容器间的隔离),这个进程同时也是容器的初始进程,这个初始进程用来执行dockerinit、entrypoint、cmd等一系列操作;
status, err := c.currentStatus() 首先判断一下容器的初始进程是否已经存在,不存在的话会返回destroyd状态;
parent, err := c.newParentProcess(process, doInit) 开启新的进程,下面插进来一下关于newParentProcess的代码
func (c *linuxContainer) newParentProcess(p *Process, doInit bool) (parentProcess, error) {
parentPipe, childPipe, err := newPipe()
if err != nil {
return nil, newSystemError(err)
}
cmd, err := c.commandTemplate(p, childPipe)
if err != nil {
return nil, newSystemError(err)
}
if !doInit {
return c.newSetnsProcess(p, cmd, parentPipe, childPipe), nil
}
return c.newInitProcess(p, cmd, parentPipe, childPipe)
}
func (c *linuxContainer) commandTemplate(p *Process, childPipe *os.File) (*exec.Cmd, error) {
cmd := &exec.Cmd{
Path: c.initPath,
Args: c.initArgs,
}
cmd.Stdin = p.Stdin
cmd.Stdout = p.Stdout
cmd.Stderr = p.Stderr
cmd.Dir = c.config.Rootfs
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.ExtraFiles = append(p.ExtraFiles, childPipe)
cmd.Env = append(cmd.Env, fmt.Sprintf("_LIBCONTAINER_INITPIPE=%d", stdioFdCount+len(cmd.ExtraFiles)-1))
if c.config.ParentDeathSignal > 0 {
cmd.SysProcAttr.Pdeathsig = syscall.Signal(c.config.ParentDeathSignal)
}
return cmd, nil
}
上面两个函数是相互关联的,上面的函数调用了下面的函数,
newParentProcess中首先调用了
parentPipe, childPipe, err := newPipe() 来创建一个socket pair,形成一个管道;这个管道是docker daemon 与 将来的dockerinit进行通信的渠道, 上面说过dockerinit的作用是初始化新的namespace 内的一些重要资源,但这些资源是需要docker daemon 在宿主机上申请的,如:veth pair,docker daemon 在自己的命名空间中创建了这些内容之后,通过这个管道将数据交给 dockerinit
接着cmd, err := c.commandTemplate(p, childPipe)。这部分主要有两个作用,将dockerinit及其参数分装成go语言中的exec.Cmd类,
&exec.Cmd{
Path: c.initPath,
Args: c.initArgs,
}
这个Cmd类就是将来要真正执行的进程;其他一些事情是绑定Cmd的表述输入输入到libcontainer.Process(之前已经将输入输出绑定到container类),还有将管道的childpipe一端绑定到Cmd类的打开的文件中。
接着在newParentProcess中,返回了 newInitProcess(p, cmd, parentPipe, childPipe),其实质是返回了一个initProcess类(vendor/src/github.com/opencontainers/runc/libcontainer/process_linux.go);
initProcess{
cmd: cmd,
childPipe: childPipe,
parentPipe: parentPipe,
manager: c.cgroupManager,
config: c.newInitConfig(p),
}
其中的cmd,就是之前封装好的exec.Cmd类、然后childPipe已经绑定到了cmd的文件描述符中、parentPipe是pipe的另一端、manager是cgroup控制资源的作用、config是将之前的libcontainer.Process的配置(其中包括entrypoint和cmd的配置)转化成一些配置信息,这部分配置信息将通过parentPipe发给cmd的childpipe,最终由dockerinit来运行、接下来会讲到;
然后回到 Start()函数中, parent就是一个initProcess类,紧接着就是调用这个类的start()方法了
func (p *initProcess) start() error {
defer p.parentPipe.Close()
err := p.cmd.Start()
p.childPipe.Close()
if err != nil {
return newSystemError(err)
}
fds, err := getPipeFds(p.pid())
if err != nil {
return newSystemError(err)
}
p.setExternalDescriptors(fds)
if err := p.manager.Apply(p.pid()); err != nil {
return newSystemError(err)
}
defer func() {
if err != nil {
// TODO: should not be the responsibility to call here
p.manager.Destroy()
}
}()
if err := p.createNetworkInterfaces(); err != nil {
return newSystemError(err)
}
if err := p.sendConfig(); err != nil {
return newSystemError(err)
}
// wait for the child process to fully complete and receive an error message
// if one was encoutered
var ierr *genericError
if err := json.NewDecoder(p.parentPipe).Decode(&ierr); err != nil && err != io.EOF {
return newSystemError(err)
}
if ierr != nil {
return newSystemError(ierr)
}
return nil
}
最主要的几个步骤,p.cmd.Start() 首先运行cmd的命令;
p.manager.Apply(p.pid()) cmd运行起来之后,是一个新的进程,也是container中的第一个进程,会有一个pid,将这个pid加入到cgroup配置中,确保以后由初始进程fork出来的子进程也能遵守cgroup的资源配置;
createNetworkInterfaces() 为进程建立网络配置,并放到config配置中;
p.sendConfig() 将配置(包括网络配置、entrypoint、cmd等)通过parentPipe发给cmd进程,并有cmd中的dockerinit执行;
json.NewDecoder(p.parentPipe).Decode(&ierr); 等待cmd的执行是否会有问题;
容器的启动主要过程就是 docker 将container的主要配置封装成一个Command类,然后交给execdriver(libcontainer),libcontainer将command中的配置生成一个libcontainer.process类和一个linuxcontainer类,然后由linux container这个类运行libcontainer.process。运行的过程是生成一个os.exec.Cmd类(里面包含dockerinit),启动这个dockerinit,然后在运行entrypoint和cmd;
年前就先分析这么多了,接下来要看看swarm、kubernates、和docker 网络相关的东西;
docker 源码分析 六(基于1.8.2版本),Docker run启动过程的更多相关文章
- Docker源码分析(六):Docker Daemon网络
1. 前言 Docker作为一个开源的轻量级虚拟化容器引擎技术,已然给云计算领域带来了新的发展模式.Docker借助容器技术彻底释放了轻量级虚拟化技术的威力,让容器的伸缩.应用的运行都变得前所未有的方 ...
- Docker源码分析(八):Docker Container网络(下)
1.Docker Client配置容器网络模式 Docker目前支持4种网络模式,分别是bridge.host.container.none,Docker开发者可以根据自己的需求来确定最适合自己应用场 ...
- Docker源码分析(五):Docker Server的创建
1.Docker Server简介 Docker架构中,Docker Server是Docker Daemon的重要组成部分.Docker Server最主要的功能是:接受用户通过Docker Cli ...
- Docker源码分析(三):Docker Daemon启动
1 前言 Docker诞生以来,便引领了轻量级虚拟化容器领域的技术热潮.在这一潮流下,Google.IBM.Redhat等业界翘楚纷纷加入Docker阵营.虽然目前Docker仍然主要基于Linux平 ...
- docker 源码分析 一(基于1.8.2版本),docker daemon启动过程;
最近在研究golang,也学习一下比较火的开源项目docker的源代码,国内比较出名的docker源码分析是孙宏亮大牛写的一系列文章,但是基于的docker版本有点老:索性自己就git 了一下最新的代 ...
- docker 源码分析 四(基于1.8.2版本),Docker镜像的获取和存储
前段时间一直忙些其他事情,docker源码分析的事情耽搁了,今天接着写,上一章了解了docker client 和 docker daemon(会启动一个http server)是C/S的结构,cli ...
- Docker源码分析(九):Docker镜像
1.前言 回首过去的2014年,大家可以看到Docker在全球刮起了一阵又一阵的“容器风”,工业界对Docker的探索与实践更是一波高过一波.在如今的2015年以及未来,Docker似乎并不会像其他昙 ...
- Docker源码分析(七):Docker Container网络 (上)
1.前言(什么是Docker Container) 如今,Docker技术大行其道,大家在尝试以及玩转Docker的同时,肯定离不开一个概念,那就是“容器”或者“Docker Container”.那 ...
- Docker源码分析(四):Docker Daemon之NewDaemon实现
1. 前言 Docker的生态系统日趋完善,开发者群体也在日趋庞大,这让业界对Docker持续抱有极其乐观的态度.如今,对于广大开发者而言,使用Docker这项技术已然不是门槛,享受Docker带来的 ...
随机推荐
- [sqoop1.99.7] sqoop入门-下载、安装、运行和常用命令
一.简介 Apache Sqoop is a tool designed for efficiently transferring data betweeen structured, semi-str ...
- python调用zabbix接口实现Action配置
要写这篇博客其实我的内心是纠结的,老实说,我对zabbix的了解实在不多.但新公司的需求不容置疑,当我顶着有两个头大的脑袋懵懵转入运维领域时,面前摆着两百多组.上千台机器等着写入zabbix监控的需求 ...
- flume介绍与原理(一)
1 .背景 flume是由cloudera软件公司产出的可分布式日志收集系统,后与2009年被捐赠了apache软件基金会,为hadoop相关组件之一.尤其近几年随着flume的不断被完善以及升级版本 ...
- shell算数运算
((i=$j+$k)) 等价于 i=`expr $j + $k`((i=$j-$k)) 等价于 i=`expr $j -$k`((i=$j*$k)) 等价于 i=`exp ...
- 多行溢出隐藏显示省略号功能的JS实现
在页面重构中,经常需要将过多的内容隐藏而显示部分.在单行文本中实现非常简单,但是在多行文本中,则需要根据实际选择不同的方式. 用CSS实现多行溢出隐藏的代码非常简单,但是兼容性也相对较低. displ ...
- 【自动化学习笔记】_环境搭建Selenium2+Eclipse+Java+TestNG_(一)
目录 第一步 安装JDK 第二步 下载Eclipse 第三步 在Eclipse中安装TestNG 第四步 下载Selenium IDE.SeleniumRC.IEDriverServer 第五步 下 ...
- JS学习笔记01
文章转载pigpigpig4587 的 1.Javascript是区分大小写的语言.也就是说.关键字.变量,函数和所有的标识符都必须采取一致的大小写形式.因为html不严格区分大小写,所以在html中 ...
- tfs中如何创建团队项目及如何操作团队项目
创建团队项目集合 tfs server管理控制台\团队项目集合页面.选择'创建集合'链接,按向导即可创建项目集合. 创建团队项目 创建好团队项目集合后,就要开始创建团队项目了. 进入vs,连接上tfs ...
- JavaEE Spring
1. Spring以一己之力撼动了Sun公司的JavaEE传统重量级框架(EJB),逐渐成为使用最多的JavaEE企业应用开发框架. 2. Spring是分层的JavaEE应用一站式的轻量级开源框 ...
- JSON简介
JSON的全称是JavaScript Object Notion,即JavaScript对象符号,它是一种轻量级的数据交换格式,JSON的数据格式既适合人来读/写,也适合计算机本身解析和生成.最早 ...