Matt 发布的文章

为什么你想要自己构建一个 web 框架呢?我想,原因有以下几点:

  • 你有一个新奇的想法,觉得将会取代其他的框架
  • 你想要获得一些名气
  • 你遇到的问题很独特,以至于现有的框架不太合适
  • 你对 web 框架是如何工作的很感兴趣,因为你想要成为一位更好的 web 开发者。

接下来的笔墨将着重于最后一点。这篇文章旨在通过对设计和实现过程一步一步的阐述告诉读者,我在完成一个小型的服务器和框架之后学到了什么。你可以在这个代码仓库中找到这个项目的完整代码。

我希望这篇文章可以鼓励更多的人来尝试,因为这确实很有趣。它让我知道了 web 应用是如何工作的,而且这比我想的要容易的多!

范围

框架可以处理请求-响应周期、身份认证、数据库访问、模板生成等部分工作。Web 开发者使用框架是因为,大多数的 web 应用拥有大量相同的功能,而对每个项目都重新实现同样的功能意义不大。

比较大的的框架如 Rails 和 Django 实现了高层次的抽象,或者说“自备电池”(“batteries-included”,这是 Python 的口号之一,意即所有功能都自足。)。而实现所有的这些功能可能要花费数千小时,因此在这个项目上,我们重点完成其中的一小部分。在开始写代码前,我先列举一下所需的功能以及限制。

功能:

  • 处理 HTTP 的 GET 和 POST 请求。你可以在这篇 wiki 中对 HTTP 有个大致的了解。
  • 实现异步操作(我喜欢 Python 3 的 asyncio 模块)。
  • 简单的路由逻辑以及参数撷取。
  • 像其他微型框架一样,提供一个简单的用户级 API 。
  • 支持身份认证,因为学会这个很酷啊(微笑)。

限制:

  • 将只支持 HTTP 1.1 的一个小子集,不支持 传输编码 transfer-encoding HTTP 认证 http-auth 内容编码 content-encoding (如 gzip)以及持久化连接等功能。
  • 不支持对响应内容的 MIME 判断 - 用户需要手动指定。
  • 不支持 WSGI - 仅能处理简单的 TCP 连接。
  • 不支持数据库。

我觉得一个小的用例可以让上述内容更加具体,也可以用来演示这个框架的 API:

from diy_framework import App, Router
from diy_framework.http_utils import Response


# GET simple route
async def home(r):
    rsp = Response()
    rsp.set_header('Content-Type', 'text/html')
    rsp.body = '<html><body><b>test</b></body></html>'
    return rsp


# GET route + params
async def welcome(r, name):
    return "Welcome {}".format(name)

# POST route + body param
async def parse_form(r):
    if r.method == 'GET':
        return 'form'
    else:
        name = r.body.get('name', '')[0]
        password = r.body.get('password', '')[0]

       return "{0}:{1}".format(name, password)

# application = router + http server
router = Router()
router.add_routes({
    r'/welcome/{name}': welcome,
    r'/': home,
    r'/login': parse_form,})

app = App(router)
app.start_server()

' 用户需要定义一些能够返回字符串或 Response 对象的异步函数,然后将这些函数与表示路由的字符串配对,最后通过一个函数调用(start_server)开始处理请求。

完成设计之后,我将它抽象为几个我需要编码的部分:

  • 接受 TCP 连接以及调度一个异步函数来处理这些连接的部分
  • 将原始文本解析成某种抽象容器的部分
  • 对于每个请求,用来决定调用哪个函数的部分
  • 将上述部分集中到一起,并为开发者提供一个简单接口的部分

我先编写一些测试,这些测试被用来描述每个部分的功能。几次重构后,整个设计被分成若干部分,每个部分之间是相对解耦的。这样就非常好,因为每个部分可以被独立地研究学习。以下是我上文列出的抽象的具体体现:

  • 一个 HTTPServer 对象,需要一个 Router 对象和一个 http\_parser 模块,并使用它们来初始化。
  • HTTPConnection 对象,每一个对象表示一个单独的客户端 HTTP 连接,并且处理其请求-响应周期:使用 http\_parser 模块将收到的字节流解析为一个 Request 对象;使用一个 Router 实例寻找并调用正确的函数来生成一个响应;最后将这个响应发送回客户端。
  • 一对 Request 和 Response 对象为用户提供了一种友好的方式,来处理实质上是字节流的字符串。用户不需要知道正确的消息格式和分隔符是怎样的。
  • 一个包含“路由:函数”对应关系的 Router 对象。它提供一个添加配对的方法,可以根据 URL 路径查找到相应的函数。
  • 最后,一个 App 对象。它包含配置信息,并使用它们实例化一个 HTTPServer 实例。

让我们从 HTTPConnection 开始来讲解各个部分。

模拟异步连接

为了满足上述约束条件,每一个 HTTP 请求都是一个单独的 TCP 连接。这使得处理请求的速度变慢了,因为建立多个 TCP 连接需要相对高的花销(DNS 查询,TCP 三次握手,慢启动等等的花销),不过这样更加容易模拟。对于这一任务,我选择相对高级的 asyncio-stream 模块,它建立在 asyncio 的传输和协议的基础之上。我强烈推荐你读一读标准库中的相应代码,很有意思!

