开发者社区> game404> 正文

再聊我的源码阅读方法-xml-rpc源码慢读

简介: 之前介绍我的源码阅读方法,有粉丝朋友说很有帮助,认为是授人予鱼。这是过誉了,也让我很受鼓舞,这次带来另外一种阅读方法,希望对大家也有帮助。
+关注继续查看

大家好,我是肖恩,源码阅读我们周四见。


之前介绍我的源码阅读方法,有粉丝朋友说很有帮助,认为是授人予鱼。这是过誉了,也让我很受鼓舞,这次带来另外一种阅读方法,希望对大家也有帮助。


在前文中我介绍了两种源码阅读方法:


  1. 概读法,对一个项目,跟随API,进行核心功能实现的阅读,跳过异常,分支等功能,快速了解项目底层实现原理
  2. 历史对比法,对比源码的版本修改历史,分析新增功能如何实现和扩展,分析如何修改bug。


如果说概读法可以了解一个项目是what。历史对比可以理解一段代码why这样写。这次我介绍的 慢读法 就是how。我们一行一行的去看代码,分析功能实现的所有细节。慢读法,可以学习如何编写代码,提高自己的编码水准。慢读法,需要有点耐心,尽量做到不漏过一行一字。像古代一字之师郑谷把僧人齐己的“前村深雪里,昨夜数枝开” 诗句修改成 “前村深雪里,昨夜一枝开”一样,一点一点去推敲。


本次慢读法使用的是python3.8中的xmlrpc-server部分,全文大概1000行,我们只阅读上半部分600行,大概30分钟内可以读完,时间紧张的朋友欢迎收藏了慢慢看。


  • 帮助和依赖分析
  • SimpleXMLRPCDispatcher分析
  • SimpleXMLRPCRequestHandler分析
  • SimpleXMLRPCServer 分析
  • MultiPathXMLRPCServer && CGIXMLRPCRequestHandler
  • rpc-doc
  • 小结


帮助和依赖分析



我们常说注释是代码的一部分,xmlrpc-server中的注释很详尽,对我们学习具有指导意义。比如头部注释中,很详细的介绍了如何扩展SimpleXMLRPCServer:


4. Subclass SimpleXMLRPCServer:
class MathServer(SimpleXMLRPCServer):
    def _dispatch(self, method, params):
        try:
            # We are forcing the 'export_' prefix on methods that are
            # callable through XML-RPC to prevent potential security
            # problems
            func = getattr(self, 'export_' + method)
        except AttributeError:
            raise Exception('method "%s" is not supported' % method)
        else:
            return func(*params)
    def export_add(self, x, y):
        return x + y
server = MathServer(("localhost", 8000))
server.serve_forever()
复制代码


MathServer方法约定对外暴露的rpc方法使用export_前缀,这个策略对服务程序管理非常有用,可以在语法层级的private和public之上增加business类型的方法。和nodejs中使用export导出函数外部接口类似。


模块依赖部分,import的try-except语句可以帮助我们更好的适配依赖:


...
try:
    import fcntl
except ImportError:
    fcntl = None
复制代码


上面的代码还不太说明问题,下面例子会更直观。优先导入速度更快的simplejson, 不存在的情况下再使用默认的json:


# requests/compat.py
try:
    import simplejson as json
except ImportError:
    import json
复制代码


模块依赖还有一个源码阅读的小技巧:依赖越少的模块,相对容易阅读,应该最先阅读。比如下面的sqlalchemy的sql模块构成,是一个金字塔结构,阅读时候从最顶端的visitors开始更容易上手:


image.png


resolve_dotted_attribute函数链式查找对象的方法:


def resolve_dotted_attribute(obj, attr, allow_dotted_names=True):
    """resolve_dotted_attribute(a, 'b.c.d') => a.b.c.d
    Resolves a dotted attribute name to an object.  Raises
    an AttributeError if any attribute in the chain starts with a '_'.
    If the optional allow_dotted_names argument is false, dots are not
    supported and this function operates similar to getattr(obj, attr).
    """
    if allow_dotted_names:
        attrs = attr.split('.')
    else:
        attrs = [attr]
    for i in attrs:
        if i.startswith('_'):
            raise AttributeError(
                'attempt to access private attribute "%s"' % i
                )
        else:
            obj = getattr(obj,i)
    return obj
