Bitcoin Forum
November 06, 2024, 05:18:32 PM *
News: Latest Bitcoin Core release: 28.0 [Torrent]
 
   Home   Help Search Login Register More  
Pages: [1]
  Print  
Author Topic: Создаем на Java Наблюдателя за Ценой Биткоина  (Read 204 times)
Herr Kaufmann (OP)
Member
**
Offline Offline

Activity: 63
Merit: 127


View Profile
December 11, 2019, 06:51:11 PM
Last edit: December 11, 2019, 07:07:19 PM by Herr Kaufmann
Merited by Symmetrick (4)
 #1

Перевод статьи:
https://medium.com/swlh/building-a-bitcoin-price-watcher-with-alerts-in-java-d52824e0631e

Создаем на Java Наблюдателя за Ценой Биткоина с Уведомлениями

Вы будете автоматически уведомлены об изменениях стоимости Биткоина.


Как разработчик вы, возможно, интересуетесь всем, что связано с технологиями. Поэтому вы, должно быть, уже слышали о Биткоине. Даже будет лучше, если я представлю, что вы уже проявляли интерес к тому, как это работает, и конкретно к технологии Блокчейн. Если вы когда-либо интересовались Биткоином, вы могли заметить, что его цена склонна к значительным колебаниям.

Волатильность Биткоина можно объяснить по большей части тем, что это новый для рынка продукт. Если вы решите купить Биткоин, вы, наверное, потратите какое-то время, наблюдая за его ценой на биржевых платформах. Это может отнять у вас невероятно много времени.

Вместо того, чтобы тратить долгие часы на наблюдения за ценой Биткоина, я научу вас, как на Java написать программу для наблюдения за ценой Биткоина, и получать оповещения непосредственно на вашем рабочем столе, когда цена достигнет заданного вами уровня.

.     .     .

Технические характеристики Наблюдателя Цены Биткоина (Bitcoin Price Watcher или BPW)

Программа BPW, которую мы будем создавать в этой статье, будет иметь следующие функции:

  • Получение цены Биткоина, используя Индекс Цены Биткоина (Bitcoin Price Index или BPI) с сайта CoinDesk.
  • Отслеживание цены Биткоина каждую минуту.
  • Отображение цен на Биткоин для просмотра в интерактивном режиме.
  • Предоставление списка всех отслеженных цен.
  • Показ уведомлений непосредственно на рабочем столе пользователя.

Прочитав эти функции, вы поняли, что наша программа будет запущена на компьютере конечного пользователя. Она будет запущена в командной строке терминала пользователем, который сможет добавлять новое значение цены для отслеживания, через ввод команды, такой как эта:

Quote
WATCH;7450

В этом случае пользователь запрашивает выдать уведомление, когда Биткоин достигнет цены 7 450 долларов.

.     .     .

Создание Проекта Java

Первым шагом будет создание проекта Java, который будет использовать Maven в качестве менеджера зависимостей. Так как мы вынуждены будем обращаться к веб-сервису CoinDesk, мы добавим библиотеку OkHttp в качестве зависимости в наш проект.

OkHttp - это эффективный HTTP-клиент, который идеально подойдет для нашего проекта.

Поскольку веб-сервис BPI CoinDesk возвращает свои данные в формате JSON, мы будем вынуждены добавить в зависимость библиотеку для парсинга данных, полученных таким образом.

Для этого я буду использовать библиотеку org.json, которая занимает мало места и легка в использовании. Все это дает нам следующий файл POM для нашего проекта:

Code:
<project
    xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.ssaurel</groupId>
    <artifactId>BitcoinPriceAlert</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <packaging>jar</packaging>
    <name>BitcoinPriceAlert</name>
    <url>http://maven.apache.org</url>
    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>
    <dependencies>
        <dependency>
            <groupId>com.squareup.okhttp3</groupId>
            <artifactId>okhttp</artifactId>
            <version>4.2.2</version>
        </dependency>
        <dependency>
            <groupId>org.json</groupId>
            <artifactId>json</artifactId>
            <version>20190722</version>
        </dependency>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>3.8.1</version>
            <scope>test</scope>
        </dependency>
    </dependencies>