一个 HTTPConnection 的实例能够处理多个任务。首先,它使用 asyncio.StreamReader 对象以增量的方式从 TCP 连接中读取数据,并存储在缓存中。每一个读取操作完成后,它会尝试解析缓存中的数据,并生成一个 Request 对象。一旦收到了这个完整的请求,它就生成一个回复,并通过 asyncio.StreamWriter 对象发送回客户端。当然,它还有两个任务:超时连接以及错误处理。

你可以在这里浏览这个类的完整代码。我将分别介绍代码的每一部分。为了简单起见,我移除了代码文档。

class HTTPConnection(object):
    def init(self, http_server, reader, writer):
        self.router = http_server.router
        self.http_parser = http_server.http_parser
        self.loop = http_server.loop

        self._reader = reader
        self._writer = writer
        self._buffer = bytearray()
        self._conn_timeout = None
        self.request = Request()

这个 init 方法没啥意思,它仅仅是收集了一些对象以供后面使用。它存储了一个 router 对象、一个 http_parser 对象以及 loop 对象,分别用来生成响应、解析请求以及在事件循环中调度任务。

然后,它存储了代表一个 TCP 连接的读写对,和一个充当原始字节缓冲区的空字节数组_conn_timeout 存储了一个 asyncio.Handle 的实例,用来管理超时逻辑。最后,它还存储了 Request 对象的一个单一实例。

下面的代码是用来接受和发送数据的核心功能:

async def handle_request(self):
    try:
        while not self.request.finished and not self._reader.at_eof():
            data = await self._reader.read(1024)
            if data:
                self._reset_conn_timeout()
                await self.process_data(data)
        if self.request.finished:
            await self.reply()
        elif self._reader.at_eof():
            raise BadRequestException()
    except (NotFoundException,
            BadRequestException) as e:
        self.error_reply(e.code, body=Response.reason_phrases[e.code])
    except Exception as e:
        self.error_reply(500, body=Response.reason_phrases[500])

    self.close_connection()

所有内容被包含在 try-except 代码块中,这样在解析请求或响应期间抛出的异常可以被捕获到,然后一个错误响应会发送回客户端。

while 循环中不断读取请求,直到解析器将 self.request.finished 设置为 True ,或者客户端关闭连接所触发的信号使得 self._reader_at_eof() 函数返回值为 True 为止。这段代码尝试在每次循环迭代中从 StreamReader 中读取数据,并通过调用 self.process_data(data) 函数以增量方式生成 self.request。每次循环读取数据时,连接超时计数器被重置。

这儿有个错误,你发现了吗?稍后我们会再讨论这个。需要注意的是,这个循环可能会耗尽 CPU 资源,因为如果没有读取到东西 self._reader.read() 函数将会返回一个空的字节对象 b''。这就意味着循环将会不断运行,却什么也不做。一个可能的解决方法是,用非阻塞的方式等待一小段时间:await asyncio.sleep(0.1)。我们暂且不对它做优化。

还记得上一段我提到的那个错误吗?只有从 StreamReader 读取数据时,self._reset_conn_timeout() 函数才会被调用。这就意味着,直到第一个字节到达时timeout 才被初始化。如果有一个客户端建立了与服务器的连接却不发送任何数据,那就永远不会超时。这可能被用来消耗系统资源,从而导致拒绝服务式攻击(DoS)。修复方法就是在 init 函数中调用 self._reset_conn_timeout() 函数。

当请求接受完成或连接中断时,程序将运行到 if-else 代码块。这部分代码会判断解析器收到完整的数据后是否完成了解析。如果是,好,生成一个回复并发送回客户端。如果不是,那么请求信息可能有错误,抛出一个异常!最后,我们调用 self.close_connection 执行清理工作。

解析请求的部分在 self.process_data 方法中。这个方法非常简短,也易于测试:

async def process_data(self, data):
    self._buffer.extend(data)

    self._buffer = self.http_parser.parse_into(
        self.request, self._buffer)

每一次调用都将数据累积到 self._buffer 中,然后试着用 self.http_parser 来解析已经收集的数据。这里需要指出的是,这段代码展示了一种称为依赖注入(Dependency Injection)的模式。如果你还记得 init 函数的话,应该知道我们传入了一个包含 http_parser 对象的 http_server 对象。在这个例子里,http_parser 对象是 diy_framework 包中的一个模块。不过它也可以是任何含有 parse_into 函数的类,这个 parse_into 函数接受一个 Request 对象以及字节数组作为参数。这很有用,原因有二:一是,这意味着这段代码更易扩展。如果有人想通过一个不同的解析器来使用 HTTPConnection,没问题,只需将它作为参数传入即可。二是,这使得测试更加容易,因为 http_parser 不是硬编码的,所以使用虚假数据或者 mock 对象来替代是很容易的。

下一段有趣的部分就是 reply 方法了:

async def reply(self):
    request = self.request
    handler = self.router.get_handler(request.path)

    response = await handler.handle(request)

    if not isinstance(response, Response):
        response = Response(code=200, body=response)

    self._writer.write(response.to_bytes())
    await self._writer.drain()

