乐者为王

Do one thing, and do it well.

为什么我们不能用估算房屋同样的方法估算软件项目

英文原文:http://www.summa-tech.com/blog/2009/01/28/why-we-cant-estimate-software-project-the-same-way-we-estimate-houses

把构造软件类比成建造房屋是非常有用的,但也有缺陷。

尽管软件建设和住宅建设都是工程实践,但比起软件,我们在估算建造房屋的成本和精力方面更成功。

词汇障碍

因为我们大多数人一生都住在房子里,我们开发了一套定义良好的、可以理解的、几乎通用的词汇来描述和讨论关于我们称之为家的地方。当被问及我们的房子是什么样子的,我们可以很容易地回答。当计划一所新房子时,我们可以极其肯定地讨论我们想要什么,并确信建筑师和工程师会明白我们谈论的,反之亦然。我们可能不明白水管设施和电气细节,但我们知道电源插座要放在哪里,知道房间的大小,知道要有多少车库门。

当我们谈论软件时,就不是那么有效了。有太多来自业务和技术方面的新术语,我们需要依赖于现实世界中的类比去解释它们。不仅发生在业务和技术人员之间,甚至还发生在业务人员和业务人员,技术人员和技术人员之间。我们不习惯于去描述软件需求因为所有软件的抽象,在估算时沟通经常受到噪音、误解、缺乏眼界的影响,增加了真正需要构建的不确定性。

物理

现实世界有非常强大和稳定的规则,比如重力,这些规则会在盖房子的时候施加约束。我们知道我们必须在构建二楼前先完成地下室。我们不能在地基完成后增加层的数量。在水管设施铺设后更改浴室的位置将会是非常非常昂贵的。

在软件项目中,我们生活在一个较少规则的世界里,就像《黑客帝国》。从技术上讲,我们可以在同一时间构造所有的应用层。它可以被设计成使用不同的数据库,运行在不同的服务器,或支持不同的语言。它可以通过浏览器、手机、或其它无线设备访问。选项几乎是无穷无尽的。

正是这种自由和灵活性,在过去的40年里驱动了软件的巨大的进步,但在同时,它也是无数软件项目失败的原因。至关重要的是需求要面对现实,让它们遵守一些基本规则,即使它们不受宇宙物理规律的约束。

程序

物理和几千年建设的结合已经带来了如何去建造一座房子的一套可靠的程序,虽然总是会有新的材料和改进的技术,核心概念都是相同的。油漆房子的过程几百年来几乎没有变化。

我们仍然处于软件工程的早期阶段。大量的“直觉”仍在估算软件时使用,实际情况是直觉还没有被证明非常有效。

材料和标准

只有数量有限的材料能被实际地用在建造房屋上。从技术上来讲,可能有成千上百的选项去建造一堵墙:夹板、混凝土、钢铁、沙子、玻璃,但在家具建材零售店里这些选项是非常有限的。涂料的类型,门窗的模型很多但有限。当购买一个水槽时,它很容易兼容已有的水管设施的几率非常高。电器有着相同的电压,灯泡也是兼容的。计划建造一所房子的一切都是兼容的,材料更是普及的,把不确定性降低到了非常小的水平,提高了估算的准确性。

软件行业确实有一些标准,但是它们处于层的最低水平,例如网络协议和文件系统。服务器和产品的集成仍处于布线阶段,XML和Web Service还有很长的路要走,在它们和建筑业达到相同级别的兼容性前,如果这是可能的。

各种各样连接到数据库和构建软件的选项和方法增加了复杂性,提高了每个人参与软件构建工作的学习曲线。我不是说所有这些选项都是不好的,但它确实给估算阶段增加了不确定性,所以产生了复杂性。

角色

每次我路过建筑工地,都会看到很多帽子,一些在积极工作,一些在等待时机采取行动。但最好的部分是我从来没有见过有人同时戴两顶帽子。角色界定的很明确,工人们专业从事于非常具体的领域。

在大型软件项目中也有一些角色被定义,但还远远没有达到建筑业相同级别的专业化。通常团队成员需要戴上很多帽子,结果是,有时候他们会执行那些他们不是专家的任务,这就增加了他们提供的估算的不确定性。

“很多帽子”现象的一个很好例子是“Webmaster”,该角色用于描述那个做网页设计、创建动画图片、编写HTML和Perl代码、配置数据库、管理网络和邮件服务器的家伙。幸运的是现在Webmaster是个很少使用的术语,因为所有这些活动现在都分配给了不同的角色,像网页设计师、DBA,程序员和系统管理员。我们确实在走向专业化,但还是有很长的路要走。

