最近写的后端项目需要云盘,由于各种原因,最终选择了 Onedrive。Onedrive 的一个优点是文档是中文的,但缺点是中文也看不懂。。。。

于是借助文档、博客、示例代码等等,慢慢摸索过来,并把摸索的这个过程形成一篇博文。

# 注册应用、用户登录授权

参考博客:zhangguanzhang's Blog (opens new window)
参考文档:Microsoft Graph 中的 OneDrive 授权和登录 (opens new window)

Onedrive 的认证(以及 MS 家的其他功能)都统一使用 Microsoft Graph 进行认证,而 Microsoft Graph 似乎只支持 OAuth2(必须让用户在浏览器完成登录,不能拿到密码然后在后台登录),不支持 OAuth1(可以在后台利用用户密码发起请求完成登录),所以会麻烦一些。

# 在 Azure 创建应用

第一件事,是按照上面的博客所说,注册应用。这个应用其实就是一个 API,以下引用并修改了 zhangguanzhang's Blog (opens new window)

我们首先要创建一个应用程序。

https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade

到上面链接里去注册一个应用程序,属性为:

  • 受支持的帐户类型记得选 任何组织目录(任何 Azure AD 目录 - 多租户)中的帐户和个人 Microsoft 帐户(例如,Skype、Xbox)
  • 重定向 URI (可选) 我用的是 http://localhost:8000/getAToken,在摸索的时候这个随便写就行。另外,这个值要存在代码里,命名为 redirect_uri

然后设置权限。点击左边侧边栏的 “API 权限”,点击中间的“添加权限”,然后在右边点“Microsoft Graph” - “委托的权限”,搜索并找到以下三个权限,勾选上。

Files.ReadWrite
Files.ReadWrite.All
offline_access

勾选权限

然后在侧边栏的“概述”里面,把应用程序(客户端) ID 复制下来(在代码里存为 client_id)。

然后在侧边栏“证书和密码”里面,点击“新客户端密码”,生成一个 ID 和值,把值存到代码里,命名为 client_secret

# 用户在浏览器登录,获取 auth_token

在 Azure 创建应用以后,就是授权直至拿到 refresh_token 的过程。这个过程就是参考 Microsoft Graph 中的 OneDrive 授权和登录 (opens new window) 了,这里我用 Python requests 实现。

现在文档迁移到了 Microsoft Graph 身份验证概述 (opens new window)而且更加详细了。而上述链接不再提供中文文档。

首先设置上述变量:

import requests
headers = {'Content-Type':'application/x-www-form-urlencoded'}
scope = "Files.ReadWrite offline_access"

client_id = "3aea792a-f5f5-49a6-b64d-e1a45d375323"
redirect_uri = "http://localhost:8000/getAToken"
client_secret = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

然后是生成登录链接。这个链接需要在浏览器里访问然后输入账号密码,于是就不用 requests.get 而是把链接 print 出来,我们复制到浏览器里面。

login_link = f"https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id={client_id}&scope={scope}&response_type=code&redirect_uri={redirect_uri}"
print(login_link)

打开 print 的链接,成功登录并授权后,跳转到 localhost,其中的参数的 code 字段就是我们需要的 auth_token,729个字符,有点长。