</project>
github.com

.     .     .

Получение Цены Биткоина

Как только проект будет создан и добавлены зависимости, мы переходим к получению цены Биткоина. Веб-сервис Bitcoin Price Index (BPI) CoinDesk доступен по следующему адресу:

https://api.coindesk.com/v1/bpi/currentprice.json

Первый вызов этого API позволит нам увидеть, в каком виде он отображается:

JSON, возвращаемый сервисом BPI

На уровне главного класса нашей программы мы создаем свойство client типа OkHttpClient. Этот объект является точкой входа в OkHttp API и затем позволит нам запустить наш запрос к сервису.

Запрос представлен объектом Request, в котором мы указали конечной точкой BPI CoinDesk. Последний хранится в статической переменной BITCOIN_PRICE_ENDPOINT.

Чтобы выполнить этот запрос с заранее созданным экземпляром OkHttpClient, мы используем его метод newCall, в который мы передаем объект Request, который мы только что создали, в качестве входных данных. Затем остается вызвать метод enqueue, принимающий в качестве входных параметров объект типа Callback. Использование этого интерфейса позволяет нам разделить обработку результата запроса и фактический вызов.

Все это дает следующий код метода loadBitcoinPrice:

Code:
private void loadBitcoinPrice(Callback callback) {
  Request request = new Request.Builder().url(BITCOIN_PRICE_ENDPOINT).build();
  client.newCall(request).enqueue(callback);
}

Так как наша программа должна постоянно мониторить цену Биткоина, необходимо, чтобы этот вызов Сервиса BPI выполнялся через регулярные промежутки времени.

Для реализации этой операции Java предлагает Timer API. Поэтому я определю объект TimerTask, внутри которого задам обращение к методу loadBitcoinPrice. Выполнение данного TimerTask запланировано каждую минуту после вызова метода schedule созданного для данного экземпляра Timer.

Поэтому мы имеем следующий код:

Code:
private void launchTimer() {
 timer = new Timer();
 timer.schedule(new TimerTask() {
   @Override
   public void run() {
     loadBitcoinPrice(new Callback() {
       @Override
       public void onResponse(Call call, Response response) throws IOException {
         String str = response.body().string();
         parseBitcoinPrice(str);
       }

       @Override
       public void onFailure(Call call, IOException ioe) { }
    });
   }
  }, 0, PERIOD);
}

private void cancelTimer() {
 if (timer != null) {
  timer.cancel();
 }
}

private void loadBitcoinPrice(Callback callback) {
 Request request = new Request.Builder().url(BITCOIN_PRICE_ENDPOINT).build();
 client.newCall(request).enqueue(callback);
}
github.com

.     .     .

Парсинг Данных Полученного JSON

В коде выше вы, должно быть, заметили наличие метода parseBitcoinPrice, принимающего в качестве входных данных результат вызова сервиса Bitcoin Price Index. Этот метод и будет отвечать за анализ данных JSON, полученных после вызова Сервиса, чтобы получить текущую цену биткойна.

В этом методе я начинаю создавать экземпляр JSONObject из данных, переданных в качестве входных. Затем я сделаю два вызова, связанных с методом getJSONObject соответственно с параметрами "bpi", а затем "USD“, прежде чем, наконец, вызвать метод getFloat с входным ”rate_float".

Это даст мне текущую стоимость Биткоина в долларах США.

Я вывожу в консоли дату и время, а также цену Биткойна, которая только что была получена. Последняя известная цена биткойна также хранится в свойстве currentPrice нашего главного класса.

На этом моменте метод parseBitcoinPrice выглядит следующим образом:

Code:
private void parseBitcoinPrice(String str) {
  JSONObject jsonObject = new JSONObject(str);
  currentPrice = jsonObject.getJSONObject("bpi").getJSONObject("USD").getFloat("rate_float");
  System.out.println(LocalDateTime.now() + " | Current price = " + currentPrice + "\n");
  
  // ...
}

Я заостряю внимание на этом моменте, потому что, как только эта цена будет получена, необходимо будет сравнить ее с ценами, заданными пользователем для мониторинга.

.     .     .

Моделирование цены для мониторинга