我并不是建议我们停止使用“让我们像建造房屋那样构造软件”的类比,但我们必须意识到这个比喻的局限性。一旦我们知道局限性我们将能更好地定位讨论这一差异,以及提供建议如何解决它们。

TabActivity is deprecated

最近在整理Android Tab导航总结的代码时发现 TabActivity在API 13中被标记为过期了,所以就去寻找它的替换类,能尽量满足最小修改的要求。发现可以使用FragmentActivity来替代,Fragment组件作为标签页添加。

Fragment是Android 3.0引入的一个新概念,主要是为了适应各种不同的屏幕大小,它非常类似于Activity,可以像Activity一样包含布局,但是不能单独存在,只能存在于Activity中。下图是Fragment在不同屏幕上的显示以及Fragment与所在Activity的关系:

android-fragments

重构前的布局:

1
2
3
4
5
6
7
8
MainActivity extends TabActivity
    TabHost - tabhost
        LinearLayout
            TabWidget - tabs
            FrameLayout - tabcontent
                TabSpec (Activity)
                ...
                TabSpec (Activity)

重构后的布局:

1
2
3
4
5
6
7
8
MainActivity extends FragmentActivity
    TabHost - tabhost
        LinearLayout
            TabWidget - tabs
            FrameLayout - tabcontent
                TabSpec (Fragment)
                ...
                TabSpec (Fragment)

把TabLeftActivity和TabRightActivity分别改成LeftFragment和RightFragment,并且要把其中的onCreate()方法改成onCreateView()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class LeftFragment extends Fragment {
    private View rootView;

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
            Bundle savedInstanceState) {
        rootView = inflater.inflate(R.layout.tab_left, container, false);

        // do something
        // 不能直接使用findViewById()方法,必须加上rootView前缀
        // 如果要引用当前绑定的Activity实例,使用getActivity()方法

        return rootView;
    }

然后在main.xml中的FrameLayout里添加两个Fragment组件:

1
2
3
4
5
6
7
8
9
<fragment android:name="com.example.fragments.LeftFragment"
    android:id="@+id/fragment_left"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />

<fragment android:name="com.example.fragments.RightFragment"
    android:id="@+id/fragment_right"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />

把MainActivity改成从FragmentActivity继承。这里不能像TabActivity一样直接用getTabHost(),需要改成如下代码:

1
2
TabHost tabHost = (TabHost)findViewById(android.R.id.tabhost);
tabHost.setup();

到这边就已经完成了,其它tabHost.addTab的使用方式一模一样。

根据Exif时间信息归类照片

先要把Exif中的信息解析出来,得到其中的时间,有个exif的gem很不错。然后再根据时间创建目录,把照片移动到对应的目录中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
require 'rubygems'
require 'exifr'
require 'fileutils'

root = ARGV[0] || Dir.pwd

Dir.foreach(root) do |file|
  next if File.extname(file) != '.jpg'

  obj = EXIFR::JPEG.new(file)
  date_time_original = obj.exif.date_time_original if obj.exif?
  next if date_time_original.nil?

  dir = date_time_original.year.to_s
  Dir.mkdir(dir) unless Dir.exist?(dir)
  FileUtils.move(file, dir)
end

重构Rails代码

在以前写的博文部署应用到Heroku时的问题里有这么一段话:

股票功能需要导入交割单文件,因为导入后的文本文件不再使用,可以把上传路径由public/uploads改为tmp,这样就避免了Heroku不能写文件的问题。

下面是那时候写的导入代码:

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
class StocksController < ApplicationController
  UPLOADS_DIRECTORY = File.join("#{Rails.root}", "public/uploads")

  def import
    filename = upload_file(params[:file])
    lines = File.readlines("#{UPLOADS_DIRECTORY}/#{filename}")
    lines.each do |line|
      # do something
    end
    redirect_to stocks_url
  end

  protected
    def upload_file(file)
      if !file.original_filename.empty?
        @filename = get_file_name(file.original_filename)
        Dir.mkdir("#{UPLOADS_DIRECTORY}") unless Dir.exist?("#{UPLOADS_DIRECTORY}")
        File.open("#{UPLOADS_DIRECTORY}/#{@filename}", "wb") do |f|
          f.write(file.read)
        end
        return @filename
      end
    end

    def get_file_name(filename)
      if !filename.nil?
        # does not support chinese filename?
        Time.now.strftime("%Y%m%d%H%M%S") + '.txt'
      end
    end
end

这里的实现方法是先将上传文件保存到服务器上应用的public/uploads目录中,然后再读取和处理。