http://localhost:8000/getAToken?code=0.AAAApOutbSzcpEiz8KuV1mGJ2ip56jr19aZJtk3hpF03UyM-AI4.AQABAAIAAAD--DLA3VO7QrddgJg7Wevry9C8xxU0YmDl1t3kRL1Rt8c4ZYINbxBW_X7KGMwL80bFg2I99rHKlQuC_bwGWIMjn1C4GjZg8fR2v2r-9J6fffSYUVMmrA5tGJ-7AUyulg076ViCOLWvtDzUh1T09f3Tt2Q8TpgCgO8P-0PqGMPqNOivzAxQz8WH5ZKSTMaI7WeOSBGe9yrpdjskO4pJrZv62E-jl2udaTBSXJAG4hKc18feCvuhJk27gT4H1W7ZiONqwMMkpzK6nlMhBgRP7-hTBNIU82Y0ASNOsOu2aAzrCQJmmbDdPHvsEYIq5jDnlOqeoNNFh-0v_AEbf-YfUfvIxN79eGpgrXvH19sLstDqFagJaOayNm6sf4HHuo2ikAot5kLZoBwYus57BaWeLI2IA_jDKd9T899Pv_Gfc_fwkgI9PynaHfoDRHb9A-6fXaJYPE5IYkTEnunNDaBf6jKtoTPub5LFIZv3OP38c3zJrTBZL5Wr5dpo3-pa0FFkbYPgHGC5APlWFNiBx-OECd4OJnbdzM7hrA2YzCLa6Bwl7SG4KTVXv2fwW1gFLgCZdI_xYBEDHfYuKUnlC5eqcebWwtkfJ8roFj9p3hzJ_GQSgEKjTgrdSUMaxrYwhSnAzS06H4BqnLKL-FrKsDBWJXuAIAA&session_state=697ec6eb-a4b9-4567-9873-6065f156164b#

在之后开发 WebApp 时,我们需要把 redirect_url 设置为我们能路由到的链接,在路由到的 View 中获取 uri 参数,就可以获得 auth_token 了。不过现在还是手动赋值吧。

# 赋值
auth_token = "0.AAAApOutbSzcpEiz8KuV1mGJ2ip56jr19aZJtk3hpF03UyM-AI4.AQABAAIAAAD--DLA3VO7QrddgJg7WevrnDiEeOedZvv94_iCOZdHtunnh-HD4-yA7CnKCnlskkTcXuD_nVujxrzDErLOPO5Kdba-gLFaUgOCxScnphpTmaR-bJbb6KCOJ6WEsecTZhdoBVvhgG8SzPAmolHemGaOOymSx1L3opaGXSo6YolsgwSCYcokocf9jl9Jq8pOAfg4tuZTYh1uX6f-IvQgLoIhUhE8i7GoaLIFhel9ICjiWgO9imtgNiLIjMX-MMRxqEKbcvbjVi9vnat1LE29v7uX_0Ogs6feGFzKEZOvT8pNlbm1t0frSwSIktQeIELEU3kzbDX7TEp1b_Cguj2u7wqF-kLv3P9w2_Gzhi-lzdRph1h9SW-RFYyWtdoCfhQgFf5m8K7aUGUEKgDK0RomoPvuZ1jfoNxe5JWQdcHqNo-LcHblYGRI1azy-p1fp28aBB6qKZ_0pcRTb4BrU5qLi9ze4RJ5kPajodTSSloUSVkf64B9OEdPeBvT8XU8_to0aVeHXOUTlsgVSlliatLPx9pFhsOZ4ec2-7fQMr8_CBhPlY4rjMfl8plefsfwcIsDBc0PRPITcsxA7OYSxZXvSchHA6XmCSzdl1413mTh7d0cvLYnBmzJm1tc6z6PEW270mtL_enHuzbtZpK9D1Epu0HIIAA"

# 用 auth_token 换 access_token 和 refresh_token

赋值以后,就可以请求拿 access_tokenrefresh_token 了。

headers={'Content-Type':'application/x-www-form-urlencoded'}
response = requests.post(
    'https://login.microsoftonline.com/common/oauth2/v2.0/token',
    data={
        'client_id': client_id,
        'redirect_uri': redirect_uri,
        'client_secret': client_secret,
        'code': auth_token,
        'grant_type': 'authorization_code'
    },
    headers=headers)