复制代码


  • 函数注释很清晰的介绍了功能:(a, 'b.c.d') => a.b.c.d, 查找a的b属性的c属性的d属性,这是一个链式调用
  • 这里使用列表循环来实现递归查找,如果用递归肯定是查找a的b属性,然后把b当做参数对象再查找c...,列表循环显然更优
  • 将attr处理成attrs数组,让后面的代码逻辑更清晰
  • 属性查找时候遵循对象的私有属性使用_的约定
  • 异常处理,程序可以抛出 AttributeError('attempt to access private attribute "%s"' % i) 这样有自定义消息的message,帮助问题排查


list_public_methods查找对象的所有公开方法:


def list_public_methods(obj):
    return [member for member in dir(obj)
                if not member.startswith('_') and
                    callable(getattr(obj, member))]
复制代码


  • 魔法函数dir可以列出对象所有的属性和方法;魔法函数getattr可以根据字符串名称动态获取对象的方法/属性
  • callable方法可以判断一个对象是不是一个方法,因为python中没有isinstance(x,function)的方法
  • for in if语句可以对生成式根据指定条件进行过滤


SimpleXMLRPCDispatcher分析



SimpleXMLRPCDispatcher负责和xmlrpc服务的核心逻辑之一:


class SimpleXMLRPCDispatcher:
    def __init__(self, allow_none=False, encoding=None,
                 use_builtin_types=False):
        self.funcs = {}
        self.instance = None
        self.allow_none = allow_none
        self.encoding = encoding or 'utf-8'
        self.use_builtin_types = use_builtin_types
复制代码


类定义除上面的写法外,还有一种是 class SimpleXMLRPCDispatcher(object), 我一般喜欢用这种。如果大家考古,可以发现在python2中还有新式类和旧式类的说法。在python3中都是新式类,不管使用哪种写法。


SimpleXMLRPCDispatcher的构造函数,我们可以思考下面写法的差异:


def __init__(self, allow_none=False, encoding="utf-8",
                 use_builtin_types=False):
    self.encoding = encoding
复制代码


encoding关键字参数,默认值是utf-8。相对来说,没有源码健壮。比如错误的传入 encoding=None 参数的时候,默认的encoding就丢失了,源码就不会存在这个问题。


构造函数中字典self.funcs = {} or self.funcs = dict() 那种更优呢?先看测试数据, 用数据说话:


# python3 -m timeit -n 1000000 -r 5 -v 'dict()'
raw times: 115 msec, 98.4 msec, 99.8 msec, 99.3 msec, 98.7 msec
1000000 loops, best of 5: 98.4 nsec per loop
# python3 -m timeit -n 1000000 -r 5 -v '{}'
raw times: 23.8 msec, 20.8 msec, 19.2 msec, 18.8 msec, 18.6 msec
1000000 loops, best of 5: 18.6 nsec per loop
复制代码


测试可见,使用 {} 效率更高。为什么呢,我们可以查看二者的字节码对比:


1           0 LOAD_NAME                0 (dict)
              2 CALL_FUNCTION            0
              4 POP_TOP
  3           6 BUILD_MAP                0
              8 POP_TOP
             10 LOAD_CONST               0 (None)
             12 RETURN_VALUE
复制代码


dict()实际上是一次函数调用,相对资源消耗更大。类似的还有list() vs [],所以大家初始化字典和列表都推荐使用{}[]关键字语法。


register_instance函数用于dispatcher接受外部注册的服务对象:


def register_instance(self, instance, allow_dotted_names=False):
    self.instance = instance
    self.allow_dotted_names = allow_dotted_names
复制代码


  • 属性 allow_dotted_names 没有在构造函数中初始化,这个其实是不符合规范。如果我们自己的代码,IDE会有智能提示,如下图:


image.png


额外提一点,代码整洁就是在IDE中不出现黄色的warningsweak warnings,绿色的typos, 当然红色的 error 更不应该出现。


register_function函数用于dispatcher接受外部注册的服务函数。和服务对象注册不一样,对象可以理解为一组函数的集合,这里是单个函数:


def register_function(self, function=None, name=None):
    # decorator factory
    if function is None:
        return partial(self.register_function, name=name)
    if name is None:
        name = function.__name__
    self.funcs[name] = function
    return function