这里,一个 HTTPConnection 的实例使用了 HTTPServer 中的 router 对象来得到一个生成响应的对象。一个路由可以是任何一个拥有 get_handler 方法的对象,这个方法接收一个字符串作为参数,返回一个可调用的对象或者抛出 NotFoundException 异常。而这个可调用的对象被用来处理请求以及生成响应。处理程序由框架的使用者编写,如上文所说的那样,应该返回字符串或者 Response 对象。Response 对象提供了一个友好的接口,因此这个简单的 if 语句保证了无论处理程序返回什么,代码最终都得到一个统一的 Response 对象。

接下来,被赋值给 self._writerStreamWriter 实例被调用,将字节字符串发送回客户端。函数返回前,程序在 await self._writer.drain() 处等待,以确保所有的数据被发送给客户端。只要缓存中还有未发送的数据,self._writer.close() 方法就不会执行。

HTTPConnection 类还有两个更加有趣的部分:一个用于关闭连接的方法,以及一组用来处理超时机制的方法。首先,关闭一条连接由下面这个小函数完成:

def close_connection(self):
    self._cancel_conn_timeout()
    self._writer.close()

每当一条连接将被关闭时,这段代码首先取消超时,然后把连接从事件循环中清除。

超时机制由三个相关的函数组成:第一个函数在超时后给客户端发送错误消息并关闭连接;第二个函数用于取消当前的超时;第三个函数调度超时功能。前两个函数比较简单,我将详细解释第三个函数 _reset_cpmm_timeout()

def _conn_timeout_close(self):
    self.error_reply(500, 'timeout')
    self.close_connection()

def _cancel_conn_timeout(self):
    if self._conn_timeout:
        self._conn_timeout.cancel()

def _reset_conn_timeout(self, timeout=TIMEOUT):
    self._cancel_conn_timeout()
    self._conn_timeout = self.loop.call_later(
        timeout, self._conn_timeout_close)

每当 _reset_conn_timeout 函数被调用时,它会先取消之前所有赋值给 self._conn_timeoutasyncio.Handle 对象。然后,使用 BaseEventLoop.call\_later 函数让 _conn_timeout_close 函数在超时数秒(timeout)后执行。如果你还记得 handle_request 函数的内容,就知道每当接收到数据时,这个函数就会被调用。这就取消了当前的超时并且重新安排 _conn_timeout_close 函数在超时数秒(timeout)后执行。只要接收到数据,这个循环就会不断地重置超时回调。如果在超时时间内没有接收到数据,最后函数 _conn_timeout_close 就会被调用。

创建连接

我们需要创建 HTTPConnection 对象,并且正确地使用它们。这一任务由 HTTPServer 类完成。HTTPServer 类是一个简单的容器,可以存储着一些配置信息(解析器,路由和事件循环实例),并使用这些配置来创建 HTTPConnection 实例:

class HTTPServer(object):
    def init(self, router, http_parser, loop):
        self.router = router
        self.http_parser = http_parser
        self.loop = loop

    async def handle_connection(self, reader, writer):
        connection = HTTPConnection(self, reader, writer)
        asyncio.ensure_future(connection.handle_request(), loop=self.loop)

HTTPServer 的每一个实例能够监听一个端口。它有一个 handle_connection 的异步方法来创建 HTTPConnection 的实例,并安排它们在事件循环中运行。这个方法被传递给 asyncio.start\_server 作为一个回调函数。也就是说,每当一个 TCP 连接初始化时(以 StreamReaderStreamWriter 为参数),它就会被调用。

   self._server = HTTPServer(self.router, self.http_parser, self.loop)
   self._connection_handler = asyncio.start_server(
        self._server.handle_connection,
        host=self.host,
        port=self.port,
        reuse_address=True,
        reuse_port=True,
        loop=self.loop)

这就是构成整个应用程序工作原理的核心:asyncio.start_server 接受 TCP 连接,然后在一个预配置的 HTTPServer 对象上调用一个方法。这个方法将处理一条 TCP 连接的所有逻辑:读取、解析、生成响应并发送回客户端、以及关闭连接。它的重点是 IO 逻辑、解析和生成响应。

讲解了核心的 IO 部分,让我们继续。

解析请求

这个微型框架的使用者被宠坏了,不愿意和字节打交道。它们想要一个更高层次的抽象 —— 一种更加简单的方法来处理请求。这个微型框架就包含了一个简单的 HTTP 解析器,能够将字节流转化为 Request 对象。

这些 Request 对象是像这样的容器:

class Request(object):
    def init(self):
        self.method = None
        self.path = None
        self.query_params = {}
        self.path_params = {}
        self.headers = {}
        self.body = None
        self.body_raw = None
        self.finished = False

它包含了所有需要的数据,可以用一种容易理解的方法从客户端接受数据。哦,不包括 cookie ,它对身份认证是非常重要的,我会将它留在第二部分。

每一个 HTTP 请求都包含了一些必需的内容,如请求路径和请求方法。它们也包含了一些可选的内容,如请求体、请求头,或是 URL 参数。随着 REST 的流行,除了 URL 参数,URL 本身会包含一些信息。比如,"/user/1/edit" 包含了用户的 id 。

一个请求的每个部分都必须被识别、解析,并正确地赋值给 Request 对象的对应属性。HTTP/1.1 是一个文本协议,事实上这简化了很多东西。(HTTP/2 是一个二进制协议,这又是另一种乐趣了)