Прежде чем я смогу сравнить текущую полученную стоимость Биткоина с ценами, определенными пользователем для мониторинга, мне нужно будет смоделировать цену для мониторинга.

Цена для мониторинга моделируется в классе Price и имеет следующие два свойства:

  • Свойство target типа float отображает цену для мониторинга.
  • Свойство type, которое является экземпляром Type типа enum (перечисление), отражает типы выполняемых наблюдений.

В самом деле, когда пользователь запрашивает мониторить заданную стоимость, мы будем определять, стоит ли выполнить мониторинг с большей или с меньшей стороны, чтобы сделать уведомление более релевантным.

Перечисление Type которое создано в соответствии с этими требованиями, имеет два значения: UP и DOWN. Кроме того, мы определяем два абстрактных метода:

  • Метод reached, принимающий в качестве входных данных текущую цену биткойна и цену для мониторинга и возвращающий значение true, если эта цена достигнута.
  • Метод msg, принимающий в качестве входных данных текущую цену биткойна и цену для мониторинга и возвращающий сообщение для отображения пользователю.

Реализация этих методов после выполняется специфическим образом для каждого из двух значений перечисления Type. Наконец, определение типа цены для мониторинга выполняется при создании объекта Price путем сравнения текущей стоимости биткойна с ценой для мониторинга, определенной пользователем.

Это дает следующий код для класса Price:

Code:
public class Price {

  enum Type {
    UP() {
      @Override
      public boolean reached(float current, float target) {
        return target < current;
      }

      @Override
      public String msg(float current, float target) {
        return "BTC has rised beyond " + target + " with price : " + current;
      }
    },
    DOWN {
      @Override
      public boolean reached(float current, float target) {
        return current < target;
      }

      @Override
      public String msg(float current, float target) {
        return "BTC has fallen below " + target + " with price : " + current;
      }
    };

    public abstract boolean reached(float current, float target);
    public abstract String msg(float current, float target);
  }

  public float target;
  public Type type;

  public Price(float current, float target) {
    this.target = target;
    type = Float.compare(current, target) < 0 ? Type.UP : Type.DOWN;
  }

}
github.com

.     .     .

Оповещение Пользователя о Достижении Заданной Цены

Ранее я говорил вам, что метод parseBitcoinPrice еще не завершен. На данный момент он отображает только цену биткойна, полученную из сервиса Bitcoin Price Index.

Для того, чтобы работа этого метода была завершена, он должен иметь возможность перебирать цены, которые отслеживает пользователь, и для каждого достижения цены отправлять пользователю предупреждение. Это позволит ему сразу быть предупрежденным о достижении цены, которую он хотел видеть.

Кроме того, как только искомая цена достигнута, не следует забывать удалить ее из списка цен для мониторинга.

Для того, чтобы сделать это, я создам свойство pricesToWatch в главном классе моей программы. Это свойство будет содержать список объектов Price. Для того, чтобы перебирать этот список или иметь возможность при  необходимости удалять пункты, я буду использовать объект Iterator.

Затем для каждой цены я получу свое свойство type, на котором я мог бы вызвать метод reached, передав в качестве входных данных текущую цену Биткоина, и искомую цену, содержащуюся в текущем объекте Price.

Если метод reached возвращает значание true, это означает, что искомая пользователем цена достигнута.

Затем мы должны получить сообщение для отображения пользователю, вызвав метод msg свойства type, связанный с текущим объектом Price. Затем определить метод displayNotification, который будет отвечать за запуск системного уведомления на принимающей машине пользователя.

Виртуальная машина Java для этого предлагает SystemTray API. Который поддерживает правильное отображение системных уведомлений независимо от операционной системы, на которой будет работать программа.

Наконец, остается удалить цену из списка отслеживаемых, вызвав метод итератора remove. Это дает нам следующий код:

