帶你進入異步Django+Vue的世界 - Didi打車實戰(5)

上一篇 帶你進入異步Django+Vue的世界 - Didi打車實戰(4)
Demo: https://didi-taxi.herokuapp.com/

上一篇,前、后端已經完整支持了Websockets。
接下來,我們來實現創建訂單、群發群收、修改訂單功能。

Refactoring: Trip返回信息

后臺返回Trip信息里,driver/rider是一個primary key,指向User。我們希望能直接看到ForeignKey: driver/rider的詳細信息。

[{created: "2019-05-20T10:08:59.950536Z"
driver: null
drop_off_address: "牛首山"
id: "4a25dde1-dd0d-422a-9e5e-706958b65046"
pick_up_address: "總統府"
rider: {id: 5, username: "rider3", first_name: "", last_name: "", group: "rider"}
status: "REQUESTED"
updated: "2019-05-20T10:08:59.950563Z"}, ...]

Serializer添加ReadOnlyTripSerializer,關聯UserSerializer即可。

# /backend/api/serializers.py
class ReadOnlyTripSerializer(serializers.ModelSerializer):
    driver = UserSerializer(read_only=True)
    rider = UserSerializer(read_only=True)

    class Meta:
        model = Trip
        fields = '__all__'

然后修改DRF view, 用戶HTTP訪問/trip/時的TripView,

#
class TripView(viewsets.ReadOnlyModelViewSet):
    lookup_field = 'id'
    lookup_url_kwarg = 'trip_id'
    permission_classes = (permissions.IsAuthenticated,)
    queryset = Trip.objects.all()
    serializer_class = ReadOnlyTripSerializer  # changed

Channels 創建訂單

當用戶創建一個訂單時,我們用Consumer來創建訂單:

  • 判斷消息type是否為create.trip
  • 調用DRFtrip = serializer.create()創建
  • 注意Django的數據庫操作,都是同步的,而Channels是異步的,所以需要加個裝飾器:@database_sync_to_async
  • 創建Trip記錄后,再添加用戶信息,調用ReadOnlyTripSerializer()
  • 發送Websockets: self.send_json()
  • 新訂單創建時,通知所有的司機:channel_layer.group_send( group='drivers', message={ 'type': 'echo.message', 'data': trip_data } )
    • 其中'type': 'echo.message',Channels會自動調用echo_message(event)函數,保證在drivers組里的司機們都能收到
# api/consumers.py

from channels.db import database_sync_to_async # new
from channels.generic.websocket import AsyncJsonWebsocketConsumer

from api.serializers import ReadOnlyTripSerializer, TripSerializer # new


class TaxiConsumer(AsyncJsonWebsocketConsumer):
    # modified
    async def connect(self):
        user = self.scope['user']
        if user.is_anonymous:
            await self.close()
        else:
            channel_groups = []
            # Add a driver to the 'drivers' group.
            user_group = await self._get_user_group(self.scope['user'])
            if user_group == 'driver':
                channel_groups.append(self.channel_layer.group_add(
                    group='drivers',
                    channel=self.channel_name
                ))
            # Get trips and add rider to each one's group.
            self.trips = set([
                str(trip_id) for trip_id in await self._get_trips(self.scope['user'])
            ])
            for trip in self.trips:
                channel_groups.append(self.channel_layer.group_add(trip, self.channel_name))
            await asyncio.gather(*channel_groups)

            await self.accept()

    # new
    async def receive_json(self, content, **kwargs):
        message_type = content.get('type')
        if message_type == 'create.trip':
            await self.create_trip(content)

    # new
    async def echo_message(self, event):
        await self.send_json(event)
    # new
    async def create_trip(self, event):
        trip = await self._create_trip(event.get('data'))
        trip_id = f'{trip.id}'
        trip_data = ReadOnlyTripSerializer(trip).data
        # Send rider requests to all drivers.
        await self.channel_layer.group_send(
            group='drivers', message={
                'type': 'echo.message',
                'data': trip_data
            }
        )
        # Add trip to set.
        if trip_id not in self.trips:
            self.trips.add(trip_id)
            # Add this channel to the new trip's group.
            await self.channel_layer.group_add(
                group=trip_id, channel=self.channel_name
            )

        await self.send_json({
            'type': 'create.trip',
            'data': trip_data
        })

    # new
    @database_sync_to_async
    def _create_trip(self, content):
        serializer = TripSerializer(data=content)
        serializer.is_valid(raise_exception=True)
        trip = serializer.create(serializer.validated_data)
        return trip

前端 - 創建訂單