response                # <Response [200]>
eval(response.content)
# {'token_type': 'Bearer',
#  'scope': 'Files.ReadWrite profile openid email',
#  'expires_in': 3599,
#  'ext_expires_in': 3599,
#  'access_token': 'eyJ0eXAiOiJKV1QiLCJub25jZSI6ImZCREdZc3JfSzVYLXBkU2o5ZWotUUJMc2JGRGFDVGFBQ0lvSk90T3I2UlEiLCJhbGciOiJSUzI1NiIsIng1dCI6Im5PbzNaRHJPRFhFSzFqS1doWHNsSFJfS1hFZyIsImtpZCI6Im5PbzNaRHJPRFhFSzFqS1doWHNsSFJfS1hFZyJ9.eyJhdWQiOiIwMDAwMDAwMy0wMDAwLTAwMDAtYzAwMC0wMDAwMDAwMDAwMDAiLCJpc3MiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC82ZGFkZWJhNC1kYzJjLTQ4YTQtYjNmMC1hYjk1ZDY2MTg5ZGEvIiwiaWF0IjoxNjE0MjYyMDgzLCJuYmYiOjE2MTQyNjIwODMsImV4cCI6MTYxNDI2NTk4MywiYWNjdCI6MCwiYWNyIjoiMSIsImFjcnMiOlsidXJuOnVzZXI6cmVnaXN0ZXJzZWN1cml0eWluZm8iLCJ1cm46bWljcm9zb2Z0OnJlcTEiLCJ1cm46bWljcm9zb2Z0OnJlcTIiLCJ1cm46bWljcm9zb2Z0OnJlcTMiLCJjMSIsImMyIiwiYzMiLCJjNCIsImM1IiwiYzYiLCJjNyIsImM4IiwiYzkiLCJjMTAiLCJjMTEiLCJjMTIiLCJjMTMiLCJjMTQiLCJjMTUiLCJjMTYiLCJjMTciLCJjMTgiLCJjMTkiLCJjMjAiLCJjMjEiLCJjMjIiLCJjMjMiLCJjMjQiLCJjMjUiXSwiYWlvIjoiRTJaZ1lORGY2OWJFVUxnd3BkR0FMM2J5aHZRZCsxNkg2THppdUwvNGxPejYrR25mbE1VQSIsImFtciI6WyJwd2QiXSwiYXBwX2Rpc3BsYXluYW1lIjoi6Ziu6JaH6JaH54K55ZCN5ZWmIiwiYXBwaWQiOiIzYWVhNzkyYS1mNWY1LTQ5YTYtYjY0ZC1lMWE0NWQzNzUzMjMiLCJhcHBpZGFjciI6IjEiLCJpZHR5cCI6InVzZXIiLCJpcGFkZHIiOiIxNTQuMTcuNy44NCIsIm5hbWUiOiLnlLXlrZDnp5HmioDlpKflraZNU0MiLCJvaWQiOiIyZDU1MTQyNS01Mzk2LTQxNTktOTQ1MC0wNzU2MzdiMDE2N2IiLCJwbGF0ZiI6IjMiLCJwdWlkIjoiMTAwMzIwMDA1N0RCODYzRSIsInJoIjoiMC5BQUFBcE91dGJTemNwRWl6OEt1VjFtR0oyaXA1NmpyMTlhWkp0azNocEYwM1V5TS1BSTQuIiwic2NwIjoiRmlsZXMuUmVhZFdyaXRlIHByb2ZpbGUgb3BlbmlkIGVtYWlsIiwic3ViIjoid2R3RDhmLXM0ajJDWHlmQ2phOWd1LXpVdk91V0ZUWFg2bXk1d0ZGODROcyIsInRlbmFudF9yZWdpb25fc2NvcGUiOiJBUyIsInRpZCI6IjZkYWRlYmE0LWRjMmMtNDhhNC1iM2YwLWFiOTVkNjYxODlkYSIsInVuaXF1ZV9uYW1lIjoidWVzdGNtc2NAZGVtbzRjLm9ubWljcm9zb2Z0LmNvbSIsInVwbiI6InVlc3RjbXNjQGRlbW80Yy5vbm1pY3Jvc29mdC5jb20iLCJ1dGkiOiJFQ0Vaei1LeEprZVhOMDRTYlVNa0FBIiwidmVyIjoiMS4wIiwid2lkcyI6WyJiNzlmYmY0ZC0zZWY5LTQ2ODktODE0My03NmIxOTRlODU1MDkiXSwieG1zX3N0Ijp7InN1YiI6Inl0WGtlTWM0dFVnV3dpMmJLbS1xc3JMazJuY3Zjc3lrYVFYcm1Zdm1lRE0ifSwieG1zX3RjZHQiOjE0OTcyNDg0OTN9.EjpbLnqwdSsjeQwy5oVWjqHfSjFyHwQHcwNNvVhq8w9J96PQEjx6xpyIc-VgrQc0h1DdfikzyOVkMEHSVifM_KZoaaUIcW3T4xgXjgZVeEtznAKEqSDB4PN5qR45hsZbJLoVFBxaRZsQrEiPK7m2F4-4_VCK-I6Dfhvhm2_XTiBXnbqSsQ87VhF01BqSVXBIlJwSS1mQKEYHJGCjKnTB5yToDzJvoh4p7GGY3SCmfWY-pDMsAvMlJbErfhXh9Hxc41eBESSEmoajZAnxFOJU5LRtVHqPQW6PwlSTHL2nlGfdXFpspTc6hy4pSyVDNq6I3HJRFXl1cW0L6DjD2FoWMw',
#  'refresh_token': '0.AAAApOutbSzcpEiz8KuV1mGJ2ip56jr19aZJtk3hpF03UyM-AI4.AgABAAAAAAD--DLA3VO7QrddgJg7WevrAgDs_wQA9P_bTRjPRZzMorqH901Y19Ppp0sTRQdBBKFPHBjjxCNhD6fQlBevSxmidflskapxgyxbPlJysKnmnXMvEw6oy9tTRGn5QAB6kuYsP9BVFNiEpqeyqyyABuuiZZXHfMVSiqcIuUYdC5Cbt-meckpgVSC9Dfhvr50ilZ99m5iUHDkFVJLdPQLlrgdHqV8Mhe1V6Eh7F5Mfqrg1xb-K-QLZ3EGrcBUsDarXTkKPWP_9rKgukN_PTFerEBaM90u--8iytv0RJAui31HTa4yIInU0g7vZ16fhaEvJSXQjMSc5TYeepCHkK2fi3UpJe8TOoQ-qpORU_CJl83YMF59_enWxi0f_5ouV_9CPpeZ0xReU1Nb2y4g7uBYVPPNyroVlSmU0zV9c_rgs-dkSXK3b6r-AN-c5msS_ZoGvjiL3qBevNKo3Ws4w4IBWO-wQV8arNtNSuCAv5u9z41gITSDDg1Z8EYizTjQHjVaP095n5CWcBghqboVL4H2AAzW1SIgDJIs7ybwhg0oVsjUW4s9FQguPe9lj3HrAHzk5_NMRJsuGIZpDJ3ENz2pldXaRBqk42X07_y549Aw3qmgd8UY2KqsbA0EMNharkf-3q0_4AyeyxtqSqJETF5F3wy0bQ0RViS5W5mVobaRU90ia-zHzOjvXtxvenaY-r2ANjh_yg3qiV2D8Z7ODW50YzLL9SgiFqMm7zg7RYd4jjpdTYOik2yCX7cn_sSaA29KAqkbWKZNqX2Ds_ZRyby8RYeHBOC_I0XbrngLHnTszik-eKI3rHBv7UdTXCM9PHJwl2X4pocIREzfFPOTnLauiyfAS_zpb2Lsw8exVEqIpesVZt1S8uMvaEPctk7P22myx-vdxrk937c9z368vGpN6dBS--LPq5ZaxX9TuxCRbfCesT6KpkgLMq6Ywho59Tc6cAeE18sw'}