解析器不需要跟踪状态,因此 http_parser 模块其实就是一组函数。调用函数需要用到 Request 对象,并将它连同一个包含原始请求信息的字节数组传递给 parse_into 函数。然后解析器会修改 Request 对象以及充当缓存的字节数组。字节数组的信息被逐渐地解析到 request 对象中。

http_parser 模块的核心功能就是下面这个 parse_into 函数:

def parse_into(request, buffer):
    _buffer = buffer[:]
    if not request.method and can_parse_request_line(_buffer):
        (request.method, request.path,
         request.query_params) = parse_request_line(_buffer)
        remove_request_line(_buffer)

    if not request.headers and can_parse_headers(_buffer):
        request.headers = parse_headers(_buffer)
        if not has_body(request.headers):
            request.finished = True

        remove_intro(_buffer)

    if not request.finished and can_parse_body(request.headers, _buffer):
        request.body_raw, request.body = parse_body(request.headers, _buffer)
        clear_buffer(_buffer)
        request.finished = True
    return _buffer

从上面的代码中可以看到,我把解析的过程分为三个部分:解析请求行(这行像这样:GET /resource HTTP/1.1),解析请求头以及解析请求体。

请求行包含了 HTTP 请求方法以及 URL 地址。而 URL 地址则包含了更多的信息:路径、url 参数和开发者自定义的 url 参数。解析请求方法和 URL 还是很容易的 - 合适地分割字符串就好了。函数 urlparse.parse 可以用来解析 URL 参数。开发者自定义的 URL 参数可以通过正则表达式来解析。

接下来是 HTTP 头部。它们是一行行由键值对组成的简单文本。问题在于,可能有多个 HTTP 头有相同的名字,却有不同的值。一个值得关注的 HTTP 头部是 Content-Length,它描述了请求体的字节长度(不是整个请求,仅仅是请求体)。这对于决定是否解析请求体有很重要的作用。

最后,解析器根据 HTTP 方法和头部来决定是否解析请求体。

路由!

在某种意义上,路由就像是连接框架和用户的桥梁,用户用合适的方法创建 Router 对象并为其设置路径/函数对,然后将它赋值给 App 对象。而 App 对象依次调用 get_handler 函数生成相应的回调函数。简单来说,路由就负责两件事,一是存储路径/函数对,二是返回需要的路径/函数对

Router 类中有两个允许最终开发者添加路由的方法,分别是 add_routesadd_route。因为 add_routes 就是 add_route 函数的一层封装,我们将主要讲解 add_route 函数:

def add_route(self, path, handler):
    compiled_route = self.class.build_route_regexp(path)
    if compiled_route not in self.routes:
        self.routes[compiled_route] = handler
    else:
        raise DuplicateRoute

首先,这个函数使用 Router.build_router_regexp 的类方法,将一条路由规则(如 '/cars/{id}' 这样的字符串),“编译”到一个已编译的正则表达式对象。这些已编译的正则表达式用来匹配请求路径,以及解析开发者自定义的 URL 参数。如果已经存在一个相同的路由,程序就会抛出一个异常。最后,这个路由/处理程序对被添加到一个简单的字典self.routes中。

下面展示 Router 是如何“编译”路由的:

@classmethod
def build_route_regexp(cls, regexp_str):
    """
    Turns a string into a compiled regular expression. Parses '{}' into
    named groups ie. '/path/{variable}' is turned into
    '/path/(?P<variable>[a-zA-Z0-9_-]+)'.

    :param regexp_str: a string representing a URL path.
    :return: a compiled regular expression.
    """
    def named_groups(matchobj):
        return '(?P<{0}>[a-zA-Z0-9_-]+)'.format(matchobj.group(1))

    re_str = re.sub(r'{([a-zA-Z0-9_-]+)}', named_groups, regexp_str)
    re_str = ''.join(('^', re_str, '$',))
    return re.compile(re_str)

这个方法使用正则表达式将所有出现的 {variable} 替换为 (?P<variable>)。然后在字符串头尾分别添加 ^$ 标记,最后编译正则表达式对象。

完成了路由存储仅成功了一半,下面是如何得到路由对应的函数:

def get_handler(self, path):
    logger.debug('Getting handler for: {0}'.format(path))
    for route, handler in self.routes.items():
        path_params = self.class.match_path(route, path)
        if path_params is not None:
            logger.debug('Got handler for: {0}'.format(path))
            wrapped_handler = HandlerWrapper(handler, path_params)
            return wrapped_handler

    raise NotFoundException()

一旦 App 对象获得一个 Request 对象,也就获得了 URL 的路径部分(如 /users/15/edit)。然后,我们需要匹配函数来生成一个响应或者 404 错误。get_handler 函数将路径作为参数,循环遍历路由,对每条路由调用 Router.match_path 类方法检查是否有已编译的正则对象与这个请求路径匹配。如果存在,我们就调用 HandleWrapper 来包装路由对应的函数。path_params 字典包含了路径变量(如 '/users/15/edit' 中的 '15'),若路由没有指定变量,字典就为空。最后,我们将包装好的函数返回给 App 对象。

如果遍历了所有的路由都找不到与路径匹配的,函数就会抛出 NotFoundException 异常。