复制代码


  • functools.partial函数非常有用,可以帮助我们固定某个函数的参数,减少重复代码。
  • 参数function和name先后顺序也是有讲究的。name在后,这样可以不用传name参数,使用 register_function(some_func) 把function参数当做位置参数使用。如果name在前面,则无法这样使用。


register_introspection_functions方法注册了xmlrpc-server的一些帮助信息:


def register_introspection_functions(self):
    self.funcs.update({'system.listMethods' : self.system_listMethods,
                  'system.methodSignature' : self.system_methodSignature,
                  'system.methodHelp' : self.system_methodHelp})
复制代码


  • 注意字典的update方法,同样还有setdefault,这些都是相对其它语言比较少见的,学会了代码更pythonic


现在先跳跃一下,查看注册的几个帮助方法,先看system_listMethods:


def system_listMethods(self):
    methods = set(self.funcs.keys())
    if self.instance is not None:
        # Instance can implement _listMethod to return a list of
        # methods
        if hasattr(self.instance, '_listMethods'):
            methods |= set(self.instance._listMethods())
        # if the instance has a _dispatch method then we
        # don't have enough information to provide a list
        # of methods
        elif not hasattr(self.instance, '_dispatch'):
            methods |= set(list_public_methods(self.instance))
    return sorted(methods)
复制代码


  • 将funcs字典的key返回的可迭代对象(类型是dict_keys)转换成set,方便进行集合的集合处理
  • None判断推荐is not None而不是 if not self.instance:
  • 集合求交集赋值可以使用 |=, 类似+=1
  • 集合没有sort方法,不像列表有sorted(list)list.sort()两种方法。后者是原地排序,但是返回None;前者会重新生成一个列表。
  • 动态获取属性这里使用self.instance._listMethods(),对比一下getattr(instance, "listMethods")() , 前者更直观。


系统帮助函数使用pydoc模块获取函数的帮助文档:


def system_methodHelp(self, method_name):
    ...
    return pydoc.getdoc(method)
复制代码


下面是一个函数的文档实例:


def incr(self, age):
    """
    长一岁
    :type age: int
    :rtype: int
    :return 年龄+1
    """
    self.age = age
    return self.age
复制代码


通过pydoc.getdoc得到的帮助信息如下,这是自动生成api文档的基础。


长一岁
:type age: int
:rtype: int
:return 年龄+1
复制代码


_methodHelp展示了如何提供额外的rpc使用示例,帮助client理解API:


def _methodHelp(self, method):
        # this method must be present for system.methodHelp
        # to work
        if method == 'add':
            return "add(2,3) => 5"
        elif method == 'pow':
            return "pow(x, y[, z]) => number"
        else:
            # By convention, return empty
            # string if no help is available
            return ""
复制代码


_marshaled_dispatch处理最关键的rpc实现:


def _marshaled_dispatch(self, data, dispatch_method = None, path = None):
    try:
        ...
    except:
        # report exception back to server
        exc_type, exc_value, exc_tb = sys.exc_info()
        try:
            ...
        finally:
            # Break reference cycle
            exc_type = exc_value = exc_tb = None
复制代码


这一部分在之前的文章[xmlrpc源码阅读]中有过介绍,就不再重复介绍。这次我们重点关注一下异常处理部分。请看示例:


def some(x, y):
    z = x / y
    return z
try:
    some(1, 0)
except:
    exc_type, exc_value, exc_tb = sys.exc_info()
    print(exc_type, exc_value)
    print(repr(traceback.extract_tb(exc_tb)))
复制代码


从下面的日志输出可以看到,使用traceback可以得到异常的调用堆栈信息,这对定位bug非常有帮助:


<class 'ZeroDivisionError'> division by zero
[<FrameSummary file /Users/*/work/yuanmahui/python/ch18-rpc/sample.py, line 59 in test_exec>, <FrameSummary file /Users/*/work/yuanmahui/python/ch18-rpc/sample.py, line 55 in some>]
Traceback (most recent call last):
  File "/Users/yoo/work/yuanmahui/python/ch18-rpc/sample.py", line 59, in test_exec
    some(1, 0)
  File "/Users/yoo/work/yuanmahui/python/ch18-rpc/sample.py", line 55, in some
    z = x / y