好耶!

MS 还提供了网址对 access_token 进行解密:https://jwt.ms/

# 用 refresh_token 换新的 access_token 和新的 refresh_token

然后,我们把上面 response 里的 refresh_token 取出来,然后请求刷新 access_tokenrefresh_token

refresh_token = eval(response.content)['refresh_token']

response_refresh = requests.post(
    'https://login.microsoftonline.com/common/oauth2/v2.0/token',
    data = {
        'client_id': client_id,
        'redirect_uri': redirect_uri,
        'client_secret': client_secret,
        'refresh_token': refresh_token,
        'grant_type': 'refresh_token'
    },
    headers = headers)

response        # <Response [200]>
eval(response.content)
# {'token_type': 'Bearer',
#  'scope': 'Files.ReadWrite profile openid email',
#  'expires_in': 3599,
#  'ext_expires_in': 3599,
#  'access_token': 'eyJ0eXAiOiJKV1QiLCJub25jZSI6ImhFSy1UT0xyZC1WZVNQOGVCU3plTURzeW1EODk5RWdlMEJzVDM5ak5uZEkiLCJhbGciOiJSUzI1NiIsIng1dCI6Im5PbzNaRHJPRFhFSzFqS1doWHNsSFJfS1hFZyIsImtpZCI6Im5PbzNaRHJPRFhFSzFqS1doWHNsSFJfS1hFZyJ9.eyJhdWQiOiIwMDAwMDAwMy0wMDAwLTAwMDAtYzAwMC0wMDAwMDAwMDAwMDAiLCJpc3MiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC82ZGFkZWJhNC1kYzJjLTQ4YTQtYjNmMC1hYjk1ZDY2MTg5ZGEvIiwiaWF0IjoxNjE0MjYyNjg4LCJuYmYiOjE2MTQyNjI2ODgsImV4cCI6MTYxNDI2NjU4OCwiYWNjdCI6MCwiYWNyIjoiMSIsImFjcnMiOlsidXJuOnVzZXI6cmVnaXN0ZXJzZWN1cml0eWluZm8iLCJ1cm46bWljcm9zb2Z0OnJlcTEiLCJ1cm46bWljcm9zb2Z0OnJlcTIiLCJ1cm46bWljcm9zb2Z0OnJlcTMiLCJjMSIsImMyIiwiYzMiLCJjNCIsImM1IiwiYzYiLCJjNyIsImM4IiwiYzkiLCJjMTAiLCJjMTEiLCJjMTIiLCJjMTMiLCJjMTQiLCJjMTUiLCJjMTYiLCJjMTciLCJjMTgiLCJjMTkiLCJjMjAiLCJjMjEiLCJjMjIiLCJjMjMiLCJjMjQiLCJjMjUiXSwiYWlvIjoiQVNRQTIvOFRBQUFBcFB5WUtCamlUK2kxWnVaY3BLNitudHh2ektkMGovNGtvSVhGVnBwOE5vQT0iLCJhbXIiOlsicHdkIl0sImFwcF9kaXNwbGF5bmFtZSI6IumYruiWh-iWh-eCueWQjeWVpiIsImFwcGlkIjoiM2FlYTc5MmEtZjVmNS00OWE2LWI2NGQtZTFhNDVkMzc1MzIzIiwiYXBwaWRhY3IiOiIxIiwiaWR0eXAiOiJ1c2VyIiwiaXBhZGRyIjoiMTU0LjE3LjcuODQiLCJuYW1lIjoi55S15a2Q56eR5oqA5aSn5a2mTVNDIiwib2lkIjoiMmQ1NTE0MjUtNTM5Ni00MTU5LTk0NTAtMDc1NjM3YjAxNjdiIiwicGxhdGYiOiIzIiwicHVpZCI6IjEwMDMyMDAwNTdEQjg2M0UiLCJyaCI6IjAuQUFBQXBPdXRiU3pjcEVpejhLdVYxbUdKMmlwNTZqcjE5YVpKdGszaHBGMDNVeU0tQUk0LiIsInNjcCI6IkZpbGVzLlJlYWRXcml0ZSBwcm9maWxlIG9wZW5pZCBlbWFpbCIsInN1YiI6Indkd0Q4Zi1zNGoyQ1h5ZkNqYTlndS16VXZPdVdGVFhYNm15NXdGRjg0TnMiLCJ0ZW5hbnRfcmVnaW9uX3Njb3BlIjoiQVMiLCJ0aWQiOiI2ZGFkZWJhNC1kYzJjLTQ4YTQtYjNmMC1hYjk1ZDY2MTg5ZGEiLCJ1bmlxdWVfbmFtZSI6InVlc3RjbXNjQGRlbW80Yy5vbm1pY3Jvc29mdC5jb20iLCJ1cG4iOiJ1ZXN0Y21zY0BkZW1vNGMub25taWNyb3NvZnQuY29tIiwidXRpIjoiUjZERXd0TWlBazJsaTVXaWRIOGpBQSIsInZlciI6IjEuMCIsIndpZHMiOlsiYjc5ZmJmNGQtM2VmOS00Njg5LTgxNDMtNzZiMTk0ZTg1NTA5Il0sInhtc19zdCI6eyJzdWIiOiJ5dFhrZU1jNHRVZ1d3aTJiS20tcXNyTGsybmN2Y3N5a2FRWHJtWXZtZURNIn0sInhtc190Y2R0IjoxNDk3MjQ4NDkzfQ.GdgiFzmdXNFuBqaDSsLzalQU0QULm1fAYZw-mKw5D1KdR4j9WSl6sIa9vPcuogg1x7oLMujcgfdyY0Cf4KNLk3fQ3vzLo395R5GDbg-djBREWCBBgFvcgbu8AyH7yj6MxdXsY59U9nFqVdeIfqEZxxxFCT2F2Nc-CfrDsI8PZQL3kn0VFcvSiUwuOMlH06ydKwyyBNhCRh5yc9x32XWwlRde-GPZCd1SdyvcPvo_mUcEJxh97tRfehHNTDltXGFsAZqcYaz18iJk9MqZ4tlyOIIWPX_lXGRtNe2dUDQE6g6znWY0ClGvU3X9mdScUUtfblmnfmzMj-ECBkAjmjKBnA',
#  'refresh_token': '0.AAAApOutbSzcpEiz8KuV1mGJ2ip56jr19aZJtk3hpF03UyM-AI4.AgABAAAAAAD--DLA3VO7QrddgJg7WevrAgDs_wQA9P89XPzxfnsn8nWMuIFittfpaEqpEzvy5sSPvkBHSIgu_y7Ppazm1F3l2nJ6hEWraRomalXRdwDi1Y78yIC_yA1xAspMEEuoxejDjoBhcWqOWLUTfXkfKk7i6pugNA-a_GOH9-EvKc_g9NvuCK4QgK6FQadMUf62wpoCkB53kgnH8GtOYKW34AutKaFi-N3v8RstrJRtsT3ZXanWmlA_q2WbDSou3-L_H6MS62u3Fr_mxnaKx4lKyy4cXaWbb_29dbx_yVO4zd_x5WHbcnrkfk3qoxD_b5kxHOMlKpVHtX_NthQvg8ZekDZ0pLawRsCDhJ8NFGwJvIXKxaMAyCbhHsyklnw97sU3M9jg08oAbaoQ0JA7eDZ4gd4juSzvkdGVK77unbROzOC1GjR12MWd9n_lYz4KMv_ErKx8wN2MHMAVK-QBBkJUsdB4JbBzSVgneD3jaDvWQRyz4BJMMx5e8qjR-bRwCBjm4Y1aK7vYNBGl2DVR1sVbf8eRZje6AxRQYy3rjdDod_30IqjNhDzX-eILlXR570RmoOGZgaWNPf-3-_3AaDGtsBE8tlCGux8lIR7jIxIKeBjfp1S5ymiofVirYYJiAg17KHy_W9FHUHS9CIs6CrKEvPesjDuFmerE14hzmJ4LCJarZSy2AsV6XiTMmamzaKX-WwFSzGXpipOwPbcO4tjcq5tFWz7-1TzDKsaAyvvh-K3fQiD2GUAv4v9zgRVczUQXN0Z37RULpnSGhj2eXaOenNXl9EoIgBLKXpj-lOshEyRbL2P3WzgvS1MUaXH31IHt-NiQuMpmeBfGg-gxrc9inQ0n7ATfD0T5vZ8YodjccP_rJvzI1Ntfl1yQs0g2h2JyZp694MOqc7Clv8W0P8F-uJIVWK2yv3iPRz-_nBM3im0A9OrCaugArHGPJ9gRt03nHzUHeJIAMc2Xqg'}

