服务注册与发现框架discovery源码解析
discovery是B站開(kāi)源的類Eurekad的一款服務(wù)注冊(cè)與發(fā)現(xiàn)框架,簡(jiǎn)單介紹如下:
1. 實(shí)現(xiàn)AP類型服務(wù)注冊(cè)發(fā)現(xiàn)系統(tǒng),在可用性極極極極強(qiáng)的情況下,努力保證數(shù)據(jù)最終一致性 2. 與公司k8s平臺(tái)深度結(jié)合,注冊(cè)打通、發(fā)布平滑、naming service等等 3. 網(wǎng)絡(luò)閃斷等異常情況,可自我保護(hù),保證每個(gè)節(jié)點(diǎn)可用 4. 基于HTTP協(xié)議實(shí)現(xiàn)接口,簡(jiǎn)單易用,維護(hù)各流行語(yǔ)言SDK## 相對(duì)Netflix Eureka的改進(jìn)* 長(zhǎng)輪詢監(jiān)聽(tīng)?wèi)?yīng)用變更(Eureka定期30s拉取一次) * 只拉取感興趣的AppID實(shí)例(Eureka一拉就是全部,無(wú)法區(qū)分) * 合并node之間的同步請(qǐng)求/(ㄒoㄒ)/~~其實(shí)還沒(méi)實(shí)現(xiàn),是個(gè)TODO * Dashboard騷操作~ * 多注冊(cè)中心信息同步支持 * 更完善的日志記錄下面簡(jiǎn)單就discovery的源碼進(jìn)項(xiàng)分析,首先先熟悉一下框架的一些基本概念:
0. 通過(guò)AppID(服務(wù)名)和hostname定位實(shí)例 1. Node: discovery server節(jié)點(diǎn) 2. Provider: 服務(wù)提供者,目前托管給k8s平臺(tái),容器啟動(dòng)后發(fā)起register請(qǐng)求給Discover server,后定期(30s)心跳一次 3. Consumer: 啟動(dòng)時(shí)拉取node節(jié)點(diǎn)信息,后隨機(jī)選擇一個(gè)node發(fā)起long polling(30s一次)拉取服務(wù)instances列表 4. Instance: 保存在node內(nèi)存中的AppID對(duì)應(yīng)的容器節(jié)點(diǎn)信息,包含hostname/ip/service等比較重要的一些特色是:
1. 心跳復(fù)制(Peer to Peer),數(shù)據(jù)一致性的保障:* AppID注冊(cè)時(shí)根據(jù)當(dāng)前時(shí)間生成dirtyTimestamp,nodeA向nodeB同步(register)時(shí),nodeB可能有以下兩種情況:* 返回-404 則nodeA攜帶dirtyTimestamp向nodeB發(fā)起注冊(cè)請(qǐng)求,把最新信息同步:1. nodeB中不存在實(shí)例2. nodeB中dirtyTimestamp較小* 返回-409 nodeB不同意采納nodeA信息,且返回自身信息,nodeA使用該信息更新自身* AppID注冊(cè)成功后,Provider每(30s)發(fā)起一次heartbeat請(qǐng)求,處理流程如上 2. Instance管理* 正常檢測(cè)模式,隨機(jī)分批踢掉無(wú)心跳Instance節(jié)點(diǎn),盡量避免單應(yīng)用節(jié)點(diǎn)被一次全踢* 網(wǎng)絡(luò)閃斷和分區(qū)時(shí)自我保護(hù)模式* 60s內(nèi)丟失大量(小于Instance總數(shù)*2*0.85)心跳數(shù),“好”“壞”Instance信息都保留* 所有node都會(huì)持續(xù)提供服務(wù),單個(gè)node的注冊(cè)和發(fā)現(xiàn)功能不受影響* 最大保護(hù)時(shí)間,防止分區(qū)恢復(fù)后大量原先Instance真的已經(jīng)不存在時(shí),一直處于保護(hù)模式 3. Consumer客戶端* 長(zhǎng)輪詢+node推送,服務(wù)發(fā)現(xiàn)準(zhǔn)實(shí)時(shí)* 訂閱式,只需要關(guān)注想要關(guān)注的AppID的Instance列表變化* 緩存實(shí)例Instance列表信息,保證與node網(wǎng)絡(luò)不通等無(wú)法訪問(wèn)到node情況時(shí)原先的Instance可用下面開(kāi)始進(jìn)行源碼分析:
discovery的入口文件非常簡(jiǎn)單:
func main() {//解析配置文件flag.Parse()if err := conf.Init(); err != nil {log.Error("conf.Init() error(%v)", err)panic(err)}fmt.Println("conf", conf.Conf)log.Init(conf.Conf.Log)//開(kāi)始一個(gè)新的discovery中心dis, cancel := discovery.New(conf.Conf)//本地實(shí)例的http監(jiān)聽(tīng)端口,提供了一系列的http接口,比如注冊(cè),更新和下線接口等http.Init(conf.Conf, dis)// init signalc := make(chan os.Signal, 1)signal.Notify(c, syscall.SIGHUP, syscall.SIGQUIT, syscall.SIGTERM, syscall.SIGINT)for {s := <-clog.Info("discovery get a signal %s", s.String())switch s {//如果監(jiān)聽(tīng)到停止信號(hào),則進(jìn)行收尾工作處理,在cancel函數(shù)中具體說(shuō)明case syscall.SIGQUIT, syscall.SIGTERM, syscall.SIGINT:cancel()time.Sleep(time.Second)log.Info("discovery quit !!!")returncase syscall.SIGHUP:default:return}} }其中最核心的函數(shù)是dis, cancel := discovery.New(conf.Conf),下面詳細(xì)分析這個(gè)函數(shù):
// New get a discovery. func New(c *conf.Config) (d *Discovery, cancel context.CancelFunc) {//構(gòu)建一個(gè)新的Discoveryd = &Discovery{protected: c.EnableProtect,c: c,client: http.NewClient(c.HTTPClient),//初始化服務(wù)注冊(cè)中心,并開(kāi)始協(xié)程進(jìn)行保護(hù)模式下的邏輯處理registry: registry.NewRegistry(c),}//讀取配置文件中的zone和node相關(guān)的配置讀取出來(lái),返回Nodes結(jié)構(gòu)體// Nodes is helper to manage lifecycle of a collection of Nodes.//type Nodes struct {// nodes []*Node //Node切片數(shù)組// zones map[string][]*Node// selfAddr string //本地http監(jiān)聽(tīng)的地址//}d.nodes.Store(registry.NewNodes(c))//將除了本地http監(jiān)聽(tīng)端口的host之外其他的host,拉取出其他host的所有實(shí)例,在本地進(jìn)行注冊(cè)d.syncUp()//注冊(cè)自己本身,并每隔30s進(jìn)行一次心跳檢測(cè)cancel = d.regSelf()//開(kāi)始協(xié)程進(jìn)行長(zhǎng)輪訓(xùn),實(shí)現(xiàn)服務(wù)注冊(cè)進(jìn)來(lái)后準(zhǔn)實(shí)時(shí)發(fā)現(xiàn)go d.nodesproc()//如果成功進(jìn)行了兩輪renew循環(huán),則關(guān)閉保護(hù)模式//保護(hù)模式下不再接受其他Discovery中心的拉取和推送請(qǐng)求,之前提供的http接口也失效,但是本地Discovery還是可以正常提供服務(wù)注冊(cè)go d.exitProtect()return }這個(gè)函數(shù)中包括了一系列的邏輯,先來(lái)看d.syncUp()函數(shù)
// syncUp populates the registry information from a peer eureka node. func (d *Discovery) syncUp() {nodes := d.nodes.Load().(*registry.Nodes)//將配置文件中的所有node拿出來(lái),循環(huán)處理for _, node := range nodes.AllNodes() {log.Info("syncUp nodes are %v", node)//如果是自己本身,則跳過(guò)if nodes.Myself(node.Addr) {continue}uri := fmt.Sprintf(_fetchAllURL, node.Addr)var res struct {Code int `json:"code"`Data map[string][]*model.Instance `json:"data"`}//獲取其他節(jié)點(diǎn)的所有instance實(shí)例,如果其他節(jié)點(diǎn)沒(méi)起來(lái)或者fetch的時(shí)候發(fā)生異常,則跳過(guò)這個(gè)節(jié)點(diǎn)的處理if err := d.client.Get(context.TODO(), uri, "", nil, &res); err != nil {log.Error("d.client.Get(%v) error(%v)", uri, err)continue}log.Info("fetch res are %v", res, res.Code)if res.Code != 0 {log.Error("service syncup from(%s) failed ", uri)continue}// sync success from other node,exit protected mode//如果能正常從其他節(jié)點(diǎn)拉取到他們的實(shí)例信息,則表示節(jié)點(diǎn)之間的通訊是正常的,如果配置文件中是開(kāi)啟保護(hù)模式的話,這個(gè)時(shí)候就可以關(guān)閉保護(hù)模式了d.protected = falsefor _, is := range res.Data {for _, i := range is {//將每個(gè)節(jié)點(diǎn)中的實(shí)例注冊(cè)到本地_ = d.registry.Register(i, i.LatestTimestamp)}}// NOTE: no return, make sure that all instances from other nodes register into self.}//將本地node狀態(tài)置為正常nodes.UP() }上面的流程主要是將其他節(jié)點(diǎn)的實(shí)例拉過(guò)來(lái),然后注冊(cè)到本地,所以下面我們來(lái)看本地注冊(cè)干了些啥
/ Register a new instance. func (r *Registry) Register(ins *model.Instance, latestTime int64) (err error) {//初始化一個(gè)APP實(shí)例,APP的結(jié)構(gòu)在這里放一下,方便后面的理解/*// App Instances distinguished by hostnametype App struct {AppID stringZone stringinstances map[string]*InstancelatestTimestamp int64 lock sync.RWMutex}*/a := r.newApp(ins)//將傳入的ins傳入后,copy后返回一個(gè)全新的instance,類似于快照,記錄這個(gè)時(shí)刻的insi, ok := a.NewInstance(ins, latestTime)if ok {//如果注冊(cè)成功,則將expPerMin+2(+2是因?yàn)槊糠昼奷iscovery會(huì)renew兩次,也計(jì)算出相應(yīng)的expThreshold,方便后面的保護(hù)模式的計(jì)算)r.gd.incrExp()}// NOTE: make sure free poll before update appid latest timestamp.//既然有新的實(shí)例注冊(cè)進(jìn)來(lái)了,當(dāng)然要廣播出去,讓本地去更新實(shí)例緩存了r.broadcast(i.Env, i.AppID)return }先來(lái)看r.newApp(ins)這個(gè)函數(shù)
func (r *Registry) newApp(ins *model.Instance) (a *model.App) {//先實(shí)例化一個(gè)appsas, _ := r.newapps(ins.AppID, ins.Env)//然后開(kāi)始搞一個(gè)新的App實(shí)例a, _ = as.NewApp(ins.Zone, ins.AppID, ins.LatestTimestamp)return }func (r *Registry) newapps(appid, env string) (a *model.Apps, ok bool) {//key是appid-env的stringkey := appsKey(appid, env)r.aLock.Lock()//先看下這個(gè)key是否有了Apps了/*// Apps app distinguished by zonetype Apps struct {apps map[string]*Applock sync.RWMutexlatestTimestamp int64}*/if a, ok = r.appm[key]; !ok {a = model.NewApps()r.appm[key] = a}r.aLock.Unlock()return }// NewApp news a app by appid. If ok=false, returns the app of already exist. func (p *Apps) NewApp(zone, appid string, lts int64) (a *App, new bool) {p.lock.Lock()a, ok := p.apps[zone]if !ok {a = NewApp(zone, appid)p.apps[zone] = a}if lts <= p.latestTimestamp {// insure increaselts = p.latestTimestamp + 1}//注意這里,在注冊(cè)的邏輯里,如果有新的實(shí)例注冊(cè)進(jìn)來(lái)的話,latestTimestamp這個(gè)字段是有更新的//正常情況下,會(huì)用注冊(cè)的實(shí)例的注冊(cè)時(shí)間進(jìn)行更新p.latestTimestamp = ltsp.lock.Unlock()new = !okreturn }通過(guò)上面返回的App實(shí)例,初始化Instance信息
// NewInstance new a instance. func (a *App) NewInstance(ni *Instance, latestTime int64) (i *Instance, ok bool) {i = new(Instance)a.lock.Lock()oi, ok := a.instances[ni.Hostname]if ok {ni.UpTimestamp = oi.UpTimestampif ni.DirtyTimestamp < oi.DirtyTimestamp {log.Warn("register exist(%v) dirty? timestamp over than caller(%v)", oi, ni)ni = oi}}a.instances[ni.Hostname] = nia.updateLatest(latestTime)*i = *nia.lock.Unlock()ok = !okreturn }實(shí)例已經(jīng)注冊(cè)好了,下面就廣播出去吧
// broadcast on poll by chan. // NOTE: make sure free poll before update appid latest timestamp. func (r *Registry) broadcast(env, appid string) {key := pollKey(env, appid)r.cLock.Lock()conns, ok := r.conns[key]//如果是剛啟動(dòng)的時(shí)候?qū)⑵渌?jié)點(diǎn)的實(shí)例注冊(cè)進(jìn)來(lái)的話,這里是空的,在這里直接返回fmt.Println("conns", conns)if !ok {fmt.Println("no co")r.cLock.Unlock()return} 第一次廣播的時(shí)候,直接返回 ....... }至此,其他節(jié)點(diǎn)的處理流程就處理完了,下面我們看下將自己本身注冊(cè)進(jìn)來(lái)
func (d *Discovery) regSelf() context.CancelFunc {ctx, cancel := context.WithCancel(context.Background())now := time.Now().UnixNano()ins := &model.Instance{Region: d.c.Env.Region,Zone: d.c.Env.Zone,Env: d.c.Env.DeployEnv,Hostname: d.c.Env.Host,AppID: model.AppID,Addrs: []string{"http://" + d.c.HTTPServer.Addr,},Status: model.InstanceStatusUP,RegTimestamp: now,UpTimestamp: now,LatestTimestamp: now,RenewTimestamp: now,DirtyTimestamp: now,}//將自己注冊(cè)進(jìn)來(lái),并將自身的實(shí)例信息同步到其他的節(jié)點(diǎn)進(jìn)行注冊(cè)d.Register(ctx, ins, now, false, false)go func() {ticker := time.NewTicker(30 * time.Second)defer ticker.Stop()for {select {case <-ticker.C:arg := &model.ArgRenew{AppID: ins.AppID,Zone: d.c.Env.Zone,Env: d.c.Env.DeployEnv,Hostname: d.c.Env.Host,}//每隔30秒renew一下if _, err := d.Renew(ctx, arg); err != nil && err == ecode.NothingFound {log.Info("renew self err is %v", err)d.Register(ctx, ins, now, false, false)}case <-ctx.Done():arg := &model.ArgCancel{AppID: model.AppID,Zone: d.c.Env.Zone,Env: d.c.Env.DeployEnv,Hostname: d.c.Env.Host,}if err := d.Cancel(context.Background(), arg); err != nil {log.Error("d.Cancel(%+v) error(%v)", arg, err)}return}}}()return cancel }?這里的主要邏輯是將自身注冊(cè)進(jìn)來(lái),并每隔30秒renew一次,將自己的信息同步到其他節(jié)點(diǎn),下面分析一下renew的邏輯
// Renew marks the given instance of the given app name as renewed, and also marks whether it originated from replication. func (d *Discovery) Renew(c context.Context, arg *model.ArgRenew) (i *model.Instance, err error) {log.Info("renew args are %v", arg)//獲取自身實(shí)例信息i, ok := d.registry.Renew(arg)if !ok {err = ecode.NothingFoundlog.Error("renew appid(%s) hostname(%s) zone(%s) env(%s) error", arg.AppID, arg.Hostname, arg.Zone, arg.Env)return}//同步信息到其他節(jié)點(diǎn)(如果需要的話,這種情況下同步后直接退出了)if !arg.Replication {_ = d.nodes.Load().(*registry.Nodes).Replicate(c, model.Renew, i, arg.Zone != d.c.Env.Zone)return}//如果renew的DirtyTimestamp大于實(shí)例的DirtyTimestamp,返回-404,滿足如下條件中的第二種條件// * 返回-404 則nodeA攜帶dirtyTimestamp向nodeB發(fā)起注冊(cè)請(qǐng)求,把最新信息同步:// 1. nodeB中不存在實(shí)例// 2. nodeB中dirtyTimestamp較小if arg.DirtyTimestamp > i.DirtyTimestamp {err = ecode.NothingFound} else if arg.DirtyTimestamp < i.DirtyTimestamp {err = ecode.Conflict}return }//同步邏輯 // Replicate replicate information to all nodes except for this node. func (ns *Nodes) Replicate(c context.Context, action model.Action, i *model.Instance, otherZone bool) (err error) {log.Warn("nodes is %v,len is %v", ns.nodes, len(ns.nodes))if len(ns.nodes) == 0 {return}eg, c := errgroup.WithContext(c)for _, n := range ns.nodes {log.Warn("Replicate node is %v", n)//將自身實(shí)例同步到其他節(jié)點(diǎn)if !ns.Myself(n.addr) {ns.action(c, eg, action, n, i)}}if !otherZone {for _, zns := range ns.zones {if n := len(zns); n > 0 {ns.action(c, eg, action, zns[rand.Intn(n)], i)}}}err = eg.Wait()return }//action函數(shù)具體邏輯 func (ns *Nodes) action(c context.Context, eg *errgroup.Group, action model.Action, n *Node, i *model.Instance) {log.Info("action arg is %v", i)switch action {case model.Register:eg.Go(func() error {_ = n.Register(c, i)return nil})case model.Renew://開(kāi)啟協(xié)程去renew到其他節(jié)點(diǎn)eg.Go(func() error {_ = n.Renew(c, i)return nil})case model.Cancel:eg.Go(func() error {_ = n.Cancel(c, i)return nil})} }//n.Renew函數(shù)的邏輯 // Renew send the heartbeat information of Instance receiving by this node to the peer node represented. // If the instance does not exist the node, the instance registration information is sent again to the peer node. func (n *Node) Renew(c context.Context, i *model.Instance) (err error) {var res *model.Instanceerr = n.call(c, model.Renew, i, n.renewURL, &res)log.Info("renew other node info are %v,url is %v,res is %v", i, n.renewURL, err)if err == ecode.ServerErr {log.Warn("node be called(%s) instance(%v) error(%v)", n.renewURL, i, err)n.status = model.NodeStatusLostreturn}n.status = model.NodeStatusUPif err == ecode.NothingFound {log.Warn("node be called(%s) instance(%v) error(%v)", n.renewURL, i, err)err = n.call(c, model.Register, i, n.registerURL, nil)return}// NOTE: register response instance whitch in conflict with peer nodeif err == ecode.Conflict && res != nil {err = n.call(c, model.Register, res, n.pRegisterURL, nil)}return }//重點(diǎn)在于n.call(c, model.Renew, i, n.renewURL, &res) func (n *Node) call(c context.Context, action model.Action, i *model.Instance, uri string, data interface{}) (err error) {params := url.Values{}params.Set("region", i.Region)params.Set("zone", i.Zone)params.Set("env", i.Env)params.Set("appid", i.AppID)params.Set("hostname", i.Hostname)params.Set("from_zone", "true")//同步到其他節(jié)點(diǎn)的時(shí)候,一般是走入為true的邏輯分支if n.otherZone {params.Set("replication", "false")} else {params.Set("replication", "true")}switch action {case model.Register:params.Set("addrs", strings.Join(i.Addrs, ","))params.Set("status", strconv.FormatUint(uint64(i.Status), 10))params.Set("version", i.Version)meta, _ := json.Marshal(i.Metadata)params.Set("metadata", string(meta))params.Set("reg_timestamp", strconv.FormatInt(i.RegTimestamp, 10))params.Set("dirty_timestamp", strconv.FormatInt(i.DirtyTimestamp, 10))params.Set("latest_timestamp", strconv.FormatInt(i.LatestTimestamp, 10))case model.Renew:params.Set("dirty_timestamp", strconv.FormatInt(i.DirtyTimestamp, 10))case model.Cancel:params.Set("latest_timestamp", strconv.FormatInt(i.LatestTimestamp, 10))}var res struct {Code int `json:"code"`Data json.RawMessage `json:"data"`}//請(qǐng)求其他節(jié)點(diǎn)的renewUrl(http接口)if err = n.client.Post(c, uri, "", params, &res); err != nil {log.Error("node be called(%s) instance(%v) error(%v)", uri, i, err)return}if res.Code != 0 {log.Error("node be called(%s) instance(%v) response code(%v)", uri, i, res.Code)if err = ecode.Int(res.Code); err == ecode.Conflict {_ = json.Unmarshal([]byte(res.Data), data)}}return }renew的時(shí)候的邏輯是根據(jù)renew的參數(shù)找到自身實(shí)例,再將自身信息同步到其他節(jié)點(diǎn)
重點(diǎn)講解一下renew的邏輯:
在將自身信息注冊(cè)到其他的discovery之后,會(huì)有兩種情況發(fā)生
1.其他節(jié)點(diǎn)向本地發(fā)起renew請(qǐng)求
先是從本地緩存中查找到renew請(qǐng)求arg中的zone,env和appid找到之前注冊(cè)在本地的app信息,再通過(guò)arg中的host找到app中的instances(map)中的instance信息(在這個(gè)過(guò)程中會(huì)更新實(shí)例的renewTimestamp),正常情況下就沒(méi)有其他的邏輯了,因?yàn)槠渌姆种Ф歼M(jìn)不去
2.本地節(jié)點(diǎn)更新
每隔30秒本地會(huì)拿著conf文件中的zone,env,host和在本地注冊(cè)時(shí)候的AppID作為參數(shù)進(jìn)行renew,
也是先找出本地緩存中的實(shí)例信息,然后同步給其他node(http POST請(qǐng)求其他discovery節(jié)點(diǎn)的接口地址),在同步其他節(jié)點(diǎn)的時(shí)候,會(huì)從zones(初始化注冊(cè)時(shí)候的其他zone的節(jié)點(diǎn)信息)切片中隨機(jī)選取一個(gè)node進(jìn)行同步
上述注冊(cè)其他節(jié)點(diǎn)和自身節(jié)點(diǎn)完成后,就是一個(gè)長(zhǎng)輪訓(xùn),實(shí)現(xiàn)服務(wù)發(fā)現(xiàn)準(zhǔn)實(shí)時(shí)
func (d *Discovery) nodesproc() {var (lastTs int64)for {arg := &model.ArgPolls{AppID: []string{model.AppID},Env: d.c.Env.DeployEnv,Hostname: d.c.Env.Host,LatestTimestamp: []int64{lastTs},}log.Info("polls times is %v", time.Now().Format("2006-01-02 15:04:05"))//返回一個(gè)消費(fèi)者chanch, _, _, err := d.registry.Polls(arg)if err != nil && err != ecode.NotModified {log.Error("d.registry(%v) error(%v)", arg, err)time.Sleep(time.Second)continue}log.Info("wait for ch out")apps := <-chins, ok := apps[model.AppID]fmt.Println("go process ins", ins)if !ok || ins == nil {return}var (nodes []stringzones = make(map[string][]string))for _, ins := range ins.Instances {for _, in := range ins {log.Info("range ins are %v", in)for _, addr := range in.Addrs {u, err := url.Parse(addr)if err == nil && u.Scheme == "http" {if in.Zone == d.c.Env.Zone {nodes = append(nodes, u.Host)} else {zones[in.Zone] = append(zones[in.Zone], u.Host)}}}}}lastTs = ins.LatestTimestampc := new(conf.Config)*c = *d.cc.Nodes = nodesc.Zones = zonesns := registry.NewNodes(c)ns.UP()d.nodes.Store(ns)log.Info("discovery changed nodes:%v zones:%v", nodes, zones)} }這個(gè)準(zhǔn)實(shí)時(shí)的實(shí)現(xiàn)我的理解是:
1.在d.registry.Polls(arg)方法調(diào)用后會(huì)返回一個(gè)消費(fèi)者channel,然后會(huì)在for循環(huán)中會(huì)阻塞在這個(gè)channel里,一旦有新的節(jié)點(diǎn)注冊(cè)進(jìn)來(lái)的時(shí)候(我們可以看一下,新實(shí)例注冊(cè)的時(shí)候,會(huì)進(jìn)行廣播通知,這里有個(gè)
會(huì)往這個(gè)channel中寫入新注冊(cè)的instance信息。)就會(huì)立馬收到注冊(cè)信息,然后更新本地實(shí)例緩存,做到服務(wù)發(fā)現(xiàn)近乎實(shí)時(shí)。
還有discovery的自保護(hù)模式,在下一篇中進(jìn)行分析了。?
總結(jié)
以上是生活随笔為你收集整理的服务注册与发现框架discovery源码解析的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: linux内核启动文档翻译(i386)
- 下一篇: rtsp的移植