调用graph api上传图片到facebook

前言

我已经给Facebook报bug了…
最近实习的任务是做个类似facebook第三方客户端, 要求用graph api. 调用graph api就是普通的http请求, 但是facebook在这方面挖了不少坑, 特别恶心. 写篇文章记录一下, 顺便介绍一下思路和方法, 有好几个方法, 有的成功了, 有的失败了, 会具体分析, 最后有一个最佳的方法.

图片上传

sdk文档的错误

查询facebook的graph api文档可以知道如果使用sdk上传图片, 需要使用下面的代码

Bundle params = new Bundle();
params.putString("source", "{image-data}");//这句错了
/* make the API call */
new Request(
    session,
    "/me/photos",
    params,
    HttpMethod.POST,
    new Request.Callback() {
        public void onCompleted(Response response) {
            /* handle the result */
        }
    }
).executeAsync();

我要用sdk, 当然会照着文档来调用, 这个api描述一看就知道是个错的, 图片再怎么也不会用String来传, 我一开始还以为facebook有什么黑科技, 结果是他们写错了, 应该是params.putByteArray.

使用graph api

我们要用graph api完成上传, 所以重点看graph api如何调用, facebook是这样写的.

Capture a photo via file upload as multipart/form-data then >use the source parameter
POST /v2.2/me/photos HTTP/1.1
Host: graph.facebook.com

multipart/form-data是什么

接下来我们就要搞清楚这个multipart/form-data是什么. 这其实是POST方法中一种用于传送多个文件或数据的协议, 比较古老了. 我第一个想到的就是用HttpClient来实现, 因为它帮我们封装了很多东西.

HttpClient实现

支持HTTPS的HttpClient

由于graph api的调用都是使用的https, 所以我先实现了一个SSLHttpClient类, 封装了信任facebook证书的逻辑, 用起来也比较简单. 代码如下, 可以不看.

public class SSLHttpClient {
        private final static boolean DEBUG = true;
        private final String TAG = "SSLHttpClient";
        //特么的下个证书都下不了chrome真垃圾
        private final static String CA_NAME = "fb_ca.cer";
        public HttpClient mHttpClient;

        public SSLHttpClient(Context context) {
            InputStream inputStream  = null;
            try {
                inputStream = context.getAssets().open(CA_NAME);
                CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
                Certificate certificate = certificateFactory.generateCertificate(inputStream);
                KeyStore keyStore = KeyStore.getInstance("PKCS12", "BC");
                keyStore.load(null, null);
                keyStore.setCertificateEntry("trust", certificate);
                SSLSocketFactory socketFactory = new SSLSocketFactory(keyStore);
                Scheme scheme = new Scheme("https", socketFactory, 443);

                mHttpClient = new DefaultHttpClient();
                mHttpClient.getConnectionManager().getSchemeRegistry().register(scheme);

            } catch (IOException e) {
                if (DEBUG == true) Log.d(TAG, "IOException");
                e.printStackTrace();
            } catch (CertificateException e) {
                if (DEBUG == true) Log.d(TAG, "CertificateException");
                e.printStackTrace();
            } catch (KeyStoreException e) {
                if (DEBUG == true) Log.d(TAG, "KeyStoreException");
                e.printStackTrace();
            } catch (NoSuchProviderException e) {
                if (DEBUG == true) Log.d(TAG, "NoSuchProviderException");
                e.printStackTrace();
            } catch (NoSuchAlgorithmException e) {
                if (DEBUG == true) Log.d(TAG, "NoSuchAlgorithmException");
                e.printStackTrace();
            } catch (KeyManagementException e) {
                if (DEBUG == true) Log.d(TAG, "KeyManagementException");
                e.printStackTrace();
            } catch (UnrecoverableKeyException e) {
                if (DEBUG == true) Log.d(TAG, "UnrecoverableKeyException");
                e.printStackTrace();
            } finally {
                if (inputStream != null) {
                    try {
                        inputStream.close();
                    } catch (IOException e) {
                        if (DEBUG == true) Log.d(TAG, "IOException");
                        e.printStackTrace();
                    }
                }
            }

        }