这个 Route.match 类方法挺简单:

def match_path(cls, route, path):
    match = route.match(path)
    try:
        return match.groupdict()
    except AttributeError:
        return None

它使用正则对象的 match 方法来检查路由是否与路径匹配。若果不匹配,则返回 None 。

最后,我们有 HandleWraapper 类。它的唯一任务就是封装一个异步函数,存储 path_params 字典,并通过 handle 方法对外提供一个统一的接口。

class HandlerWrapper(object):
    def init(self, handler, path_params):
        self.handler = handler
        self.path_params = path_params
        self.request = None

    async def handle(self, request):
        return await self.handler(request, **self.path_params)

组合到一起

框架的最后部分就是用 App 类把所有的部分联系起来。

App 类用于集中所有的配置细节。一个 App 对象通过其 start_server 方法,使用一些配置数据创建一个 HTTPServer 的实例,然后将它传递给 asyncio.start\_server 函数asyncio.start_server 函数会对每一个 TCP 连接调用 HTTPServer 对象的 handle_connection 方法。

def start_server(self):
    if not self._server:
        self.loop = asyncio.get_event_loop()
        self._server = HTTPServer(self.router, self.http_parser, self.loop)
        self._connection_handler = asyncio.start_server(
            self._server.handle_connection,
            host=self.host,
            port=self.port,
            reuse_address=True,
            reuse_port=True,
            loop=self.loop)

        logger.info('Starting server on {0}:{1}'.format(
            self.host, self.port))
        self.loop.run_until_complete(self._connection_handler)

        try:
            self.loop.run_forever()
        except KeyboardInterrupt:
            logger.info('Got signal, killing server')
        except DiyFrameworkException as e:
            logger.error('Critical framework failure:')
            logger.error(e.traceback)
        finally:
            self.loop.close()
    else:
        logger.info('Server already started - {0}'.format(self))

总结

如果你查看源码,就会发现所有的代码仅 320 余行(包括测试代码的话共 540 余行)。这么少的代码实现了这么多的功能,让我有点惊讶。这个框架没有提供模板、身份认证以及数据库访问等功能(这些内容也很有趣哦)。这也让我知道,像 Django 和 Tornado 这样的框架是如何工作的,而且我能够快速地调试它们了。

这也是我按照测试驱动开发完成的第一个项目,整个过程有趣而有意义。先编写测试用例迫使我思考设计和架构,而不仅仅是把代码放到一起,让它们可以运行。不要误解我的意思,有很多时候,后者的方式更好。不过如果你想给确保这些不怎么维护的代码在之后的几周甚至几个月依然工作,那么测试驱动开发正是你需要的。

我研究了下整洁架构以及依赖注入模式,这些充分体现在 Router 类是如何作为一个更高层次的抽象的(实体?)。Router 类是比较接近核心的,像 http_parserApp 的内容比较边缘化,因为它们只是完成了极小的字符串和字节流、或是中层 IO 的工作。测试驱动开发(TDD)迫使我独立思考每个小部分,这使我问自己这样的问题:方法调用的组合是否易于理解?类名是否准确地反映了我正在解决的问题?我的代码中是否很容易区分出不同的抽象层?

来吧,写个小框架,真的很有趣:)


via: http://mattscodecave.com/posts/simple-python-framework-from-scratch.html

作者:Matt 译者:Cathon 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