ZeroDivisionError: division by zero
复制代码


_dispatch同样在之前的文章[xmlrpc源码阅读]中有过详细介绍,这次关注一下函数参数的自动封箱与拆箱:


def _dispatch(self, method, params):
    ...
    return func(*params)
复制代码


下面代码显示了如何使用args传递参数:


def some(a, b, c=2):
    print(a, b, c)
def f(args):
    some(*args)
def f2(*args):
    some(*args)
# 0 1 2
f((0, 1))
f((1, 2, 3))
# a b c # 这样竟然也可以...
f({"a": 4, "b": 5, "c": 6})
f2(*(1, 2, 3))
f2(1, 2, 3)
复制代码


  • 总结 *args在函数定义中,会自动把位置参数进行封箱(多个参数合并成一个元祖参数,就像用箱子封装一样)成元祖; *args在函数调用的时候,又自动把元祖参数拆箱成多个参数。


SimpleXMLRPCRequestHandler分析



SimpleXMLRPCRequestHandler继承自BaseHTTPRequestHandler。 encode_threshold定义了一个MTU推荐值,就是一个tcp包的在网络传输过程中的上限大小。如果超过则会拆分成多个IP包进行传输,这样会有多次网络传输,效率会变低。解决的办法就是超过MTU值的数据使用gzip压缩算法:


#if not None, encode responses larger than this, if possible
encode_threshold = 1400 #a common MTU
...
if self.encode_threshold is not None:
    if len(response) > self.encode_threshold:
        q = self.accept_encodings().get("gzip", 0)
        if q:
            try:
                response = gzip_encode(response)
                self.send_header("Content-Encoding", "gzip")
复制代码


SimpleXMLRPCRequestHandler有多个类属性:


class SimpleXMLRPCRequestHandler(BaseHTTPRequestHandler):
    ...
    wbufsize = -1
    disable_nagle_algorithm = True
    ...
复制代码


类属性如果希望被扩展,可以使用小写;如果不推荐扩展,可以使用大写,类似常量定义:


# bottle
class BaseRequest(object):
    #: Maximum size of memory buffer for :attr:`body` in bytes.
    MEMFILE_MAX = 102400
    #: Maximum number pr GET or POST parameters per request
    MAX_PARAMS  = 100
复制代码


如果希望提供常量值,又可以被扩展,可以参考下面的写法:


# http.server
DEFAULT_ERROR_CONTENT_TYPE = "text/html;charset=utf-8"
DEFAULT_ERROR_MESSAGE = """..."""
class BaseHTTPRequestHandler(socketserver.StreamRequestHandler):
    sys_version = "Python/" + sys.version.split()[0]
    server_version = "BaseHTTP/" + __version__
    error_message_format = DEFAULT_ERROR_MESSAGE
    error_content_type = DEFAULT_ERROR_CONTENT_TYPE
    default_request_version = "HTTP/0.9"
复制代码


http头上的Accept-Encoding可以使用下面的正则进行解析:


# a re to match a gzip Accept-Encoding
aepattern = re.compile(r"""
                        \s* ([^\s;]+) \s*            #content-coding
                        (;\s* q \s*=\s* ([0-9\.]+))? #q
                        """, re.VERBOSE | re.IGNORECASE)
def accept_encodings(self):
    r = {}
    ae = self.headers.get("Accept-Encoding", "")
    for e in ae.split(","):
        match = self.aepattern.match(e)
        if match:
            v = match.group(3)
            v = float(v) if v else 1.0
            r[match.group(1)] = v
    return r
复制代码


下面是一些常见的Accept-Encoding举例:


Accept-Encoding: gzip
Accept-Encoding: gzip, compress, br
Accept-Encoding: br;q=1.0, gzip;q=0.8, *;q=0.1
# q 介于0和1之间,不存在则为1,数值表示优先级
复制代码


do_POST函数一样,查看一下500状态的处理:


except Exception as e: # This should only happen if the module is buggy
    self.send_response(500)
    # Send information about the exception if requested
    if hasattr(self.server, '_send_traceback_header') and \
            self.server._send_traceback_header:
        self.send_header("X-exception", str(e))
        trace = traceback.format_exc()
        trace = str(trace.encode('ASCII', 'backslashreplace'), 'ASCII')
        self.send_header("X-traceback", trace)