其实根本不需要写的这么复杂,因为导入的文件被读取一次后就不再使用了。所以在当时写代码的时候一直有这样的想法,如果能直接获得上传文件的数据,那么就不需要再另外去写保存和读取文件的代码了。

事实也是如此。通过表单提交的file字段数据会首先在服务器上形成临时文件,这时其实可以通过临时文件的路径来读取上传文件的数据。

根据该想法重构后的代码如下:

1
2
3
4
5
6
7
8
class StocksController < ApplicationController
  def import
    lines = File.readlines(params[:file].tempfile.to_path.to_s)
    lines.each do |line|
      # do something
    end
    redirect_to stocks_url
  end

重构后的代码果然清爽多了,不过还是有改进的空间。

作为控制器,它应该只负责接收请求,并返回响应。而具体的业务逻辑,则应该交由模型去完成。下面是依照该理念再次重构后的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class StocksController < ApplicationController
  def import
    Stock.import(params[:file])
    redirect_to stocks_url
  end
end

class Stock < ActiveRecord::Base
  def self.import(file)
    lines = File.readlines(file.tempfile.to_path.to_s)
    lines.each do |line|
      # do something
    end
  end
end

打错multipart引发的血案

浪费几个小时,杀死无数脑细胞,最终发现是单词打错了。不过错有错招,这个问题也让我重新温习了一遍关于form数据编码的知识。

在做上传表单时,一直报告如下错误:

1
undefined method 'original_filename' for "example.csv":String

http://guides.rubyonrails.org/form_helpers.html#what-gets-uploaded 有这样一段文字:

The object in the params hash is an instance of a subclass of IO. Depending on the size of the uploaded file it may in fact be a StringIO or an instance of File backed by a temporary file. In both cases the object will have an original_filename attribute containing the name the file had on the user's computer and a content_type attribute containing the MIME type of the uploaded file.

说明example.csv应该是IO类型的,这里怎么显示是String呢?

表单代码为

1
<%= form_tag import_stocks_path, mutlipart: true do %>

1
<%= form_tag import_stocks_path, method: :post, mutlipart: true do %>

生成后的HTML代码都是:

1
<form action="/stocks/import" method="post" mutlipart="true">

正确的HTML代码应该是:

1
<form action="/stocks/import" method="post" enctype="multipart/form-data">

使用HttpFox工具抓取表单提交信息如下:

1
2
3
4
5
6
POST /stocks/import HTTP/1.1
Host localhost:3000
Referer http://localhost:3000/stocks/import
Connection keep-alive
Content-Type application/x-www-form-urlencoded
Content-Length 112

什么是application/x-www-form-urlencoded?含有file类型字段的表单编码不应该是multipart/form-data吗。

form的enctype属性通常有两种:application/x-www-form-urlencoded和multipart/form-data,默认为前者。当method=get时,浏览器用application/x-www-form-urlencoded编码方式把form数据转换成一个字串(name1=value1&name2=value2...),然后把这个字串附加到url后面,用?分割。当method=post的时候,浏览器把form数据封装到post-body中。如果没有type=file的控件,就用默认的application/x-www-form-urlencoded编码。但是如果有type=file的话,就要用到multipart/form-data了。浏览器会把整个表单以控件为单位分割,并为每个部分加上Content-Disposition(form-data或者file),Content-Type(默认为text/plain),name等信息,并加上分割边界(boundary)。

这时才发现原来是把multipart打错成mutlipart了,真是惨痛的教训啊!

如何禁止VirtualBox虚拟机和物理机之间的时间同步

主机是Windows Server 2008,虚拟机Windows XP,VirtualBox的版本为4.3.6。

因为某种原因,需要修改XP系统的时间设置。但在设置后不到10秒钟就又和主机的时间自动同步了。

实时同步时间功能是由Guest Additions提供的,把它卸载就可以修改时间,不过这不是好的解决方法。

翻阅VirtualBox User Manual,找到“Disabling the Guest Additions time synchronization”章节,说明如何能把时间同步给禁止掉。在主机环境执行以下命令:

1
VBoxManage setextradata "YOUR_VM_NAME" "VBoxInternal/Devices/VMMDev/0/Config/GetHostTimeDisabled" 1

YOUR_VM_NAME是你的虚拟机名字,可以通过VBoxManage list vms命令查询到。

还有种方法是修改虚拟机的注册表,把HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\VBoxService项下ImagePath值改为system32\VBoxService.exe --disable-timesync,禁止Guest Additions启动时间同步。修改好后重启虚拟机就可以了。

