-
Notifications
You must be signed in to change notification settings - Fork 4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Епик Александр - Phase 1 #1
base: master
Are you sure you want to change the base?
Changes from 3 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
package DTOs | ||
|
||
import play.api.libs.json.{Json, Reads} | ||
|
||
case class HolderDTO(pages: Int, | ||
items: Option[Seq[VacancyDTO]]) | ||
|
||
object HolderDTO { | ||
implicit val holderDtoReader: Reads[HolderDTO] = Json.reads[HolderDTO] | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
package DTOs | ||
|
||
import play.api.libs.json.{Json, Reads} | ||
|
||
case class RegionDTO(id: String, | ||
name: String, | ||
areas: Seq[RegionDTO]) | ||
|
||
object RegionDTO { | ||
implicit val regionDtoReader: Reads[RegionDTO] = Json.reads[RegionDTO] | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
package DTOs | ||
|
||
import play.api.libs.json.{Json, Reads} | ||
|
||
case class SalaryDTO(currency: Option[String], | ||
from: Option[Int], | ||
gross: Option[Boolean], | ||
to: Option[Int]) | ||
|
||
object SalaryDTO { | ||
implicit val salaryDtoReader: Reads[SalaryDTO] = Json.reads[SalaryDTO] | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
package DTOs | ||
|
||
import play.api.libs.json.{Json, Reads} | ||
|
||
case class SnippetDTO(requirement: Option[String], | ||
responsibility: Option[String]) | ||
|
||
object SnippetDTO { | ||
implicit val snippetDtoReader: Reads[SnippetDTO] = Json.reads[SnippetDTO] | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
package DTOs | ||
|
||
import play.api.libs.json.{Json, Reads} | ||
|
||
case class VacancyDTO(id: String, | ||
name: Option[String], | ||
alternate_url: Option[String], | ||
snippet: Option[SnippetDTO], | ||
salary: Option[SalaryDTO]) | ||
|
||
object VacancyDTO { | ||
implicit val vacancyDtoReader: Reads[VacancyDTO] = Json.reads[VacancyDTO] | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,13 @@ | ||
package model | ||
|
||
import java.util.UUID | ||
|
||
case class Job(id: UUID, title: String) | ||
case class Job(id: Int, | ||
title: Option[String], | ||
requirement: Option[String], | ||
responsibility: Option[String], | ||
alternateUrl: Option[String], | ||
salaryFrom: Option[Int], | ||
salaryTo: Option[Int], | ||
salaryCurrency: Option[String], | ||
salaryGross: Option[Boolean], | ||
city: Option[String], | ||
keyWord: Option[String]) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
package scheduler | ||
|
||
import javax.inject.Inject | ||
import akka.actor.ActorSystem | ||
|
||
import scala.concurrent.ExecutionContext | ||
import scala.concurrent.duration._ | ||
import play.api.{Configuration, Logger, Mode} | ||
import service.JobAggregatorService | ||
|
||
import scala.util.Try | ||
|
||
class Task @Inject()(actorSystem: ActorSystem, | ||
configuration: Configuration, | ||
jobAggregatorService: JobAggregatorService)(implicit executionContext: ExecutionContext) { | ||
|
||
if (!configuration.has("initialDelay") || !configuration.has("interval") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Мы проверяем сразу все поля и, если какого-то нет, падаем с ошибкой общего содержания. Обычно так делать не следует, потому что пользователю (техподдержке) будет не понятно где именно произошла ошибка и придется идти проверять всё. Лучше перенести эти сообщения непосредственно в попытки чтения соответствующего поля и, если его нет, выводить конкретное сообщение |
||
|| !configuration.has("cities") || !configuration.has("keyWords")) | ||
throw new NoSuchFieldException("Configuration doesn't have some of these paths:" + | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. В общем случае кидать исключения не ФП стиль, т.к. исключения это сайд эффект. Альтернатива - конструирование объекта Но конкретно тут без конфигурации не получится запустить приложение в принципе (не будет параметров для запуска), так что не сильно критично. Но лучше переместить это в соответствующие места. |
||
" initialDelay, interval, cities, keyWords") | ||
|
||
val initialDelay: Option[String] = configuration.getOptional[String]("initialDelay") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Тут можно сделать одно из двух действий (зависит от задуманной логики):
val initialDelay: String = configuration.getOptional[String]("initialDelay").getOrElse("SomeDefaultValue") 2.при отсутствии значения в конфиге выкинуть исключение: val initialDelay: String = configuration.getOptional[String]("initialDelay").getOrElse(sys.error("Error message")) |
||
val interval: Option[String] = configuration.getOptional[String]("interval") | ||
val cities: Option[Seq[String]] = configuration.getOptional[Seq[String]]("cities") | ||
val keyWords: Option[Seq[String]] = configuration.getOptional[Seq[String]]("keyWords") | ||
|
||
if (initialDelay.isEmpty || interval.isEmpty || cities.isEmpty || keyWords.isEmpty) | ||
throw new ClassCastException("Some of these paths have wrong type: initialDelay, interval, cities, keyWords") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Снова общее сообщение об ошибке. Мало того, что не ясно, какой именно параметр имеет не верный формат, не ясно ещё и какой формат должен быть. Видимо, раз идет проверка на пустоту, должен быть Some. Но тогда следует сразу и читать параметр не как Option'ы |
||
|
||
val initDelay: Try[FiniteDuration] = Try(Duration(initialDelay.get).asInstanceOf[FiniteDuration]) | ||
val interv: Try[FiniteDuration] = Try(Duration(interval.get).asInstanceOf[FiniteDuration]) | ||
|
||
if(initDelay.isFailure || interv.isFailure) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. лучше проверки проводить сразу в момент чтения |
||
throw new ClassCastException("Initial delay or interval have wrong format") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. аналогично общее сообщение без подробностей |
||
|
||
actorSystem.scheduler.scheduleAtFixedRate(initialDelay = initDelay.get, | ||
interval = interv.get) { () => | ||
jobAggregatorService.aggregateData(keyWords.get, cities.get) | ||
Logger("play").info("Scheduled task executed") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Действительно ли это логгирование относится к play'ю? Обычно делают так, что каждый модуль имеет свой собственный логгер и через него логгирует сообщения. Так делают, чтобы в логах можно было быстро найти сообщения от соответствующего компонента. Т.е. тут предлагаю сделать 2 действия:
val logger = Logger("RightName") |
||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
package scheduler | ||
|
||
import play.api.inject.SimpleModule | ||
import play.api.inject._ | ||
|
||
class TasksModule extends SimpleModule(bind[Task].toSelf.eagerly()) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,21 +1,184 @@ | ||
package service | ||
|
||
import DTOs._ | ||
import model.Job | ||
import model.db.DBTables.jobTable | ||
import play.api.db.slick.{DatabaseConfigProvider, HasDatabaseConfigProvider} | ||
import slick.jdbc.JdbcProfile | ||
import slick.jdbc.PostgresProfile.api._ | ||
|
||
import java.util.UUID | ||
import javax.inject.{Inject, Singleton} | ||
import play.api.{Configuration, Logger, Mode} | ||
import play.api.libs.ws._ | ||
import play.api.libs.json._ | ||
|
||
import scala.util.{Failure, Success, Try} | ||
import scala.concurrent.ExecutionContext.Implicits.global | ||
import scala.collection.mutable.ListBuffer | ||
import scala.concurrent.{Await, Future} | ||
|
||
import scala.concurrent.Future | ||
|
||
@Singleton | ||
class JobAggregatorService @Inject()(val dbConfigProvider: DatabaseConfigProvider) extends HasDatabaseConfigProvider[JdbcProfile] { | ||
class JobAggregatorService @Inject()(ws: WSClient, | ||
configuration: Configuration, | ||
val dbConfigProvider: DatabaseConfigProvider) extends HasDatabaseConfigProvider[JdbcProfile] { | ||
|
||
val perPage: Int = configuration.getOptional[Int]("perPage") match { | ||
case Some(value) => value | ||
case None => | ||
Logger("play").warn("perPage is not configured. The default value(100) will be used") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Аналогично с логгером. Плюс значение по умолчанию лучше вынести в отдельную переменную, так константа используется больше, чем в одном месте (в присваивании и в выводе сообщения). |
||
100 | ||
} | ||
|
||
/** | ||
* агрегирует вакансии по ключевому слову и региону | ||
* | ||
* @param text ключевое слов | ||
* @param area индекс региона | ||
*/ | ||
def aggregateData(text: String, area: Int) = { | ||
|
||
ws.url(s"https://api.hh.ru/vacancies?text=$text&area=$area&per_page=$perPage&page=0") | ||
.get() | ||
.flatMap(x => getJobs(x.json, text, area)) | ||
.map(buff => buff.foreach(x => addToDB(x))) | ||
} | ||
|
||
/** | ||
* агрегирует вакансии по ключевому слову и региону | ||
* | ||
* @param keyWords ключевые слова | ||
* @param areas индексы регионов | ||
*/ | ||
def aggregateData(keyWords: Seq[String], areas: Seq[String]): Unit = { | ||
getRegions() match { | ||
case Success(value) => { | ||
val regionsMap = value.map(x => x._2) | ||
for { | ||
keyWord <- keyWords | ||
area <- areas | ||
} regionsMap.map(x => aggregateData(keyWord, x(area))) | ||
} | ||
case Failure(exception) => Logger("play").error(exception.getMessage, exception) | ||
} | ||
} | ||
|
||
/** | ||
* делает несколько запросов к hh.ru, если все вакансии не помещаются в один ответ | ||
* | ||
* @param firstResp первый ответ от hh.ru | ||
* @return Future от массива всех полученных вакансий | ||
*/ | ||
private def getJobs(firstResp: JsValue, keyWord: String, area: Int): Future[List[Job]] = { | ||
|
||
def addJobTest(title: String): Future[Int] = { | ||
db.run(jobTable += Job(UUID.randomUUID(), title)) | ||
def handleJobs(jobs: Try[List[Job]]) = { | ||
jobs match { | ||
case Success(value) => value | ||
case Failure(exception) => Logger("play").error(exception.getMessage, exception) | ||
Nil | ||
} | ||
} | ||
|
||
def parseAllPages(areaText: String) = { | ||
val pages = firstResp.asOpt[HolderDTO] match { | ||
case Some(holder) => holder.pages | ||
case None => throw new ClassCastException("json can't be parsed: " + firstResp.toString) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Здесь кидать исключение уже излишне. Раз |
||
} | ||
val jobs: List[Job] = handleJobs(getJobsFromPage(firstResp, keyWord, areaText)) | ||
|
||
val requests = for (n <- 1 until pages) | ||
yield ws.url(s"https://api.hh.ru/vacancies?text=$keyWord&area=$area&per_page=$perPage&page=$n") | ||
|
||
requests.foldLeft(Future.successful(jobs))((fut, req) => | ||
req.get().map(resp => handleJobs(getJobsFromPage(resp.json, keyWord, areaText))) | ||
.flatMap(newJobs => fut.map(oldJobs => oldJobs ++ newJobs))) | ||
} | ||
|
||
getRegions() match { | ||
case Success(value) => value.flatMap(x => parseAllPages(x._1(area))) | ||
case Failure(exception) => Logger("play").error(exception.getMessage, exception) | ||
Future.successful(Nil) | ||
} | ||
} | ||
|
||
/** | ||
* парсит вакансии в массив DTO Job | ||
* | ||
* @param json json, содержащий массив вакансий | ||
* @return массив DTO Job | ||
*/ | ||
private def getJobsFromPage(json: JsValue, keyWord: String, area: String): Try[List[Job]] = { | ||
var jobs = ListBuffer[Job]() | ||
|
||
def addJob(item: VacancyDTO): Unit = { | ||
var requirement: Option[String] = None | ||
var responsibility: Option[String] = None | ||
|
||
item.snippet match { | ||
case Some(value) => { | ||
requirement = value.requirement | ||
responsibility = value.responsibility | ||
} | ||
case _ => | ||
} | ||
|
||
Try(item.id.toInt).toOption match { | ||
case Some(id) => item.salary match { | ||
case Some(salary) => jobs += Job(id, item.name, requirement, responsibility, | ||
item.alternate_url, salary.from, salary.to, salary.currency, salary.gross, Option(area), Option(keyWord)) | ||
case None => jobs += Job(id, item.name, requirement, responsibility, | ||
item.alternate_url, None, None, None, None, Option(area), Option(keyWord)) | ||
} | ||
case None => throw new NumberFormatException("id value can't be converted to int" + json.toString()) | ||
} | ||
} | ||
|
||
Try { | ||
json.asOpt[HolderDTO] match { | ||
case Some(holder) => holder.items match { | ||
case Some(items) => items.foreach(addJob) | ||
case None => throw new RuntimeException("Items doesn't exist" + json.toString()) | ||
} | ||
case None => throw new RuntimeException("Can't be parsed to HolderDTO" + json.toString()) | ||
} | ||
|
||
jobs.toList | ||
} | ||
} | ||
|
||
/** | ||
* @return возвращает два словаря: (id -> название_региона) и (название_региона -> id) | ||
*/ | ||
private def getRegions() = { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Аналогично в этом методе ручной парсинг Json'а можно заменить на парсинг по модели |
||
val resp = ws.url("https://api.hh.ru/areas").get() | ||
val indexToRegion = scala.collection.mutable.Map[Int, String]() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Мне кажется необязательно использовать мутабельные мапы. Мы же пишем на скала используя пользу монад, значит и тут сможем преобразовать код до функционального подхода. Тут помогут хвостовые рекурсивные функции, которые на каждом шаге генерируют новую мапу на один элемент больше предыдущей и, т.о., на выходе дающие результирующую мапу There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Проблема в том, что регионы в ответе от hh.ru организованы как дерево. Например, объект Россия содержит в себе номер региона и список областей [Свердловская, Челябинская ...], а объект Сверд. обл. так же содержит номер региона и список городов. И, как я понимаю, хвостовые рекурсии не очень подходят для обхода деревьев. Я гуглил и нашел следующий сайт: https://nrinaudo.github.io/scala-best-practices/unsafe/recursion.html, где сказано следующее:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ок, допустим хвостовая рекурсия для обхода дерева не годится. Но из этого же сообщения следует, что тут обычная рекурсия подходит лучше, чем хвостовая, при этом риска StackOverflowError нет, т.к. глубина рекурсии ограничена высотой дерева, а она тут вряд ли будет слишком большой. Так что шанс избавиться от мутабельности всё-таки есть. Но в реальной разработке всё-таки иногда действительно встречаются ситуации, когда по определенным причинам (критичная производительность, особенности работы со сторонней библиотекой, особенности алгоритма и т.д.) приходится применять императивный подход с мутабельными данными. В этом случае всю мутабельность стоит инкапсулировать внутри метода и не показывать это наружу. В данном же случае тип результата функции |
||
val regionToIndex = scala.collection.mutable.Map[String, Int]() | ||
|
||
|
||
def initialParse(json: JsValue) = { | ||
json.asOpt[Seq[RegionDTO]] match { | ||
case Some(regions) => regions.foreach(parseRegions) | ||
case None => throw new ClassCastException("Can't be parsed to Seq[RegionDTO]: " + json.toString()) | ||
} | ||
(indexToRegion.toMap, regionToIndex.toMap) | ||
} | ||
|
||
def parseRegions(regionDTO: RegionDTO): Unit = { | ||
Try(regionDTO.id.toInt).toOption match { | ||
case Some(id) => { | ||
indexToRegion += (id -> regionDTO.name) | ||
regionToIndex += (regionDTO.name -> id) | ||
if (regionDTO.areas.nonEmpty) { | ||
regionDTO.areas.foreach(area => parseRegions(area)) | ||
} | ||
} | ||
case None => throw new NumberFormatException("region id can't be converted to int") | ||
} | ||
} | ||
|
||
Try(resp.map(x => initialParse(x.json))) | ||
} | ||
|
||
private def addToDB(job: Job) = { | ||
db.run(DBIO.seq(jobTable += job)) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,13 @@ | ||
slick.dbs.default.driver="slick.driver.PostgresDriver$" | ||
slick.dbs.default.db.driver="org.postgresql.Driver" | ||
slick.dbs.default.db.url="jdbc:postgresql://10.106.0.26:5434/postgres" | ||
slick.dbs.default.db.url="jdbc:postgresql://localhost:5434/postgres" | ||
slick.dbs.default.db.user="postgres" | ||
slick.dbs.default.db.password="12345678" | ||
|
||
//play.modules.enabled += "scheduler.TasksModule" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Закомментирование значение? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Эта строчка, собственно, подключает шедулер, если раскомментировать, то он начнет выполняться. При разработке мне не нужно, чтобы код выполнялся с некоторой переодичностью, поэтому я закомментил. Можно, конечно, просто удалить эту строчку There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ок |
||
|
||
initialDelay="10 seconds" | ||
interval="30 seconds" | ||
cities=["Москва","Екатеринбург"] | ||
keyWords=["java","scala"] | ||
perPage=100 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
вместо конкатенации можно использовать интерполяцию: