Go语言类库中,有两个官方的服务器框架,一个HTTP,一个是RPC。使用这个两个框架,已经能解决大部分的问题,但是,也有一些需求,这些框架是不够的,这篇文章,我们先分析一下HTTP 和 RPC服务器的特点, 然后结合这两个服务器的特点,我实现了一个新的服务器,这个服务器非常适合客户端和服务器端有大量交互的情况。

HTTP服务器的特点:

HTTP的请求 和 响应的周期如下:

对于一个HTTP 长连接,一个请求必须等到一个响应完成后,才能进行下一个请求。这就是http协议最本质的特点,是串行化的。而这个特点保证了http协议的简洁性,一个请求中间不会插入其他的请求干扰,这样不需要去对应请求和响应。但是,同时也有个弱点,那就是不适合做大量的请求。举个实际中我们遇到的例子,我们要把大量的中国客户的订单送入英国的交易所,交易所的接口是http协议的,从中国到英国,一次http的请求到响应至少需要 300ms左右,这样一秒一个连只能发送3个,就算是开十个线程发送(接口对线程总数是有限制的),1s 也只能是30个。而最高峰的时候,我们可能1s 要发送1万个订单,那采用http协议就不能满足我们的要求了(这个可以通过fix协议解决)。

当然,http可以解决批量提交的需求,只要增加一个批量提交的接口就可以了。但是,这样的实现方式不够自然,而且增加了额外的接口。

RPC服务的特点:

PRC服务器克服了http服务器串流模型,可以并发的提交请求。请求响应的周期图如下:

RPC服务,已经可以客服http服务器的串流的劣势,可以批量提交大量的数据。在局域网的中测试,1s钟可以实现3万次左右的请求。而相同的条件下,http在局域网中,只能实现1500次左右的请求,真实环境下面,延时严重,http性能会急剧下降。在两个不同的机房中,有百兆带宽相连,实际测试rpc请求是两万次左右,http是 500次左右,而且http占用很多头部的带宽。

RPC的一个核心特点是类似一次函数调用。这样一个请求 只能 对应于 一个响应。在某些情下,这似乎是不够的。举个实际的例子,我要获取一个报价的行情数据,这个时候,类似一个MessageQueue,服务器会不断的push数据给客户端。也就是一次请求,会有多次返回,持续不断的返回。

当然,RPC的一个非常重要的优势是,你不需要知道怎么去解析数据,你可以当做网络是空气,完全像写本地调用函数一样去调用rpc的函数。

异步服务器:

因为暂时我没有很好的名字来命名这个服务器,所以暂时就叫做异步服务器吧,这个服务器的特点类似一个界面程序的消息体系。我们不断的吧鼠标键盘等各种事件提交给界面程序,界面程序根据消息的类型,参数做出相应的处理。所以,我们就叫做异步服务器吧。经典的金融服务器都是异步服务器,处理机制都类似界面的消息循环机制,比如国内期货最常用的ctp交易系统,还有就是银行间,交易所和银行之间,经常用的一个协议叫做 fix,也是这样的架构。请求是一种消息,响应也是一种消息。请求响应的时序图如下:

msg1 请求之后,有两个响应,Resp1 , resp2,

msg2 有一个响应 resp3.

借鉴了rpc的特点,请求和响应都自动编码,写服务器不再为编码而烦恼,同时也不需要为是否要压缩而头痛。现在提供三种方式,gob , json, protocolbuffer. 并且可以 设置是否启用压缩的,以及压缩的格式。我

们把客户端和服务器的交互抽象为一个消息系统,先来看看客户端客户端调用

  1. 1: client, err := NewClient("http://localhost:8080", jar, "gob", "gzip")

  1. 2: if err != nil {

  1. 3: log.Println(err)

  1. 4: return

  1. 5: }

  1. 6: defer client.Close()

  1. 7: req := NewRequest("hello", "jack", func(call *Call, status int) {

  1. 8: log.Println(call, call.Resp, status)

  1. 9: })

  1. 10: client.Go(req)

  1. 11: req2 := NewRequest("hello", "fuck", func(call *Call, status int) {

  1. 12: log.Println(call, call.Resp, status)

  1. 13: })

  1. 14: client.Go(req2)

  1. 15: //wait for all req is done

  1. 16: client.Wait()

1-6行,我们建立了一个到服务器的连接,注意,我们这个服务器底层是用http包实现的。jar 是用来管理session的,这里暂时忽略,gob是编码,gzip是压缩格式。可以动态设置各种编码和压缩格式。

7-13行,NewRequest 的第一个参数是消息的类型(我建议再后面的版本中,改成NewMessage, Client.GO 改成 client.Send),叫做hello, 详细类型为了方便查看也打印,我采用字符串的格式。后面是消息的参数,可以是任何的go的结构,变量。每个请求对应一个回调函数,处理响应的消息,响应的消息保存在 call.Resp 里面,如果status == StatusDone , 表示请求结束了,服务器不会响应任何消息了,status  == StatusUpdate ,说明,还会有下一个消息过来。

16行 Wait函数,其实就是一个消息循环函数,不断的从服务器端读取消息,对应到某个请求的回调函数里面。类似event loop

我们在Client里面加入心跳函数,保证能检查到链接损坏的情况,如果连接损坏,会自动结束消息循环,错误处理是一个服务器非常重要的一环。

然后我们再来看看服务器端的实现:

  1. 1: func helloWorld(w *ResponseWriter, r *Request) {

  1. 2: resp := w.Resp

  1. 3: resp.MsgType = MsgTString

  1. 4: //表示我已经没有其他数据包了,这个请求已经结束了

  1. 5: resp.Done = true

  1. 6: //向客户端发送请求

  1. 7: w.WriteResponse(resp, "hello: " + r.GetBody().(string))

  1. 8: }

第7行中,r.GetBody() 获取的到是上面NewRequest 中的第二个参数。

这样就是一个最简单的hello world 程序。要实现一个实战有用的服务器,的细节当然还有很多,主要的是流量控制。比如,一个用户写错程序了,错误的发起了10万个请求,服务器端不能开个10万个go进行处理,这样的话,会直接拖垮服务器,我们给每个用户设置了一个并发处理数目,最多这个用户可以并发处理多少个请求。还有一个比较重要的,对服务器来说,就是服务器服务的量的限制。我们会实时监控 cpu 内存,io的使用情况,当发现使用到某个限额的时候,服务会拒绝接受连接(事先要对性能进行测试)这些都是为了防止服务器过载 ,而实际中的服务器,这个问题其实是很常见的。

实例:可靠消息通知系统。

可靠消息通知系统实际上是一个非常常见的系统。最常用的一个例子就是数据库的master slave 模式。master里面的事件要非常可靠的通知到slave,中间不能有任何的丢失。还有一种比如交易系统中,我们会调用银行或者交易所的接口,银行在交易成功后会给我们一个通知,这个通知的消息必须可靠的被通知到目标,不能有任何的丢失。在我们的系统中,行情数据的复制也是不能有任何数据丢失的情景,为了保证A 服务器 和 B服务器有相同的行情,在从A服务器的消息要被B服务器准确的接收。当然,你也可以做一个聊天系统,这个聊天系统不会丢失任何消息。

那么如何实现这个系统呢,首先,为了保证不在内存中丢失消息,那么消息必须写盘,并且为了检测消息是否丢失,必须给消息编号。消息写盘也可以用我们开发的事务日志系统,如果消息非常的大量,那么还需要批量提交模式(Group Commit)。大部分情况下,消息丢失不是因为服务器崩溃,而且网络意外中断,这些中断往往时间很短,在1分钟以内,所以,有必要在内存中缓存部分的消息,如果网络中断,客户端再次请求时,发送当时的消息序号,这样就可以补全网络中断丢失的数据。如果时间太长了,内存中的数据不够补了,那么首先要从消息源数据库中下载历史消息,然后再接受实时的消息。整体的思路就是这样的,在这里,我们就看看我们的消息通知系统的实时广播部分的设计。

  1. 消息广播基本流程: 订阅 –> 广播:

首先客户端向服务器说明,我要订阅哪些消息,比如,master slave 中,我只要写消息就好了,读消息就不需要了。然后,再向服务器请求数据,服务器广播数据给我们。注意,我们这里把订阅 和 广播分成两个部分,两个请求,那么怎么知道这两个请求是同一个人发出的呢?或者,怎么关联起来呢?这里,我用了一个session的概念,订阅的时候,把订阅的消息类型保存到session,广播的时候,从session中读取消息类型,然后发送对应的数据。