        /**
         * 用GET访问网址,返回结果
         * @param url
         * @return
         * @throws IOException 
         * @throws IllegalStateException 
         * @throws UnsupportedEncodingException 
         */
        public String executeGet(String url) throws IOException {
            String result = "";
            if (mHttpClient == null) return result;
            BufferedReader reader = null;
            HttpGet request = new HttpGet();
            HttpContext httpContext = new BasicHttpContext();
            try {
                request.setURI(new URI(url));
                if(DEBUG == true) Log.d(TAG, "发送GET请求 " + url);
                HttpResponse response = mHttpClient.execute(request, httpContext);
                if(DEBUG == true) Log.d(TAG, "过execute");
                if (response.getStatusLine().getStatusCode() != 200) {
                    request.abort();
                    return result;
                }
                reader = new BufferedReader(new InputStreamReader(response
                        .getEntity().getContent(), HTTP.UTF_8));
                StringBuffer buffer = new StringBuffer();
                String line = null;
                while ((line = reader.readLine()) != null) {
                    buffer.append(line);
                }
                result = buffer.toString();
                if(DEBUG == true) Log.d(TAG, "收到消息 "+result);
            } catch (URISyntaxException e) {
                if (DEBUG == true) Log.d(TAG, "URISyntaxException");
                e.printStackTrace();
            } finally {
                if (reader != null) {
                    try {
                        reader.close();
                    } catch (IOException e) {
                        if (DEBUG == true) Log.d(TAG, "IOException");
                        e.printStackTrace();
                    }
                }
            }
            return result;
        }

        /**
         * POST带参数的请求
         * @param url
         * @param paramList
         * List<NameValuePair>参数列表
         * @return
         * @throws IOException 
         * @throws IllegalStateException 
         */
        public String executePost(String url, List<NameValuePair> paramList) throws IOException {
            String result = "";
            if (mHttpClient == null) return result;
            BufferedReader reader = null;
            HttpPost request = new HttpPost();
            HttpContext httpContext = new BasicHttpContext();
            try {
                request.setURI(new URI(url));
                if (paramList != null) {
                    request.setEntity(new UrlEncodedFormEntity(paramList, HTTP.UTF_8));
                }
                if(DEBUG == true) Log.d(TAG, "发送POST请求 " + url);
                HttpResponse response = mHttpClient.execute(request, httpContext);
                if (response.getStatusLine().getStatusCode() != 200) {
                    request.abort();
//                    return result;
                }
                reader = new BufferedReader(new InputStreamReader(response
                        .getEntity().getContent(), HTTP.UTF_8));
                StringBuffer buffer = new StringBuffer();
                String line = null;
                while ((line = reader.readLine()) != null) {
                    buffer.append(line);
                }
                result = buffer.toString();
                if(DEBUG == true) Log.d(TAG, "收到消息 "+ result);
            } catch (URISyntaxException e) {
                // TODO Auto-generated catch block
                e.printStackTrace();
            } finally {
                if (reader != null) {
                    try {
                        reader.close();
                    } catch (IOException e) {
                        if (DEBUG == true) Log.d(TAG, "IOException");
                        e.printStackTrace();
                    }
                }
            }
            return result;
        }

        /**
         * 本来是为multipart/form-data准备的
         * @param url
         * @param entity
         * @return
         * @throws IOException
         */
        public String executePost(String url, HttpEntity entity) throws IOException {
            String result = "";
            if (mHttpClient == null) return result;
            BufferedReader reader = null;
            HttpPost request = new HttpPost();
            HttpContext httpContext = new BasicHttpContext();
            try {
                request.setURI(new URI(url));
                if (entity != null) {
                    request.setEntity(entity);
                }
                if(DEBUG == true) Log.d(TAG, "发送POST请求 " + url);
                HttpResponse response = mHttpClient.execute(request, httpContext);
                if (response.getStatusLine().getStatusCode() != 200) {
                    request.abort();
//                    return result;
                }
                reader = new BufferedReader(new InputStreamReader(response
                        .getEntity().getContent(), HTTP.UTF_8));
                StringBuffer buffer = new StringBuffer();
                String line = null;
                while ((line = reader.readLine()) != null) {
                    buffer.append(line);
                }
                result = buffer.toString();
                if(DEBUG == true) Log.d(TAG, "收到消息 "+ result);
            } catch (URISyntaxException e) {
                // TODO Auto-generated catch block
                e.printStackTrace();
            } finally {
                if (reader != null) {
                    try {
                        reader.close();
                    } catch (IOException e) {
                        if (DEBUG == true) Log.d(TAG, "IOException");
                        e.printStackTrace();
                    }
                }
            }
            return result;
        }