(题图来自:es-static.us

前一篇文章中,我们了解了微软在开源了 .NET 框架中最大一部分一年以来社区的参与情况。

接下来,我们将继续重复这个分析,但是这次我们将针对 ASP.NET 系列项目进行分析:

  • MVC - 通过分成“模型-视图-控制器(MVC)”等不同的概念部分来构建动态网站的框架,包括合并的 MVC、 Web API、 和 Web Pages w/ Razor。
  • DNX - DNX(一个 .NET 扩展环境)包含了用于启动和运行应用的代码,包括编译系统、SDK 工具和原生 CLR 宿主。
  • EntityFramework - 微软推荐用于新的 .NET 应用的数据访问技术。
  • KestrelHttpServer - 一个基于 libuv 的 ASP.NET 5 的 Web 服务器。

方法

上一篇中,我们首先把 问题 issue / 拉取请求 PR 分成 拥有者 Owner 协作者 Collaborator 社区 Community 三类。然而这有一些问题,正如在评论中指出的那样,有几个人并非微软雇员,但是由于其在某个项目上的积极贡献也被列为了协作者,比如 @kangaroo@benpye

为了解决这个问题,我决定分成两类:

  • 微软
  • 社区

这是可行的,因为(基本上)所有的微软雇员都会在其 GitHub 个人页面上标记其为微软雇员,比如:

David Fowler Profile

这种情况有一些例外,比如 @shanselman 显然是在微软工作,不过这种情况很好解决。

结果

在结束了所有分析之后,我得到了结果。总的来说,超过 60% 的“ 发现的问题 Issues Created ”和 33% 的“ 合并的 PR Merged Pull Requests ”来自社区。然而,PR 的占比受到了 Entity Framework 项目中微软雇员超高的 PR 数量的影响,从而有些不能准确反映情况。如果忽略这个项目,社区贡献的 PR 将占到 44%。

发现的问题(2013/11 - 2015/12)

项目微软社区合计
aspnet/MVC71613802096
aspnet/dnx89712062103
aspnet/EntityFramework106614272493
aspnet/KestrelHttpServer89176265
合计276841896957

合并的 PR(2013/11 - 2015/12)

项目微软社区合计
aspnet/MVC385228613
aspnet/dnx406368774
aspnet/EntityFramework9372251162
aspnet/KestrelHttpServer6988157
合计17989092706

备注:我包括了 Kestrel Http Server 项目,因为它是一个有趣的例子。当前它的第一号贡献者 Ben Adams 并非微软雇员,他为改善其内存使用做出了很大的贡献,让 Kestrel 可以每秒钟接受更多的请求。

通过观察随时间推移的变化,可以很清楚的看到社区(浅色条)在过去两年(2013/11 - 2015/12)来的参与情况,看起来并不像是趋于停止。

每月发现的问题数 - 按提交者

每月问题数 - 按提交者(微软或社区)

此外,虽然社区参与情况可以很容易地从每月发现的问题数上看出来,不过从合并的 PR 数上也可以再次印证这两年来的趋势。

每月合并的 PR 数 - 按提交者

每月合并 PR 数 - 按提交者(微软或社区)

贡献总数

每个项目的贡献人数也很有意思。通过这个你可以看到社区贡献者的实际规模,并不是少量的人做了大量的工作,而是这些工作由大量的人分散完成的。

这个表格展示了每个项目中发现问题和提交了被合并的 PR 的人数:

项目微软社区合计
aspnet/MVC39395434
aspnet/dnx46421467
aspnet/EntityFramework31570601
aspnet/KestrelHttpServer2295117
合计13814811619

FSharp

在我的第一篇文章的评论中,Isaac Abraham 指正说:

.NET 的一部分已经开源一年多了,F# 编译器和 FSharp.Core 已经开源一段时间了。

所以,为了解决这个问题,我去了解了一下主要的 FSharp 仓库:

按 Isaac 的解释,他们之间的关系是:

... visualfsharp 是微软自己的 Visual F# 版本仓库。而另外一个是社区管理的一个。前一个是直接作为 Visual Studio 其中的 Visual F# 工具;而后一个则是类似 Xamarin 的东西。这里有个(稍微过时的)解析它们关系的图表,以及另外一个有用的资源:http://fsharp.github.io/

FSharp - 发现的问题(2010/12 - 2015/12)

项目微软社区合计
fsharp/fsharp9312321
microsoft/visualfsharp161367528
合计170679849

FSharp - 合并的 PR(2011/5 - 2015/12)

项目微软社区合计
fsharp/fsharp27134161
microsoft/visualfsharp363369
合计63167230

结论

我认为,公平地说社区已经对微软越来越多地开源其代码的动作做出了回应。在几个项目上社区花费了大量时间,做出了显著的贡献。虽然你可以说微软也花费了大量的时间来开源,但是看起来 .NET 开发人员很喜欢他们做的事情,体现了可观的社区响应。

大约一年前,微软宣布开源了 .NET 框架的大部分。当时,Scott Hanselman 使用微软 Power BI 对代码库做了一个漂亮的分析。 现在一年过去了,我想要试试对以下问题做个解答:

微软开源了 .NET 框架的大部分之后,社区参与贡献了多少?

我着眼于以下三个项目做了分析,它们是 .NET 生态系统中最主要部分之一,也是 .NET 基金会内 最活跃/收藏/分支的项目之一:

  • Roslyn – .NET 编译器平台,提供了开源的 C# 和 Visual Basic 编译器,以及丰富的代码分析 API。
  • CoreCLR – .NET Core 运行时环境和底层库(mscorlib),它包括垃圾回收、JIT 编译器、基本的 .NET 数据类型和许多底层类。
  • CoreFX – .NET Core 基础库,包括 collections、文件系统、console、XML、异步以及其它方面的类。

数据来自哪里?

GitHub 自身已经内建了很多漂亮的图表了,你可以看看这一年来每月提交数的图表:

Commits Per Month

还可以看看每月动态

github stats - monthly pulse

但是要回答上面的问题,我需要更多的数据。幸运的是, GitHub 提供了非常全面的 API, 再配合上出色的 Octokit.net 库以及 brilliant LINQPad,我就很容易的得到了我所需的全部数据。如果你想要自己试试这些 API ,这儿有个示例的 LINQPad 脚本

然而,仅仅知道它的每月 “ 问题 issue 数量” 或 “接受的PR( 拉取请求 Pull Request )”并没有太大用处,这并不能告诉我们是谁提交了这些问题或 PR。 幸运的是, GitHub 典型的用户是有分类的,比如下图来自 Roslyn 第 670 号问题 ,我们可以看到是哪种类型的用户提交的备注:“ 拥有者 Owner ”、 “ 协作者 Collaborator ” 或者为空——这就是“社区”成员,比如下面的某人(我觉得)并没有在微软工作。

owner collaborator or community

结果呢?

现在我们可以得到我们所需的数据,也就可以生成结果了。

全部问题 - 按提交者分组

项目拥有者协作者社区全部
Roslyn481186715963944
CoreCLR86298487871
CoreFX3349117351980
全部90130762818

这里你可以看到拥有者和协作者在某些情况下占有主导地位,比如,在 Roslyn 项目中 60% 的问题是他们汇报的。但是在另外的例子中社区非常活跃,特别是在 CoreCLR 项目中社区成员汇报的问题超过了拥有者/协作者之和。造成这种情况的部分原因是项目的不同, CoreCLR 是 .NET 框架中最引人注目的部分,因为它包含了 .NET 开发者日常使用的大部分库,所以并不用对社区提交了很多改进建议和错误修复的事情感到惊奇。 另外, CoreCLR 已经出现了较长时间,社区已经使用了一段时间,也能找到它的一些不足的部分。而 Roslyn 则相对较新一些,还没有被太多的使用过,而且找到一个编译器的 bug 显然会更难。

全部已接受的 PR - 按提交者分组

项目拥有者协作者社区全部
Roslyn46520931182676
CoreCLR3785672011146
CoreFX51614094642389
全部13594069783

但是,如果我们来看一下已接受的 PR ,可以看到在这三个项目中社区的贡献量非常低,仅仅只有 12% 左右。不过,这并不令人吃惊,因为 PR 需要达到相当高的水准才能被接受。如果项目采用这种机制,首先你必须找到一个 “需要解决” up for grabs 的问题,然后如果你要改变 API 就必须通过代码审查,最后你必须在代码审查中符合可比性/性能提升/正确性等。所以,实际上 12% 是个相当不错的结果,接受的 PR 解决了不少的问题,特别是考虑到大部分贡献都是社区成员在工作之余完成的。

更新:关于对“需要解决”的要求,参见 David Kean这个评论,以及这条推来了解更多信息。“需要解决”是一个准则,旨在帮助新手,但是并不是必需的,你可以提交一个解决问题的 PR 而不打上这个标签。

最后,如果你看一下每月的数量(参见下面的两张图,点击放大),很难找到特定的趋势,或者说,社区肯定会随着时间的变化或多或少的做出贡献。不过,你也可以说,过去一年来社区一直在做贡献,而且看起来还会继续贡献下去。这不是仅仅出现在项目刚刚开源后的一次性喷发,而是一整年以来的贡献的持续水平。

每月的问题数 - 按提交者分组

Issues Per Month - By Submitter (Owner, Collaborator or Community)

每月接受的 PR - 按提交者分组

Merged Pull Requests Per Month - By Submitter (Owner, Collaborator or Community)

前 20 的问题标签

最后一件我想对我拥有的这些数据所做的事情是找到那些最流行的问题标签,这可以揭示从三个项目开源以来哪种类型的工作不断出现。

Top 20 Issue Labels

以下是关于这些结果的一些看法:

我有时候会听到我们的团队成员这样议论:

"项目的Code review 只是浪费时间。"

"我没有时间做Code review。"

"我的发布时间延迟了,因为我的同事还没有完成我代码的Code review。"

"你相信我的同事居然要求我对我的代码做修改吗?请跟他们说代码中的一些联系会被打断——如果在我原来代码的基础之上做修改的话。"

(LCTT 译注:Code Review中文可以翻译成代码审查,一般由开发待review的代码的成员以外的团队成员来进行这样的工作。由于是专业术语,没有将Code review用中文代替。)

为什么要做Code review?

每个专业软件开发者都有一个重要的目标:持续的提高他们的工作质量。即使你团队中都是一些优秀的程序员,但是你依然不能将你自己与一个有能力的自由职业者区分开来,除非你从团队的角度来工作。Code review是团队工作的一个重要的方面。尤其是:

代码复查者(reviewer)能从他们的角度来发现问题并且提出更好的解决方案。

确保至少你团队的另一个其他成员熟悉你的代码,通过给新员工看有经验的开发者的代码能够某种程度上提高他们的水平。

公开reviewer和被复查者的想法和经验能够促进团队间的知识的分享。

能够鼓励开发者将他们的工作进行的更彻底,因为他们知道他们的代码将被其他的人阅读。

在review的过程中的注意点

但是,由于Code review的时间有限,上面所说的目标未必能全部达到。就算只是想要打一个补丁,都要确保意图是正确的。如果只是将变量名改成骆驼拼写法(camelCase),那不算是code review。在开发过程中进行结对编程是有益处的,它能够使两个人得到公平的锻炼。你能够在code review上花许多时间,并且仍然能够比在结对编程中使用更少的时间。

我的感受是,在项目开发的过程中,25%的时间应该花费在code review上。也就是说,如果开发者用两天的时间来开发一个东西,那么复查者应该使用至少四个小时来审查。

当然,只要你的review结果准确的话,具体花了多少时间就显得不是那么的重要。重要的是,你能够理解你看的那些代码。这里的理解并不是指你看懂了这些代码书写的语法,而是你要知道这段代码在整个庞大的应用程序、组件或者库中起着什么样的作用。如果你不理解每一行代码的作用,那么换句话说,你的code review就是没有价值的。这就是为什么好的code review不能很快完成的原因。需要时间来探讨各种各样的代码路径,让它们触发一个特定的函数,来确保第三方的API得到了正确的使用(包括一些边缘测试)。

为了查阅你所审查的代码的缺陷或者是其他问题,你应该确保:

  • 所有必要的测试都已经被包含进去。
  • 合理的设计文档已经被编写。

再熟练的开发者也不是每次都会记得在他们对代码改动的时候把测试程序和文档更新上去。来自reviewer的一个提醒能够使得测试用例和开发文档不会一直忘了更新。

避免code review负担太大

如果你的团队没有强制性的code review,当你的code review记录停留在无法管理的节点上时会很危险。如果你已经两周没有进行code review了,你可以花几天的时间来跟上项目的进度。这意味着你自己的开发工作会被阻断,当你想要处理之前遗留下来的code review的时候。这也会使得你很难再确保code review的质量,因为合理的code review需要长期认真的努力,最终会很难持续几天都保持这样的状态。

由于这个原因,开发者应当每天都完成他们的review任务。一种好办法就是将code review作为你每天的第一件事。在你开始自己的开发工作之前完成所有的code review工作,能够使你从头到尾都集中注意力。有些人可能更喜欢在午休前或午休后或者在傍晚下班前做review。无论你在哪个时间做,都要将code review看作你的工作之一并且不能分心,你要避免:

  • 没有足够的时间来处理你的review任务。
  • 由于你的code review工作没有做完导致版本的推迟发布。
  • 提交不再相关的review,由于代码在你review期间已经改动太大。
  • 因为你要在最后一分钟完成他们,以至于review质量太差。

书写易于review的代码

有时候review没有按时完成并不都是因为reviewer。如果我的同事使用一周时间在一个大工程中添加了一些乱七八糟的代码,且他们提交的补丁实在是太难以阅读。在一段代码中有太多的东西要浏览。这样会让人难以理解它的作用,自然会拖慢review的进度。

为什么将你的工作划分成一些易于管理的片段很重要有很多原因。我们使用scrum方法论(一种软件开发过程方法),因此对我们来说一个合理的单元就是一个story。通过努力将我们的工作使用story组织起来,并且只是将review提交到我们正在工作的story上,这样,我们写的代码就会更加易于review。你们也可以使用其他的软件开发方法,但是目的是一样的。

书写易于review的代码还有其他先决条件。如果要做一些复杂的架构决策,应该让reviewer事先知道并参与讨论。这会让他们之后review你们的代码更加容易,因为他们知道你们正在试图实现什么功能并且知道你们打算如何来实现。这也避免了开发者需要在reviewer提了一个不同的或者更好的解决方案后大片的重写代码。

项目需要应当在设计文档中详细的描述。这对于一个项目新成员想要快速上手并且理解现有的代码来说非常重要。这从长远角度对于一个reviewer来说也非常有好处。单元测试也有助于reviewer知道一些组件是怎么使用的。

如果你在你的补丁中包含的第三方的代码,记得单独的提交它。当jQuery的9000行代码被插入到了项目代码的中间,毫无疑问会造成难以阅读。

创建易读的review代码的另一个非常重要的措施是添加相应的注释代码。这就要求你事先自己做一下review并且在一些你认为会帮助reviewer进行review的地方加上相应的注释。我发现加上注释相对于你来说往往只需要很短的时间(通常是几分钟),但是对于review来说会节约很多的时间。当然,代码注释还有其他相似的好处,应该在合理的地方使用,但往往对code review来说更重要。事实上,有研究表明,开发者在重读并注释他们代码的过程中,通常会发现很多问题。

代码大范围重构的情况

有时候,有必要重构一段代码使其能够作用于多个其他组件。若是一个大型的应用要这样做,会花费几天甚至是更多的时间,结果是生成一个诺大的补丁包。在这种情况下,进行一个标准的code review可能是不切实际的。

最好的方法是增量重构你的代码。找出合理范围内的一部分改变,以此为基础来重构。一旦修改和review完成,进入第二个增量。以此类推,直到整个重构完成。这种方法可能不是在所有的情况下都可行,但是尽管如此,也能避免在重构时出现大量的单片补丁。开发者使用这种方式重构可能会花去更多的时间,但这也使得代码质量更高并且之后的review会更简单。

如果实在是没有条件去通过增量方式重构代码(有人可能会说之前的代码书写并组织的是多么的好),一种解决方案是在重构时进行结对编程来代替code review。

解决团队成员之间的纠纷

你的团队中都是一些有能力的专家,在一些案例中,完全有可能因为对一个具体编码问题的意见的不同而产生争论。作为一个开发者,应该保持一个开发的头脑并且时刻准备着妥协,当你的reviewer更想要另一种解决方法时。不要对你的代码持有专有的态度,也不要自己持有审查的意见。因为有人会觉得你应该将一些重复的代码写入一个能够复用的函数中去,这并不意味着这是你的问题。

作为一个reviewer,要灵活。在提出修改建议之前,考虑你的建议是否真的更好或者只是无关紧要。如果你把力气和注意力花在那些原来的代码会明确需要改进的地方会更加成功。你应该说"它或许值得考虑..."或者"一些人建议..."而不是”我的宠物都能写一个比这个更加有效的排序方法"。

如果你真的决定不了,那就询问另一个你及你所审查的人都尊敬的开发者来听一下你意见并给出建议。


via: http://blog.salsitasoft.com/practical-lessons-in-peer-code-review/

作者:Matt 译者:john 校对:wxy

本文由 LCTT 原创翻译,Linux中国 荣誉推出