至此,关于 token 的申请方法就讲完了。

# Django 实现

我把上面的过程改成了一个 OnedriveAuthentication 类(代码见 GitHub (opens new window),views 部分在 views.py (opens new window) 中)。

另外,MS 也给了一个利用 requests_oauthlib.OAuth2Session 封装的示例 (opens new window),也可以参考。


另外需要注意的是,如果在 Django 后端中使用 requests 请求 Onedrive,会使得线程等待,在 Onedrive 相关请求并发量高的时候很容易造成数秒甚至数十秒的延迟。

可以在部署的时候使用更多线程缓解这个症状。想要根本改变这个问题,就不得不提到异步了。

异步请求可以使用 aiohttp 库。Django 3.1 也提供了异步视图的支持(但没有提供异步数据库访问的支持,不过也提供了临时解决办法)。但是,Django REST Framework 3.2.3 还不支持异步视图,想要使用异步视图,还得使用纯 Django 视图。

所以,目前我的解决办法,是把并发最高的一个 API 改写为纯 Django 视图然后异步化,并对该视图用到的 requests 请求改为 aiohttp 请求。

# 在前后端分离的情况下使用 Onedrive API

完成登录授权,拿到 Access Token 以后,就可以用 MS Graph 的 API 进行操作了。可以在 Graph Explorer (opens new window) 进行测试。

