让错的程序看得出错

让错的程序看得出错


作者: 周思博 (Joel Spolsky)
译: Paul May 梅普华
2005.5.11


 时间回到1983年九月, 我第一个真正的工作是在以色列的Oranim. 这家大型面包工厂每晚都用六个货机般大的巨型炉子烤出为数十万的面包.

我第一次走进那家面包厂时觉得里头实在脏得离谱. 炉壁发黄机器生锈而且到处都是油.

"这里一直都这么脏吗?"我问道.

"什么? 你讲这什么话?"经理回答说."我们才刚打扫过. 这已经是几周以来最干净的时候了."

说得真好!

我花了好几个月每天早上打扫才真正了解他们的意思. 对面包工厂来说, 干净是指机器里没有生面团在烤, 垃圾堆里没有发酵的面团, 而且地板上也没有堆生面团.

干净并不是指炉子漆得雪白亮丽. 炉子大概十年才会漆一次, 并不会每天都来一回. 干净也不是说把油擦得干干净净. 事实上很多机器都得定期上油, 一层薄净的油通常暗示机器刚做过清洁保养.

This is what a dough rounder looks like. 面包工厂里这整套干净的概念都得经由学习而来. 圈外人不可能走进去就能说出哪里干净哪里脏. 圈外人绝不会想到要看面团滚圆机(把方面团滚成球形的机器, 见右边附图)内壁有没有刮干净. 圈外人会觉旧炉子外壁镶板掉色是有问题的,因为镶板很很显眼. 不过面包师傅根本不在意炉子的涂漆开始发黄. 因为面包的味道还是一样棒.

在面包工厂待两个月, 你学会如何"看出"干净.

程序代码也是一样的.

当你刚开始写程序或尝试读用新语言写的程序时, 所有程序代码看起来都一样神秘不可解. 而在了解该种程序语言前, 你连明显的语法错误都看不出来.

在学习的第一阶段, 你会开始发现一种我们通常称为"编程风格"的东西. 于是你开始注意那些不遵循缩排标准的程序代码和有用多个大写字母的变量.

也就是这个阶段你会说:"该死的混蛋, 我们这里一定要定出一些一致的编程风格!" 然后第二天写出一份你们团队用的编程风格, 接下来用六天来讨论One True Brace Style(译着:就是K&R style), 然后再花三星期把旧程序代码改写成符合One True Brace Style, 一直做到经理发现并责怪你把时间浪费在不能赚钱的事为止. 你想想其实不需要一次全部改好, 看到哪里改到哪里也没什么关系. 于是有一半的程序代码已经改成True Brace Style, 而没多久你就忘记这件事了. 接下来你就开始满脑子想着其他与赚钱无关的事, 比如把某个字符串类别换成另一个字符串类别等等.

当你对某特定环境下的程序愈来愈精通时, 就会开始学着看到其他东西. 那些东西可能完全合法并符合编程风格, 却又会让你担心不已.

举例来说在C语言里:

char* dest, src;

这是合语法的程序代码; 这可能符合你的编程规范, 甚至可能是故意这样写的, 不过如果你写C的经验够, 就会注意这种写法把dest宣告成字符 指针却把src宣告成字符而已, 这可能是你的意思, 不过也可能不是. 反正这段程序看起来有点不对劲.

来看更细微的例子:

if (i != 0)
    foo(i);

这段程序是百分之百正确的;它符合大多数的编程规范也完全没有错误, 不过你可能会质疑if叙述所接的单叙述主体并未用大括号包起来, 因为你脑子里想到有人可能会插入另一行程序代码

if (i != 0)
    bar(i);
    foo(i);

...又忘记加上大括号, 结果让foo(i)变成永远会执行! 所以当你看到没有用大括号包起来的程序代码区段时, 可能就会感觉到一丝丝让你不舒服的气味.

好啦, 到目前为止我已经提到三种程序员的成就层级:

1. 你不知道干净和脏有什么分别.

2. 你对干净有粗浅的认知, 主要以是否符合编程规范为准.

3. 你开始能嗅出藏在表面下不对劲的蛛丝马迹. 你会察觉这是问题并且找出来修正.

不过其实还有更高的层次, 而这也就是我真正要说的:

4. 你有计划地架构程序代码, 藉助能察觉问题的灵眼让程序代码更正确.