點擊導航條上的叫車按鈕,顯示對話框:


image.png

<template>

# /src/App.vue
  <v-dialog v-model="dialog">
      <v-card>
        <v-card-title class="headline">你想去哪里?</v-card-title>

        <v-card-text>
          <v-layout row>
          <v-flex xs12 sm8>
                    <v-text-field
                      name="from"
                      label="出發地點"
                      v-model="from"
                      type="text"
                      required></v-text-field>
                  </v-flex>
                </v-layout>
                <v-layout row wrap>
                  <v-flex xs12 sm8>
                    <v-text-field
                      name="dest"
                      label="目的地"
                      v-model="dest"
                      type="text"
                      required></v-text-field>
                  </v-flex>
                </v-layout>
        </v-card-text>

        <v-card-actions>
          <v-btn
            color="red"
            flat="flat"
            @click="dialog = false"
          >
            Cancel
          </v-btn>
          <v-spacer></v-spacer>
          <v-btn
            color="green"
            flat outline
            :disabled="!(from && dest)"
            @click="dialog = false; callTaxi()"
          >
            叫車
          </v-btn>
        </v-card-actions>
      </v-card>
    </v-dialog>

點擊對話框里的“叫車”時,調用Vuex的createTrip action來發送WebSockets消息:
<script>

  data () {
    return {
      dialog: false,
      from: '',
      dest: ''
    }
  },
  methods: {
    ...mapActions(['clearAlert']),
    menu_click (title) {
      if (title === 'Exit') {
        this.$store.dispatch('messages/signUserOut')
      } else if (title === 'Call') {
        this.dialog = true
      }
    },
    callTaxi () {
      let data = { pick_up_address: this.from, drop_off_address: this.dest, rider: this.user.id }
      this.$store.dispatch('ws/createTrip', data)
    }
  }

同axios,所有與后臺交互的WS操作,全部集中到wsService.js中,方便管理和更新。

# /src/services/wsService.js
// send Websockets msg to server

export default {
  async createTrip (ws, payload) {
    let data = JSON.stringify({
      type: 'create.trip',
      data: payload
    })
    await ws.send(data)
  }
}

然后,Vuex store里,根據需求,添加不同的actions:

# /src/store/modules/ws.js
const actions = {
  async createTrip ({ commit }, message) {
    await wsService.createTrip(state.websocket.ws, message)
  },
  async updateTrip ({ commit }, message) {
    await wsService.updateTrip(state.websocket.ws, message)
  }
}

測試:按F12,瀏覽器Console窗口,點叫車按鈕,輸入數據,就能看到創建成功的WS消息了:

WS received: {
"type":"create.trip",
"data":{
  "id":"69caf2d4-a9cb-4b3e-80d3-2412a2debe99","driver":null,
  "rider":{"id":2,"username":"rider1","first_name":"","last_name":""},
  "created":"2019-05-19T11:40:41.278098Z",
  "updated":"2019-05-19T11:40:41.278126Z",
  "pick_up_address":"南京",
  "drop_off_address":"大理",
  "status":"REQUESTED"}
}

收到后臺WS消息后,setAlert消息,并且更新“當前訂單”。這是前端業務邏輯,集中放在ws.js