这部分的代码如下:

  1. 1: var bmu sync.Mutex

  1. 2: var defaultBroadcast = make(map[int64]*Broadcast)

  1. 3: var ErrNotRingItemer = errors.New("ErrNotRingItemer")

  1. 4: //基本上可以保证有1个小时的数据

  1. 5: const btickSize = 3600 * 4

  1. 6: //可以传递任意的数据

  1. 7: 

  1. 8: func GetBroadcast(name int64, n int) (*Broadcast, error) {

  1. 9: bmu.Lock()

  1. 10: defer bmu.Unlock()

  1. 11: b, ok := defaultBroadcast[name]

  1. 12: if ok {

  1. 13: return b, nil

  1. 14: }

  1. 15: b , err := NewBroadcast(name, n)

  1. 16: if err != nil {

  1. 17: return nil, err

  1. 18: }

  1. 19: defaultBroadcast[name] = b

  1. 20: return b, nil

  1. 21: }

  1. 22: 

  1. 23: type Broadcast struct {

  1. 24: mu sync.RWMutex

  1. 25: targets map[int64]*Subscribe

  1. 26: ringbuffer *algo.RingBuffer

  1. 27: name int64

  1. 28: }

  1. 29: 

  1. 30: func NewBroadcast(name int64, n int) (*Broadcast, error) {

  1. 31: b := &Broadcast{}

  1. 32: b.targets = make(map[int64]*Subscribe)

  1. 33: b.ringbuffer = algo.NewRingBuffer(n, nil)

  1. 34: b.name = name

  1. 35: return b, nil

  1. 36: }

  1. 37: 

  1. 38: func (b *Broadcast) GetName() int64 {

  1. 39: return b.name

  1. 40: }

  1. 41: 

  1. 42: func (b *Broadcast) Sub(id int64, req *Subscribe) {

  1. 43: b.mu.Lock()

  1. 44: defer b.mu.Unlock()

  1. 45: b.targets[id] = req

  1. 46: }

  1. 47: 

  1. 48: func (b *Broadcast) Unsub(id int64) {

  1. 49: b.mu.Lock()

  1. 50: defer b.mu.Unlock()

  1. 51: delete(b.targets, id)

  1. 52: }

  1. 53: 

  1. 54: //是否在buffer内部

  1. 55: func (b *Broadcast) InBuffer(start int64, end int64) (bool, error) {

  1. 56: return b.ringbuffer.InBuffer(start, end)

  1. 57: }

  1. 58: 

  1. 59: func (b *Broadcast) Query(start int64, end int64, ty int64) (algo.Iterator, error) {

  1. 60: find := &algo.RingFind{start, end, ty}

  1. 61: return b.ringbuffer.Find(find, true) //模糊查找,不是精确匹配

  1. 62: }

  1. 63: 

  1. 64: //如果要提供查询功能,那么就要缓存数据,一般采用ringbuffer

  1. 65: //data要满足下面的条件:

  1. 66: //1. 存在一个递增着的ID

  1. 67: //2. 实现BufferItemer接口

  1. 68: func (b *Broadcast) Push(item algo.RingItemer) error {

  1. 69: b.mu.RLock()

  1. 70: defer b.mu.RUnlock()

  1. 71: item2, err := b.ringbuffer.Push(item)

  1. 72: if err != nil {

  1. 73: return err

  1. 74: }

  1. 75: for _, v := range b.targets {

  1. 76: //过滤不想发送的

  1. 77: if (v.Check(b.name, item2.Type)) {

  1. 78: v.Send(item)

  1. 79: }

  1. 80: }

  1. 81: return nil

  1. 82: }

  1. 83: 

  1. 84: func (b *Broadcast) Find(find *algo.RingFind) (algo.Iterator, error) {

  1. 85: return b.ringbuffer.Find(find, true)

  1. 86: }

  1. 87: 

  1. 88: type Subscribe struct {

  1. 89: mu sync.Mutex

  1. 90: ch chan interface{}

  1. 91: tys map[int64]int64

  1. 92: }

  1. 93: 

  1. 94: func NewSubscribe(n int) (*Subscribe) {

  1. 95: s := &Subscribe{}

  1. 96: s.ch = make(chan interface{}, n)

  1. 97: s.tys = make(map[int64]int64)

  1. 98: return s

  1. 99: }

  1. 100: 

  1. 101: func (s *Subscribe) Add(bname int64, ty int64) {

  1. 102: s.mu.Lock()

  1. 103: defer s.mu.Unlock()

  1. 104: s.tys[bname] = ty

  1. 105: }

  1. 106: 

  1. 107: func (s *Subscribe) Check(bname int64, dataty int64) bool {

  1. 108: s.mu.Lock()

  1. 109: defer s.mu.Unlock()

  1. 110: ty, ok := s.tys[bname]

  1. 111: if !ok { //没有订阅

  1. 112: return false

  1. 113: }

  1. 114: if ty == algo.AnyType || dataty == ty {

  1. 115: return true

  1. 116: }

  1. 117: return false

  1. 118: }

  1. 119: 

  1. 120: func (s *Subscribe) Read(buf []interface{}) (int) {

  1. 121: var i = 1

  1. 122: buf[0] = <-s.ch

  1. 123: for {

  1. 124: if i == len(buf) {

  1. 125: return i

  1. 126: }

  1. 127: select {

  1. 128: case data := <-s.ch:

  1. 129: buf[i] = data

  1. 130: i++

  1. 131: default:

  1. 132: return i

  1. 133: }

  1. 134: }

  1. 135: panic("nerver reach")

  1. 136: }

  1. 137: 

  1. 138: func (s *Subscribe) Send(data interface{}) {

  1. 139: select {

  1. 140: case s.ch <- data :

  1. 141: default:

  1. 142: //清除旧的数据

  1. 143: s.Clear()

  1. 144: //发送结束标志位

  1. 145: s.ch <- nil

  1. 146: }

  1. 147: }

  1. 148: 

  1. 149: func (s *Subscribe) Clear() {

  1. 150: for {

  1. 151: select {

  1. 152: case <-s.ch:

  1. 153: default:

  1. 154: return

  1. 155: }

  1. 156: }

  1. 157: }

  1. 158: 