这是真正的艺术: 仔细地设计让错误显而易见的编程规范, 藉此制作出稳固的程序.

所以现在我要带你看一个小例子然后再展示一个通用的规则. 你可以利用这个通则设计出创造增加程序稳固的编程规范. 最后我会把主题导引到为某种匈牙利命名法(可能不是让人们晕到的那种)进行辩护, 并且批判某些环境(也可能不是你最常用的那种环境)下的例外处理.

不过如果你深信匈牙利命名法不是好东西, 认为例外处理是从自巧克力奶昔以来最棒的发明, 而且完全不想听听其他意见, 没问题, 你可以改去罗力那里看看好看的漫画; 反正你在这里也没什么好看的; 事实上在一分钟内我就会拿出实际的程序代码范例, 这些范例很可能会让你在不爽前就晕睡过去了. 没错. 我想我的计划是把你哄到沉沉入睡, 趁你睡着无法抵抗时把"匈牙利命名法=好, 例外处理=坏"的想法偷偷塞进你脑子里面.

一个例子

Somewhere in Umbria好了. 提到这个例子. 让我们假装你正在写某种web应用程序, 因为这阵子小朋友似乎都流行写这玩意.

现在有一种叫跨站脚本漏洞(Cross Site Scripting Vulnearability)的安全漏洞, 缩写为XSS. 我在这里不谈细节: 你只需要知道在写web应用程序时, 一定要小心绝不能把用户填入窗体的任何字符串直接传回来.

举例来说, 如果你有一个网页会让使用者在编辑框输入姓名, 传送后就会跳到另一个写着"你好啊, 张三!"(假设使用者的名字是张三)的网页. 很好, 这就是个安全漏洞, 因为使用者可能不输入"张三"而输入某种奇怪的HTML及JavaScript, 这些奇怪的JavaScript就可能会做些低级事情, 比如读出你写的cookie内容转送到坏人的坏网站去. 而这些低级事现在看起来就是你搞的鬼.

让我们把程序用伪码的方法写出来. 想象以下的程序

s = Request("name")

会由HTML表格读取用户输入(一个POST的参数). 如果你曾经写出下面的程序代码:

Write "你好, " & Request("name")

那你的网站已经有让XSS攻击的漏洞了. 光这样就够了.

你必须在复制回HTML之前先编码才能避免这个漏洞. 所谓编码就是把"换成", 把>换成>, 如此类推. 所以

Write "你好, " & Encode(Request("name"))

是绝对安全的.

所有来自用户的字符串都是不安全的. 任何不安全的字符串都得先编码后才能输出.

让我们尝试设计一组编程规范, 确保当你犯这种错时程序代码看起来就是错的. 如果程序代码有错(至少看起来错), 就很有机会被修改或审视这段程序的人抓到.

可能方案一

方案一是将所有字符串立即编码, 由用户取得后马上就进行:

s = Encode(Request("name"))

所以我们的规范会写着: 如果你看到没有被Encode包住的Request, 程序一定是错的.

你开始训练自己的眼睛找寻落单的Request, 因为它们违反规范.

这是有用的, 因为只要你遵循规范就不会有XSS问题. 不过这并不是最好的架构. 比方说你可能想要把这些用户字符串存到数据库里, 这时候储存以HTML编码过的字符串并不合理, 因为字符串有可能会用在HTML网页以外的场合. 假如是信用卡处理程序要用时编码过的数据就会产生问题. 大部份web应用程序开发都会依循一个原则: 所有字符串在内部都是编码的, 要等到送至HTML网页的前一瞬间才会处理, 因此这可能并不是正确的架构.

我们真的要能让字符串维持在不安全格式一段时间.

好吧. 我再试看看.

可能方案二

如果建立一种编程规范, 要求在写出任何字符串时必须加以编码, 是否可以满足要求吗?

s = Request("name")

// 很后面:
Write Encode(s)

现在当你看到一个落单没有Encode跟着的Write时就知道有有问题了.

唉, 这也不太好...有时候你的程序里会有一小段的HTML码, 这种情况下是不能够编码的:

If mode = "linebreak" Then prefix = "<br>"

// 很后面:
Write prefix

这照我们的规范来看是错的, 我们必须要在输出时加以编码:

Write Encode(prefix)