另外就是虚拟机冷启动时都会和主机时间同步先,这意味着关机再开机的话上次的时间修改就失效了,必须再次手动调整。

Ruby中读二进制文件时大小错误

经常会遇到这类场景,要把文件内容一次性全部读取出来。使用IO.read('example.bin')读取二进制文件时,发现读出来的大小与实际结果不符合。原来默认不加参数时仅限于读文本文件,需要指定mode为b。

1
IO.read('example.bin', { mode: 'rb' })

还有种简洁的读取方式是:

1
File.open('example.bin', 'rb').readlines.join

Octopress 2.0使用技巧

Octopress 2.0带的RDiscount支持表格等Markdown扩展语法了。具体语法看 https://michelf.ca/projects/php-markdown/extra/#table 。不过默认表格是不具有边框的,在显示数据时会很难看。http://programus.github.io/blog/2012/03/07/add-table-data-css-for-octopress/ 修复了这个问题,只是它的修改有些复杂,其实只要把data-table.css的内容粘贴到sass/custom/_styles.scss里就出效果了。

还有就是Octopress中的列表项应该是右移的,实际左移了。可以在sass/custom/_styles.scss添加以下代码解决:

1
2
3
4
5
article {
  ol, ul {
    padding-left: 3em;
  }
}

更简单的办法是把sass/custom/_layout.scss中被注释的这行代码打开:

1
//$indented-lists: true;

列表、表格前要有空行,例如

1
2
3
4
5
6
7
8
9
10
List
* item
* item
* item

Table
column | column
------ | ------
value  | value
value  | value

不会正常显示,必须在List和Table后空一行才行。就像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
List

* item
* item
* item

Table

column | column
------ | ------
value  | value
value  | value

如果你已经发表了许多的文章,Octopress站点的生成速度将会非常之慢,解决方式是使用:

1
rake isolate['title']

隔离你所工作的文章(使用此命令前请确保该文章已经存在)。

现在使用以下命令:

1
2
rake generate
rake preview

将会仅工作在被隔离的文章上。

解除文章的隔离可以使用以下命令:

1
rake integrate

如果想在rake new_post/page后使用指定编辑器自动打开生成的文件,可以编辑Rakefile,在Misc Configs段中添加以下代码:

1
2
3
4
5
editor="open"
# open,使用系统默认编辑器
# open -a Mou,使用Mou打开
# open -a Byword,使用Byword打开
# subl,使用Sublime Text2打开

然后在task :new_post和task :new_page的末尾添加如下代码:

1
2
3
if #{editor}
  system "sleep 1; #{editor} #{filename}"
end

在rake preview后自动打开浏览器,也可以编辑Rakefile,在task :preview任务中添加:

1
system "sleep 2; open http://localhost:#{server_port}/"

如果日后Octopress有新版本发布,可以使用以下指令升级:

1
2
3
4
5
git remote add octopress git://github.com/imathis/octopress.git
git pull octopress master  # Get the latest Octopress
bundle install             # Keep gems updated
rake update_source         # update the template's source
rake update_style          # update the template's style

Wordpress迁移到Octopress

GitHub Pages提供免费的静态站点托管服务,有两种类型:User/Organization Pages和Project Pages。前者是用户/组织的主页,一个用户/组织仅有一个;后者是每个项目的主页。

安装Octopress时,rake setup_github_pages会要求输入GitHub Pages项目的URL,根据URL判断是哪种服务。如果是前者,会在新建的_deploy目录上创建master分支,用于存放发布的静态文件,并把原master分支改为source分支;后者则仅在_deploy目录上创建gh-pages分支。

Octopress默认的permalink是/blog/:year/:month/:day/:title/,想把它改成/:title/这种格式。修改后,发现生成的文章都跑到了public目录下,默认应该是在public/blog目录里的。为保持目录的简洁,把它改成了/blog/:title/,这样虽然生成的文章没有按年月日分目录,但是用户在访问系列文章时就可以只修改相应数字,不然必须得记住几乎不可能知道的文章的发布年月日。

至于评论的迁移,不想费过多的手脚,直接使用Octopress自带的Disqus。在Wordpress中安装好Disqus插件,通过插件设置将现有的评论内容导入Disqus。不过Disqus处理导入数据的时间有点长,需要等待一段时间,可以在 http://import.disqus.com 查看导入进度。导入完成后,把Wordpress文章的permalink改成和Octopress相同,登录Disqus后台,找到Migrate Threads栏目,在Domain Migration Wizard里把旧域名改成新域名,然后一路Next就大功告成了。