# /src/store.modules/ws.js
const actions = {
  // handle msg from server
  wsOnMessage ({ dispatch, commit }, e) {
    const rdata = JSON.parse(e.data)
    console.log('WS received: ' + JSON.stringify(rdata))
    switch (rdata.type) {
      case 'create.trip':
        commit('messages/addTrip', rdata.data, { root: true })
        break
      case 'update.trip':
        break
    }
  },

添加addTrip action,并且我們讓trips按更新時間逆序排序:

# /scr/store/modules/messages.js
const getters = {
  trips: state => {
    return state.trips.sort((a, b) => new Date(b.updated) - new Date(a.updated))
  },
}

const mutations = {
  addTrip (state, messages) {
    state.trips.splice(0, 0, message)
  },
image.png

Channels 更新消息的群發群收

用戶創建訂單后,如果有司機接單,則用戶應能即時得到通知。
用戶退出時,司機也能收到通知。
實現:利用Channels group

Consumer

  • 每個用戶,維護一個trips列表
  • 在新訂單創建后,channel_layer.group_add來新建一個group - 群,在群內的所有成員(乘客和司機),會同時收到更新提醒
  • 用戶WS連接關閉(可能是退出程序,也可能是無信號),則Channels里解散用戶所處的群,并把trips列表清空
# backend/api/consumers.py
import asyncio # new

from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncJsonWebsocketConsumer

from api.serializers import ReadOnlyTripSerializer, TripSerializer


class TaxiConsumer(AsyncJsonWebsocketConsumer):

    # new
    def __init__(self, scope):
        super().__init__(scope)

        # Keep track of the user's trips.
        self.trips = set()

    async def connect(self): ...

    async def receive_json(self, content, **kwargs): ...

    # new
    async def echo_message(self, event):
        await self.send_json(event)

    # changed
    async def create_trip(self, event):
        trip = await self._create_trip(event.get('data'))
        trip_id = f'{trip.id}'
        trip_data = ReadOnlyTripSerializer(trip).data

        # Add trip to set.
        self.trips.add(trip_id)

        # Add this channel to the new trip's group.
        await self.channel_layer.group_add(
            group=trip_id,
            channel=self.channel_name
        )

        await self.send_json({
            'type': 'create.trip',
            'data': trip_data
        })

    # new
    async def disconnect(self, code):
        # Remove this channel from every trip's group.
        channel_groups = [
            self.channel_layer.group_discard(
                group=trip,
                channel=self.channel_name
            )
            for trip in self.trips
        ]
        asyncio.gather(*channel_groups)

        # Remove all references to trips.
        self.trips.clear()

        await super().disconnect(code)

    @database_sync_to_async
    def _create_trip(self, content): ...

用戶恢復WS連接時,應該能從數據庫里,讀取已有trip,然后重新添加用戶到群里

Consumer

  • _get_trips讀取數據庫記錄,排除已完成的訂單
  • channel_layer.group_add添加用戶到所有未完成訂單的群里
# api/consumers.py

import asyncio

from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncJsonWebsocketConsumer

from api.models import Trip # new
from api.serializers import ReadOnlyTripSerializer, TripSerializer


class TaxiConsumer(AsyncJsonWebSocketConsumer):

    def __init__(self, scope): ...

     # changed
    async def connect(self):
        user = self.scope['user']
        if user.is_anonymous:
            await self.close()
        else:
            # Get trips and add rider to each one's group.
            channel_groups = []
            self.trips = set([
                str(trip_id) for trip_id in await self._get_trips(self.scope['user'])
            ])
            for trip in self.trips:
                channel_groups.append(self.channel_layer.group_add(trip, self.channel_name))
            asyncio.gather(*channel_groups)
            await self.accept()

    async def receive_json(self, content, **kwargs): ...

    async def echo_message(self, event): ...

    async def create_trip(self, event): ...

    async def disconnect(self, code): ...

    @database_sync_to_async
    def _create_trip(self, content): ...

    # new
    @database_sync_to_async
    def _get_trips(self, user):
        if not user.is_authenticated:
            raise Exception('User is not authenticated.')
        user_groups = user.groups.values_list('name', flat=True)
        if 'driver' in user_groups:
            return user.trips_as_driver.exclude(
                status=Trip.COMPLETED
            ).only('id').values_list('id', flat=True)
        else:
            return user.trips_as_rider.exclude(
                status=Trip.COMPLETED
            ).only('id').values_list('id', flat=True)

創建訂單時,檢查是否已存在記錄。如果已存在,則跳過加群的步驟。

# api/consumers.py

async def create_trip(self, event):
    trip = await self._create_trip(event.get('data'))
    trip_id = f'{trip.id}'
    trip_data = ReadOnlyTripSerializer(trip).data

    # Handle add only if trip is not being tracked.
    if trip_id not in self.trips:
        self.trips.add(trip_id)
        await self.channel_layer.group_add(
            group=trip_id,
            channel=self.channel_name
        )

    await self.send_json({
        'type': 'create.trip',
        'data': trip_data
    })

更新訂單

Consumer

  • 如果司機/乘客更新了訂單,則觸發update_trip動作
  • 通過Serializer,更新訂單狀態
  • 如果是司機接單,則把司機加入到群里channel_layer.group_add()
  • 通知乘客,已有司機接單。(group=trip_id, message={ 'type': 'echo.message', 'data': trip_data })
    • 注意message={'type': 'echo.message',Channels會自動尋找對應的方法函數:echo_message(event)
# api/consumers.py

import asyncio

from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncJsonWebsocketConsumer

from api.models import Trip
from api.serializers import ReadOnlyTripSerializer, TripSerializer


class TaxiConsumer(AsyncJsonWebsocketConsumer):

    def __init__(self, scope): ...

    async def connect(self): ...

    async def receive_json(self, content, **kwargs):
        message_type = content.get('type')
        if message_type == 'create.trip':
            await self.create_trip(content)
        elif message_type == 'update.trip':  # new
            await self.update_trip(content)

    async def echo_message(self, event): ...

    async def create_trip(self, event): ...

    # new
    async def update_trip(self, event):
        trip = await self._update_trip(event.get('data'))
        trip_id = f'{trip.id}'
        trip_data = ReadOnlyTripSerializer(trip).data
        # Send updates to riders that subscribe to this trip.
        await self.channel_layer.group_send(group=trip_id, message={
            'type': 'echo.message',
            'data': trip_data
        })
        if trip_id not in self.trips:
            self.trips.add(trip_id)
            await self.channel_layer.group_add(
                group=trip_id,
                channel=self.channel_name
            )

        await self.send_json({
            'type': 'update.trip',
            'data': trip_data
        })

    async def disconnect(self, code): ...

    @database_sync_to_async
    def _create_trip(self, content): ...

    @database_sync_to_async
    def _get_trips(self, user): ...

    # new
    @database_sync_to_async
    def _update_trip(self, content):
        instance = Trip.objects.get(id=content.get('id'))
        # https://www.django-rest-framework.org/api-guide/serializers/#partial-updates
        serializer = TripSerializer(data=content, partial=True)
        serializer.is_valid(raise_exception=True)
        trip = serializer.update(instance, serializer.validated_data)
        return trip

引入User group概念

為了區分用戶是乘客還是司機,需要把用戶分組。
數據模型添加group計算字段,類似于Vue computed():

class User(AbstractUser):
    # photo = models.ImageField(upload_to='photos', null=True, blank=True)

    @property
    def group(self):
        groups = self.groups.all()
        return groups[0].name if groups else None

DRF Serializer在注冊時,增加group字段的處理:

# /backend/api/serializers.py
class UserSerializer(serializers.ModelSerializer):
    password1 = serializers.CharField(write_only=True)
    password2 = serializers.CharField(write_only=True)
    group = serializers.CharField()
    # photo = MediaImageField(allow_empty_file=True)

    def validate(self, data):
        if data['password1'] != data['password2']:
            raise serializers.ValidationError('兩次密碼不一致')
        return data

    def create(self, validated_data):
        group_data = validated_data.pop('group')
        group, _ = Group.objects.get_or_create(name=group_data)
        data = {
            key: value for key, value in validated_data.items()
            if key not in ('password1', 'password2')
        }
        data['password'] = validated_data['password1']
        user = self.Meta.model.objects.create_user(**data)
        user.groups.add(group)
        user.save()
        return user

    class Meta:
        model = get_user_model()
        fields = (
            'id', 'username', 'password1', 'password2', 'first_name', 'last_name', 'group', #'photo',
        )
        read_only_fields = ('id',)

admin后臺管理頁面:

# /backend/api/admin.py
@admin.register(User)
class UserAdmin(DefaultUserAdmin):
    list_display = (
        'username', 'id', 'group', 'first_name', 'last_name', 'email', 'is_staff',
    )
    readonly_fields = (
        'id',
    )

注意:數據庫不需要重新migrate,應該不是新字段,而且計算字段。
注意:已有用戶,需要在admin里添加“group”字段。或者刪除重新注冊。

前端Sign-Up頁面

我們在注冊用戶時,讓用戶選擇不同角色:

image.png

更新一下Vue view:
<template>

# /src/views/Signup.vue
                <v-radio-group v-model="group" row>
                  <v-radio label="乘客" value="rider"></v-radio>
                  <v-radio label="司機" value="driver"></v-radio>
                </v-radio-group>
                <v-layout>
                  <v-flex xs12>
                    <v-card-actions>
                    <v-spacer />
                    <v-btn round type="submit" :loading="loading" class="orange">Register</v-btn>
                  </v-card-actions>
                  </v-flex>
                </v-layout>

<script>

data () {
    return {
      username: '',
      password: '',
      confirmPassword: '',
      group: 'rider'
    }
  },
methods: {
    onSignup () {
      this.$store.dispatch('messages/signUserUp', { username: this.username, password2: this.confirmPassword, password1: this.password, group: this.group })
    },

總結

后臺對訂單的更新、群發群收,已經全部ready了。

下一篇,會介紹前端如何處理訂單更新
帶你進入異步Django+Vue的世界 - Didi打車實戰(6)

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,546評論 6 533
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,570評論 3 418
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,505評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,017評論 1 313
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,786評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,219評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,287評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,438評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,971評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,796評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,995評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,540評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,230評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,662評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,918評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,697評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,991評論 2 374

推薦閱讀更多精彩內容