这里,有个数据结构叫做RingBuffer, 是一个环状的buffer,非常适合做缓存固定数目的数据,用于广播。广播是用管道来传输数据的,管道的性能实际上已经非常的高,不需要什么无锁队列之类的。在这里也给管道加上buffer使得,消息意外的扰动,不会使得带宽不够用而立马堵塞。

2. 接受消息:

在用户登录后,如果有权限,那么就可以作为消息源客户端,消息源的代码如下:

  1. 1: func pushTick(w *asyn.ResponseWriter, r *asyn.Request) {

  1. 2: event := r.GetBody().(*response.OrderBookEvent)

  1. 3: b, _ := GetBroadcast(event.InstrumentId, btickSize)

  1. 4: b.Push(event)

  1. 5: asyn.Log().Println(event)

  1. 6: asyn.OKHandle(w, r)

  1. 7: }

第2行: 从请求中获取 消息事件。

第3行: event.InstrumentId 是消息的类型,btickSzie 是缓存的数据数目。

第6行: 向客户端发送OK,确认消息发送成功。

每个消息是否发送成功,都有确认。这样,客户端就知道上次消息发送到哪里了。

3. 订阅:

  1. 1: func subscribe(w *asyn.ResponseWriter, r *asyn.Request) {

  1. 2: instId := r.GetBody().(int64)

  1. 3: log.Println("sub", instId)

  1. 4: b, err := GetBroadcast(instId, btickSize)

  1. 5: if err != nil {

  1. 6: r.SetErr(err)

  1. 7: asyn.ErrorHandle(w, r)

  1. 8: return

  1. 9: }

  1. 10: //订阅的size

  1. 11: //get and set 要成为一个原子操作

  1. 12: session := r.GetSession()

  1. 13: session.Get3("subscribe", func (data interface{}) interface{} {

  1. 14: if data == nil {

  1. 15: data = NewSubscribe(4096)

  1. 16: }

  1. 17: sub := data.(*Subscribe)

  1. 18: //广播, 类型

  1. 19: id := int64(uintptr(unsafe.Pointer(session)))

  1. 20: sub.Add(instId, algo.AnyType)

  1. 21: b.Sub(id, sub)

  1. 22: session.OnDelete(func () {

  1. 23: b.Unsub(id)

  1. 24: })

  1. 25: return sub

  1. 26: })

  1. 27: asyn.OKHandle(w, r)

  1. 28: }

 
 

第2行:获取消息的类型,通过这个类型,可以找到对应的广播对象。