        /**
         * 用完之后调用
         */
        public void closeConnection() {
            mHttpClient.getConnectionManager().shutdown();
            mHttpClient = null;
        }
}

注意上面的fb_ca.cer是我从graph api的网站上下载下来的证书, 放在/res/assets目录下.

org.apache.http官方jar

使用HttpClient来进行multipart/form-data POST的代码我就不贴了, 因为我已经删了, 我发现android sdk中的HttpClient根本不支持multipart/form-data.
我们会想到到apache官网下一个最新的HttpClient, 需要的是其中的httpcore.jar和httpmime.jar, 放到/libs下面, 你会发现adt不报错了
其实这一点用处也没有, 只是能让你编译通过, 运行时肯定出错.
因为android中的HttpClient和我们下载的jar中的包名是一样的, 在编译的时候eclipse会使用我们提供的包, 但是运行的时候android则会优先用自己的旧版本HttpClient, 结果是代码在调用一些新的方法或者访问一些新的field的时候, android会发现根本没有这种方法或field, 最后的结果就是抛异常, 程序FC, 我记得异常的名字是NoSuchFieldException.

重新打包HttpClient

既然包名重了导致我们的程序RuntimeError, 那么我就改包名, 去下载HttpClient的源码, 注意HttpClient的项目使用Maven管理, 和eclipse非常不同, 但是我们可以通过查看pom.xml来了解依赖关系, 再把依赖jar导入到eclipse, 把源码直接合并一份导入eclipse, 这样可以很顺利的将httpclient, httpcore, httpmimie三个独立的工程打成一个包, 然后把我们工程中的跟他们有关的调用全部换成我们打出来的包中的类.
结果是android会报NoSuchClass, 因为HttpClient的代码中import了一个android中没有的java类, 所以我们的repackage作战失败了.
既然上层的类无法完成我们的目标, 我们就往底层看.

Socket模拟HTTP POST

详解multipart/form-data

既然我们要模拟HTTP POST, 那么POST数据的时候, 输出的到底有些什么呢?
首先看GET方法的头部

GET / HTTP/1.1
Host: www.baidu.com
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,/;q=0.8
Accept-Encoding:gzip, deflate, sdch
Accept-Language:zh-CN,zh;q=0.8,zh-TW;q=0.6,en;q=0.4
Connection:keep-alive

这是我用chrome访问www.baidu.com时, chrome发出的请求头, 其实很好理解, 但是要注意格式, 毕竟协议对格式是有约定的, 比如第一行叫start-line, start-line必须是request-linestatus-line中的一种. 请求的时候就是request-line, request-line的格式是

method SP request-target SP HTTP-version CRLF

其中method有八种, 最常见的是POST和GET, graph api中还有DELETE的应用, 其他的比较少见, 这里不介绍, SP代表一个空格, request-target说简单点就是路径, 比如/index.html, HTTP-version现在一般是HTTP/1.1, CRLF是”\r\n”
定义这种东西的方法是BNF, 这个就不扩展了, 总之通过BNF可以描述协议, 也可以检查某个报文是否合乎标准.

对于multipart/form-data, 网上也给出了一些答案, 比如下面这个

POST /path/to/script.php HTTP/1.1
Host: example.com
Content-type: multipart/form-data, boundary=AaB03x
Content-Length: {requestlen}

–AaB03x
content-disposition: form-data; name=”field1”

{field1}
–AaB03x
content-disposition: form-data; name=”field2”

{field2}
–AaB03x
content-disposition: form-data; name=”{userfile}”; filename=”{filename}”
Content-Type: {mimetype}
Content-Transfer-Encoding: binary

{binary-data}
–AaB03x–

但是最保险的方法还是查RFC文档.
下面是RFC1867中的例子

Content-type: multipart/form-data, boundary=AaB03x

--AaB03x
content-disposition: form-data; name="field1"

Joe Blow
--AaB03x
content-disposition: form-data; name="pics"; filename="file1.txt"
Content-Type: text/plain

 ... contents of file1.txt ...
