帶你進(jìn)入異步Django+Vue的世界 - Didi打車實(shí)戰(zhàn)(5)

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

上一篇讼积,前、后端已經(jīng)完整支持了Websockets蔫仙。
接下來,我們來實(shí)現(xiàn)創(chuàng)建訂單蝗罗、群發(fā)群收栖忠、修改訂單功能。

Refactoring: Trip返回信息

后臺返回Trip信息里设凹,driver/rider是一個primary key舰讹,指向User。我們希望能直接看到ForeignKey: driver/rider的詳細(xì)信息闪朱。

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

Serializer添加ReadOnlyTripSerializer月匣,關(guān)聯(lián)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 創(chuàng)建訂單

當(dāng)用戶創(chuàng)建一個訂單時锄开,我們用Consumer來創(chuàng)建訂單:

  • 判斷消息type是否為create.trip
  • 調(diào)用DRFtrip = serializer.create()創(chuàng)建
  • 注意Django的數(shù)據(jù)庫操作,都是同步的称诗,而Channels是異步的萍悴,所以需要加個裝飾器:@database_sync_to_async
  • 創(chuàng)建Trip記錄后,再添加用戶信息寓免,調(diào)用ReadOnlyTripSerializer()
  • 發(fā)送Websockets: self.send_json()
  • 新訂單創(chuàng)建時癣诱,通知所有的司機(jī):channel_layer.group_send( group='drivers', message={ 'type': 'echo.message', 'data': trip_data } )
    • 其中'type': 'echo.message',Channels會自動調(diào)用echo_message(event)函數(shù)袜香,保證在drivers組里的司機(jī)們都能收到
# 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

前端 - 創(chuàng)建訂單

點(diǎn)擊導(dǎo)航條上的叫車按鈕撕予,顯示對話框:


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="出發(fā)地點(diǎn)"
                      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>

點(diǎn)擊對話框里的“叫車”時蜈首,調(diào)用Vuex的createTrip action來發(fā)送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里踩寇,根據(jù)需求鸟废,添加不同的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窗口姑荷,點(diǎn)叫車按鈕,輸入數(shù)據(jù)缩擂,就能看到創(chuàng)建成功的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消息,并且更新“當(dāng)前訂單”胯盯。這是前端業(yè)務(wù)邏輯懈费,集中放在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 更新消息的群發(fā)群收

用戶創(chuàng)建訂單后博脑,如果有司機(jī)接單憎乙,則用戶應(yīng)能即時得到通知票罐。
用戶退出時,司機(jī)也能收到通知泞边。
實(shí)現(xiàn):利用Channels group

Consumer

  • 每個用戶该押,維護(hù)一個trips列表
  • 在新訂單創(chuàng)建后,channel_layer.group_add來新建一個group - 群阵谚,在群內(nèi)的所有成員(乘客和司機(jī))蚕礼,會同時收到更新提醒
  • 用戶WS連接關(guān)閉(可能是退出程序,也可能是無信號)梢什,則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): ...

用戶恢復(fù)WS連接時,應(yīng)該能從數(shù)據(jù)庫里嗡午,讀取已有trip囤躁,然后重新添加用戶到群里

Consumer

  • _get_trips讀取數(shù)據(jù)庫記錄,排除已完成的訂單
  • 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)

創(chuàng)建訂單時荔睹,檢查是否已存在記錄狸演。如果已存在,則跳過加群的步驟应媚。

# 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

  • 如果司機(jī)/乘客更新了訂單严沥,則觸發(fā)update_trip動作
  • 通過Serializer,更新訂單狀態(tài)
  • 如果是司機(jī)接單中姜,則把司機(jī)加入到群里channel_layer.group_add()
  • 通知乘客消玄,已有司機(jī)接單。(group=trip_id, message={ 'type': 'echo.message', 'data': trip_data })
    • 注意message={'type': 'echo.message'丢胚,Channels會自動尋找對應(yīng)的方法函數(shù):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

引入U(xiǎn)ser group概念

為了區(qū)分用戶是乘客還是司機(jī)翩瓜,需要把用戶分組。
數(shù)據(jù)模型添加group計(jì)算字段携龟,類似于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',
    )

注意:數(shù)據(jù)庫不需要重新migrate,應(yīng)該不是新字段峡蟋,而且計(jì)算字段坟桅。
注意:已有用戶,需要在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="司機(jī)" 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 })
    },

總結(jié)

后臺對訂單的更新夸楣、群發(fā)群收,已經(jīng)全部ready了。

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

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末石洗,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子紧显,更是在濱河造成了極大的恐慌讲衫,老刑警劉巖,帶你破解...
    沈念sama閱讀 206,013評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件鸟妙,死亡現(xiàn)場離奇詭異焦人,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)重父,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,205評論 2 382
  • 文/潘曉璐 我一進(jìn)店門花椭,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人房午,你說我怎么就攤上這事矿辽。” “怎么了郭厌?”我有些...
    開封第一講書人閱讀 152,370評論 0 342
  • 文/不壞的土叔 我叫張陵袋倔,是天一觀的道長。 經(jīng)常有香客問我折柠,道長宾娜,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 55,168評論 1 278
  • 正文 為了忘掉前任扇售,我火速辦了婚禮前塔,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘承冰。我一直安慰自己华弓,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,153評論 5 371
  • 文/花漫 我一把揭開白布困乒。 她就那樣靜靜地躺著寂屏,像睡著了一般。 火紅的嫁衣襯著肌膚如雪娜搂。 梳的紋絲不亂的頭發(fā)上迁霎,一...
    開封第一講書人閱讀 48,954評論 1 283
  • 那天,我揣著相機(jī)與錄音百宇,去河邊找鬼欧引。 笑死,一個胖子當(dāng)著我的面吹牛恳谎,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 38,271評論 3 399
  • 文/蒼蘭香墨 我猛地睜開眼因痛,長吁一口氣:“原來是場噩夢啊……” “哼婚苹!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起鸵膏,我...
    開封第一講書人閱讀 36,916評論 0 259
  • 序言:老撾萬榮一對情侶失蹤膊升,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后谭企,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體廓译,經(jīng)...
    沈念sama閱讀 43,382評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,877評論 2 323
  • 正文 我和宋清朗相戀三年债查,在試婚紗的時候發(fā)現(xiàn)自己被綠了非区。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 37,989評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡盹廷,死狀恐怖征绸,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情俄占,我是刑警寧澤管怠,帶...
    沈念sama閱讀 33,624評論 4 322
  • 正文 年R本政府宣布,位于F島的核電站缸榄,受9級特大地震影響渤弛,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜甚带,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,209評論 3 307
  • 文/蒙蒙 一她肯、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧欲低,春花似錦辕宏、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,199評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至腊瑟,卻和暖如春聚假,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背闰非。 一陣腳步聲響...
    開封第一講書人閱讀 31,418評論 1 260
  • 我被黑心中介騙來泰國打工膘格, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人财松。 一個月前我還...
    沈念sama閱讀 45,401評論 2 352
  • 正文 我出身青樓瘪贱,卻偏偏與公主長得像纱控,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子菜秦,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,700評論 2 345

推薦閱讀更多精彩內(nèi)容