Code:
private void parseBitcoinPrice(String str) {
  JSONObject jsonObject = new JSONObject(str);
  currentPrice = jsonObject.getJSONObject("bpi").getJSONObject("USD").getFloat("rate_float");
  System.out.println(LocalDateTime.now() + " | Current price = " + currentPrice + "\n");

  for (Iterator<Price> it = pricesToWatch.iterator(); it.hasNext();) {
    Price priceToWatch = it.next();

    if (priceToWatch.type.reached(currentPrice, priceToWatch.target)) {
      String message = priceToWatch.type.msg(currentPrice, priceToWatch.target);
      System.out.println(message);
      displayNotification("Bitcoin Watcher", message);
      // remove from list to watch
      it.remove();
    }

  }
}

public void displayNotification(String title, String message) {
  if (SystemTray.isSupported()) {
    //Obtain only one instance of the SystemTray object
    SystemTray tray = SystemTray.getSystemTray();
    Image image = Toolkit.getDefaultToolkit().createImage("icon.png");
    TrayIcon trayIcon = new TrayIcon(image, "Bitcoin Watcher Notif");
    trayIcon.setImageAutoSize(true);
    trayIcon.setToolTip("Bitcoin Watcher");
  
    try {
      tray.add(trayIcon);
    } catch (AWTException e) {}

    trayIcon.displayMessage(title, message, MessageType.INFO);
  } else {
    System.err.println("System tray not supported!");
  }
}
github.com

.     .     .
Herr Kaufmann (OP)
Member
**
Offline Offline

Activity: 63
Merit: 127


View Profile
December 11, 2019, 06:53:46 PM
 #2

Взаимодействие С Пользователем

Как бы то ни было, наша программа Bitcoin Price Watcher позволяет нам получать цену биткойна через регулярные промежутки времени, а затем сравнивать ее со списком цен для отслеживания. Если какая-то из цен, за которыми нужно мониторить, достигнута, то пользователю выводится системное уведомление.

Взаимодействие с пользователем однако отсутствует, чтобы позволить ему ввести цены Биткойна, которые он хочет отследить.

Часть взаимодействия с пользователем реализуется в рамках метода scanConsole.

Наше приложение будет поддерживать следующие три команды:

* QUIT чтобы выйти из программы.
* LIST чтобы внести в список цены, которые программа должна мониторить.
* WATCH чтобы через точку с запятой внести новую цену для отслеживания.

Затем мы будем использовать класс JDK Scanner для получения команд, введенных пользователем. Если введена команда QUIT, программа закрывается, позаботившись о том, чтобы закрыть соединение со Scanner и отменить выполнение Timer. В противном случае, мы продолжаем ждать команды пользователя.

При вводе команды LIST вызывается метод listPricesToWatch, который отображает все отслеживаемые цены в консоли, указывая каждый раз выше она или ниже.

Наконец, при помощи команды WATCH мы вводим цену. После проверки того, что эта цена является допустимым значением с плавающей точкой, будет создан новый экземпляр объекта Price и добавлен в список цен для мониторинга.

Все это дает нам следующий код:

Code:
private void scanConsole() {
  String command = null;
  Scanner scanner = new Scanner(System.in);

  while (!QUIT_COMMAND.equals(command)) {
    String line = scanner.nextLine();
    line = line.toUpperCase();

    if (line.startsWith(QUIT_COMMAND)) {
      command = QUIT_COMMAND;
      System.out.println("Goodbye!");
    } else if (line.startsWith(LIST_COMMAND)) {
      command = LIST_COMMAND;
      listPricesToWatch();

    } else {
      String[] tmp = line.split(";");

      if (tmp.length != 2) {
        System.out.println("Bad entry: COMMAND;Price");
      } else {
        command = tmp[0];

        // we use a switch for future commands
        switch (command) {
          case WATCH_COMMAND:
            Float price = null;

            try {
              price = Float.parseFloat(tmp[1]);
            } catch (Exception e) {
              price = null;
            }

            if (price == null) {
              System.out.println("Bad price");
            } else {
              Price priceObj = new Price(currentPrice, price);
              pricesToWatch.add(priceObj);
              System.out.println("Watch for BTC " + priceObj.type + " = " + price);
            }
            break;
          }

      }

    }
  }

  scanner.close();
  cancelTimer();
  System.exit(0);
}
github.com

.     .     .

Сборка Разных Частей

Все, что нам нужно сделать сейчас, это собрать различные части нашей программы Bitcoin Price Watcher в метод main класса App.

Конкретно, это заключается в создании экземпляра объекта App, а затем получении цены Биткоина через регулярные промежутки времени путем вызова метода launchTimer, а затем вызова метода scanConsole, чтобы остаться в ожидании ввода данных пользователем в консоли.

Итак, мы имеем следующий полный код класса App:

Code:
package com.ssaurel.BitcoinPriceAlert;

import java.awt.AWTException;
import java.awt.Image;
import java.awt.SystemTray;
import java.awt.Toolkit;
import java.awt.TrayIcon;
import java.awt.TrayIcon.MessageType;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Scanner;
import java.util.Timer;
import java.util.TimerTask;

import org.json.JSONObject;

import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;

public class App {

 public static final String WATCH_COMMAND = "WATCH";
 public static final String QUIT_COMMAND = "QUIT";
 public static final String LIST_COMMAND = "LIST";
 public static final long PERIOD = 60 * 1000; // 30 sec
 public static final String BITCOIN_PRICE_ENDPOINT = "https://api.coindesk.com/v1/bpi/currentprice.json";
 private OkHttpClient client = new OkHttpClient();
 private Timer timer;
 private List<Price> pricesToWatch;
 private float currentPrice;

 public static void main(String[] args) {
  App app = new App();
  app.launchTimer();
  app.scanConsole();
 }

 public App() {
  pricesToWatch = new ArrayList<>();
 }

 private void scanConsole() {
  String command = null;
  Scanner scanner = new Scanner(System.in);

  while (!QUIT_COMMAND.equals(command)) {
   String line = scanner.nextLine();
   line = line.toUpperCase();
 
   if (line.startsWith(QUIT_COMMAND)) {
    command = QUIT_COMMAND;
    System.out.println("Goodbye!");
   } else if (line.startsWith(LIST_COMMAND)) {
    command = LIST_COMMAND;
    listPricesToWatch();
   
   } else {
    String[] tmp = line.split(";");

    if (tmp.length != 2) {
     System.out.println("Bad entry: COMMAND;Price");
    } else {
     command = tmp[0];

     // use a switch for future commands
     switch (command) {
     case WATCH_COMMAND:
      Float price = null;

      try {
       price = Float.parseFloat(tmp[1]);
      } catch (Exception e) {
       price = null;
      }

      if (price == null) {
       System.out.println("Bad price");
      } else {
       Price priceObj = new Price(currentPrice, price);
       pricesToWatch.add(priceObj);
       System.out.println("Watch for BTC " + priceObj.type + " = " + price);
      }
      break;
     }

    }

   }
  }

  scanner.close();
  cancelTimer();
  System.exit(0);
 }

 private void listPricesToWatch() {
  if (pricesToWatch.isEmpty()) {
   System.out.println("No prices to watch");
  } else {
   System.out.println("Prices to watch:");
   
   for (Price price : pricesToWatch) {
    System.out.println("\t" + price.type + "\t" + price.target);
   }
  }
 
  System.out.println();
 }
 
 private void launchTimer() {
  timer = new Timer();
  timer.schedule(new TimerTask() {

   @Override
   public void run() {
    loadBitcoinPrice(new Callback() {

     @Override
     public void onResponse(Call call, Response response) throws IOException {
      String str = response.body().string();
      parseBitcoinPrice(str);
     }

     @Override
     public void onFailure(Call call, IOException ioe) {

     }
    });
   }
  }, 0, PERIOD);
 }

 private void cancelTimer() {
  if (timer != null) {
   timer.cancel();             
  }
 }

 private void loadBitcoinPrice(Callback callback) {
  Request request = new Request.Builder().url(BITCOIN_PRICE_ENDPOINT).build();
  client.newCall(request).enqueue(callback);
 }

 private void parseBitcoinPrice(String str) {
  JSONObject jsonObject = new JSONObject(str);
  currentPrice = jsonObject.getJSONObject("bpi").getJSONObject("USD").getFloat("rate_float");
  System.out.println(LocalDateTime.now() + " | Current price = " + currentPrice + "\n");

  // we check if one price is reached
  for (Iterator<Price> it = pricesToWatch.iterator(); it.hasNext();) {
   Price priceToWatch = it.next();

   if (priceToWatch.type.reached(currentPrice, priceToWatch.target)) {
    String message = priceToWatch.type.msg(currentPrice, priceToWatch.target);
    System.out.println(message);
    displayNotification("Bitcoin Watcher", message);
    // remove from list to watch
    it.remove();
   }

  }
 }
 