--AaB03x--

其实还是比较好理解的, 就是要注意换行, 请求头和消息体之间有一个空行, 这个必须有, 空行不算请求体, boundary前面加--的一行表示下面是form-data, 然后是form-data的描述, 空一行之后是内容, 然后又是boundary前面的--, 如果是最后一个, 则在boundary后面也要加上--.
根据RFC7230

HTTP-message = start-line
*( header-field CRLF )
CRLF
[ message-body ]

message-body后面是不能跟CRLF的, 网上很多文章都说最后有一个CRLF, 这都是错的.

SSLSocket实现

所以我们的代码就应该按这种协议来输出数据, 代码如下

final String HOST = "graph.facebook.com";
int PORT = 443;
try {
    SSLContext sslcontext = SSLContext.getInstance("SSL");
    X509TrustManager xtm = new X509TrustManager() {

        @Override
        public X509Certificate[] getAcceptedIssuers() {
            // TODO Auto-generated method stub
            return null;
        }

        @Override
        public void checkServerTrusted(X509Certificate[] chain, String authType)
                throws CertificateException {
            // TODO Auto-generated method stub

        }

        @Override
        public void checkClientTrusted(X509Certificate[] chain, String authType)
                                    throws CertificateException {
            // TODO Auto-generated method stub

        }
    };
    sslcontext.init(null,  
            new TrustManager[] { xtm },  
            new SecureRandom());
    SSLSocketFactory factory = sslcontext.getSocketFactory();
    final String CRLF = "\r\n";
    final String BOUNDERY = "3i2ndDfv2rTHiSisAbouNdArYfORhtTPEefj3q2f";
    //构造body
    StringBuilder sbBody_1 = new StringBuilder();

    sbBody_1.append("--" + BOUNDERY + CRLF);
    sbBody_1.append("Content-Disposition: form-data; filename=\"source\"" + CRLF);
    sbBody_1.append(CRLF);

    StringBuilder sbBody_2 = new StringBuilder();
    sbBody_2.append(CRLF + "--" + BOUNDERY + "--");
    //构造header
    StringBuilder sbHeader = new StringBuilder();
    sbHeader.append("POST "
            + "/v2.2/me/photos"
            + "?access_token=" + accessToken 
            + " HTTP/1.1" + CRLF);
    sbHeader.append("Host: " + HOST + CRLF);
    sbHeader.append("Accept: text/html,application/xhtml+xml,application/xml" + CRLF);
    sbHeader.append("Accept-language: zh-CN,zh;q=0.8,en;q=0.6" + CRLF);
    sbHeader.append("Content-Type: multipart/form-data; boundary=" + BOUNDERY + CRLF);
    //注意在这里加这句, 20150319修订
    sbHeader.append("Connection: close" + CRLF);
    //注意上面这句一定要有, 否则超时
    sbHeader.append("Content-length: " + String.valueOf(sbBody_1.length() + sbBody_2.length() + bFile.length) + CRLF);
    sbHeader.append("User-Agent: Mozilla/5.0" + CRLF);
    sbHeader.append(CRLF);
    Socket socket = factory.createSocket();
    socket.connect(new InetSocketAddress(HOST, PORT), 10000);
    OutputStream ops =  socket.getOutputStream();
    ops.write(sbHeader.toString().getBytes());
    Log.d("header", sbHeader.toString());
    ops.write(sbBody_1.toString().getBytes());
    Log.d("header", sbBody_1.toString());
    ops.write(bFile);
    Log.d("header", new String(bFile));
    ops.write(sbBody_2.toString().getBytes());
    Log.d("header", sbBody_2.toString());
    ops.flush();
    ops.close();
    InputStream ips = socket.getInputStream();
    final int BUFFERSIZE = 100;
    byte[] bbuf = new byte[BUFFERSIZE];
    int bytenum = 0;
    while ((bytenum = ips.read(bbuf)) != -1) {
        String s = new String(bbuf);
    }
    Log.d("fbsb", "is close");
    ips.close();
    String t = null;
    boolean isSucceed = false;
    Log.d("fbsb", "socket close");
    socket.close();
    return isSucceed;
} catch (UnknownHostException e) {
    // TODO Auto-generated catch block
    e.printStackTrace();
} catch (IOException e) {
    // TODO Auto-generated catch block
    e.printStackTrace();
} catch (NoSuchAlgorithmException e) {
    // TODO Auto-generated catch block
    e.printStackTrace();
} catch (KeyManagementException e) {
    // TODO Auto-generated catch block
    e.printStackTrace();
}
return false;