不过现在应该要新增一行的"<br>"却被编码成&lt;br&gt;, 结果变成用户可以看到的字符< b r >. 这样的解法也不对.

所以说有时候你不能在读入字符串时编码, 有时候你也不能在输出时编码, 这两种提案都不能用. 可是没有适当的编码规范, 我们还是有出下列问题的风险:

s = Request("name")

...好几页之后...
name = s

...好几页之后...
recordset("name") = name // 把名字存在数据库中的姓名栏

...好几天后...
theName = recordset("name")

...好几页甚至好几个月之后...
Write theName

我们还会记得要对字符串编码吗? 你在任何单一的地方都看不到问题. 连可以嗅的地方都没有. 如果这种程序有一大缸子, 要一大票侦探才能追踪出所有字符串的来源并确认是否已编码..

正解

所以让我提议一种能用的编程规范. 我们只有一个规则:

所有来自用户的字符串都必须存在以"us"(表示Unsafe String,不安全字符串)为前缀的变量(或数据库字段)中. 所有经HTML编码或来自确认安全来源的字符串都必须存在以"s"(表示Safe String,安全字符串)为前缀的变量中.

让我们重写程序, 只是依规范重新命名变量, 其他完全不动.

us = Request("name")

...好几页之后...
usName = us

...好几页之后...
recordset("usName") = usName

...好几天后...
sName = Encode(recordset("usName"))

...好几页甚至好几个月之后...
Write sName

新规范中值得注意的是, 只要遵循编码规范, 不安全字符串相关的错误一定可以由单一行的程序代码看出来:

s = Request("name")

是之前的错误, 因为你可以看到Request的结果被指派给以s开头的变量, 这违反了规则. Request的结果一定是不安全的, 所以必须指派给以"us"开头的变量.

us = Request("name")

一定没问题.

usName = us

一定没问题.

sName = us

一定是错的.

sName = Encode(us)

一定是对的.

Write usName

一定是错的.

Write sName

没问题, 下面也一样没问题

Write Encode(usName)

每一行程序光是看程序代码本身就足以检查, 而且如果每一行程序都对, 组合起来整个程序也是对的.

终于好了, 利用这套编码规范, 你的眼睛学着看到Write usXXX就知道是错的, 而且你也立即知道要如何修正. 我知道一开始要看到错误的程序是有一点难, 不过进行三个星期后你的眼睛就会习惯, 就像面包厂的工人看到大面包工厂就会马上说:"搞什么鬼, 这里都没人在扫哦! 这算啥面包厂."

事实上我们可以再把规则延伸一点, 把RequestEncode函数改名(或封装)成UsRequestSEncode...换句话说, 传回不安全字符串以及安全字符串的函数要和变量一样, 分别要用UsS作为前缀. 现在看看程序代码:

us = UsRequest("name")
usName = us
recordset("usName") = usName
sName = SEncode(recordset("usName"))
Write sName

看到我们的成果没? 现在你可以看看等号两边的前缀是否相同就能找到错误.

us = UsRequest("name") // 没问题, 两边都以US开头
s = UsRequest("name") // 错
usName = us // 对
sName = us // 一定错.
sName = SEncode(us) // 一定对.

我还能再进一步把Write改名成WriteS并把SEncode改名成SFromUs:

us = UsRequest("name")
usName = us
recordset("usName") = usName
sName = SFromUs(recordset("usName"))
WriteS sName

这使得错误更加显而易见. 你的眼睛会学习"看出"可疑的程序代码, 另外这也能协助你经由一般撰写或阅读程序代码的动作找到隐藏的安全漏洞.

让错的程序看得出错是很棒没错, 不过却不是所有安全问题的最佳解答. 它无法找到所有可能的问题或错误, 因为你可能没法子看过每一行程序代码. 不过绝对比什么都不做要好, 而我很希望有套编码规范能让错误的程序代码至少看起来是错的. 你马上就能获得好处, 每当程序员的眼睛扫过一行程序, 就能检查并防止某些特定的错误.

一个通则

这种让错误程序看起来错的作法有个前提, 就是要让对的东西在屏幕上紧靠在一起. 当我看到某个字符串时并要决定 程序代码正确与否, 我必须知道字符串出现的所有位置以及字符串是安全的还是不安全的. 我不希望这些资料出现在另一个档案或是要卷动画面才能看到的另一页. 我必须能当场看到, 而这说的就是一套变量命名规范.