第12-30行:这是一个线程安全的session操作,具体看一下session.Get3 的实现就知道了:

  1. 1: func (s *Session) Get3(name string, callback func (interface{}) interface{}) interface{} {

  1. 2: s.mu.Lock()

  1. 3: defer s.mu.Unlock()

  1. 4: data, err := s.get(name)

  1. 5: if err != nil {

  1. 6: data = nil

  1. 7: }

  1. 8: data = callback(data)

  1. 9: s.set(name, data)

  1. 10: return data

  1. 11: }

s.get 获取session的数据,如果没有session数据,那么为nil。简单的说,这里的意思是:如果session “subscribe” 如果还没有设置,那么就新建一个对象,如果已经设置了,那么读取这个对象,并且,这个操作是线程安全的。

这里还添加了一个session撤销时候的操作。

4. 广播:

  1. 1: //读取广播数据

  1. 2: func read(w *asyn.ResponseWriter, r *asyn.Request) {

  1. 3: session := r.GetSession()

  1. 4: //从session 中获取subscribe 对象

  1. 5: sub := session.Get3("subscribe", func (data interface{}) interface{} {

  1. 6: if data == nil {

  1. 7: data = NewSubscribe(4096)

  1. 8: }

  1. 9: return data

  1. 10: }).(*Subscribe)

  1. 11: depth := r.GetBody().(int)

  1. 12: log.Println("get subscribe")

  1. 13: resp := w.Resp

  1. 14: if depth == 0 {

  1. 15: resp.MsgType = "ticks"

  1. 16: } else {

  1. 17: resp.MsgType = "ticks1"

  1. 18: }

  1. 19: buf := make([]interface{}, 1024)

  1. 20: dg := make([]*response.OrderBookEvent, 1024)

  1. 21: tick1 := make([]*base.TickGo, 1024)

  1. 22: for {

  1. 23: n := sub.Read(buf)

  1. 24: for i := 0; i < n; i++ {

  1. 25: if buf[i] == nil {

  1. 26: //close by broadcast

  1. 27: r.SetErr(errors.New("501"))

  1. 28: asyn.ErrorHandle(w, r)

  1. 29: return

  1. 30: }

  1. 31: if depth == 0 {

  1. 32: dg[i] = buf[i].(*response.OrderBookEvent)

  1. 33: } else {

  1. 34: tick1[i] = buf[i].(*response.OrderBookEvent).ToTickGo()

  1. 35: }

  1. 36: }

  1. 37: var err error

  1. 38: if depth == 0 {

  1. 39: err = w.WriteResponse(resp, dg[:n])

  1. 40: } else {

  1. 41: err = w.WriteResponse(resp, tick1[:n])

  1. 42: }

  1. 43: if err != nil {

  1. 44: r.SetErr(err)

  1. 45: asyn.ErrorHandle(w, r)

  1. 46: return

  1. 47: }

  1. 48: }

  1. 49: }

read 有个depth参数,这是行情的深度。股票期货里面都有后这个概念。传说中的几档行情。

第26行:这里有个close。一般来说,是因为网络拥堵 或者 异常,无法发送数据了。

还有一点要注意,这里的行情是批量发送的。sub.Read 尽可能多的读取数据,减少网络io的次数。

当然,服务器框架本身提供了心跳机制,对消息广播系统,实时性是非常重要的,即时的检查出网络异常,才能保证实时性。

以上是对我们的异步消息服务器框架的一个简单的介绍。设计这框架,非常重要的两个理念:

1. 模块化的设计,一个功能,就对应一个函数。

2. 模块之间的通讯采用session,而对于比较复杂的通讯,可以自己建立一个线程安全的数据结构,比如这里的Broadcast 和 Subscribe