不过在实践中碰到了问题,打开某篇有评论的文章,发现只有评论框,没有评论。搞不明白是怎么回事。后来受到 http://www.ducea.com/2012/11/12/disqus-comments-not-visible-in-octopress/ 的启发,原来是_config.yml中的URL还是生成项目时的,与CNAME中配置的域名不一致,修改后就没问题了。

在侧边栏显示最新评论也很简单,添加recent_comments.html到source/_includes/custom/asides目录下,在_config.yml的default_asides做好配置,最新评论内容就会出现在侧边栏中了:

1
2
3
4
5
6
<section>
  <h1>Recent Comments</h1>
  <div class="dsq-widget">
    <script type="text/javascript" src="http://codemany.disqus.com/recent_comments_widget.js"></script>
  </div>
</section>

在侧边栏显示Tag Cloud参照了 http://codemacro.com/2012/07/18/add-tag-to-octopress/ 教程,没几分钟就搞定了。

分享使用的是JiaThis,只要把source/_includes/post/sharing.html中的代码替换成以下代码就可以:

1
2
3
4
5
6
7
8
9
10
<div id="jiathis_style_32x32">
  <a class="jiathis_button_qzone"></a>
  <a class="jiathis_button_tsina"></a>
  <a class="jiathis_button_tqq"></a>
  <a class="jiathis_button_weixin"></a>
  <a class="jiathis_button_renren"></a>
  <a href="http://www.jiathis.com/share/" class="jiathis jiathis_txt jtico jtico_jiathis" target="_blank"></a>
  <a class="jiathis_counter_style"></a>
</div>
<script type="text/javascript" src="http://v2.jiathis.com/code/jia.js" charset="utf-8"></script>

相关文章本想用Octopress自带的lsi模块实现,只是当文章较多时生成速度实在是巨慢,按照推荐安装gsl模块,结果在Windows Server 2008系统上死活装不上,采用手动编译方式也总是死在rb-gsl的安装上。失望之余找到 https://github.com/LawrenceWoodman/related_posts-jekyll_plugin 这个插件,它使用起来非常简单,只需将related_posts.rb放到自己的plugins文件夹中,然后在source/includes/post中新建relatedposts.html文件:

1
2
3
4
5
6
7
8
9
10
{ % if site.related_posts % }
  <h3>Related posts</h3>
  <ul class="posts">
  { % for post in site.related_posts limit:3 % }
    <li class="related">
      <a href=""></a>
    </li>
  { % endfor % }
  </ul>
{ % endif % }

修改source/_layouts/post.html,在

1
2
3
{ % include post/author.html % }
{ % include post/date.html % }{ % if updated % }{ % else % }{ % endif % }
{ % include post/categories.html % }

后面添加

1
{ % include post/related_posts.html % }

就可以了。使用这个插件会有个小问题,就是它和Jekyll 2.1不兼容,rake generate时会报错,可以使用jumanji27提供的fork版本

在Wordpress的Tools/Export页面选择导出文章内容,保存为wordpress.xml文件。然后使用工具把它转换成markdown格式。这里使用了YORKXIN的修改版本,将脚本和wordpress.xml放到Octopress根目录下,然后运行:

1
ruby -r ./wordpressdotcom.rb -e Jekyll::WordpressDotCom.process

会把转换好的文章都放到source/_posts目录下,文件后缀名是html,直接改成markdown就是。

最后就是苦力活:修改文中的站内链接、上传的图片路径以及代码高亮语法等。

Nokogiri抓取页面URL含有中文参数的问题

使用Nokogiri抓取某网站的长江现货数据,被抓取页面的URL中含有中文参数,使用以下的代码抓取数据失败:

1
2
url = 'http://example.com/search.asp?type=长江有色&sort=asc'
doc = Nokogiri::HTML(open(url))

http://dingr.iteye.com/blog/647244 讲是因为浏览器给服务器发送参数的时候是经过编码的,按照该文的意思试着也给URL里的中文编了下码:

1
2
3
url = 'http://example.com/search.asp?type=长江有色&sort=asc'
url = URI.escape(url)
doc = Nokogiri::HTML(open(url))

结果还是抓取失败。查看URL的编码信息:

1
puts url.encoding  # 输出utf-8

网站页面采用的是GB2132编码,猜测网站后台处理数据时很有可能也是采用的GB2132。做个实验就清楚了,将URL转成GB2132后再编码:

1
2
3
4
url = 'http://example.com/search.asp?type=长江有色&sort=asc'
url = url.encode('gbk', 'utf-8')
url = URI.escape(url)
doc = Nokogiri::HTML(open(url))

发现果然OK了。