有很多其他的例子可以说明, 只要把某些东西搬在一起就可以改善程序代码. 大多数的编程规范都有如下的规则:

  • 保持函数名称简短.
  • 变量宣告的地方离使用的位置愈近愈好.
  • 不要用宏建立你个人专属的程序语言.
  • 不要使用goto.
  • 不要让右括号离左括号超过一个画面.

这些规则有一个共同点, 就是尽量让一行程序代码实际作用的相关信息在画面上愈近愈好. 这样能提高眼球找出程序实质运作内容的机会.

大体上我得承认我有点害怕会藏东西的程序语言功能. 当你看到程序代码

i = j * 5;

... 就C来说你至少会知道j会乘以5而结果会存到i.

不过如果你在C++里看到相同的片段, 你什么都不知道. 在C++中唯一能知道真正发生什么事的方法就是找出ij所属的型别, 而这个型别可能会在完全不一样的地方宣告. 因为j运算符*可能有过荷, 在你要做乘法时会做些很机灵的事. 而i运算符=可 能也是过荷的, 而两者型别可能是不兼容的, 于是又呼叫到某个自动型别强制转换的函数. 光是检查变数的型别还不足以确认, 还得检查实作该型别的程序代码才行, 万一实作时又有继承其他型别就更麻烦了, 因为你得回溯类别继承的祖宗八代才能找到真正的程序代码, 不巧又有用到别处的多型就真的有大麻烦了, 因为光是知道i和j宣告的型别并不够, 还得知道它们此刻的型别, 这不知道要看多少的程序代码, 而且依照计算理论的停机问题, 你永远都不能真的百分之百确定自己已经看完所有地方了(啊啊啊啊啊!!!).

当你看到C++的i=j*5时你只能自求多福了, 兄弟. 这对我来说就降低了光看程序代码找出在问题的能力.

当然啰, 理论上这应该没什么关系. 当你做些过荷运算符*之类聪明事时, 只要为了要提供一个优美而安全的抽象罢了. 天啊, 其实j是个万国码字符串型别, 一个万国码字符串乘以一个整数显然是把正体中文转成简体中文的良好抽象作法, 对吗?

问题当然出在没有绝对安全的抽象方法. 我已经在抽象出错定律里讨论很多了, 所以不会在这里重复.

Scott Meyers示范了各种抽象出错(至少是C++)的型式以及所造成的伤害, 他靠这个主题就创出一番事业了. (顺便一提, Scott的书Effective C++第三版刚刚上市; 整本书都重写过; 今天就去买一本吧!)

好吧.

有点失焦了. 我最好回顾一下到目前为止的内容:

找出能让错误程序看起来错的编程规范. 让正确的信息集中在程序代码中相同的地方, 方便你看出某些问题并立即修正.

我是匈牙利

Lugnano, Umbria, Italy 我们现在回到恶名昭彰的匈牙利命名法.

匈牙利命名法是微软程序设计师Charles Simonyi发明的. Simonyi在微软做的主要计划是Word; 事实上他还主持了世界上第一个所见即所得的字处理器(在Xerox Parc名为Bravo计划).

在所见即所得的字处理中会用到可卷动的窗口, 所以坐标值有两种意义:相对于窗口或相对于处理页. 两种坐标的差异很大, 所以好好安排是非常重要的.

我猜这正是Simonyi开始采用某些之后被称作匈牙利命名法的原因之一. 它看起来像匈牙利文, 而Simonyi是从匈牙利来, 所以以匈牙利为名. 在Simonyi版本的匈牙利命名法中, 每个变量都会加一个小写的前缀, 表示变量内容的种类.

For example, if the variable name is rwCol, rw is the prefix.

我是故意用种类(kind)这个词, 因为Simonyi在他的文章中误用了型别(type), 结果好几世代的程序员都误解了他的意思.

如果你仔细读Simonyi的文章, 就会发现他所讲的和我之前范例所用的命名规范是一样的, 在我的范例中把uss分别定义为不安全字符串和安全字符串. 这两者的型别都是字符串. 如果你把某种字符串指派另一种, 编译程序并不会给任何警告, Intellisense也不会说些什么. 可是他们的语意是不同的; 他们解读和处理的方式都不同, 要把两种字符串互相指派时还要某些转换函数做转换, 否则就会有执行时期的问题. 你好运.