复制代码


  • 这里定义了X-exceptionX-traceback两个特别的http头,一般来说使用X-前缀表示自定义的头,在django中会看到这种方式。


decode_request_content函数处理请求数据,下面是调用方法和函数实现:


def do_POST(self):
    data = self.decode_request_content(data)
    if data is None:
        return #response has been sent
    ...
    self.send_response(200)
    ...
    self.end_headers()
    self.wfile.write(response)
def decode_request_content(self, data):
    #support gzip encoding of request
    encoding = self.headers.get("content-encoding", "identity").lower()
    if encoding == "identity":
        return data
    if encoding == "gzip":
        try:
            return gzip_decode(data)
        except NotImplementedError:
            self.send_response(501, "encoding %r not supported" % encoding)
        except ValueError:
            self.send_response(400, "error decoding gzip content")
    else:
        self.send_response(501, "encoding %r not supported" % encoding)
    self.send_header("Content-length", "0")
    self.end_headers()
复制代码


个人觉得,这个函数实现不太好。如果解析请求失败,异常处理header在另外的地方,导致逻辑结构不对等。我尝试修改一下:


error_code, error_info = None, None
if encoding == "identity":
        return 0, data
if encoding == "gzip":
    try:
        return 0, gzip_decode(data)
    except NotImplementedError:
        error_code, error_info = 501, "encoding %r not supported" % encoding
    except ValueError:
        error_code, error_info = 400, "error decoding gzip content"
else:
    error_code, error_info = 501, "encoding %r not supported" % encoding
return error_code, error_info
复制代码


这样关于header解析失败的逻辑在do_POST中实现,更统一。这里的error_code参考了golang的语法实现。大家觉得呢?欢迎在评论区留言点评。


log_request中使用了比较老式的调用父类方法的语法,而不是使用super关键字:


class BaseHTTPRequestHandler(socketserver.StreamRequestHandler):
    def log_request(self, code='-', size='-'):
        """Log an accepted request.
        This is called by send_response().
        """
        if isinstance(code, HTTPStatus):
            code = code.value
        self.log_message('"%s" %s %s',
                         self.requestline, str(code), str(size))
   
class SimpleXMLRPCRequestHandler(BaseHTTPRequestHandler):
    def log_request(self, code='-', size='-'):
        """Selectively log an accepted request."""
        if self.server.logRequests:
            BaseHTTPRequestHandler.log_request(self, code, size)
复制代码


SimpleXMLRPCServer 分析



SimpleXMLRPCServer展示多类继承时候的父类初始化方法,不同的父类的初始化参数不一样:


class SimpleXMLRPCServer(socketserver.TCPServer,
                         SimpleXMLRPCDispatcher):
     def __init__(self, addr, requestHandler=SimpleXMLRPCRequestHandler,
                 logRequests=True, allow_none=False, encoding=None,
                 bind_and_activate=True, use_builtin_types=False):
        SimpleXMLRPCDispatcher.__init__(self, allow_none, encoding, use_builtin_types)
        socketserver.TCPServer.__init__(self, addr, requestHandler, bind_and_activate)
复制代码


MultiPathXMLRPCServer && CGIXMLRPCRequestHandler



MultiPathXMLRPCServer支持注册多个dispatcher实现,每个dispatcher支持不同的rpc路径,不像SimpleXMLRPCServer仅支持/RPC2,功能更强劲:


class MultiPathXMLRPCServer(SimpleXMLRPCServer):
    def __init__(...):
        self.dispatchers = {}
   
    def add_dispatcher(self, path, dispatcher):
        self.dispatchers[path] = dispatcher
        return dispatcher
   
    def _marshaled_dispatch(self, data, dispatch_method = None, path = None):
        ...
        response = self.dispatchers[path]._marshaled_dispatch(
               data, dispatch_method, path)
        ...
复制代码


CGIXMLRPCRequestHandler提供了一种CGI实现。CGI在之前的[python http 源码阅读]有过介绍,特点就是输入输出使用标准流,CGI现在用的较少,简单了解即可。


CGIXMLRPCRequestHandler(SimpleXMLRPCDispatcher)
    def handle_xmlrpc(self, request_text):
        ...
        sys.stdout.buffer.write(response)
        sys.stdout.buffer.flush()
    ...
    def handle_request(self, request_text=None):
        ...
        request_text = sys.stdin.read(length)
        ...