Go语言异步服务器框架原理和实现的更多相关文章

  1. 教你如何构建异步服务器和客户端的 Kotlin 框架 Ktor

    Ktor 是一个使用 Kotlin 以最小的成本快速创建 Web 应用程序的框架. Ktor 是一个用于在连接系统(connected systems)中构建异步服务器和客户端的 Kotlin 框架. ...

  2. Sql Server 简单查询 异步服务器更新语句

    //结构:select 子句 [into 子句] from 子句  [where 子句] [group by 子句]  [having 子句] [order by 子句] select  dept_c ...

  3. Swoole 中使用 WebSocket 异步服务器、WebSocket 协程服务器

    WebSocket 异步风格服务器 WebSocket\Server 继承自 Http\Server,所以 Http\Server 提供的所有 API 和配置项都可以使用. # ws_server.p ...

  4. ajax异步服务器获取时间

    1.创建ajax对象 <script type="text/javascript"> //创建AJAX异步对象 function createAJAX(){ var a ...

  5. Swoole 中使用 HTTP 异步服务器、HTTP 协程服务器

    HTTP 异步风格服务器 # http_server.php $http = new Swoole\Http\Server("0.0.0.0", 9501); // 设置服务器运行 ...

  6. Swoole 中使用 UDP 异步服务器、UDP 同步客户端、UDP 协程客户端

    UDP 异步风格服务器 # udp_server.php // 创建 UDP 服务器对象,监听0.0.0.0:9502端口,类型为SWOOLE_SOCK_UDP $serv = new Swoole\ ...

  7. Swoole 中使用 TCP 异步服务器、TCP 协程服务器、TCP 同步客户端、TCP 协程客户端

    TCP 异步风格服务器 异步风格服务器通过监听事件的方式来编写程序.当对应的事件发生时底层会主动回调指定的函数. 由于默认开启协程化,在回调函数内部会自动创建协程,遇到 IO 会产生协程调度,异步风格 ...

  8. 【go语言实现服务器接收http请求以及出现泄漏时的解决方案】

    一.关于基础的程序的实现 刚开始的时候程序是这样实现的: // Hello package main import ( "database/sql" "fmt" ...

  9. C语言客户端服务器代码

    /* sockclnt.c*/ #include <stdio.h>#include <string.h>#include <stdlib.h>#include & ...

随机推荐

  1. 基于吉日嘎拉的OA协同办公模块重写

    这一个月的业余时间主要是在忙这个重构的事情,将吉日嘎拉自带的文档管理.公司公告.留言板.通讯录.周任务.考勤,全部重新建表,重构代码和UI. 目前根据中小企业常用的日常办公需要,搞定了公告栏.任务中心 ...

  2. Spring中常用的连接池配置

    首先,我们准备Jdbc属性文件 jdbc.properties,用于保存连接数据库的信息,利于我们在配置文件中的使用 jdbc.driver=com.mysql.jdbc.Driver jdbc.ur ...

  3. 使用 github + jekyll 搭建个人博客

    github + jekyll 本地写markdown,然后push到github,就成了博客 其实我一早就知道这两者可以搭建个人博客,因为本人有个很好的习惯——每天都会去看看一些热门文章,了解行业最 ...

  4. Java2_J2EE体系架构

    J2EE是Java2平台企业版(Java 2 Platform,Enterprise Edition),它的核心是一组技术规范与指南,提供基于组件的方式来设计.开发.组装和部署企业应用.J2EE使用多 ...

  5. go语言 匿名变量

    我们在使用传统的强类型语言编程时,经常会出现这种情况,即在调用函数时为了获取一个值,却因为该函数返回多个值而不得不定义一堆没用的变量.在Go中这种情况可以通过结合使用多重返回和匿名变量来避免这种丑陋的 ...

  6. JavaScript学习笔记-函数实例

    函数实例 var p = { a:15, b:'5', f1:function(){ var self = this; console.log(self.a+self.b); f2(); functi ...

  7. Mongodb的备份,恢复,导入与导出(cmd命令行实现)

    要用MongoDB,自然就要用到数据导入导出,就自己学习了一下. 在Mongo学习(二)中就讲到了在bin目录下有一些工具,本篇就是使用这些工具进行数据的导入导出及备份恢复. 注意:以下命令均在cmd ...

  8. 《Javascript高级程序设计》:创建对象

    工厂模式 function createPerson(name,age, job){ var o = new Object(); o.name = name; o.age = age; o.job = ...

  9. web前端命名规范

    在做web项目的时候,命名的规范是很重要.初学者一般急于求成对命名规范没有概念,觉得花时间这些还不如多看几遍框架.其实在我看来,一个良好的命名习惯是很重要的.下面就来介绍一下我总结的命名规范: (1) ...

  10. .NET破解之图片下载器

    自去年五月加入吾爱后,学习了三个月,对逆向破解产生了深厚的兴趣,尤其是对.NET方面的分析:但由于这一年,项目比较忙,事情比较多,破解这方面又停滞了许久,不知道还要好久. 前些天,帮忙批量下载QQ相册 ...