而具体使用方法,就直接看文档 (opens new window)啦。这文档蛮详细的,边写边看就行。

由于 REST API 的寻址部分比较麻烦,我使用 Python 对 API 进行了简单封装,代码在 GitHub (opens new window)

这里只写了自己在开发过程中遇到的一些场景,以及解决方案。

项目的场景是使用一个账户的云盘作为整个项目的共有云盘,文件上传、删除等操作全部交给后端完成。后端判断前端登录的用户权限后,使用该账户的 access_token 执行操作。

# 上传

为了省去文件经过后端服务器的流量,后端可以使用 Onedrive 的通过上传会话上传大文件 (opens new window) 方案上传文件。前后端和 Onedrive 的交互过程如下:

  1. 在登陆状态下,前端向后端 POST /api/cloud/file 发起请求,后端请求 Onedrive 生成一个临时上传对话,并将 Onedrive 的应答(格式见上面的链接)转发给前端;
  2. 前端按照上面链接所述方法,直接向 Onedrive 上传文件。上传完成后,Onedrive 返回文件的 id 等信息,文件将位于 /(应用文件夹)/temp/{userid}/ 文件夹;
  3. 前端根据需求(如上传沙龙相关文件、沙龙照片)向对应接口发起请求(请求需包含文件 id),后端将文件移动至每个功能对应的文件夹,并完成后续操作(录入数据库等)。