注意我们是先构造的消息体, 这样我们才能计算出请求头中Content-Length的大小.

还有一点需要注意, facebook文档中说的source作为参数, 其实是指我们要让filename的值变成source, 而不是name. 但是根据RFC文档, name参数是必须的, filename是可选的, facebook恰好把它俩弄反了, 我当初在这里陷了一天, 后来debug了facebook sdk才发现这点.

但是如果真的运行我们就会发现, 我们在read完所有的数据之后, 仍然阻塞在那里不退出, 直到连接超时(60秒左右)才被迫关闭输入流, 我一开始以为是facebook又不按标准实现, 后来才发现其实是我的实现有问题.(已经在20150319中修订的代码实现中解决, 上面的实现可以正常运行, 不会在正常情况下超时)

Connection: keep-alive的存在

Socket模拟HTTP, 但是它本质上还是socket, 所以对HTTP的协议有很多不了解的地方, 比如响应头中的Connection: keep-alive, HTTP虽然是无状态的面向连接的协议, 但是它是可以保持TCP连接的, 这样可以在后面的数据传输中重用前面的连接, keep-alive就是暗示客户端服务器在发送完响应数据之后希望保持TCP连接, Socket不知道数据其实已经传完了, 以为服务器还要传什么数据过来, 于是就一直等着, 直到保持TCP连接的时间上限到了, 服务器关闭连接, read()才返回. 在这里我们可以通过content-length来判断数据是否读取完了, 但是那太麻烦了.

一个更简单的方法是在请求头中加入Connection: close, 这样graph api的服务器会在传输完数据之后关闭TCP连接, 我们这边的socket自然就可以断开连接了.

查看facebook sdk源码, 我发现它使用了HttpURLConnection.

最佳方法:HttpURLConnection

这个类不用不知道, 一用吓一跳, 以前用HttpClient连接HTTPS网页, 还得下证书单独信任, 或者信任全部证书, 而HttpURLConnection直接可以和https页面通信, 就像我们在使用浏览器一样, 一切的认证对我们都是透明的. 而且也在请求头上给我们了一些灵活性, 更有趣的是它还可以免去我们计算content-length的麻烦. 代码如下, 比前面所有的代码都短

URL url;
try {
    final String BOUNDERY = "3i2ndDfv2rTHiSisAbouNdArYfORhtTPEefj3q2f";
    final String CRLF = "\r\n";
    StringBuilder sbBody_1 = new StringBuilder();
    sbBody_1.append("--" + BOUNDERY + CRLF);
    sbBody_1.append("Content-Disposition: form-data; filename=\"source\"" + CRLF);
    sbBody_1.append(CRLF);
    StringBuilder sbBody_2 = new StringBuilder();
    sbBody_2.append(CRLF + "--" + BOUNDERY + "--");
    url = new URL("https://graph.facebook.com/me/photos?access_token=" + accessToken);
    HttpURLConnection connection = (HttpURLConnection) url.openConnection();
    connection.setDoOutput(true);
    connection.setRequestMethod("POST");
    connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + BOUNDERY);
    connection.setChunkedStreamingMode(0);
    OutputStream out = new BufferedOutputStream(connection.getOutputStream());
    out.write(sbBody_1.toString().getBytes());
    out.write(bFile);
    out.write(sbBody_2.toString().getBytes());
    out.close();
    BufferedReader bips = new BufferedReader(new InputStreamReader(connection.getInputStream()));
    String temp = null;
    while ((temp = bips.readLine()) != null) {
        Log.d("fbnb", temp);
    }
    bips.close();
    connection.disconnect();
    return true;
} catch (MalformedURLException e1) {
    // TODO Auto-generated catch block
    e1.printStackTrace();
} catch (IOException e) {
    // TODO Auto-generated catch block
    e.printStackTrace();
}
return false;
阅读更多
换一批

没有更多推荐了,返回首页