复制代码


rpc-doc



ServerHTMLDoc, XMLRPCDocGenerator, DocXMLRPCRequestHandler和DocXMLRPCServer提供了更详细的RPC文档实现。文档对RPC服务来说非常重要,是RPC-client和RPC-server之间的重要规范,所以xmlrpc-server中用了大量篇幅来实现。不过,由于篇幅和时间原因,本文就不再进行详细分析其实现。


小结



xmlrpc-server核心由SimpleXMLRPCDispatcher,SimpleXMLRPCRequestHandler 和SimpleXMLRPCServer三个类实现,分工非常明确。 SimpleXMLRPCServer实现一个http服务,SimpleXMLRPCRequestHandler用于处理http请求和响应,SimpleXMLRPCDispatcher实现xml-rpc服务。当然其中还有一个核心的地方是dumpsloads方法,负责在http请求和rpc请求之间互换,这部分在client中,也先略过了。分拆类后,扩展性也很强,MultiPathXMLRPCServer扩展SimpleXMLRPCServer,实现多路径支持;CGIXMLRPCRequestHandler扩展SimpleXMLRPCDispatcher实现cgi支持;DocXMLRPCRequestHandler扩展SimpleXMLRPCRequestHandler实现文档支持。


MultiPathXMLRPCServer是前端控制器模式的实现,MultiPathXMLRPCServer充当了Front Controller的角色,支持多个dispatcher;SimpleXMLRPCDispatcher是Dispatcher的角色;而业务注册RPC函数,比如ExampleService.getData函数就是View了。这样就不是只会单例模式这一种设计模式,或者是设计模式一看就懂,一用就抓瞎。


小技巧



大家可以猜猜看,下图中的字符是什么?


image.png


参考链接




版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

相关文章
如何设置阿里云服务器安全组?阿里云安全组规则详细解说
阿里云安全组设置详细图文教程(收藏起来) 阿里云服务器安全组设置规则分享,阿里云服务器安全组如何放行端口设置教程。阿里云会要求客户设置安全组,如果不设置,阿里云会指定默认的安全组。那么,这个安全组是什么呢?顾名思义,就是为了服务器安全设置的。安全组其实就是一个虚拟的防火墙,可以让用户从端口、IP的维度来筛选对应服务器的访问者,从而形成一个云上的安全域。
18869 0
阿里云服务器如何登录?阿里云服务器的三种登录方法
购买阿里云ECS云服务器后如何登录?场景不同,阿里云优惠总结大概有三种登录方式: 登录到ECS云服务器控制台 在ECS云服务器控制台用户可以更改密码、更换系.
28069 0
阿里云服务器如何登录?阿里云服务器的三种登录方法
购买阿里云ECS云服务器后如何登录?场景不同,大概有三种登录方式:
13089 0
阿里云服务器安全组设置内网互通的方法
虽然0.0.0.0/0使用非常方便,但是发现很多同学使用它来做内网互通,这是有安全风险的,实例有可能会在经典网络被内网IP访问到。下面介绍一下四种安全的内网互联设置方法。 购买前请先:领取阿里云幸运券,有很多优惠,可到下文中领取。
22084 0
阿里云服务器端口号设置
阿里云服务器初级使用者可能面临的问题之一. 使用tomcat或者其他服务器软件设置端口号后,比如 一些不是默认的, mysql的 3306, mssql的1433,有时候打不开网页, 原因是没有在ecs安全组去设置这个端口号. 解决: 点击ecs下网络和安全下的安全组 在弹出的安全组中,如果没有就新建安全组,然后点击配置规则 最后如上图点击添加...或快速创建.   have fun!  将编程看作是一门艺术,而不单单是个技术。
20143 0
阿里云服务器ECS登录用户名是什么?系统不同默认账号也不同
阿里云服务器Windows系统默认用户名administrator,Linux镜像服务器用户名root
15548 0
+关注
game404
己所不欲勿施于人
66
文章
0
问答
文章排行榜
最热
最新
相关电子书
更多
JS零基础入门教程(上册)
立即下载
性能优化方法论
立即下载
手把手学习日志服务SLS,云启实验室实战指南
立即下载