现代化个人网站构建与部署方法
现代化个人网站构建与部署方法
Hugo 作为静态网站生成器,是一个将 Markdown 转化为漂亮的静态网站的工具
AWS S3 用于托管网站本身
Docker 用于运行 Hugo 并从的 Markdown 文件生成网站
网站仍然使用 Hugo,托管在云负载平衡器后面的谷歌云存储(GCS)上。使用 Cloud Build 来生成和部署我的网站。在我仓库上推送 Git 会触发网站的构建,然后自动发布到 GCS 上。博客现在托管在 Cloud Run 上。Cloud Run是谷歌云平台(GCP)推出的全新无服务器托管服务。它基本上是 Knative 的托管版本,Knative 是一个基于 Istio 和 Kubernetes 的开源无服务器平台。
‘
Knative示意
Cloud Run 是一款使用起来相当简单的产品:你只需给它一个 Docker 镜像,设置 CPU 和内存的使用限制,Cloud Run 就会负责运行、公开和扩展你的服务。
让我们来详细了解一下设置:
生成内容
这一点其实没有改变。仍在使用 mrtrustor/hugo Docker 镜像。正在生成一个静态网站,所以它不会对安全造成任何影响。
还在使用 Hugo 的学术主题,因此在构建内容之前需要调用这个主题。我将其作为 git 子模块使用。
以下是 cloudbuild.yaml 文件中与构建内容相关的部分
steps:
- id: 'Download academic theme'
name: 'gcr.io/cloud-builders/git'
args: ['submodule', 'update', '--init', '--recursive']
- id: 'Run Hugo'
name: 'mrtrustor/hugo:0.46'
args: ['--baseURL=https://blog.mrtrustor.net']
这两个步骤结束后,网站就在 Cloud Build Worker 的 /workspace/blog/public 目录中生成了。
构建 Docker 映像
当您使用 Cloud Run 时,需要注意的一个重要事项就是容器运行时合约。容器将使用 $PORT 环境变量启动,您的应用程序必须监听该端口。
为了为我的静态网站提供服务,选择了 Nginx,因为我为什么要使用其他东西呢?在 Nginx 官方 Docker 镜像的基础上构建我的 Docker 镜像。这是我的 Dockerfile:
FROM nginx:1.15
ENV PORT=8080 \
ROBOTS_FILE=robots-prod.txt
ADD site.template /etc/nginx/site.template
ADD blog/public /usr/share/nginx/html/ENTRYPOINT [ "/bin/bash", "-c", "envsubst '$PORT $HOST $ROBOTS_FILE' < /etc/nginx/site.template > /etc/nginx/conf.d/default.conf && exec nginx -g 'daemon off;'" ]
此时,网站已经生成,我只需复制镜像中的文件即可。也许这里最有趣的是使用 envsubst 在容器启动时生成一个有效的 Nginx 配置文件。envsubst 是一个小型的 "模板化 "工具,它能将文件中的环境变量替换为其值。
下面是我的 Nginx 配置模板:
server {
listen ${PORT};error_page 404 /404.html;
if ( $http_x_forwarded_proto = "http" ) {
return 301 https://${HOST}$request_uri;
}location / {
root /usr/share/nginx/html;
index index.html;
}location /robots.txt {
alias /usr/share/nginx/html/${ROBOTS_FILE};
}
}
我使用了 3 个环境变量:
根据 Cloud Run 文档的说明,使用 $PORT;使用 $HOST 将用户从 HTTP 重定向到 HTTPS;使用 $ROBOTS_FILE 在博客的暂存版和生产版之间切换 robots.txt 文件。
构建和推送 Docker 镜像非常简单。下面就是我的 cloudbuild.yaml 文件:
steps:
- id: 'Download academic theme'
name: 'gcr.io/cloud-builders/git'
args: ['submodule', 'update', '--init', '--recursive']
- id: 'Run Hugo'
name: 'mrtrustor/hugo:0.46'
args: ['--baseURL=https://blog.mrtrustor.net']
dir: 'blog'
- id: 'Build Image'
name: 'gcr.io/cloud-builders/docker'
args: ["build", "-t", "gcr.io/${PROJECT_ID}/blog:${SHORT_SHA}", "."]
- id: 'Push Image'
name: 'gcr.io/cloud-builders/docker'
args: ["push", "gcr.io/${PROJECT_ID}/blog:${SHORT_SHA}"]
在 Cloud Run 上部署
Docker 镜像
为了能够从 Cloud Build 部署到 Cloud Run,您需要向 Cloud Build 服务帐户授予一些额外的权限。Cloud Build 服务帐户是 [YOUR_PROJECT_NUMBER]@cloudbuild.gserviceaccount.com。
在 IAM 设置页面中,您需要赋予该服务帐户 Cloud Run Admin 角色。 在服务帐户页面中,您需要赋予 Cloud Build 服务帐户 Compute Engine 默认服务帐户的服务帐户用户角色。这样,Cloud Build 就可以充当计算引擎。 完成上述操作后,您就可以从 Cloud Build 部署到 Cloud Run 了。下面是我完整的 cloudbuild.yaml 文件:
steps:
- id: 'Download academic theme'
name: 'gcr.io/cloud-builders/git'
args: ['submodule', 'update', '--init', '--recursive']
- id: 'Run Hugo'
name: 'mrtrustor/hugo:0.46'
args: ['--baseURL=https://blog.mrtrustor.net']
dir: 'blog'
- id: 'Build Image'
name: 'gcr.io/cloud-builders/docker'
args: ["build", "-t", "gcr.io/${PROJECT_ID}/blog:${SHORT_SHA}", "."]
- id: 'Push Image'
name: 'gcr.io/cloud-builders/docker'
args: ["push", "gcr.io/${PROJECT_ID}/blog:${SHORT_SHA}"]
- id: 'Deploy to Cloud Run'
name: 'gcr.io/cloud-builders/gcloud'
args: ['beta', 'run', 'deploy', 'blog', '--set-env-vars=HOST=blog.mrtrustor.net,ROBOTS_FILE=robots-prod.txt', '--image', 'gcr.io/${PROJECT_ID}/blog:${SHORT_SHA}', '--allow-unauthenticated', '--region', 'us-central1']
images:
- "gcr.io/${PROJECT_ID}/blog:${SHORT_SHA}"
在 gcloud beta 运行部署命令中,有几个有趣的选项值得关注:
--set-env-vars 允许定义运行时环境变量。这就是我定义 $HOST 和 $ROBOTS_FILE 变量的地方。最后几个细节为了在向 Git 仓库推送修改内容时自动更新网站,我设置了云构建触发器。这意味着只要我向主分支推送,网站就会生成并部署。整个过程不到一分钟。最后,为了使用我自己的域名(blog.mrtrustor.net),我在 Cloud Run 中配置了域名映射。这样,Cloud Run 就能知道你要使用的实际域名,并为你生成 SSL 证书。你只需创建它提供的 A 和 AAAA 记录即可。
Cloud Run vs Cloud Run on GKE Cloud Run 实际上有两个版本。Cloud Run(此处使用的版本)和 Cloud Run on GKE。
第一个版本直接在 Google 内部基础架构上运行,而第二个版本则是在 GKE 上的 Knative 部署。它们共享相同的 API。如下图
对于JAVA应用优化实践部署给出建议
本指南介绍了对使用 Java 编程语言编写的 Cloud Run 服务的优化以及有助于您了解某些优化所涉及的权衡的背景信息。此页面上的信息是对常规优化提示的补充,这些提示同样适用于 Java。
传统的 Java Web 应用旨在高并发和低延迟地处理请求,并且通常为长时间运行的应用。JVM 自身还会通过 JIT 逐渐优化执行代码,以使热路径得到优化,并使应用运行更加高效。
这些传统的 Java Web 应用中的许多最佳做法和优化都围绕着以下内容:
- 处理并发请求(基于线程的 I/O 和非阻塞 I/O)
- 通过使用连接池和批处理非关键函数减少响应延迟时间,例如将跟踪记录和指标发送到后台任务。
许多传统优化非常适合于长时间运行的应用,但对于 Cloud Run 服务可能效果不佳,后者仅在主动处理请求时运行。本页面介绍了一些不同的 Cloud Run 优化和权衡,可用于减少启动时间和内存用量。
使用启动 CPU 加速功能缩短启动延迟时间
您可以启用启动 CPU 加速,在实例启动期间临时增加 CPU 分配,以缩短启动延迟时间。
Google 的指标表明,如果使用启动 CPU 加速,Java 应用将受益,此功能可将启动时间缩短最多 50%。
优化容器映像
通过优化容器映像,您可以缩短加载时间和启动时间。您可以通过以下方式优化映像:
- 尽可能减小化容器映像大小
- 避免使用嵌套库归档 JAR
- 使用 Jib
尽可能减小容器映像大小
请参阅有关尽可能减小容器映像大小的常规提示页面,了解此问题的更多背景信息。常规提示页面建议将容器映像大小减少为仅包含必需的内容。例如,确保容器映像不包含:
- 源代码
- Maven 构建工件
- 构建工具
- Git 目录
- 未使用的二进制文件/实用程序
如果您是从 Dockerfile 内构建代码,请使用 Docker 多阶段构建,以使最终容器映像仅具有 JRE 和应用 JAR 文件本身。
避免嵌套库归档 JAR
一些流行的框架(如 Spring Boot)会创建一个应用归档 (JAR) 文件,其中包含其他库 JAR 文件(嵌套 JAR)。这些文件需要在启动期间解压缩,并且可以提高 Cloud Run 中的启动速度。尽可能使用外部化的库创建精简 JAR:这可以通过使用 Jib 将应用容器化来自动完成。
使用 Jib
您可以使用 Jib 插件创建最小容器并自动展平应用归档。Jib 同时支持 Maven 和 Gradle,并且可以为 Spring Boot 应用提供开箱即用的支持。某些应用框架可能需要额外的 Jib 配置。
JVM 优化
优化 Cloud Run 服务的 JVM 可以提高性能和内存使用率。
使用容器感知的 JVM 版本
在虚拟机和机器中,对于 CPU 和内存分配,JVM 会从常见位置(例如,Linux 中的 /proc/cpuinfo
和 /proc/meminfo
)查找其可以使用的 CPU 和内存。但是,在容器中运行时,CPU 和内存限制条件存储在 /proc/cgroups/...
中。较旧版本的 JDK 会继续在 /proc
(而不是 /proc/cgroups
)中查找,这可能会导致 CPU 和内存用量超出分配的上限。这可能会导致:
- 线程过多,因为线程池大小由
Runtime.availableProcessors()
配置 - 超出容器内存上限的默认最大堆。JVM 在进行垃圾回之前大量使用内存。这很容易导致容器超出容器内存限制,并导致 OOMKilled。
因此,请使用容器感知的 JVM 版本。默认情况下,大于或等于 8u192
的 OpenJDK 版本是容器感知的。
如何了解 JVM 内存用量
Java 内存用量由本机内存用量和堆用量组成。应用的工作内存通常位于堆中。堆的大小受最大堆配置的限制。使用 Cloud Run 256MB RAM 实例时,您无法将所有 256 MB 分配给最大堆,因为 JVM 和操作系统也需要本机内存,例如线程栈、代码缓存、文件处理程序、缓冲区等。如果应用发生 OOMKilled,并且您需要了解 JVM 内存用量(原生内存 + 堆),请开启 Native Memory Tracking,以便在应用成功退出时查看用量。如果应用发生 OOMKilled,则无法打印信息。在这种情况下,请先使用更多内存运行应用,以便它可以成功生成输出。
您无法通过 JAVA_TOOL_OPTIONS
环境变量开启 Native Memory Tracking。您需要将 Java 命令行启动参数添加到容器映像入口点,以便您的应用使用以下参数启动应用:
java -XX:NativeMemoryTracking=summary \
-XX:+UnlockDiagnosticVMOptions \
-XX:+PrintNMTStatistics \
...
可以根据要加载的类的数量来估算本机内存用量。请考虑使用开源 Java 内存计算器来估算内存需求。
关闭优化编译器
默认情况下,JVM 有多个阶段的 JIT 编译。虽然这些阶段可以逐渐提高应用的效率,但它们也会增加内存使用的开销,并增加启动时间。
对于短期运行的无服务器应用(例如函数),请考虑关闭优化阶段,以牺牲长期效率换取更短的启动时间。
对于 Cloud Run 服务,请配置以下环境变量:
JAVA_TOOL_OPTIONS="-XX:+TieredCompilation -XX:TieredStopAtLevel=1"
使用应用类数据共享
如需进一步减少 JIT 时间和内存用量,请考虑使用应用类数据共享 (AppCDS) 来共享预先编译的 Java 类归档。启动同一 Java 应用的另一个实例时,可以重复使用 AppCDS 归档。JVM 可以重复使用归档中预先计算的数据,从而缩短启动时间。
使用 AppCDS 时需注意以下几点:
- 要重复使用的 AppCDS 归档必须使用最初生成它的 OpenJDK 发行版、版本和架构来重现。
- 您必须至少运行一次应用以生成要共享的类列表,然后使用该列表生成 AppCDS 归档。
- 类的范围取决于应用运行期间执行的代码路径。为扩大范围,采用编程方式可以触发更多代码路径。
- 应用必须成功退出才能生成此类列表。请考虑实现用于表示 AppCDS 归档生成的应用标志,以便立即退出。
- 只有使用与生成 AppCDS 归档完全相同的方式来启动新实例,才能重复使用该归档。
- AppCDS 归档仅适用于常规 JAR 文件包;不支持使用嵌套 JAR。
使用阴影 JAR 文件的 Spring Boot 示例
Spring Boot 应用默认使用嵌套超级 JAR,这不适用于 AppCDS。因此,如果您要使用 AppCDS,则需要创建一个阴影 JAR。例如,使用 Maven 和 Maven Shade 插件:
<build>
<finalName>helloworld</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<configuration>
<keepDependenciesWithProvidedScope>true</keepDependenciesWithProvidedScope>
<createDependencyReducedPom>true</createDependencyReducedPom>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
</configuration>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
<resource>META-INF/spring.handlers</resource>
</transformer>
<transformer implementation="org.springframework.boot.maven.PropertiesMergingResourceTransformer">
<resource>META-INF/spring.factories</resource>
</transformer>
<transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
<resource>META-INF/spring.schemas</resource>
</transformer>
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer" />
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>${mainClass}</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
如果您的阴影 JAR 包含所有依赖项,您可以在容器构建期间使用 Dockerfile
生成简单归档:
# Use Docker's multi-stage build
FROM eclipse-temurin:11-jre as APPCDS
COPY target/helloworld.jar /helloworld.jar
# Run the application, but with a custom trigger that exits immediately.
# In this particular example, the application looks for the '--appcds' flag.
# You can implement a similar flag in your own application.
RUN java -XX:DumpLoadedClassList=classes.lst -jar helloworld.jar --appcds=true
# From the captured list of classes (based on execution coverage),
# generate the AppCDS archive file.
RUN java -Xshare:dump -XX:SharedClassListFile=classes.lst -XX:SharedArchiveFile=appcds.jsa --class-path helloworld.jar
FROM eclipse-temurin:11-jre
# Copy both the JAR file and the AppCDS archive file to the runtime container.
COPY --from=APPCDS /helloworld.jar /helloworld.jar
COPY --from=APPCDS /appcds.jsa /appcds.jsa
# Enable Application Class-Data sharing
ENTRYPOINT java -Xshare:on -XX:SharedArchiveFile=appcds.jsa -jar helloworld.jar
减小线程栈大小
大多数 Java Web 应用都是基于每个连接一个线程的模式。每个 Java 线程都会消耗本机内存(而不是堆内存)。这称为线程栈,并且每个线程默认为 1 MB。如果您的应用处理 80 个并发请求,则它可能至少有 80 个线程,这相当于使用了 80 MB 的线程栈空间。该内存不计入堆大小。默认值可能大于必要值。您可以减小线程栈大小。
如果减小得太多,则将出现 java.lang.StackOverflowError
。您可以对应用进行分析,并找到要配置的最佳线程栈大小。
对于 Cloud Run 服务,请配置以下环境变量:
JAVA_TOOL_OPTIONS="-Xss256k"
减少线程
您可以通过使用非阻塞反应式策略和避免后台活动来减少线程数量,从而优化内存。
减少线程数量
由于线程栈,每个 Java 线程都可能会增加内存用量。Cloud Run 允许最多 1000 个并发请求。使用每个连接一个线程模式时,您最多需要 1000 个线程来处理所有并发请求。大多数 Web 服务器和框架都允许您配置线程数和连接数上限。例如,在 Spring Boot 中,您可以在 applications.properties
文件中设置最大连接数:
server.tomcat.max-threads=80
编写非阻塞反应式代码以优化内存和启动
要真正减少线程数量,请考虑采用非阻塞反应式编程模型,以便在处理更多并发请求时可以显著减少线程数量。Spring Boot Webflux、MicrosoftNavt 和 Quarkus 等应用框架支持反应式 Web 应用。
Spring Boot Webflux、Micronaut、Quarkus 等反应式框架通常具有更快的启动时间。
如果您继续在非阻塞框架中写入阻塞代码,则 Cloud Run 服务中的吞吐量和错误率会显著恶化。这是因为非阻塞框架将只有几个线程,例如 2 或 4。如果您的代码被阻塞,则仅可以处理极少的并发请求。
这些非阻塞框架还可以将阻塞代码分流到无界限线程池,这意味着,虽然它可以接受许多并发请求,但阻塞代码将在新线程中执行。如果线程以无界限的方式累积,则会耗尽 CPU 资源并开始抖动。延迟时间将受到严重影响。如果您使用非阻塞框架,请务必了解线程池模型并相应地绑定池。
使用后台活动时,将 CPU 配置为始终分配
后台活动是指在 HTTP 响应送达后发生的任何活动。具有传统任务的传统工作负载在 Cloud Run 中运行时需要特别注意。
将 CPU 配置为始终分配
如果您希望在 Cloud Run 服务中支持后台活动,请将 Cloud Run 服务 CPU 设置为始终分配,以便您可以在请求之外运行后台活动,并且仍拥有 CPU 访问权限。
如果仅在处理请求期间分配 CPU,请避免进行后台活动
如果您需要将服务设置为仅在处理请求期间分配 CPU,则需要注意后台活动存在的潜在问题。例如,如果您要收集应用指标并在后台批处理指标以进行定期发送,则在未分配 CPU 时,这些指标不会发送。如果您的应用不断收到请求,您可能会看到较少的问题。如果您的应用具有较低的 QPS,则后台任务可能永远不会执行。
如果您选择仅在处理请求期间分配 CPU,以下是您需要注意的一些在后台运行的常见模式:
- JDBC 连接池 - 清理和连接检查在后台进行
- 分布式跟踪记录发送器 - 分布式跟踪记录通常会定期或在后台缓冲区已满时进行批处理和发送。
- 指标发送器 - 指标通常会在后台进行批量处理和发送。
- 对于 Spring Boot,任何带有
@Async
注释的方法 - 计时器 - 任何基于计时器的触发器(例如,ScheduledThreadPoolExecutor、Quartz 或
@Scheduled
Spring 注释)可能无法在未分配 CPU 时执行。 - 消息接收器 - 例如,Pub/Sub 流式拉取客户端、JMS 客户端或 Kafka 客户端,通常在后台线程中运行,无需请求。当您的应用没有请求时,它们将不起作用。在 Cloud Run 中不建议以这种方式接收消息。
应用优化
在 Cloud Run 服务代码中,您也可以进行优化以减少启动时间和内存用量。
减少启动任务
传统的 Java Web 应用会在启动期间完成许多任务,例如预加载数据、预热缓存、建立连接池等。依次执行这些任务会很慢。但是,如果您希望它们并行执行,则应增加 CPU 核心数。
Cloud Run 目前会发送一个实际用户请求以触发冷启动实例。其请求被分配到新启动实例的用户可能会遇到较长的延迟。Cloud Run 目前没有“就绪”检查来避免向未就绪的应用发送请求。
使用连接池
如果您使用连接池,请注意,连接池可能会在后台逐出不需要的连接(请参阅避免后台任务)。如果应用的 QPS 较低,并且可以容忍高延迟,请考虑为每个请求打开和关闭连接。如果应用的 QPS 较高,则只要存在活跃请求,后台逐出就可能会继续执行。
在这两种情况下,应用的数据库访问都将在数据库允许的连接数上限方面遭遇瓶颈。计算每个 Cloud Run 实例可建立的最大连接数,并配置 Cloud Run 实例数上限,以使实例数上限与每个实例的连接数的乘积小于允许的连接数上限。
如果您使用 Spring Boot
注意:启用 CPU 加速可将启动时间缩短 50%。如果您使用 Spring Boot,则需要考虑以下优化
使用 Spring Boot 2.2 或更高版本
从 2.2 版开始,Spring Boot 已针对启动速度进行了大量优化。如果您使用的是低于 2.2 版的 Spring Boot,请考虑升级或手动应用各项优化。
使用延迟初始化
在 Spring Boot 2.2 及更高版本中,可以开启一个全局延迟初始化标志。这将提高启动速度,但代价是第一个请求的延迟时间可能变长,因为需要等待组件首次初始化。
您可以在 application.properties
中开启延迟初始化:
spring.main.lazy-initialization=true
或者,使用以下环境变量:
SPRING_MAIN_LAZY_INITIALIZATIION=true
但是,如果您使用的是 min-instances,由于 min-instance 启动时应已执行了初始化,因此延迟初始化没有什么用处。
避免类扫描
类扫描会在 Cloud Run 中导致额外的磁盘读取,因为在 Cloud Run 中,磁盘访问速度通常比常规机器慢。请确保进行有限的组件扫描或完全不进行组件扫描。考虑使用 Spring Context Indexer 来预生成索引。这是否会提高启动速度取决于您的应用。
例如,在 Maven pom.xml
中添加索引器依赖项(实际上是注释处理器):
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context-indexer</artifactId>
<optional>true</optional>
</dependency>
使用不在生产中使用的 Spring Boot 开发者工具
如果您在开发过程中使用 Spring Boot 开发者工具,请确保未将其打包到生产容器映像中。如果您在没有 Spring Boot 构建插件(例如,使用 Shade 插件或使用 Jib 进行容器化)的情况下构建 Spring Boot 应用,则可能会发生这种情况。
在这种情况下,请确保构建工具明确排除 Spring Boot 开发者工具。或者,明确关闭 Spring Boot 开发者工具。
参考:
- Cloud Run documentation
- Continuous Deployment from git
- Mapping custom domains
- Container runtime contract
今天先到这儿,希望对云原生,技术领导力, 企业管理,系统架构设计与评估,团队管理, 项目管理, 产品管管,团队建设 有参考作用 , 您可能感兴趣的文章:
领导人怎样带领好团队
构建创业公司突击小团队
国际化环境下系统架构演化
微服务架构设计
视频直播平台的系统架构演化
微服务与Docker介绍
Docker与CI持续集成/CD
互联网电商购物车架构演变案例
互联网业务场景下消息队列架构
互联网高效研发团队管理演进之一
消息系统架构设计演进
互联网电商搜索架构演化之一
企业信息化与软件工程的迷思
企业项目化管理介绍
软件项目成功之要素
人际沟通风格介绍一
精益IT组织与分享式领导
学习型组织与企业
企业创新文化与等级观念
组织目标与个人目标
初创公司人才招聘与管理
人才公司环境与企业文化
企业文化、团队文化与知识共享
高效能的团队建设
项目管理沟通计划
构建高效的研发与自动化运维
某大型电商云平台实践
互联网数据库架构设计思路
IT基础架构规划方案一(网络系统规划)
餐饮行业解决方案之客户分析流程
餐饮行业解决方案之采购战略制定与实施流程
餐饮行业解决方案之业务设计流程
供应链需求调研CheckList
企业应用之性能实时度量系统演变
如有想了解更多软件设计与架构, 系统IT,企业信息化, 团队管理 资讯,请关注我的微信订阅号:
作者:Petter Liu
出处:http://www.cnblogs.com/wintersun/
本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。
该文章也同时发布在我的独立博客中-Petter Liu Blog。