zoukankan      html  css  js  c++  java
  • FFPLAY的原理

    概要
    电影文件有很多基本的组成部分。首先,文件本身被称为容器Container,容器的类型决定了信息被存放在文件中的位置。AVI和Quicktime就是容器的例子。接着,你有一组流,例如,你经常有的是一个音频流和一个视频流。(一个流只是一种想像出来的词语,用来表示一连串的通过时间来串连的数据元素)。在流中的数据元素被称为帧Frame。每个流是由不同的编码器来编码生成的。编解码器描述了实际的数据是如何被编码Coded和解码DECoded的,因此它的名字叫做CODEC。Divx和 MP3就是编解码器的例子。接着从流中被读出来的叫做包Packets。包是一段数据,它包含了一段可以被解码成方便我们最后在应用程序中操作的原始帧的数据。根据我们的目的,每个包包含了完整的帧或者对于音频来说是许多格式的完整帧。

    基本上来说,处理视频和音频流是很容易的:
    1. 从video.avi文件中打开视频流video_stream
    2.从视频流中读取包到帧中
    3. 如果这个帧还不完整,跳到2
    4. 对这个帧进行一些操作
    5. 跳回到2

    在这个程序中使用ffmpeg来处理多种媒体是相当容易的,虽然很多程序可能在对帧进行操作的时候非常的复杂。因此在这篇指导中,我们将打开一个文件,读取里面的视频流,而且我们对帧的操作将是把这个帧写到一个PPM文件中。

    打开文件
    首先,来看一下我们如何打开一个文件。通过ffmpeg,你必需先初始化这个库。(注意在某些系统中必需用<ffmpeg/avcodec.h>和<ffmpeg/avformat.h>来替换)
    #include <avcodec.h>
    #include <avformat.h>
    ...
    int main(int argc, charg *argv[]) {
    av_register_all();
    这里注册了所有的文件格式和编解码器的库,所以它们将被自动的使用在被打开的合适格式的文件上。注意你只需要调用 av_register_all()一次,因此我们在主函数main()中来调用它。如果你喜欢,也可以只注册特定的格式和编解码器,但是通常你没有必要这样做。
    现在我们可以真正的打开文件:
    AVFormatContext *pFormatCtx;
    // Open video file
    if(av_open_input_file(&pFormatCtx, argv[1], NULL, 0, NULL)!=0)
    return -1; // Couldn't open file
    我们通过第一个参数来获得文件名。这个函数读取文件的头部并且把信息保存到我们给的AVFormatContext结构体中。最后三个参数用来指定特殊的文件格式,缓冲大小和格式参数,但如果把它们设置为空NULL或者0,libavformat将自动检测这些参数。
    这个函数只是检测了文件的头部,所以接着我们需要检查在文件中的流的信息:
    // Retrieve stream information
    if(av_find_stream_info(pFormatCtx)<0)
    return -1; // Couldn't find stream information
    这个函数为pFormatCtx->streams填充上正确的信息。我们引进一个手工调试的函数来看一下里面有什么:
    // Dump information about file onto standard error
    dump_format(pFormatCtx, 0, argv[1], 0);
    现在pFormatCtx->streams仅仅是一组大小为pFormatCtx->nb_streams的指针,所以让我们先跳过它直到我们找到一个视频流。
    int i;
    AVCodecContext *pCodecCtx;
    // Find the first video stream
    videoStream=-1;
    for(i=0; i<pFormatCtx->nb_streams; i++)
    if(pFormatCtx->streams[i]->codec->codec_type==CODEC_TYPE_VIDEO) {
    videoStream=i;
    break;
    }
    if(videoStream==-1)
    return -1; // Didn't find a video stream
    // Get a pointer to the codec context for the video stream
    pCodecCtx=pFormatCtx->streams[videoStream]->codec;
    流中关于编解码器的信息就是被我们叫做"codec context"(编解码器上下文)的东西。这里面包含了流中所使用的关于编解码器的所有信息,现在我们有了一个指向他的指针。但是我们必需要找到真正的编解码器并且打开它:
    AVCodec *pCodec;
    // Find the decoder for the video stream
    pCodec=avcodec_find_decoder(pCodecCtx->codec_id);
    if(pCodec==NULL) {
    fprintf(stderr, "Unsupported codec! ");
    return -1; // Codec not found
    }
    // Open codec
    if(avcodec_open(pCodecCtx, pCodec)<0)
    return -1; // Could not open codec
    有些人可能会从旧的指导中记得有两个关于这些代码其它部分:添加CODEC_FLAG_TRUNCATED到 pCodecCtx->flags和添加一个hack来粗糙的修正帧率。这两个修正已经不在存在于ffplay.c中。因此,我必需假设它们不再必要。我们移除了那些代码后还有一个需要指出的不同点:pCodecCtx->time_base现在已经保存了帧率的信息。time_base是一个结构体,它里面有一个分子和分母 (AVRational)。我们使用分数的方式来表示帧率是因为很多编解码器使用非整数的帧率(例如NTSC使用29.97fps)。

    保存数据
    现在我们需要找到一个地方来保存帧:
    AVFrame *pFrame;
    // Allocate video frame
    pFrame=avcodec_alloc_frame();
    因为我们准备输出保存24位RGB色的PPM文件,我们必需把帧的格式从原来的转换为RGB。FFMPEG将为我们做这些转换。在大多数项目中(包括我们的这个)我们都想把原始的帧转换成一个特定的格式。让我们先为转换来申请一帧的内存。
    // Allocate an AVFrame structure
    pFrameRGB=avcodec_alloc_frame();
    if(pFrameRGB==NULL)
    return -1;
    即使我们申请了一帧的内存,当转换的时候,我们仍然需要一个地方来放置原始的数据。我们使用avpicture_get_size来获得我们需要的大小,然后手工申请内存空间:
    uint8_t *buffer;
    int numBytes;
    // Determine required buffer size and allocate buffer
    numBytes=avpicture_get_size(PIX_FMT_RGB24, pCodecCtx->width,
    pCodecCtx->height);
    buffer=(uint8_t *)av_malloc(numBytes*sizeof(uint8_t));
    av_malloc是ffmpeg的malloc,用来实现一个简单的malloc的包装,这样来保证内存地址是对齐的(4字节对齐或者2字节对齐)。它并不能保护你不被内存泄漏,重复释放或者其它malloc的问题所困扰。
    现在我们使用avpicture_fill来把帧和我们新申请的内存来结合。关于AVPicture的结成:AVPicture结构体是AVFrame结构体的子集――AVFrame结构体的开始部分与AVPicture结构体是一样的。
    // Assign appropriate parts of buffer to image planes in pFrameRGB
    // Note that pFrameRGB is an AVFrame, but AVFrame is a superset
    // of AVPicture
    avpicture_fill((AVPicture *)pFrameRGB, buffer, PIX_FMT_RGB24,
    pCodecCtx->width, pCodecCtx->height);
    最后,我们已经准备好来从流中读取数据了。

    读取数据
    我们将要做的是通过读取包来读取整个视频流,然后把它解码成帧,最好后转换格式并且保存。
    int frameFinished;
    AVPacket packet;
    i=0;
    while(av_read_frame(pFormatCtx, &packet)>=0) {
    // Is this a packet from the video stream?
    if(packet.stream_index==videoStream) {
    // Decode video frame
    avcodec_decode_video(pCodecCtx, pFrame, &frameFinished,
    packet.data, packet.size);
    // Did we get a video frame?
    if(frameFinished) {
    // Convert the image from its native format to RGB
    img_convert((AVPicture *)pFrameRGB, PIX_FMT_RGB24,
    (AVPicture*)pFrame, pCodecCtx->pix_fmt,
    pCodecCtx->width, pCodecCtx->height);
    // Save the frame to disk
    if(++i<=5)
    SaveFrame(pFrameRGB, pCodecCtx->width,
    pCodecCtx->height, i);
    }
    }
    // Free the packet that was allocated by av_read_frame
    av_free_packet(&packet);
    }
    这个循环过程是比较简单的:av_read_frame()读取一个包并且把它保存到AVPacket结构体中。注意我们仅仅申请了一个包的结构体 ――ffmpeg为我们申请了内部的数据的内存并通过packet.data指针来指向它。这些数据可以在后面通过av_free_packet()来释放。函数avcodec_decode_video()把包转换为帧。然而当解码一个包的时候,我们可能没有得到我们需要的关于帧的信息。因此,当我们得到下一帧的时候,avcodec_decode_video()为我们设置了帧结束标志frameFinished。最后,我们使用 img_convert()函数来把帧从原始格式(pCodecCtx->pix_fmt)转换成为RGB格式。要记住,你可以把一个 AVFrame结构体的指针转换为AVPicture结构体的指针。最后,我们把帧和高度宽度信息传递给我们的SaveFrame函数。

    关于包Packets的注释
    从技术上讲一个包可以包含部分或者其它的数据,但是ffmpeg的解释器保证了我们得到的包Packets包含的要么是完整的要么是多种完整的帧。
    现在我们需要做的是让SaveFrame函数能把RGB信息定稿到一个PPM格式的文件中。我们将生成一个简单的PPM格式文件,请相信,它是可以工作的。
    void SaveFrame(AVFrame *pFrame, int width, int height, int iFrame) {
    FILE *pFile;
    char szFilename[32];
    int y;
    // Open file
    sprintf(szFilename, "frame%d.ppm", iFrame);
    pFile=fopen(szFilename, "wb");
    if(pFile==NULL)
    return;
    // Write header
    fprintf(pFile, "P6 %d %d 255 ", width, height);
    // Write pixel data
    for(y=0; y<height; y++)
    fwrite(pFrame->data[0]+y*pFrame->linesize[0], 1, width*3, pFile);
    // Close file
    fclose(pFile);
    }
    我们做了一些标准的文件打开动作,然后写入RGB数据。我们一次向文件写入一行数据。PPM格式文件的是一种包含一长串的RGB数据的文件。如果你了解 HTML色彩表示的方式,那么它就类似于把每个像素的颜色头对头的展开,就像#ff0000#ff0000....就表示了了个红色的屏幕。(它被保存成二进制方式并且没有分隔符,但是你自己是知道如何分隔的)。文件的头部表示了图像的宽度和高度以及最大的RGB值的大小。
    现在,回顾我们的main()函数。一旦我们开始读取完视频流,我们必需清理一切:
    // Free the RGB image
    av_free(buffer);
    av_free(pFrameRGB);
    // Free the YUV frame
    av_free(pFrame);
    // Close the codec
    avcodec_close(pCodecCtx);
    // Close the video file
    av_close_input_file(pFormatCtx);
    return 0;
    你会注意到我们使用av_free来释放我们使用avcode_alloc_fram和av_malloc来分配的内存。
    上面的就是代码!下面,我们将使用Linux或者其它类似的平台,你将运行:
    gcc -o tutorial01 tutorial01.c -lavutil -lavformat -lavcodec -lz -lavutil -lm
    如果你使用的是老版本的ffmpeg,你可以去掉-lavutil参数:
    gcc -o tutorial01 tutorial01.c -lavutil -lavformat -lavcodec -lz -lm
    大多数的图像处理函数可以打开PPM文件。可以使用一些电影文件来进行测试。

    输出到屏幕
    SDL和视频
    为了在屏幕上显示,我们将使用SDL.SDL是Simple Direct Layer的缩写。它是一个出色的多媒体库,适用于多平台,并且被用在许多工程中。你可以从它的官方网站的网址http://www.libsdl.org/上来得到这个库的源代码或者如果有可能的话你可以直接下载开发包到你的操作系统中。按照这个指导,你将需要编译这个库。(剩下的几个指导中也是一样)
    SDL库中有许多种方式来在屏幕上绘制图形,而且它有一个特殊的方式来在屏幕上显示图像――这种方式叫做YUV覆盖。YUV(从技术上来讲并不叫 YUV而是叫做YCbCr)是一种类似于RGB方式的存储原始图像的格式。粗略的讲,Y是亮度分量,U和V是色度分量。(这种格式比RGB复杂的多,因为很多的颜色信息被丢弃了,而且你可以每2个Y有1个U和1个V)。SDL的YUV覆盖使用一组原始的YUV数据并且在屏幕上显示出他们。它可以允许4种不同的 YUV格式,但是其中的YV12是最快的一种。还有一个叫做YUV420P的YUV格式,它和YV12是一样的,除了U和V分量的位置被调换了以外。 420意味着它以4:2:0的比例进行了二次抽样,基本上就意味着1个颜色分量对应着4个亮度分量。所以它的色度信息只有原来的1/4。这是一种节省带宽的好方式,因为人眼感觉不到这种变化。在名称中的P表示这种格式是平面的――简单的说就是Y,U和V分量分别在不同的数组中。FFMPEG可以把图像格式转换为YUV420P,但是现在很多视频流的格式已经是YUV420P的了或者可以被很容易的转换成YUV420P格式。
    于是,我们现在计划把指导1中的SaveFrame()函数替换掉,让它直接输出我们的帧到屏幕上去。但一开始我们必需要先看一下如何使用SDL库。首先我们必需先包含SDL库的头文件并且初始化它。
    #include <SDL.h>
    #include <SDL_thread.h>
    if(SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_TIMER)) {
    fprintf(stderr, "Could not initialize SDL - %s ", SDL_GetError());
    exit(1);
    }
    SDL_Init()函数告诉了SDL库,哪些特性我们将要用到。当然SDL_GetError()是一个用来手工除错的函数。
    创建一个显示
    现在我们需要在屏幕上的一个地方放上一些东西。在SDL中显示图像的基本区域叫做面surface。
    SDL_Surface *screen;
    screen = SDL_SetVideoMode(pCodecCtx->width, pCodecCtx->height, 0, 0);
    if(!screen) {
    fprintf(stderr, "SDL: could not set video mode - exiting ");
    exit(1);
    }
    这就创建了一个给定高度和宽度的屏幕。下一个选项是屏幕的颜色深度――0表示使用和当前一样的深度。(这个在OS X系统上不能正常工作,原因请看源代码)
    现在我们在屏幕上来创建一个YUV覆盖以便于我们输入视频上去:
    SDL_Overlay *bmp;
    bmp = SDL_CreateYUVOverlay(pCodecCtx->width, pCodecCtx->height,
    SDL_YV12_OVERLAY, screen);
    正如前面我们所说的,我们使用YV12来显示图像。
    显示图像
    前面那些都是很简单的。现在我们需要来显示图像。让我们看一下是如何来处理完成后的帧的。我们将原来对RGB处理的方式,并且替换 SaveFrame() 为显示到屏幕上的代码。为了显示到屏幕上,我们将先建立一个AVPicture结构体并且设置其数据指针和行尺寸来为我们的YUV覆盖服务:
    if(frameFinished) {
    SDL_LockYUVOverlay(bmp);
    AVPicture pict;
    pict.data[0] = bmp->pixels[0];
    pict.data[1] = bmp->pixels[2];
    pict.data[2] = bmp->pixels[1];
    pict.linesize[0] = bmp->pitches[0];
    pict.linesize[1] = bmp->pitches[2];
    pict.linesize[2] = bmp->pitches[1];
    // Convert the image into YUV format that SDL uses
    img_convert(&pict, PIX_FMT_YUV420P,
    (AVPicture *)pFrame, pCodecCtx->pix_fmt,
    pCodecCtx->width, pCodecCtx->height);
    SDL_UnlockYUVOverlay(bmp);
    }
    首先,我们锁定这个覆盖,因为我们将要去改写它。这是一个避免以后发生问题的好习惯。正如前面所示的,这个AVPicture结构体有一个数据指针指向一个有4个元素的指针数据。由于我们处理的是YUV420P,所以我们只需要3个通道即只要三组数据。其它的格式可能需要第四个指针来表示alpha通道或者其它参数。行尺寸正如它的名字表示的意义一样。在YUV覆盖中相同功能的结构体是像素pixel和程度pitch。(程度pitch是在SDL里用来表示指定行数据宽度的值)。所以我们现在做的是让我们的覆盖中的pict.data中的三个指针有一个指向必要的空间的地址。类似的,我们可以直接从覆盖中得到行尺寸信息。像前面一样我们使用img_convert来把格式转换成PIX_FMT_YUV420P。
    绘制图像
    但我们仍然需要告诉SDL如何来实际显示我们给的数据。我们也会传递一个表明电影位置、宽度、高度和缩放大小的矩形参数给SDL的函数。这样,SDL为我们做缩放并且它可以通过显卡的帮忙来进行快速缩放。
    SDL_Rect rect;
    if(frameFinished) {
    // Convert the image into YUV format that SDL uses
    img_convert(&pict, PIX_FMT_YUV420P,
    (AVPicture *)pFrame, pCodecCtx->pix_fmt,
    pCodecCtx->width, pCodecCtx->height);
    SDL_UnlockYUVOverlay(bmp);
    rect.x = 0;
    rect.y = 0;
    rect.w = pCodecCtx->width;
    rect.h = pCodecCtx->height;
    SDL_DisplayYUVOverlay(bmp, &rect);
    }

    让我们再花一点时间来看一下SDL的特性:它的事件驱动系统。SDL被设置成当你在SDL中点击或者移动鼠标或者向它发送一个信号它都将产生一个事件的驱动方式。如果你的程序想要处理用户输入的话,它就会检测这些事件。你的程序也可以产生事件并且传递给SDL事件系统。当使用SDL进行多线程编程的时候,这相当有用,这方面代码我们可以在指导4中看到。在这个程序中,我们将在处理完包以后就立即轮询事件。现在而言,我们将处理SDL_QUIT事件以便于我们退出:
    SDL_Event event;
    av_free_packet(&packet);
    SDL_PollEvent(&event);
    switch(event.type) {
    case SDL_QUIT:
    SDL_Quit();
    exit(0);
    break;
    default:
    break;
    }
    让我们去掉旧的冗余代码,开始编译。如果你使用的是Linux或者其变体,使用SDL库进行编译的最好方式为:
    gcc -o tutorial02 tutorial02.c -lavutil -lavformat -lavcodec -lz -lm
    `sdl-config --cflags --libs`
    这里的sdl-config命令会打印出用于gcc编译的包含正确SDL库的适当参数。为了进行编译,在你自己的平台你可能需要做的有点不同:请查阅一下SDL文档中关于你的系统的那部分。一旦可以编译,就马上运行它。
    当运行这个程序的时候会发生什么呢?电影简直跑疯了!实际上,我们只是以我们能从文件中解码帧的最快速度显示了所有的电影的帧。现在我们没有任何代码来计算出我们什么时候需要显示电影的帧。最后(在指导5),我们将花足够的时间来探讨同步问题。但一开始我们会先忽略这个,因为我们有更加重要的事情要处理:音频!

    播放声音
    现在我们要来播放声音。SDL也为我们准备了输出声音的方法。函数SDL_OpenAudio()本身就是用来打开声音设备的。它使用一个叫做SDL_AudioSpec结构体作为参数,这个结构体中包含了我们将要输出的音频的所有信息。
    在我们展示如何建立之前,让我们先解释一下电脑是如何处理音频的。数字音频是由一长串的样本流组成的。每个样本表示声音波形中的一个值。声音按照一个特定的采样率来进行录制,采样率表示以多快的速度来播放这段样本流,它的表示方式为每秒多少次采样。例如22050和44100的采样率就是电台和CD常用的采样率。此外,大多音频有不只一个通道来表示立体声或者环绕。例如,如果采样是立体声,那么每次的采样数就为2个。当我们从一个电影文件中等到数据的时候,我们不知道我们将得到多少个样本,但是ffmpeg将不会给我们部分的样本――这意味着它将不会把立体声分割开来。
    SDL播放声音的方式是这样的:你先设置声音的选项:采样率(在SDL的结构体中被叫做freq的表示频率frequency),声音通道数和其它的参数,然后我们设置一个回调函数和一些用户数据userdata。当开始播放音频的时候,SDL将不断地调用这个回调函数并且要求它来向声音缓冲填入一个特定的数量的字节。当我们把这些信息放到SDL_AudioSpec结构体中后,我们调用函数SDL_OpenAudio()就会打开声音设备并且给我们送回另外一个AudioSpec结构体。这个结构体是我们实际上用到的--因为我们不能保证得到我们所要求的。
    设置音频
    目前先把讲的记住,因为我们实际上还没有任何关于声音流的信息。让我们回过头来看一下我们的代码,看我们是如何找到视频流的,同样我们也可以找到声音流。
    // Find the first video stream
    videoStream=-1;
    audioStream=-1;
    for(i=0; i < pFormatCtx->nb_streams; i++) {
    if(pFormatCtx->streams[i]->codec->codec_type==CODEC_TYPE_VIDEO
    &&
    videoStream < 0) {
    videoStream=i;
    }
    if(pFormatCtx->streams[i]->codec->codec_type==CODEC_TYPE_AUDIO &&
    audioStream < 0) {
    audioStream=i;
    }
    }
    if(videoStream==-1)
    return -1; // Didn't find a video stream
    if(audioStream==-1)
    return -1;
    从这里我们可以从描述流的AVCodecContext中得到我们想要的信息,就像我们得到视频流的信息一样。
    AVCodecContext *aCodecCtx;
    aCodecCtx=pFormatCtx->streams[audioStream]->codec;
    包含在编解码上下文中的所有信息正是我们所需要的用来建立音频的信息:
    wanted_spec.freq = aCodecCtx->sample_rate;
    wanted_spec.format = AUDIO_S16SYS;
    wanted_spec.channels = aCodecCtx->channels;
    wanted_spec.silence = 0;
    wanted_spec.samples = SDL_AUDIO_BUFFER_SIZE;
    wanted_spec.callback = audio_callback;
    wanted_spec.userdata = aCodecCtx;
    if(SDL_OpenAudio(&wanted_spec, &spec) < 0) {
    fprintf(stderr, "SDL_OpenAudio: %s ", SDL_GetError());
    return -1;
    }
    让我们浏览一下这些:
    ·freq 前面所讲的采样率
    ·format 告诉SDL我们将要给的格式。在“S16SYS”中的S表示有符号的signed,16表示每个样本是16位长的,SYS表示大小头的顺序是与使用的系统相同的。这些格式是由avcodec_decode_audio2为我们给出来的输入音频的格式。
    ·channels 声音的通道数
    ·silence 这是用来表示静音的值。因为声音采样是有符号的,所以0当然就是这个值。
    ·samples 这是当我们想要更多声音的时候,我们想让SDL给出来的声音缓冲区的尺寸。一个比较合适的值在512到8192之间;ffplay使用1024。
    ·callback 这个是我们的回调函数。我们后面将会详细讨论。
    ·userdata 这个是SDL供给回调函数运行的参数。我们将让回调函数得到整个编解码的上下文;你将在后面知道原因。
    最后,我们使用SDL_OpenAudio函数来打开声音。
    如果你还记得前面的指导,我们仍然需要打开声音编解码器本身。这是很显然的。
    AVCodec *aCodec;
    aCodec = avcodec_find_decoder(aCodecCtx->codec_id);
    if(!aCodec) {
    fprintf(stderr, "Unsupported codec! ");
    return -1;
    }
    avcodec_open(aCodecCtx, aCodec);

    其他与视频一样,只是把采样数据给写到audio driver中

    读取视频的例子:
    1.注册编码器,码流过滤器,解析器,协议等av_register_all
    2.通过av_open_input_file读取视频文件,返回AVFormatContext指针
    3.通过AVFormatContext指针传递给av_find_stream_info检查在文件中的流的信息
      填充了AVFormatContext的streams
    4.通过AVFormatContext的nb_steams循环streams,找出CODEC_TYPE_VIDEO
    5.找到后,把streams的CODEC取出(AVCodecContext)
    6.通过AVCodecContext的codec_id找出AVCodec(编码器) avcodec_find_decoder
    7.打开编码器 avcodec_open
    8.分配AVFrame avcodec_alloc_frame,并用avpicture_fill填充从屏幕获取的pixel buffer
    9.通过AVCodecContext指针不断读取AVPacket av_read_frame,如果packet的stream_index为
      videostream则通过avcodec_decode_video(AVCodecContext *, AVFrame, frameFinish,packet.data, packet.size解析
    10.读取每一祯时,要转化颜色格式,并AVFrame update下

    播放声音
    1.上面4以上的步骤,但是streams为CODEC_TYPE_AUDIO
    2.在AVCodecContext有采用频率(sample_rate), 声音的通道数(channels)
    3.通过AVCodecContext的codec_id找出AVCodec(编码器) avcodec_find_decoder
    4.打开编码器 avcodec_open
    5.通过AVCodecContext指针不断读取AVPacket av_read_frame
    6.通过avcodec_decode_audio3获取采样数据,并写入AUDIO DRIVER

  • 相关阅读:
    Python3常用内置函数
    你所不知道的Python奇技淫巧
    Django1.8教程——从零开始搭建一个完整django博客(三)
    Django1.8教程——从零开始搭建一个完整django博客(二)
    Django1.8教程——从零开始搭建一个完整django博客(一)
    Django1.8教程——安装Django
    Django实际站点项目开发经验谈
    docker swarm集群搭建
    kubernetes 集群的安装部署
    linux下简洁优化部署tomcat应用
  • 原文地址:https://www.cnblogs.com/djzny/p/3429682.html
Copyright © 2011-2022 走看看