# 前端交互

前端交互原理很简单好像也不简单,对于错误处理就更麻烦了,虽然 MS Graph 最后给了对各种错误处理的详细策略,但是没有示例代码还是很麻烦。

因此,这里放一个我的 JavaScript 实现,其中 createUploadSession 是创建上传会话的接口。

// 调用后端接口,将文件上传至 onedrive。
// 参考文档:https://docs.microsoft.com/zh-cn/graph/api/driveitem-createuploadsession
export async function uploadFile(file) {
  let res = await createUploadSession({"filename": file.name});
  const uploadUrl = JSON.parse(res.data).uploadUrl;
  let size = file.size;

  const totalRetryTimes = 5;  // 4xx 导致的上传失败的重试次数
  const firstDelay = 1000;    // 5xx 导致的上传失败的第一次等待时间(之后采用 * 2 的指数退避策略)

  // MS 直接给了上传时错误处理的策略,nb!
  // https://docs.microsoft.com/zh-cn/graph/api/driveitem-createuploadsession#best-practices
  async function uploadPart(start, end, retryTimes = 0, nextDelay = firstDelay) {
    // console.log(`uploadPart: start=${start}, end=${end}, retryTimes=${retryTimes}, nextDelay=${nextDelay}`);
    let headers = {};
    let status = 0;
    headers['Content-Range'] = `bytes ${start}-${end - 1}/${size}`;
    headers['Content-Type'] = file.type;
    let config = {
      withCredentials: false,
      timeout: 0,
      headers
    };
    try {
      res = await axios.put(uploadUrl, file.slice(start, end), config);
      status = res.status;
    } catch (err) {
      status = 500;
    }

    if (status >= 500) {
      // 继续或重试由于连接中断或任意 5xx 错误而失败的上载
      // 请使用指数退避战略
      console.warn(`上传失败,等待 ${nextDelay / 1000}s 后上传...`);
      await sleep(nextDelay);
      return await uploadPart(start, end, retryTimes, nextDelay * 2);
    } else if (status >= 200 && status < 300) {
      // 成功上传该段,返回
      return res;
    } else {
      // 对于其他错误,不应使用指数退避战略,而应限制尝试重试的次数
      if (retryTimes > 0) {
        if (retryTimes > totalRetryTimes) {
          console.warn(`上传失败 ${retryTimes} 次,取消上传`);
          throw res;
        } else {
          console.warn(`上传失败 ${retryTimes} 次,重试中...`);
          return await uploadPart(start, end, retryTimes+1, nextDelay);
        }
      }
    }
  }

  let status = 0;
  let start = 0;
  // 200 为上传成功(覆盖),201 为上传成功(新建)
  while (status !== 200 && status !== 201) {
    let end = Math.min(start + maxFileContentLength, file.size);
    res = await uploadPart(start, end);
    start = end;
    status = res.status;
  }
  return res.data.id;
}

这段代码还没有加上传进度等功能,不过上传的大体思路就是这样的。

# 下载

为了省去文件经过后端服务器的流量,本后端只提供下载链接。由于 Onedrive API 限制,提供两种下载链接:

  • 永久链接,但需在浏览器中访问
  • 临时链接(Onedrive 链接有效期 15min,但本后端提供即时获取链接并重定向的 REST API)

# 永久链接

有网友发现,手动或利用 Onedrive API (opens new window) 生成分享链接后,在分享链接后追加 ?download=1 参数,在浏览器访问该链接即可自动下载文件。

但该链接对应的 html 需要运行 JavaScript,因此不能通过 Python requests 或 JavaScript XMLHTTPRequest 直接下载。推荐使用后面的 API,或者在 JavaScript 使用 window.open() 在新窗口打开这个链接。

# 临时链接

利用 Onedrive API 下载文件 (opens new window) 时,响应报文为 302 FoundLocation 为一个下载 URL。该 URL 仅在较短的一段时间 (几分钟后)内有效,不需要认证即可下载。

本后端提供 /cloud/file/{id}/download/ API,该 API 调用上述 API 后将报文返回给前端。