微软内部称Simonyi对匈牙利命名法的原始概念为应用匈牙利命名法, 因为它用于应用程序部门, 也就是Word及Excel. 在Excel的源代码里有大量的rwcol, 你看到这些前缀就知道它们指的是行(row)和列(column). 没错, 它们都是整数, 可是两者间的转换完全没有意义. 有人告诉我说Word的程序代码里有大量的xlxw, xl代表相对于排版页面的水平坐标, 而 xw则代表相对窗口的水平坐标. 两者都是整数但却是不能互转的. 两个程序里都有很多cb, 意思是字节的个数. 没错, 这也是整数型别, 不过光看变量名就可以得到更多信息: 这是字节的个数, 也就是缓冲区的大小. 另外如果你看到xl = cb就可以拉警报了. 这显然是错的程序, 虽然xlcb都是整数, 可是把以像素为单位的水平位移设成字节个数绝对是疯了.

在应用匈牙利命名法中前缀可以用于函数和变量. 因此虽然我真的没看过Word的原始码, 我还是敢打赌Word里一定有个叫YlFromYw的函数, 可以把垂直方向的窗口坐标转成垂直方向的排版页坐标. 应用匈牙利命名法用TypeFromType取代传统的 TypeToType, 这样每个函数名就会以传回的型别开头, 这正与我稍早在范例中把Encode改名为SFromUs的作法相同. 事实上在正规的应用匈牙利命名法中Encode函数一定要改名为SFromUs. 应用匈牙利命名法在该函数命名上并没有提供其他选择. 这其实是件好事, 因为你少一件事要背, 另外也不必担心Encode究竟是用什么型别. 程序也变得精确多了.

应用匈牙利命名法非常有用, 特别是当初C语言盛行, 而编译程序尚未提供很有用的型别系统时.

不过接下来却出了一些问题.

黑暗世界占用了匈牙利命名法.

似乎没有人知道为什么或是如何发生的, 不过似乎是窗口团队中写文件的人不小心创造出后来名为系统匈牙利命名法的东西.

某处有人读了Simonyi的文章看到里面用了"型别"这个字眼, 因此认为作者指的就是型别, 意思就像是类别或是型别系统中, 或是编译程序所做的型别检查. 其实不然. 作者很小心并精确的解释他用"型别"这个字的意义, 不过没有用. 伤害已经造成了.

应用匈牙利命名法的前缀很有用而且有意义, "ix"表示数组索引, "c"表示个数, "d"表示两个数字间的差(比如"dx"表示"宽度"), 如此类推.

系统匈牙利命名法的前缀作用就差多了, "l"表示长整数, "ul"表示正长整数而"dw"代表双字组(呃, 事实上就是正长整数). 在系统匈牙利命名法中, 前缀只能告诉你变量真正的数据型别.

这误解了Simonyi的意图和实作, 差异虽细微实质上却是完全不同. 这件事唯一的教训是让你知道, 如果你写出些没人能懂的艰深难解学术文章, 你的想法可能会一再被误解, 结果变得非常荒谬, 完全违背你的原意. 所以在系统匈牙利命名法中会出现大量的dwFoo表示"双字组的某某", 可恶的是某个变数是双字组这件事对你几乎是完全没用的. 难怪大家都很讨厌系统匈牙利命名法.

系统匈牙利命名法的流传既深又广; 它是整个窗口程序设计文件的标准; Charles Petzold的窗口程序设计(学习窗口程序设计的圣经)等书籍更为它广为宣扬, 很快的它也成为匈牙利命名法的主要势力, 即使在微软内部也一样. 在微软内也只有少数不在Word和Excel团队的程序员了解他们搞出什么样的错.

接下来就是大反抗了. 有群程序员们从一开始就没搞懂过匈牙利命名法, 他们发现自己用的竟是烦人又几近无用的分支, 于是就起来反抗. 不过系统匈牙利命名法里还是有些好东西可以帮你看出问题. 如果用系统匈牙利命名法, 至少会在使用时知道变量型别. 不过没应用匈牙利命名法那么有价值就是了.