 public void displayNotification(String title, String message) {
  if (SystemTray.isSupported()) {
   //Obtain only one instance of the SystemTray object
   SystemTray tray = SystemTray.getSystemTray();
   Image image = Toolkit.getDefaultToolkit().createImage("icon.png");

   TrayIcon trayIcon = new TrayIcon(image, "Bitcoin Watcher Notif");
   //Let the system resize the image if needed
   trayIcon.setImageAutoSize(true);
   //Set tooltip text for the tray icon
   trayIcon.setToolTip("Bitcoin Watcher");
   
   try {
     tray.add(trayIcon);
   } catch (AWTException e) { }

   trayIcon.displayMessage(title, message, MessageType.INFO);
  } else {
   System.err.println("System tray not supported!");
  }
 }

}
github.com

.     .     .

Bitcoin Price Watcher в действии

Прежде чем запустить нашу программу Bitcoin Price Watcher, я позаботился о создании архива JAR, позволяющего пользователю использовать его непосредственно из терминала.

Первый шаг заключается в запуске программы с помощью следующей команды:

Quote
java -jar BitcoinPriceWatcher.jar

После запуска программы цена Биткоина запрашивается в первый раз. Я добавляю несколько цен для мониторинга, а затем я ввожу команду LIST, чтобы проверить, что цены были учтены программой Bitcoin Price Watcher:


Программа продолжает запрашивать цену Биткоина через регулярные промежутки времени:


После этого я могу оставить программу запущенной и возобновить нормальную деятельность. Действительно, Bitcoin Price Watcher уведомит меня, когда цены, которые мне интересны, будут Биткоином достигнуты.

Через некоторое время я получаю уведомление, когда читаю статью о криптовалютах на Medium :


Вблизи системное уведомление выглядит так:


Быстрый взгляд на терминал, запущенный в фоновом режиме, показывает, что программа продолжала исправно работать и следить за ценой биткойна:


Bitcoin Price Watcher, который мы только что разработали, прекрасно функционирует.

.     .     .

Двигаемся Дальше

Bitcoin Price Watcher может быть улучшен, добавлением отправки электронных писем на заданный адрес, когда цена достигает какого-либо из уровней, которые пользователь хочет отслеживать.

В добавок, вы можете расширить программу другими криптовалютами. Для этого вам нужно будет использовать API, который предлагает цены других криптовалют, чтобы отслеживать развитие их цен. Тогда для мониторинга нужно будет учитывать еще и код криптовалюты в дополнение к цене.

Теперь вы можете сами написать более полную программу, если вам это нужно.

#Programming #Java #SoftwareDevelopment #Bitcoin #SortwareEngineering
spring.blockchain
Newbie
*
Offline Offline

Activity: 4
Merit: 0


View Profile
April 01, 2020, 10:36:11 AM
 #3

Автор и переводчик молодцы, только это как прикол для прокачки скила в программировании,а так  зачем лезть в систему ставить софт на десктоп или ноут где биткойны лежат ), если такая приспособа нужна можно логи просто кидать в телеграм а приспособа крутиться на вдске будет и 24/7/365 алерты, а не когда только ноут работает. будет больше времени покажу мейджик как срезать лишнее при достижении той же цели)
LOFTbtc
Newbie
*
Offline Offline

Activity: 10
Merit: 0


View Profile
April 02, 2020, 05:55:40 PM
 #4

Вау, мужик, дествительно спасибо ! Я пытаюсь писать программки на java и мне трудно было найти начало, но это было до этого момента ! Спасибо ещё раз.
Pages: [1]
  Print  
 
Jump to:  

Powered by MySQL Powered by PHP Powered by SMF 1.1.19 | SMF © 2006-2009, Simple Machines Valid XHTML 1.0! Valid CSS!