大反抗在.NET.第一版发行时到达巅峰, 那时微软终于告诉大家"不建议使用匈牙利命名法". 这还真是欢声雷动啊. 我根本不认为微软会花心思解释原因. 他们只是扫瞄文件中命名指引的章节然后加上"不要使用匈牙利命名法"的字句. 当时匈牙利命名法非常不受欢迎所以没有人会真的抱怨, 而除Excel及Word以外的人都因为不必再用这么麻烦的命名规范而松了一口气, 他们认为在有强型别检查及Intellisense的时代也不需要这种规范.

不过应用匈牙利命名法还是很有价值的, 它加强了程序代码的连结让程序代码更易阅读, 撰写, 除错及维护, 最重要的是它让错误的程序看得出错.

在继续之前还有一件事我说过要做, 就是再骂一次例外处理. 我上次这样做惹来很多麻烦. 我在周思博趣谈软件首页上一篇即兴的评论中写说我不喜欢例外处理, 因为它实际上就是隐藏的goto, 我认为这比看得到的goto更糟糕. 当然就有几百万人跑出来痛骂我. 全世界唯一跳出来替我辩护的当然也就是Raymond Chen. 顺带一提, 他既然是世界上最好的程序员, 当然得出来讲讲话, 对吗?

这篇文章讲到例外处理的重点了. 你的眼睛学着看到错误的程序代码, 这样就能防止问题发生. 为了让程序能变得真正稳固, 进行程序代码检视时得有一套能集中信息的命名规范. 换而言之, 你眼前有关程序运作的信息愈多, 寻找错误的结果愈好. 当你看到以下的程序代码时

dosomething();
cleanup();

...你的眼睛会说没什么问题啊. 我们总是要做清除的动作! 不过dosomething有可能会引发一个例外, 所以有可能不会呼叫cleanup. 用finally等很简单就能修正这个问题, 不过这并不是我的重点: 问题在于要知道cleanup一定会被呼叫到的唯一方法, 就是调查整个dosomething呼叫树, 看看是否有任何场合会产生例外. 这也还好, 可控制式例外处理(checked exception)可以让你不用那么辛苦, 不过重点是例外处理把信息分散开来了. 你得去看其他地方才能知道程序能正确执行, 所以无法运用你眼睛天赋的功能去学习看出错的程序代码, 因为根本没东西可看.

如果我写个小脚本程序, 只是每天一次到处收集资料然后印出来, 这时候例外处理好用得不得了. 我只想忽略所有可能出错的地方, 直接把整个程序用一个大try/catch包起来, 如果有出什么问题就用catch把错误电邮给自己. 例外处理对简单随便写的程序很有用, 对脚本程序或是不是非常重要或无关生死的程序也不错. 不过如果你在写一套操作系统或核电厂程序, 或是用于开心手术的高速电锯, 例外处理可是危险的很.

我知道大家会认为我是个无法正确理解例外处理的笨程序员, 完全不知道只有当我衷心接纳例外处理后它才能改善我的生活. 这种想法真是太糟糕了. 想要写出真正可信赖的程序代码, 应该要尝试用考虑到人有弱点的简单工具, 而不是靠那些提供有问题的抽象并把副作用隐藏起来, 还认为程序员绝不出错的复杂工具.

补充读物

如果你还是衷心于例外处理, 读读Raymond Chen的文章更干净更优雅, 不过更难读. "例外处理用得正确与否, 很难由程序代码看得出来... 例外处理太难了, 我实在不够聪明无法掌握."

Raymond对致命宏的文章A rant against flow control macros讨论了另一个让信息分散导致程序无法维护的例子. "当看到使用[宏]的程序代码时, 你必须看遍各个头文件才能了解它们的作用."

想要了解匈牙利命名法的历史背景, 可以由Simonyi的原文匈牙利命名法开始. Doug Klunder在另一篇比较清楚的文章中把它引进Excel团体 . 想知道更多匈牙利命名法的故事以及如何被文件撰写人破坏的始末, 可以去看Larry Osterman站上的贴文, 特别是Scott Ludwig的评论, 或是Rick Schaut贴的文章

 

关于作者:

112035_YbHN_566335.png
约耳.斯珀儿斯奇是Fog Creek Software (设立在纽约的一家小型软件公司) 的创立者. 约耳毕业于耶鲁大学 (Yale University) ,并曾经在微软, Viacom 和 Juno 担任程序人员与管理工作.

转载于:https://my.oschina.net/Jiuyong/blog/744682

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值